@phnx-labs/agents-cli 1.22.118 → 1.22.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,80 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.120
4
+
5
+ - **An editor terminal now follows a `claude` restarted by hand (PHNX-4218).** After
6
+ `/exit`, running `claude` again in the same tab starts it without a launcher, so the
7
+ SessionStart hook records its session with an empty agent kind, and the active scan
8
+ skipped that record. The tab kept the old session. The scan now takes the kind
9
+ from the process name when the record has none. Source:
10
+ `cli/src/lib/session/active.ts`.
11
+
12
+ ## 1.22.119
13
+
14
+ - **A setup-token (provider) Claude account no longer opens on Claude Code's first-run
15
+ theme picker on a worker.** A provider account has no slot, so it runs in the
16
+ executable's shared version home, and nothing ever marked that home onboarded: an AGI
17
+ EXT tab dispatched to a worker landed on "Choose the text style…" instead of the agent.
18
+ A credentialed Claude spawn on a non-headed device now seeds `hasCompletedOnboarding` in
19
+ the home it runs in, right after the worker login-trap preflight in `spawnAgentLeased`
20
+ (explicit `#account`, default account, balanced pick, interactive tmux). Headless paths
21
+ that spawn elsewhere (`--loop`, routines) never show onboarding in print mode; the
22
+ Windows `.cmd` shim passthrough is not covered. With no email the seed writes only the
23
+ flag, so the home's own login identity is untouched; it writes through the
24
+ `.claude/.claude.json -> ../.claude.json` link instead of replacing it; and a document
25
+ already onboarded is left byte-identical. Source: `cli/src/lib/exec.ts`,
26
+ `cli/src/lib/claude-account-token.ts`.
27
+
28
+ - **A headless Cursor run in plan mode now runs in folders Cursor has not seen
29
+ before, and prints its answer.** Two bugs stopped `agents run cursor "…" --mode
30
+ plan`, a Cursor teammate (`agents teams add … cursor …`), and a plan-mode Cursor
31
+ routine from working unattended:
32
+ - They passed `--plan` without `--trust`. In a directory Cursor had not seen,
33
+ Cursor stopped on "Do you trust the contents of this directory?" and exited
34
+ with no one to answer, so a teammate failed in 7 seconds with 0 tools. Every
35
+ headless mode except skip (which passes `-f`) now passes `--trust`. `--trust`
36
+ only accepts the configured working directory and does not bypass tool
37
+ permissions.
38
+ - In `--plan` mode Cursor delivers its answer through its createPlan tool, which
39
+ `-p` text output never prints. A run would read the repo for 3 minutes, exit 0,
40
+ and leave an empty log. A headless plan run now uses Cursor's read-only ask
41
+ mode (`--mode ask`), which refuses file writes the same way and prints its
42
+ answer.
43
+
44
+ Interactive Cursor runs keep `--plan` and Cursor's own trust prompt. Source:
45
+ `cli/src/lib/harness/adapters/cursor.ts`.
46
+
47
+ - **Grok subscription seats log in per worker through the device-code flow.** `grok` now has the
48
+ same `per-device:device-auth` worker path as `codex`: `agents accounts add grok <name> --per-device`
49
+ registers the account with no API key, and `agents accounts login grok#<name> --per-device` on a
50
+ worker runs `grok login --device-auth` into that account's slot. The box prints a URL and code
51
+ that you approve in your own browser, so SuperGrok / X Premium+ seats run on workers without an
52
+ `XAI_API_KEY`, which bills API credits rather than the subscription. `accounts login --per-device`
53
+ is new and is the one login a worker may run for a dual-path harness (codex too); the plain
54
+ re-login stays headed-only. Source: `cli/src/lib/harness-auth-capabilities.ts`,
55
+ `cli/src/lib/accounts/add.ts`, `cli/src/commands/accounts.ts`.
56
+
57
+ - **The `grok` shim runs the release `grok update` installed.** Grok's updater now writes
58
+ `~/.grok/bin/grok-<version>` and repoints `~/.grok/bin/grok`, leaving `downloads/` alone, but the
59
+ shim only scanned `downloads/`, so it kept running the old binary after an update (on one worker,
60
+ 1.0.4, which xAI rejects with `426 ... outdated`). The shim, the `grok@<version>` alias, and
61
+ `getBinaryPath` now follow grok's own `bin/grok` pointer first and fall back to the `downloads/`
62
+ scan for older layouts. The shim schema version is bumped so installed shims regenerate.
63
+ Source: `cli/src/lib/installations/shims.ts`, `cli/src/lib/installations/store.ts`.
64
+
65
+ - **Editor terminals follow the current agent session after a restart (PHNX-4218).**
66
+ Active/watch rows adopt the live agent descendant's recorded session, transcript,
67
+ terminal ID and launch ID while keeping the tab's shell PID and metadata. Only the
68
+ nearest recorded agents qualify, so nested agents cannot take over the tab. The most
69
+ recent recorded start wins at that depth, with PID as a stable tie-breaker; known
70
+ tab kinds must match. Windows retains the published identity until process starts
71
+ can be verified. Published terminal IDs
72
+ also survive when no descendant supplies one. Rows expose a display-only `accountLabel`
73
+ when the indexed account identifies one registered native slot; ambiguous identities
74
+ stay unlabeled, and org-only keys require a matching email.
75
+
76
+ - **`agents projects prs` scopes a shared monorepo per project, and `agents projects prs merge` merges one PR (PHNX-4215).** When several projects share one repository, each project now lists only the PRs whose changed files touch the paths it claims (`scope: project`) or touch no sharing project's paths (`scope: repo-wide`). It uses the same `defaultPath`/`subpath` claim that attributes a session's cwd, and changed files are cached per head SHA. Rows gain `createdAt`. The envelope gains `viewer` and a per-repo `sharedWith`, and the `--number` detail adds `mergeable`/`mergeableState`. `agents projects prs merge <name> --repo … --number … --sha <head>` merges with one REST call pinned to the reviewed head, so GitHub refuses if the branch moved since. Repo canonicalization moved from GraphQL `gh repo view` to a cached REST read, which drops a warm refresh from about 2s to under 1s. Source: `cli/src/lib/github/project-prs.ts`, `cli/src/lib/projects.ts`, `cli/src/commands/projects.ts`.
77
+
3
78
  ## 1.22.118
4
79
 
5
80
  - **`agents sessions <read> --device <name>` routes a SINGLE-device READ through the standalone `sessions --host` when both sides support it (PHNX-4012).**
@@ -364,7 +364,7 @@ agents accounts list --fleet`,
364
364
  const addCmd = accounts.command('add <target> [name]')
365
365
  .description('Add an account. Harness form: add <harness> [name] runs the native login in a fresh credential slot and provisions workers. Provider form: add <name> --provider <p> --auth <t> stores a durable credential.')
366
366
  .option('--api-key <key>', 'Worker credential for api-key harnesses (codex/grok/cursor/opencode); prompted when omitted')
367
- .option('--per-device', 'codex: ChatGPT-plan account — no API key stored; workers log in per box')
367
+ .option('--per-device', 'codex/grok: subscription seat (ChatGPT plan, SuperGrok, X Premium+) — no API key stored; workers log in per box')
368
368
  .option('--no-worker-token', 'Skip minting/collecting the worker credential (this device only)')
369
369
  .option('--provider <provider>', `Provider form: credential provider (${listAccountProviders().join(', ')})`)
370
370
  .option('--auth <type>', 'Provider form: api-key | setup-token | bearer-token')
@@ -428,12 +428,13 @@ agents accounts add codex gmail --per-device # ChatGPT-plan seat; workers
428
428
  agents accounts add claude # name derived from the login's email
429
429
  agents accounts add work --provider anthropic --auth setup-token # provider form (non-harness name)
430
430
  agents run claude#work`,
431
- notes: `Headed devices only: on a worker, add refuses before any slot, install, or browser — workers are provisioned automatically from the durable credential the add mints (claude: a setup-token; codex/grok/cursor/opencode: an API key via --api-key or a prompt; codex also accepts --per-device for a ChatGPT-plan seat; kimi/antigravity log in per box). An account is a credential SLOT, not a second installation: the harness has one managed install and each account is a HOME-shaped slot with its own native OAuth login (never copied, never fleet-synced). Re-running add for an already-added identity points at 'agents accounts login <harness>#<name>'. Supported: ${addSupportedList()}; other harnesses fail with the reason. An ambient CLAUDE_CODE_OAUTH_TOKEN in your shell refuses the mint (unset it or pass --no-worker-token).`,
431
+ notes: `Headed devices only: on a worker, add refuses before any slot, install, or browser — workers are provisioned automatically from the durable credential the add mints (claude: a setup-token; codex/grok/cursor/opencode: an API key via --api-key or a prompt; codex and grok also accept --per-device for a subscription seat; kimi/antigravity log in per box). An account is a credential SLOT, not a second installation: the harness has one managed install and each account is a HOME-shaped slot with its own native OAuth login (never copied, never fleet-synced). Re-running add for an already-added identity points at 'agents accounts login <harness>#<name>'. Supported: ${addSupportedList()}; other harnesses fail with the reason. An ambient CLAUDE_CODE_OAUTH_TOKEN in your shell refuses the mint (unset it or pass --no-worker-token).`,
432
432
  });
433
433
  const loginCmd = accounts.command('login <account>')
434
434
  .description('Re-authenticate an account into its slot on this device (<harness>#<name>); re-mints and re-syncs the worker credential')
435
435
  .option('--api-key <key>', 'Rotate the stored worker API key (api-key harnesses)')
436
436
  .option('--no-worker-token', 'Skip re-minting the worker credential')
437
+ .option('--per-device', 'codex/grok: sign this box in to the subscription seat via the device-code flow (allowed on workers)')
437
438
  .option('--json', 'Machine-readable result (never includes a credential)')
438
439
  .action(async (selector, o, command) => {
439
440
  await runAccountsAction(command, async () => {
@@ -448,6 +449,7 @@ agents run claude#work`,
448
449
  console.log(chalk.gray(` ${m}`)); },
449
450
  apiKey: o.apiKey,
450
451
  noWorkerToken: o.workerToken === false,
452
+ perDevice: o.perDevice,
451
453
  });
452
454
  printAddResult(result, json);
453
455
  });
@@ -455,8 +457,9 @@ agents run claude#work`,
455
457
  setHelpSections(loginCmd, {
456
458
  examples: `agents accounts login claude#work
457
459
  agents accounts login codex#personal --api-key sk-… # rotate the worker key
458
- agents accounts login kimi#main # per-device: logs THIS box in`,
459
- notes: 'Re-runs the native login into the SAME slot (never a new home) and fails closed when the completed login is a different identity than the account. Headed devices only for harnesses with a portable worker credential; a per-device harness (kimi) may log in on any box — that is how that box gets its login. The daemon re-syncs the account row and worker credential after a successful login.',
460
+ agents accounts login kimi#main # per-device: logs THIS box in
461
+ agents ssh <worker> agents accounts login grok#work --per-device # the worker signs in to the subscription seat`,
462
+ notes: 'Re-runs the native login into the SAME slot (never a new home) and fails closed when the completed login is a different identity than the account. Headed devices only for harnesses with a portable worker credential; a per-device harness (kimi) may log in on any box — that is how that box gets its login. With --per-device a codex or grok account signs THIS box in to its subscription seat through the device-code flow: the box prints a URL and code, you approve it in your own browser, and no API key is stored or synced. The daemon re-syncs the account row and worker credential after a successful login.',
460
463
  });
461
464
  const defaultCmd = accounts.command('default <harness> [name]')
462
465
  .description('Set the fleet-wide default account for a harness (picker when no name)')
@@ -30,7 +30,8 @@ import { checkRepoSlug } from '../lib/project-doctor.js';
30
30
  import { formatFocusAreas, readFocusAreas } from '../lib/project-focus.js';
31
31
  import { formatVerdict, scheduleVerdict } from '../lib/project-schedule.js';
32
32
  import { buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
33
- import { buildProjectPrs } from '../lib/github/project-prs.js';
33
+ import { buildProjectPrs, mergeProjectPr, resolveTargetSlugs, MERGE_METHODS } from '../lib/github/project-prs.js';
34
+ import { ghExec } from '../lib/github/pr-mergeable.js';
34
35
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
35
36
  const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
36
37
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
@@ -820,9 +821,14 @@ export function registerProjectsCommands(program) {
820
821
  await runProjectCard(name, opts, mode);
821
822
  });
822
823
  // ---- prs ----
824
+ // `list` is the default so `prs <name>` keeps its shape; a bare parent with
825
+ // no options of its own is what lets `prs merge` own --repo/--number/--json.
823
826
  const prsCmd = projects
824
- .command('prs <name>')
825
- .description('Every OPEN pull request across a project\'s attached repos (drafts included, no author filter).')
827
+ .command('prs')
828
+ .description('A project\'s open pull requests: list them (default), or merge one.');
829
+ const prsListCmd = prsCmd
830
+ .command('list <name>', { isDefault: true })
831
+ .description('Every OPEN pull request across a project\'s attached repos (drafts included, no author filter), scoped to this project\'s paths in a shared repo.')
826
832
  .option('--json', 'Machine-readable output (the AGI Menu contract shape)')
827
833
  .option('--repo <owner/repo>', 'Restrict to one of the project\'s attached repos')
828
834
  .option('--number <n>', 'Lazy detail: enrich exactly this PR with checks + reviewDecision (requires --repo)')
@@ -849,7 +855,7 @@ export function registerProjectsCommands(program) {
849
855
  }
850
856
  let envelope;
851
857
  try {
852
- envelope = await buildProjectPrs(def, { repo: opts.repo, number }, undefined);
858
+ envelope = await buildProjectPrs(def, { repo: opts.repo, number }, undefined, listProjectDefs());
853
859
  }
854
860
  catch (e) {
855
861
  // A restriction error (repo not attached) is a hard, up-front failure —
@@ -875,7 +881,8 @@ export function registerProjectsCommands(program) {
875
881
  console.log(`${chalk.bold(r.slug)} ${chalk.dim(`${r.pullRequests.length} open`)}`);
876
882
  for (const pr of r.pullRequests) {
877
883
  const draft = pr.isDraft ? chalk.gray(' [draft]') : '';
878
- console.log(` #${pr.number}${draft} ${pr.title} ${chalk.gray(`@${pr.author.login} · ${pr.headRefName}`)}`);
884
+ const wide = pr.scope === 'repo-wide' ? chalk.gray(' [repo-wide]') : '';
885
+ console.log(` #${pr.number}${draft}${wide} ${pr.title} ${chalk.gray(`@${pr.author.login} · ${pr.headRefName}`)}`);
879
886
  }
880
887
  }
881
888
  if (envelope.partial) {
@@ -886,11 +893,81 @@ export function registerProjectsCommands(program) {
886
893
  if (envelope.partial)
887
894
  process.exit(1);
888
895
  });
896
+ const mergeCmd = prsCmd
897
+ .command('merge <name>')
898
+ .description('Merge one open PR of a project, pinned to the head SHA you reviewed.')
899
+ .requiredOption('--repo <owner/repo>', 'One of the project\'s attached repos')
900
+ .requiredOption('--number <n>', 'The PR number')
901
+ .requiredOption('--sha <head-sha>', 'The head SHA you reviewed; GitHub refuses if the branch moved since')
902
+ .option('--method <method>', `${MERGE_METHODS.join(' | ')} (default: the first the repo allows, in that order)`)
903
+ .option('--json', 'Machine-readable result')
904
+ .action(async (name, opts) => {
905
+ const def = loadProjectDef(name);
906
+ if (!def) {
907
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
908
+ process.exit(1);
909
+ }
910
+ const raw = opts.number.trim();
911
+ const number = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) : NaN;
912
+ if (!Number.isSafeInteger(number) || number <= 0) {
913
+ console.error(chalk.red(`--number expects a positive integer, got "${opts.number}".`));
914
+ process.exit(1);
915
+ }
916
+ if (!/^[0-9a-f]{7,40}$/i.test(opts.sha.trim())) {
917
+ console.error(chalk.red(`--sha expects a commit SHA, got "${opts.sha}".`));
918
+ process.exit(1);
919
+ }
920
+ if (opts.method !== undefined && !MERGE_METHODS.includes(opts.method)) {
921
+ console.error(chalk.red(`--method expects one of ${MERGE_METHODS.join(', ')}, got "${opts.method}".`));
922
+ process.exit(1);
923
+ }
924
+ let repo;
925
+ try {
926
+ [repo] = await resolveTargetSlugs(def, opts.repo, ghExec);
927
+ }
928
+ catch (e) {
929
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
930
+ process.exit(1);
931
+ }
932
+ const result = await mergeProjectPr(repo, number, opts.sha.trim(), opts.method);
933
+ if (opts.json) {
934
+ console.log(JSON.stringify(result, null, 2));
935
+ }
936
+ else if (result.merged) {
937
+ console.log(`${chalk.green('Merged')} ${repo}#${number} (${result.method}) ${chalk.gray(result.sha ?? '')}`);
938
+ }
939
+ else {
940
+ console.error(chalk.red(`Not merged: ${repo}#${number}: ${result.message}`));
941
+ }
942
+ if (!result.merged)
943
+ process.exit(1);
944
+ });
945
+ setHelpSections(mergeCmd, {
946
+ examples: `
947
+ agents projects prs rush --json --repo phnx-labs/agi-cli --number 3646 # read headSha + mergeableState
948
+ agents projects prs merge rush --repo phnx-labs/agi-cli --number 3646 --sha <headSha>
949
+ agents projects prs merge rush --repo phnx-labs/agi-cli --number 3646 --sha <headSha> --method squash --json
950
+ `,
951
+ notes: `
952
+ The merge is a single REST call pinned to --sha: if anything was pushed after
953
+ you read the PR, GitHub refuses and nothing merges. Branch protection, required
954
+ checks and reviews stay GitHub's to enforce; a refusal exits 1 with GitHub's
955
+ message (merged: false in --json).
956
+ `,
957
+ });
889
958
  setHelpSections(prsCmd, {
890
959
  examples: `
960
+ agents projects prs rush --json # every open PR across rush's repos (= prs list rush)
961
+ agents projects prs rush --json --repo phnx-labs/agi-cli --number 3646 # one PR, with checks + mergeability
962
+ agents projects prs merge rush --repo phnx-labs/agi-cli --number 3646 --sha <headSha>
963
+ `,
964
+ });
965
+ setHelpSections(prsListCmd, {
966
+ examples: `
891
967
  agents projects prs rush --json # every open PR across rush's repos
892
968
  agents projects prs rush --json --repo phnx-labs/agi-cli
893
- agents projects prs rush --json --repo phnx-labs/agi-cli --number 3646 # one PR, with checks + review
969
+ agents projects prs rush --json --repo phnx-labs/agi-cli --number 3646 # one PR, with checks + review + mergeability
970
+ agents projects prs merge rush --repo phnx-labs/agi-cli --number 3646 --sha <headSha>
894
971
  `,
895
972
  notes: `
896
973
  Repos come from the project definition's attached repos only (repo + repos[].slug);
@@ -898,7 +975,15 @@ export function registerProjectsCommands(program) {
898
975
 
899
976
  The list is read from GitHub over REST and paginated in full, drafts included,
900
977
  with no author filter. checks and reviewDecision are null in the list; --number
901
- enriches exactly that one PR against its live head SHA.
978
+ enriches exactly that one PR against its live head SHA, with mergeable and
979
+ mergeableState.
980
+
981
+ A repository attached to several projects (a monorepo) is scoped by the paths
982
+ each project claims, the same rule that attributes a session's cwd: a
983
+ defaultPath narrowed under root, or a repos[] subpath. A PR is listed when it
984
+ touches this project's paths (scope: project) or no sharing project's paths
985
+ (scope: repo-wide). Changed files are cached per head SHA, so only a new push
986
+ costs a read.
902
987
 
903
988
  A repository whose fetch fails is reported with an error and marks the result
904
989
  partial (JSON reports errors per repository; text mode exits non-zero).
@@ -128,6 +128,8 @@ export declare function runAdd(agent: AgentId, name: string | undefined, opts: A
128
128
  * Drive one `agents accounts login <harness>#<name>`: re-auth into the SAME
129
129
  * slot (re-running add's steps 4–8), re-minting the worker credential and
130
130
  * re-syncing. On a per-device harness (worker `none`) any box may run it —
131
- * that IS how such a box logs in.
131
+ * that IS how such a box logs in. `--per-device` extends that to a dual-path
132
+ * harness (codex, grok): this box signs the account's subscription seat in for
133
+ * itself through the device-code flow instead of using a portable key.
132
134
  */
133
135
  export declare function runLogin(agent: AgentId, name: string, opts: AddOptions, runners?: AddRunners): Promise<AddResult>;
@@ -73,6 +73,12 @@ export function workerApiKeyEnv(agent) {
73
73
  function hasPortableWorkerKind(agent) {
74
74
  return harnessWorkerKinds(agent).some((k) => k === 'setup-token' || k.startsWith('api-key:'));
75
75
  }
76
+ function hasPerDeviceWorkerKind(agent) {
77
+ return harnessWorkerKinds(agent).some((k) => k.startsWith('per-device'));
78
+ }
79
+ function perDeviceRefusal(agent) {
80
+ return `--per-device is only valid for a harness with a per-device worker path; ${agent} is provisioned from ${workerProvisioningHint(agent)}.`;
81
+ }
76
82
  /** Whether `accounts add` can drive this harness (nameable, version-scoped, a real login command). */
77
83
  export function addSupported(agent) {
78
84
  const cap = nativeAccountCapability(agent);
@@ -232,9 +238,8 @@ async function mintWorkerCredential(agent, account, home, opts, runners, warning
232
238
  return { outcome: 'skipped', provisioning: 'per-device' };
233
239
  }
234
240
  if (opts.perDevice) {
235
- if (!kinds.some((k) => k.startsWith('per-device'))) {
236
- throw new Error(`--per-device is only valid for a harness with a per-device worker path (codex); ${agent} is provisioned from ${workerProvisioningHint(agent)}.`);
237
- }
241
+ if (!hasPerDeviceWorkerKind(agent))
242
+ throw new Error(perDeviceRefusal(agent));
238
243
  return { outcome: 'per-device', provisioning: 'per-device' };
239
244
  }
240
245
  const { seedReservedStoreKey, seedReservedAuthToken, workerCredentialStoreKey, } = await import('../auth-mint.js');
@@ -260,7 +265,7 @@ async function mintWorkerCredential(agent, account, home, opts, runners, warning
260
265
  let value = opts.apiKey?.trim();
261
266
  if (!value) {
262
267
  if (!runners.promptApiKey) {
263
- throw new Error(`${agent}'s worker credential is an ${env} API key. Pass --api-key <key> (or --no-worker-token to skip, --per-device for a codex ChatGPT-plan account).`);
268
+ throw new Error(`${agent}'s worker credential is an ${env} API key. Pass --api-key <key> (or --no-worker-token to skip, or --per-device for a subscription seat when the harness has one).`);
264
269
  }
265
270
  const entered = await runners.promptApiKey(agent, env);
266
271
  if (entered === null) {
@@ -290,8 +295,8 @@ export async function runAdd(agent, name, opts, runners) {
290
295
  // before the lock, slot, install, or browser — no side effects on a worker.
291
296
  assertAddAllowedOnThisDevice(agent, name);
292
297
  const kinds = harnessWorkerKinds(agent);
293
- if (opts.perDevice && !kinds.some((k) => k.startsWith('per-device'))) {
294
- throw new Error(`--per-device is only valid for a harness with a per-device worker path (codex); ${agent} is provisioned from ${workerProvisioningHint(agent)}.`);
298
+ if (opts.perDevice && !hasPerDeviceWorkerKind(agent)) {
299
+ throw new Error(perDeviceRefusal(agent));
295
300
  }
296
301
  // The ambient-token refusal is checked up front (not at the mint step) so the
297
302
  // user doesn't complete a browser login only to be refused at the end.
@@ -410,13 +415,18 @@ async function _runAddLocked(agent, name, opts, lock, runners) {
410
415
  * Drive one `agents accounts login <harness>#<name>`: re-auth into the SAME
411
416
  * slot (re-running add's steps 4–8), re-minting the worker credential and
412
417
  * re-syncing. On a per-device harness (worker `none`) any box may run it —
413
- * that IS how such a box logs in.
418
+ * that IS how such a box logs in. `--per-device` extends that to a dual-path
419
+ * harness (codex, grok): this box signs the account's subscription seat in for
420
+ * itself through the device-code flow instead of using a portable key.
414
421
  */
415
422
  export async function runLogin(agent, name, opts, runners) {
416
423
  // A harness with a portable worker credential authenticates workers from that
417
424
  // credential, so the interactive re-login stays headed-only. A per-device
418
- // harness (kimi) is logged in per box BY DESIGN — any role may run this.
419
- if (hasPortableWorkerKind(agent))
425
+ // harness (kimi), or a per-device login of a dual-path harness, is logged in
426
+ // per box BY DESIGN — any role may run it.
427
+ if (opts.perDevice && !hasPerDeviceWorkerKind(agent))
428
+ throw new Error(perDeviceRefusal(agent));
429
+ if (hasPortableWorkerKind(agent) && !opts.perDevice)
420
430
  assertAddAllowedOnThisDevice(agent, name);
421
431
  if (!opts.noWorkerToken && harnessWorkerKinds(agent).includes('setup-token')) {
422
432
  const refusal = ambientTokenRefusal(agent, opts.env ?? process.env);
@@ -437,6 +447,7 @@ export async function runLogin(agent, name, opts, runners) {
437
447
  async function _runLoginLocked(agent, name, opts, lock, runners) {
438
448
  const run = runners ?? await defaultAddRunners();
439
449
  lock.assertHeld();
450
+ const perDevice = !!opts.perDevice || !hasPortableWorkerKind(agent);
440
451
  const meta = readMeta();
441
452
  const account = findAddAccount(agent, name, meta);
442
453
  if (!account) {
@@ -452,7 +463,7 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
452
463
  const slot = ensureSlot(agent, account.id);
453
464
  recordSlot(account.id, {
454
465
  ...slot,
455
- authMode: hasPortableWorkerKind(agent) ? 'native' : 'per-device',
466
+ authMode: perDevice ? 'per-device' : 'native',
456
467
  });
457
468
  }
458
469
  // Pre-launch guard: refuse to launch a login into a slot signed in as a
@@ -481,8 +492,8 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
481
492
  const warnings = [];
482
493
  let outcome = 'per-device';
483
494
  let ref;
484
- let provisioning = account.provisioning ?? (hasPortableWorkerKind(agent) ? 'portable' : 'per-device');
485
- if (hasPortableWorkerKind(agent)) {
495
+ let provisioning = perDevice ? 'per-device' : (account.provisioning ?? 'portable');
496
+ if (!perDevice) {
486
497
  if (opts.apiKey && workerApiKeyEnv(agent)) {
487
498
  // Explicit rotation of the stored API key.
488
499
  const minted = await mintWorkerCredential(agent, account, home, opts, run, warnings);
@@ -520,7 +531,7 @@ async function _runLoginLocked(agent, name, opts, lock, runners) {
520
531
  recordSlot(account.id, {
521
532
  accountId: account.id,
522
533
  slotDir: home,
523
- authMode: hasPortableWorkerKind(agent) ? 'native' : 'per-device',
534
+ authMode: perDevice ? 'per-device' : 'native',
524
535
  verdict: 'live',
525
536
  checkedAt: new Date().toISOString(),
526
537
  });
@@ -269,6 +269,7 @@ export const AGENTS = {
269
269
  variableSyntax: '$ARGUMENTS',
270
270
  supportsHooks: true,
271
271
  nativePluginSkills: true,
272
+ ownedSkillDirs: ['synced'],
272
273
  // Claude Code grew a native `claude --cloud "<prompt>"` (Anthropic-managed
273
274
  // infra, claude.ai/code; requires claude.ai subscription auth). Routing
274
275
  // still goes to Rush Cloud deliberately — it keeps cloud tasks in one
@@ -103,5 +103,11 @@ export declare function isClaudeWorkerHomeSeeded(home: string): boolean;
103
103
  * at it, so nothing else can complete Claude Code's first-run onboarding (theme
104
104
  * picker, "Let's get started"); without the flag every slot launch re-onboarded.
105
105
  * Everything else in the document is preserved.
106
+ *
107
+ * Without an email only the onboarding flag is seeded: a provider (setup-token)
108
+ * launch runs in the shared version home, whose identity belongs to whatever
109
+ * native login lives there, so it must not be rewritten. A document already
110
+ * carrying the requested state is left untouched, so a launch never rewrites a
111
+ * config Claude Code is using.
106
112
  */
107
- export declare function seedClaudeWorkerHomeIdentity(versionHome: string, email: string): void;
113
+ export declare function seedClaudeWorkerHomeIdentity(versionHome: string, email?: string): void;
@@ -393,15 +393,34 @@ export function isClaudeWorkerHomeSeeded(home) {
393
393
  * at it, so nothing else can complete Claude Code's first-run onboarding (theme
394
394
  * picker, "Let's get started"); without the flag every slot launch re-onboarded.
395
395
  * Everything else in the document is preserved.
396
+ *
397
+ * Without an email only the onboarding flag is seeded: a provider (setup-token)
398
+ * launch runs in the shared version home, whose identity belongs to whatever
399
+ * native login lives there, so it must not be rewritten. A document already
400
+ * carrying the requested state is left untouched, so a launch never rewrites a
401
+ * config Claude Code is using.
396
402
  */
397
403
  export function seedClaudeWorkerHomeIdentity(versionHome, email) {
398
- const trimmed = email.trim();
399
- if (!trimmed)
400
- return;
404
+ const trimmed = email?.trim() || undefined;
405
+ // A version home usually links `.claude/.claude.json -> ../.claude.json`. The
406
+ // temp-write + rename below would replace that link with a regular file and
407
+ // split one config into two, so write through a link to the file it names.
408
+ const targets = new Set();
401
409
  for (const p of [
402
410
  path.join(versionHome, '.claude', '.claude.json'),
403
411
  path.join(versionHome, '.claude.json'),
404
412
  ]) {
413
+ let target = p;
414
+ try {
415
+ if (fs.lstatSync(p).isSymbolicLink())
416
+ target = path.resolve(path.dirname(p), fs.readlinkSync(p));
417
+ }
418
+ catch {
419
+ // Missing: written fresh below.
420
+ }
421
+ targets.add(target);
422
+ }
423
+ for (const p of targets) {
405
424
  let doc = {};
406
425
  try {
407
426
  doc = JSON.parse(fs.readFileSync(p, 'utf-8'));
@@ -416,7 +435,10 @@ export function seedClaudeWorkerHomeIdentity(versionHome, email) {
416
435
  const existing = (doc.oauthAccount && typeof doc.oauthAccount === 'object'
417
436
  ? doc.oauthAccount
418
437
  : {});
419
- doc.oauthAccount = { ...existing, emailAddress: trimmed };
438
+ if (doc.hasCompletedOnboarding === true && (!trimmed || existing.emailAddress === trimmed))
439
+ continue;
440
+ if (trimmed)
441
+ doc.oauthAccount = { ...existing, emailAddress: trimmed };
420
442
  doc.hasCompletedOnboarding = true;
421
443
  fs.mkdirSync(path.dirname(p), { recursive: true });
422
444
  // Temp-write + rename: a reader mid-write never sees a truncated doc.
package/dist/lib/exec.js CHANGED
@@ -33,12 +33,12 @@ import { recordRunName } from './session/run-names.js';
33
33
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
34
34
  import { composeWin32CommandLine } from './platform/index.js';
35
35
  import { isTmuxInstalled } from './tmux/binary.js';
36
- import { isTmuxEnabled, selfConfiguredDeviceRole } from './device-config.js';
36
+ import { isHeadedDeviceRole, isTmuxEnabled, selfConfiguredDeviceRole } from './device-config.js';
37
37
  import { machineId } from './machine-id.js';
38
38
  import { shellQuote } from './ssh-exec.js';
39
39
  import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
40
40
  import { probeUnprivilegedUserns } from './linux-userns.js';
41
- import { resolveClaudeSetupToken } from './claude-account-token.js';
41
+ import { resolveClaudeSetupToken, seedClaudeWorkerHomeIdentity } from './claude-account-token.js';
42
42
  import { applyAddDirs } from './add-dir.js';
43
43
  import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
44
44
  import { applySystemResourcesAtRun } from './system-run-sync.js';
@@ -1932,6 +1932,14 @@ async function spawnAgentLeased(options) {
1932
1932
  process.stderr.write(`\x1b[31m${loginTrap}\x1b[0m\n`);
1933
1933
  return { exitCode: 1, stdout: '', stderr: loginTrap };
1934
1934
  }
1935
+ // A credentialed run on a worker never needs Claude Code's first-run
1936
+ // onboarding, and in a provider (setup-token) launch's shared version home
1937
+ // nothing else completes it: the tab opened on the theme picker instead of
1938
+ // the agent (yosemite-m1, 2026-09-28). A headed device keeps its own
1939
+ // onboarding; the person at it answers it.
1940
+ if (hasWorkerCredential && versionHome && !isHeadedDeviceRole(selfConfiguredDeviceRole())) {
1941
+ seedClaudeWorkerHomeIdentity(versionHome);
1942
+ }
1935
1943
  }
1936
1944
  // Budget live kill-switch (issue #346). For headless runs we incrementally
1937
1945
  // parse stream-json usage off stdout, accumulate cost, and kill the child the
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * This helper:
9
9
  * 1. Collects GitHub slugs from `~/.agents/projects/*.yaml` (`repo` / `repos[].slug`).
10
- * 2. Canonicalizes each slug (`gh repo view` — `phnx-labs/agents-cli` lists
10
+ * 2. Canonicalizes each slug (REST `repos/{slug}` — `phnx-labs/agents-cli` lists
11
11
  * nothing; the live name is `phnx-labs/agi-cli`).
12
12
  * 3. Runs `gh pr list --repo <slug> --author @me` (cwd-independent).
13
13
  * 4. Keeps CI-green PRs with a merge-guard verdict (formal APPROVED review
@@ -27,7 +27,9 @@ export declare function projectRepoSlugs(defs: readonly ProjectDef[]): string[];
27
27
  /**
28
28
  * Resolve a slug to GitHub's current `nameWithOwner`. A renamed repo
29
29
  * (`phnx-labs/agents-cli` → `phnx-labs/agi-cli`) lists zero PRs under the old
30
- * name; `gh repo view` returns the live one. On error, keep the input slug.
30
+ * name; the REST repo read follows the rename redirect and returns the live one.
31
+ * REST + a 24h gh cache: a rename is rare, and every `projects prs` refresh asks.
32
+ * On error, keep the input slug.
31
33
  */
32
34
  export declare function canonicalizeRepo(slug: string, gh: GhExec): Promise<string>;
33
35
  interface ListedPr {
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * This helper:
9
9
  * 1. Collects GitHub slugs from `~/.agents/projects/*.yaml` (`repo` / `repos[].slug`).
10
- * 2. Canonicalizes each slug (`gh repo view` — `phnx-labs/agents-cli` lists
10
+ * 2. Canonicalizes each slug (REST `repos/{slug}` — `phnx-labs/agents-cli` lists
11
11
  * nothing; the live name is `phnx-labs/agi-cli`).
12
12
  * 3. Runs `gh pr list --repo <slug> --author @me` (cwd-independent).
13
13
  * 4. Keeps CI-green PRs with a merge-guard verdict (formal APPROVED review
@@ -65,12 +65,14 @@ export function projectRepoSlugs(defs) {
65
65
  /**
66
66
  * Resolve a slug to GitHub's current `nameWithOwner`. A renamed repo
67
67
  * (`phnx-labs/agents-cli` → `phnx-labs/agi-cli`) lists zero PRs under the old
68
- * name; `gh repo view` returns the live one. On error, keep the input slug.
68
+ * name; the REST repo read follows the rename redirect and returns the live one.
69
+ * REST + a 24h gh cache: a rename is rare, and every `projects prs` refresh asks.
70
+ * On error, keep the input slug.
69
71
  */
70
72
  export async function canonicalizeRepo(slug, gh) {
71
73
  try {
72
74
  const out = (await gh([
73
- 'repo', 'view', slug, '--json', 'nameWithOwner', '--jq', '.nameWithOwner',
75
+ 'api', `repos/${slug}`, '--cache', '24h', '--jq', '.full_name',
74
76
  ])).trim();
75
77
  return out || slug;
76
78
  }