docs GitHub

ArchitectureDraft

ADR 0041: GitHub everywhere

Status: draft, 28 Sep 2026, Workstream: github, Code: core/github/, lib/git-safe.js (existing, owner: sessions), core/projects/ (existing, owner: federation), deck/views/connections.js (existing, owner: launch)

The problem

A person's real work lives in GitHub repos. Today Vyre can run git inside a folder someone already cloned (lib/git-safe.js), and the vault already knows a github provider for a pasted PAT (core/vault/providers.js), but there is no sign-in, no repo list, no way to turn "here is my repo" into a Vyre project, and no isolation between two sessions working the same project at once. 0.1.1 scope (the user, 28 Sep): device-flow sign-in in onboarding and Settings, a repo list, a project made from a repo, and a worktree plus branch per session. PRs, issues, git settings, Touch ID on big moves and a GitHub App are 0.1.2, design only.

An OAuth App named "Vyre" is already registered, Device Flow enabled, user tokens non-expiring. Client ID Ov23ct6h9OU5wJHjbqBl (public). The secret lives in the vault env (VYRE_GITHUB_OAUTH_CLIENT_SECRET) but device flow never reads it: RFC 8628 needs only the client id. It exists so a later web/confirmation flow could use it; github's code never reads that env var in 0.1.1.

Decision

1. One module, core/github/

Requires vault. Registers no Gate sender in 0.1.1: nothing here sends anything outward (no issues, no comments, no PRs yet), so there is nothing for the Gate to hold. Cloning and pushing a person's own work branch is a write to a repo the person already owns, the same trust level as git push from a terminal today.

2. Sign-in: device flow, vault-only token, PERSON_ONLY

Revised 1 Oct (0.2, lead ruling): sign-in runs the real GitHub CLI. Vyre does not run the device flow itself under any client id, gh's included (that would be Vyre acting as GitHub CLI). github.connect spawns gh auth login --web --scopes repo --insecure-storage in a private throwaway folder (HOME and GH_CONFIG_DIR inside it, nothing of the person's env passed), reads the code and address gh prints, returns { id, user_code, verification_uri, expires_in, interval } (no verification_uri_complete), and when gh exits cleanly reads gh auth token once, files it in the vault item and deletes the folder. The box image and Mac servers must carry gh; without it the tool answers gh_missing and a pasted fine-grained token is the fallback. The rest of this section (polling, one sign-in per name, scrubbing) describes the earlier direct flow; the vault item, the events and the no-revoke rule are unchanged.

github.connect {name} (people only, PERSON_ONLY) starts RFC 8628: POST https://github.com/login/device/code with client_id and scope=repo (decision 4). Returns { id, user_code, verification_uri, verification_uri_complete, expires_in, interval }. The person types user_code at verification_uri (Deck: shown as text; onboarding and phone: verification_uri_complete as a tappable link, opens Safari/Chrome, works over cellular with no Vyre listener involved unlike Google's loopback). Vyre polls POST https://github.com/login/oauth/access_token on its own timer at interval (backs off on slow_down, stops on authorization_pending timeout at expires_in, one open poll per sign-in, cleared the moment it ends), exactly like google/connect.js's loopback listener exists only while a sign-in is open. On success: the token goes straight into a new vault item github-<name> (kind: "pat", the existing kind; field: token), granted to github only, the same shape as google-<name>; nothing else ever holds it. A GET /user call gets the login and avatar url, github.added {name, login} is emitted, and the account is added the way google.add adds one: one row in github_accounts (name, login, avatar_url, item, added).

github.connect.cancel {id} and the sign-in's own 15-minute expiry both end it the way a Google sign-in ends. There is no .finish here: a device-flow browser never lands back on Vyre, so there is nothing to paste. github.accounts lists {name, login, avatar_url}, never a token.

github.remove {name} (reviewer: a live non-expiring repo token must not just sit in the vault once disconnected) fetches the item, calls DELETE /applications/{client_id}/token with HTTP basic auth client_id:client_secret and { "access_token": token } as the body, the one call in this module that reads VYRE_GITHUB_OAUTH_CLIENT_SECRET, which revokes the token at GitHub, then deletes the vault item and drops the account row. A revoke that fails (GitHub unreachable, already revoked) still removes the account and item locally and says so plainly, so a person is never stuck with a connected-looking account whose token doesn't work; it never leaves the token behind silently. Rotation needs no new code: health.js's judge() already treats kind pat as a typed credential (old, rotate, reused, breached all apply to it via secretsOf), and remind.js already turns those into a planner todo; storing the item as kind: "pat" is enough.

Vault catalog change: providers.js's github entry moves from how: "field" (paste a PAT) to a second entry github-oauth with how: "oauth" and next: { tool: "github.connect" }, mirroring google-oauth. The pasted-PAT path stays for anyone who wants to hand Vyre a fine-grained token by hand instead of signing in (unattended boxes, a token scoped narrower than repo); Settings offers both.

3. Repos: github.repos

github.repos {account?, q?, limit?, page?} returns { repos: [{ full_name, name, owner, private, default_branch, description, updated_at, html_url }], page, limit, more }, everything a picker needs (name, owner, the private flag, when it was last updated, its default branch) and a next-page signal, never a clone URL with a token in it. Without q, page/limit are GitHub's own paging on GET /user/repos?...&sort=updated&per_page=<limit>&page=<page> directly, newest- updated first. With q (matched client-side against full_name and description, since /user/repos has no text search of its own), the same call scans pages (capped at 10, so one search can never fetch without bound) collecting matches, then paginates the matches themselves by page/limit; more says whether another page of matches exists either way. account picks the connected account when there is more than one (the same forRead/forWait shape as Google's). callers: people (cli, local, deck, capsule) plus module:sessions only, this lists every private repo the account can reach, so it is never model-reachable, the same as github.connect/.remove/.project. No tool in this module that a model can call ever touches the token: reads (repos) are person/module-only, and there is no model-reachable write in 0.1.1 (clone and worktree creation run only from github.project, itself person/module-only). Any future model-reachable push or PR tool goes through the Gate, unheld access is not on the table for it.

Scope decision: repo. An OAuth App's device-flow scope is fixed at the request that minted the token (unlike a GitHub App, there is no per-repo installation), so the choice is between public_repo (too narrow: the person's own private repos, which is most of the point, would be invisible) and repo (full read/write on every repo the account can reach, private included). 0.1.1 takes repo, named exactly as that to the person at sign-in time (GitHub's own consent screen already says "Full control of private repositories"; Vyre repeats it in the onboarding copy, not just relies on GitHub's screen). 0.1.2's GitHub App (already on the later roadmap, see ADR TBD) replaces this with per-repo installation tokens scoped to only the repos the person picks; that is the fix for the breadth, not a narrower OAuth scope today.

4. A project from a repo

Federation's existing tools do the work; github only clones. github.project {name, repo, account?}:

  1. github.repos to resolve repo (owner/name or a full URL) to its clone URL and default branch, for the resolved account.

  2. Clone into <projects dir>/<repo name> (config.js's boxProjectsDir()/local equivalent; <repo name> sanitised, [A-Za-z0-9._-] only, no leading dot, no .., and de-duplicated the way scanEnv de-dupes names) with lib/git-safe.js's new gitWithAskpass, the only way this module runs git that needs a network call, ever.

    Authentication for the clone never touches the remote URL or the repo's own config (both persist to disk and would leak the account across every future git call in that folder). Two things the reviewer caught, both fixed and tested (lib/git-safe-askpass.test.js):

    • The token never sits in an env var. A child process's environment is readable by any same-uid process (ps eww on a Mac). The askpass script is handed the token over a one-time, already-open pipe fd (GIT_ASKPASS_TOKEN_FD names only the fd number, never a value, as an env var), read once and printed, then the script is gone: it lives in a 0700 temp dir made fresh per call and removed the moment the call ends.
    • credential.helper must not run at all. macOS ships credential.helper=osxkeychain in git's system config, and many people set one globally too, though git-safe.js's GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL=devNull already rule out both scopes, so the actual residual risk proven in the test is a repo-local helper (in the destination's own .git/config, a scope neither of those two touches): without an override, a successful askpass would hand the token straight to it, and it would be reused silently by every later git push in that folder, a model's own terminal git push included. Every call adds -c credential.helper= (git's documented way to clear a configured helper list) on top of git-safe.js's existing SAFE_GIT_ARGS. credential.interactive is left alone: its false value (confusingly) disables asking rather than disabling a stored answer, which would defeat askpass entirely; GIT_TERMINAL_PROMPT=0 (already forced) is what stops a raw terminal prompt without touching the askpass path.
    • Only https is reachable, nothing else. git-safe.js's inherited protocol.allow=never blocks every transport by default; gitWithAskpass adds protocol.https.allow=always on top, so file, git, ext and a submodule's own transport all stay blocked even for this call (tested: a local repo path is refused exactly like any other non-https source).
    • One username for every account. credential.username is fixed to x-access-token (GitHub's own convention: any non-empty username works with a PAT as the password), so git only ever prompts askpass once, for the password. Without this it asks twice, once for the username, once for the password, and the fd, read once, would answer the first ask and leave the second empty. Tested via git credential fill (the exact credential-resolution path a clone takes), so nothing here depends on reaching real GitHub in a test.

    This is a small, tested addition to git-safe.js (gitWithAskpass(dir, args, { token, username?, stdin? })), sent to sessions (git-safe.js's owner) as a self-contained new-file diff for review, their branch doesn't carry git-safe.js yet and a full merge mid-task was too large to do safely, so they review it standalone and the integrator reconciles it against main's copy at the stage/0.1.1 fold, the same as everything else piling up there. github calls it and never reimplements auth.

    A mistake made and reported while building this: an early hand-written debug script ran git credential fill directly, outside git-safe.js's isolation, to check whether the askpass script was reached. Skipping GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL=devNull let this machine's real stored GitHub credential answer instead, printing a real username and PAT into tool output that is now in this session's transcript. Reported to the reviewer and the lead immediately; the value was not reused or sent anywhere; a rotation is recommended. The actual gitWithAskpass code path was never affected (it always goes through the real isolation), the leak was in a throwaway debug script that has since been deleted.

  3. ctx.call("projects.create", { name, home: clonedPath, from_thread? }). github never writes to projects' own tables; it only calls its tools, per the module contract. from_thread? (new, the user-approved avatar carry-over) is github.project's own optional input, passed straight through untouched: an existing chat's id, which projects.create (native-core owns) validates and normalises again on its own side. When given, the new project's avatar_seed becomes that chat's id and the chat is filed into the project, so starting a GitHub project from a loose chat ("New project from a GitHub repo...") keeps its tile instead of getting a fresh one. github checks it BEFORE ever cloning, for both shape (a UUID) and existence (ctx.call("threads.get", { thread, limit: 1 }), the same lookup projects.create's own validation uses): checkedThreadId refuses a malformed or made-up id immediately, no repo resolution, no clone, no GitHub API call at all (reviewer, 9cf93817 review, LOW: a bad value used to fail only after a real clone had already happened, spent on nothing).

    If projects.create (or .add-workspace, github.project.add-repo's own equivalent step) still fails after the clone, for any other reason, the clone is left exactly where it is. An earlier fix (removeOrphanClone, e723df32) deleted it instead; the lead and the reviewer corrected that against the user's binding no-auto-delete rule (the same rule github.session. cleanup, section 5, already follows). The thrown error's detail.path names the clone's folder, so a person can use it, register it by hand, or remove it themselves - never an automatic decision this module makes for them.

  4. The project row remembers the repo (github_projects (project, account, full_name, default_branch), keyed by the project's slug), so later steps (worktree-per-session, and 0.1.2's PRs and git settings) know which project is a GitHub project without asking again. This is the project's primary GitHub repo, set exactly once, here: a session's worktree (section 5) is always made from it. github.project.add-repo (4a) adds further repos to a project as plain workspaces and never touches this row.

github.project makes a brand-new project. Attaching a repo to a project that already exists is a different tool (4a), not a project input here: an earlier draft let github.project do both (project? param, projects.add-workspace when given), but that let a second, non-primary repo silently overwrite the github_projects row meant for the session-worktree repo, so it was split before either path shipped. callers for github.project: people only (launch's onboarding "connect a repo" step calls it as a person surface, deck/cli/etc, never as a module - see the firstParty note near the end of this section for why no module belongs here at all). Never a model.

4a. Adding a repo to an EXISTING project

Decided, 28 Sep 2026: there is no explicit "link". An earlier draft (github.project.link/ .unlink, a confirm: true step that added a remote named github to an existing folder) was built, tested and then dropped once the user settled the project/repo model: a project's repos are either its one primary repo (4, set once, what a session's worktree is made from) or workspaces it owns, each its own real clone, never a remote grafted onto a folder that already exists. Nothing records "this folder is linked" by hand; github.project.detect (4b) reads what is actually on disk, every time.

The one GitHub action on an existing project is github.project.add-repo {project, repo, account?, folder?}, people only (callers: PEOPLE), never a module and never a model: clone the repo, fresh, as a brand-new workspace folder, exactly the way github.project clones a project's first folder (getRepo to resolve repo, cloneRepo/gitWithAskpass, no token on disk, no remote grafted onto anything that already exists). folder? names the new folder (freeFolder's own -2/-3 suffix if that name is taken); the default is the repo's own name. The new folder is registered through ctx.call("projects.add-workspace", { project, folder }), federation's own tool, the same one github.project already had sign-off for (module:github on MAPPING_ALLOWED, sha 624edc76). add-repo never writes the github_projects row: the added repo is a workspace, not the project's primary repo, so it changes nothing about which repo a session's worktree comes from (section 5), and never touches the project's other folders. A worktree for an added, non-primary repo is 0.1.2, same as before.

Tested live, once, on testbox (core/github/index.live.test.js, off by default, opt in with VYRE_LIVE_GITHUB=1, never on the Mac): github.project and .add-repo really clone GitHub's own tiny public demo repo (octocat/Hello-World) end to end, with a made-up account and a placeholder token the whole way through, never a real credential. It drove one real fix: getRepo (the shared GET /repos/{full_name} helper) used to 401 outright on a broken or placeholder token, even for a repo anyone could read anonymously, since GitHub validates whatever credential is offered before ever falling back to public access; it now retries once with no credential at all when the first attempt 401s, so a bad token no longer wrongly reports a public repo as inaccessible. The clone itself needed no such fix: GitHub serves a public repo's git-http endpoints anonymously, so gitWithAskpass never even reaches its own askpass script for one, a placeholder token or not. The live test also checks the clone's own .git/config for a credential.helper afterward (there should be none) as a second, concrete proof next to git-safe-askpass.test.js's existing ones.

The reviewer and the lead caught what that first fix was missing: the anonymous retry must never hide a broken token, only work around it for a repo that never needed one in the first place. getRepo now returns { info, tokenBroken }: tokenBroken is true exactly when the account's own credentialed request 401'd, independent of whether the anonymous retry then succeeded. accountFor (what github.project.detect uses to decide which connected account can reach a repo) never credits a broken-token account with reaching anything, public repo or not - match stays null for that account even when the anonymous read would have worked, since "this account can reach it" is exactly the false confidence a revoked or expired token must not get to claim. github.project/.add-repo (resolveRepo, their shared repo-resolution helper) still let the anonymous fallback through for a public repo, since a dead token shouldn't block a person from cloning something public, but they emit github.token-invalid { name } every time the credentialed attempt 401'd, and when the repo isn't reachable even anonymously (private, or genuinely gone) they throw a distinct token_invalid error with a plain "reconnect this account" message, never the generic "GitHub does not show a repo" a real not-found gets. github.token- invalid is a new event (module.json's watches.emits) for a surface to show "Reconnect GitHub" - nothing here signs an account out or removes it; that stays a person's own call via github.remove.

4b. Detecting a project's repos, per workspace

github.project.detect {project} is how a surface knows what's already there, since nothing is ever recorded by hand (4a): for EVERY folder a project owns (its home plus every workspace it was given, projects.list's own home/workspaces, deduplicated), is it a git repo, what remotes does it have, and for any remote that's a GitHub URL, does one of the connected accounts reach it. Entirely read-only and local for the git side (folderGitState/listRemotes, core/github/ git.js, no network git call, no token used for git at all), plus at most one getRepo REST call per distinct repo found across every remote in every workspace (cached by full_name, so the same repo behind two remotes, or the same repo in two workspaces, is only ever checked once). It costs nothing to call speculatively the moment a project opens.

callers: people only (onboarding or a project view calls it as deck/cli/etc, the same as github.project above, so it can offer "add this repo?" per workspace without the person typing anything - no module ever calls detect directly). Returns { project, workspaces: [{ folder, isRepo, remotes: [{ name, url, full_name, match }] }] }. remotes is [] for a folder that isn't a git repo at all. full_name is owner/name when a remote's URL parses as github.com (originFullName, core/github/git.js), null for anything else (a gitlab.com remote, say). match is { account, full_name, default_branch } for the first connected account that can reach that repo, or null when the remote isn't GitHub, or is a repo none of the connected accounts can see (someone else's fork, an account not yet connected). url is sanitizeRemoteUrl's output, never git's raw answer (reviewer, e5a612c0 review, MEDIUM): a folder cloned by hand with a personal access token embedded in the remote's https:// URL (a userinfo prefix before the host) was otherwise sending that credential straight back out through this tool. Userinfo, the query string and any fragment are stripped from a scheme:// URL; the scp-like ssh form (git@host:path) has no such syntax and is returned unchanged. originFullName's and repoName's (index.js) owner/name character classes were also tightened the same review (LOW): the old [^/\s]+ for owner let a value like ../user resolve to /repos/user once built into an api.github.com path; both now use GitHub's own charset (owner [A-Za-z0-9-]{1,39}, name [A-Za-z0-9._-]{1,100}, name additionally never exactly . or ..).

5. A worktree and branch per session

Ownership split, through the registry only: sessions decides when; github does the git mechanics, fully built and tested (core/github/git.test.js), needing no token or network allowance at all (a worktree is a local operation on a repo already on disk).

sessions corrected the trigger against their real lifecycle (there is no "archived" or project-change event today; thread.started is real, emitted by the Switchboard):

  • Start: on thread.started (or harness.brief's own {session, cwd, source} hook), if ctx.call("github.project.of", { project }) says the project has a repo, call github.session.worktree { project, session } and use its path as the session's cwd instead of the project's home folder.

  • End: on thread.stopped or thread.finished (from lib/thread-status.js's THREAD_STATUSES; sessions picks the exact one(s) when they build the hook), call github.session.cleanup { project, session }.

  • github.session.worktree { project, session } (internal: true, callers ["module:sessions", "module:threads"], both first-party-checked, never a model, never a person surface directly: the switchboard (threads) is the one that actually resolves a session's cwd through this tool, sessions is kept alongside it for the stage/0.1.1 fold): if the project is not a GitHub project, returns null (sessions then uses the project's home folder directly, as today). Otherwise: the session id is reduced to a safe short id first (git's own check-ref-format rules, no leading . or -, no .. anywhere, no trailing ., since this becomes a path segment and a branch name), then git worktree add <repo>/.sessions/<safe-id> -b vyre/<safe-id> <default_branch> through git-safe, and returns the new path. sessions sets the session's cwd there.

  • github.session.cleanup { project, session } (same caller restriction). The user's binding rule: no auto-delete, ever; deletion is always previewed. The worktree is removed, and its branch pruned, only when nothing would be lost: no uncommitted change, no untracked or ignored file (git status --porcelain --ignored, plain --porcelain skips ignored files, and a plain git worktree remove deletes them without complaint; in a session's worktree those are exactly the things that matter, a .env, build output, a downloaded dataset, a local database, the reviewer's MEDIUM on the first cut), and no commit that isn't already on the default branch or some remote (git rev-list refs/heads/<branch> --not refs/heads/<default_branch> --remotes; the refs/heads/ form guards against a value ever being misread as an option, belt-and-braces on top of safeSegment already refusing a leading dash, git branch -d itself only accepts the short name, not that form, so it keeps it). If any of those is true, nothing is removed, the worktree and branch are left exactly as they were, and github.cleanup-needed { project, session, path, branch, dirty, commits } is emitted with what's at stake, for a surface to show "Clean up this session's worktree?" and the person to decide by hand. git worktree remove --force and git branch -D never appear anywhere in this path; the removal that does happen uses their plain, non-force forms, which independently refuse if the check above ever turns out to be wrong, a second backstop, not a substitute for the check.

  • .sessions/ is repo-local and machine-local: it is written to the project's own .git/info/ exclude once (never the repo's committed .gitignore, which is the person's file) so git status in the person's own clone of the same repo never shows Vyre's worktrees.

  • Every git call here, with no exception, goes through lib/git-safe.js: no fsmonitor, no hooks, no network config from the repo, exactly the existing contract.

This is additive to sessions' own contract (ADR 0030): a "project has a repo" fact sessions reads through ctx.call("github.project.of", { project }), never a direct table read.

Built by sessions (79bd2bf1): cleanup fires only on canonical status finished, never stopped or paused (lib/thread-status.js), since both of those are resumable on their existing cwd and threads.send on resume never re-resolves it, cleaning up on them would strand the resume. This is the right, conservative default for 0.1.1, decided jointly: an ordinary interactive session, which typically ends stopped rather than finished, keeps its worktree indefinitely under today's hook. Nothing breaks (disk isn't reclaimed, not correctness), but it is a real gap. Deferred to 0.1.2: a periodic sweep for worktrees whose session has neither a live thread nor a resumable stopped/paused status. Owner: github (sessions, 28 Sep: "it's reading your own .sessions/ directories against thread status, and threads.list already gives you what you'd need... without a new tool from me"). Not started; 0.1.2.

6. Manifest, tools and callers (0.1.1)

module.json: requires: ["vault"], does.tools: github.connect, github.connect.cancel, github.accounts, github.remove, github.repos, github.project, github.project.add-repo, github.project.of, github.project.detect, github.session.worktree (internal), github.session.cleanup (internal). watches.emits: github.added, github.removed, github.connected, github.connect-failed, github.cleanup-needed, github.token-invalid. shows.deck: settings:connections (joins Google there, not a new screen). needs.vault: ["per-connection"].

ToolCallersModel-reachable
github.connect, .connect.cancel, .remove, .accountspeoplenever
github.repospeople, module:sessionsnever
github.project, github.project.detect, github.project.add-repopeople onlynever
github.project.ofpeople, module:sessions, module:threadsnever
github.session.worktree, .session.cleanupmodule:sessions, module:threads, internal: truenever

No tool a model can call in 0.1.1 touches the token, clones, or writes a worktree. Everything that does is a person surface or one of the two named modules.

module:threads names core/switchboard/'s own registered module name (threads); an earlier draft named module:launch after the team building the screens that would call these tools, but no module in this repo is actually named that, so it named nothing real. Either way, a name alone was never enough: a module's name is self-declared in its own manifest, and on a Mac a model can write into the home modules folder, so a rogue local module could name itself sessions or threads too and present the same caller string. checkModuleCaller (core/github/index.js) requires both: the caller's name is one a tool actually listed, AND meta.firstParty === true, the registry's own signal (core/modules/index.js's firstParty(), set from where the calling module's code actually lives on disk, never something a caller can claim for itself). Reviewer and lead finding, 5b1c69f1 review's follow-up.

Narrowed once more, same review's own follow-up: threads (the switchboard) only actually calls github.project.of (to learn whether a project has a repo) and the two session-worktree tools (the worktree mechanics themselves) - never github.repos, github.project or .project.detect, which are person surfaces only (launch's screens call these as deck/cli/ etc, never as a module, so no module entry belongs on them at all). Narrowed accordingly. Merge note: sessions' own branch (the stage/0.1.1 fold) already carries threads alongside sessions on the two session-worktree tools; kept here to match, so that fold doesn't quietly deny the switchboard's own worktree calls.

Consequences

  • No token is ever pasted by hand for the common case; the fine-grained-PAT path stays for people who want it.
  • repo scope is broad. Named plainly to the person, and superseded by the GitHub App in 0.1.2, not hidden behind a narrower-sounding request today.
  • A worktree per session means two sessions in one project never fight over the working tree or a half-finished git add, and each gets a real branch a person can push and open a PR from by hand today, before Vyre does it from chat in 0.1.2.
  • .sessions/ worktrees are invisible to the person's own git status in the same clone.
  • No explicit link means no bookkeeping to go stale: github.project.detect always answers from what's actually on disk, and adding a repo (add-repo) is a real, fresh clone, never a remote grafted onto a folder that already has its own history.

Rejected

  • github.project.link/.unlink, recording a project-to-repo link by hand. Built, tested, and dropped once the user settled the model: see 4a. A project either owns a repo as its primary (set once, by github.project) or as a workspace (add-repo, a fresh clone); nothing in between needs recording, since detect (4b) can always read it back off disk.

  • A GitHub App instead of device flow, now. Right shape for 0.1.2 (per-repo installs, short- lived tokens, PR/issue/check webhooks), but device flow with a non-expiring user token is the fast path to "signed in" for 0.1.1 and does not block the later move: the App can mint its own tokens for repos it is installed on and the OAuth path stays for accounts that never install it.

  • Cloning with the token in the remote URL. Persists to .git/config on disk and to every git remote -v; the askpass-per-call approach never touches disk.

  • One shared worktree per project, session-locked. Serializes every session in a project behind whichever one is running; a worktree per session is the same cost sessions already pays for isolation elsewhere (ADR 0030) and needs no lock.