@phnx-labs/agents-cli 1.22.66 → 1.22.69

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.
Files changed (161) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +21 -8
  3. package/dist/bootstrap.js +6 -3
  4. package/dist/commands/browser.js +46 -21
  5. package/dist/commands/daemon-test-harness.d.ts +1 -1
  6. package/dist/commands/daemon-test-harness.js +2 -2
  7. package/dist/commands/daemon.js +21 -22
  8. package/dist/commands/exec.js +50 -0
  9. package/dist/commands/feed.js +20 -7
  10. package/dist/commands/monitors.js +5 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/repo.js +57 -19
  14. package/dist/commands/resume.d.ts +16 -0
  15. package/dist/commands/resume.js +41 -8
  16. package/dist/commands/routines.js +42 -21
  17. package/dist/commands/send.js +29 -2
  18. package/dist/commands/sessions-inject.d.ts +58 -0
  19. package/dist/commands/sessions-inject.js +143 -7
  20. package/dist/commands/sessions-optimize.js +1 -1
  21. package/dist/commands/sessions-picker.js +1 -0
  22. package/dist/commands/sessions.js +4 -11
  23. package/dist/commands/share.d.ts +5 -3
  24. package/dist/commands/share.js +73 -20
  25. package/dist/commands/ssh.js +205 -2
  26. package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
  27. package/dist/lib/accounting/account-pool-collect.js +6 -4
  28. package/dist/lib/accounting/usage-ingest.js +4 -2
  29. package/dist/lib/accounting/usage-sync.d.ts +38 -93
  30. package/dist/lib/accounting/usage-sync.js +66 -210
  31. package/dist/lib/accounting/usage.d.ts +18 -5
  32. package/dist/lib/accounting/usage.js +165 -20
  33. package/dist/lib/auth-health.d.ts +8 -0
  34. package/dist/lib/auth-health.js +4 -4
  35. package/dist/lib/boot-profile.d.ts +14 -0
  36. package/dist/lib/boot-profile.js +66 -0
  37. package/dist/lib/browser/caller-identity.d.ts +12 -0
  38. package/dist/lib/browser/caller-identity.js +19 -0
  39. package/dist/lib/browser/ipc.d.ts +37 -32
  40. package/dist/lib/browser/ipc.js +146 -94
  41. package/dist/lib/browser/task-index.d.ts +10 -2
  42. package/dist/lib/browser/task-index.js +22 -3
  43. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  44. package/dist/lib/channels/providers/desktop.js +5 -4
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
  47. package/dist/lib/daemon/account-state-daemon-service.js +81 -18
  48. package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
  49. package/dist/lib/daemon/auth-sync-service.js +17 -6
  50. package/dist/lib/daemon/catchup-service.d.ts +51 -0
  51. package/dist/lib/daemon/catchup-service.js +51 -0
  52. package/dist/lib/daemon/daemon.d.ts +12 -22
  53. package/dist/lib/daemon/daemon.js +463 -176
  54. package/dist/lib/daemon/runner.js +2 -0
  55. package/dist/lib/daemon/service.d.ts +22 -4
  56. package/dist/lib/daemon/service.js +2 -2
  57. package/dist/lib/daemon/supervisor.d.ts +55 -15
  58. package/dist/lib/daemon/supervisor.js +119 -29
  59. package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
  60. package/dist/lib/daemon/usage-sync-service.js +22 -18
  61. package/dist/lib/daemon-health.js +36 -31
  62. package/dist/lib/daemon-services.d.ts +1 -1
  63. package/dist/lib/daemon-services.js +12 -2
  64. package/dist/lib/daemon-ticks.d.ts +9 -6
  65. package/dist/lib/daemon-ticks.js +14 -8
  66. package/dist/lib/devices/health.d.ts +38 -2
  67. package/dist/lib/devices/health.js +43 -5
  68. package/dist/lib/devices/registry.js +2 -0
  69. package/dist/lib/devices/worker-pick.d.ts +1 -1
  70. package/dist/lib/devices/worker-pick.js +4 -1
  71. package/dist/lib/exec.js +15 -0
  72. package/dist/lib/feed/watch.d.ts +3 -0
  73. package/dist/lib/feed/watch.js +13 -3
  74. package/dist/lib/feed-broadcast.d.ts +64 -5
  75. package/dist/lib/feed-broadcast.js +124 -22
  76. package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
  77. package/dist/lib/fleet-shared-repo-sync.js +333 -0
  78. package/dist/lib/fleet-shared-state.d.ts +38 -0
  79. package/dist/lib/fleet-shared-state.js +105 -0
  80. package/dist/lib/hosts/remote-cmd.d.ts +2 -0
  81. package/dist/lib/hosts/remote-cmd.js +12 -3
  82. package/dist/lib/lock-compromise.d.ts +8 -0
  83. package/dist/lib/lock-compromise.js +12 -0
  84. package/dist/lib/monitors/engine.d.ts +2 -1
  85. package/dist/lib/monitors/engine.js +27 -2
  86. package/dist/lib/monitors/sources/command.js +13 -3
  87. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  88. package/dist/lib/monitors/sources/failure.js +52 -0
  89. package/dist/lib/monitors/sources/types.d.ts +9 -0
  90. package/dist/lib/owner-message.d.ts +12 -0
  91. package/dist/lib/owner-message.js +44 -0
  92. package/dist/lib/refresh-coordinator.js +2 -0
  93. package/dist/lib/run-trace-sync.d.ts +28 -0
  94. package/dist/lib/run-trace-sync.js +99 -0
  95. package/dist/lib/secrets/filestore.d.ts +4 -0
  96. package/dist/lib/secrets/filestore.js +164 -3
  97. package/dist/lib/secrets/push.d.ts +10 -0
  98. package/dist/lib/secrets/push.js +86 -7
  99. package/dist/lib/secrets/remote.d.ts +18 -6
  100. package/dist/lib/secrets/remote.js +29 -4
  101. package/dist/lib/secrets/reserved-sync.d.ts +28 -27
  102. package/dist/lib/secrets/reserved-sync.js +119 -101
  103. package/dist/lib/session/active.d.ts +13 -1
  104. package/dist/lib/session/active.js +5 -0
  105. package/dist/lib/session/actor-sidecar.d.ts +12 -0
  106. package/dist/lib/session/actor-sidecar.js +2 -0
  107. package/dist/lib/session/db.d.ts +16 -2
  108. package/dist/lib/session/db.js +73 -22
  109. package/dist/lib/session/discover.d.ts +12 -3
  110. package/dist/lib/session/discover.js +187 -26
  111. package/dist/lib/session/linear.d.ts +13 -0
  112. package/dist/lib/session/linear.js +44 -0
  113. package/dist/lib/session/live-metadata.js +1 -0
  114. package/dist/lib/session/parse.js +2 -3
  115. package/dist/lib/session/prompt.d.ts +17 -0
  116. package/dist/lib/session/prompt.js +35 -0
  117. package/dist/lib/session/recovery.d.ts +43 -6
  118. package/dist/lib/session/recovery.js +80 -10
  119. package/dist/lib/session/remote/remote-list.d.ts +17 -1
  120. package/dist/lib/session/remote/remote-list.js +29 -4
  121. package/dist/lib/session/remote/watch.d.ts +25 -2
  122. package/dist/lib/session/remote/watch.js +188 -11
  123. package/dist/lib/session/session-cache.d.ts +2 -1
  124. package/dist/lib/session/session-cache.js +1 -0
  125. package/dist/lib/session/state.js +11 -13
  126. package/dist/lib/session/types.d.ts +2 -0
  127. package/dist/lib/share/backend.d.ts +2 -2
  128. package/dist/lib/share/backend.js +20 -9
  129. package/dist/lib/share/delete.d.ts +5 -1
  130. package/dist/lib/share/delete.js +7 -2
  131. package/dist/lib/share/http-error.d.ts +52 -0
  132. package/dist/lib/share/http-error.js +65 -0
  133. package/dist/lib/share/publish.d.ts +67 -11
  134. package/dist/lib/share/publish.js +98 -16
  135. package/dist/lib/share/worker-template.js +105 -9
  136. package/dist/lib/smart-launch.js +27 -4
  137. package/dist/lib/ssh-exec.d.ts +2 -0
  138. package/dist/lib/ssh-exec.js +20 -4
  139. package/dist/lib/storage/index.d.ts +14 -0
  140. package/dist/lib/storage/index.js +14 -0
  141. package/dist/lib/storage/selection.d.ts +48 -0
  142. package/dist/lib/storage/selection.js +39 -0
  143. package/dist/lib/storage/visibility.d.ts +82 -0
  144. package/dist/lib/storage/visibility.js +99 -0
  145. package/dist/lib/teams/agents.js +3 -1
  146. package/dist/lib/teams/placement-probe.js +1 -0
  147. package/dist/lib/teams/registry.js +2 -0
  148. package/dist/lib/teams/scheduler.d.ts +8 -1
  149. package/dist/lib/teams/scheduler.js +4 -1
  150. package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
  151. package/dist/lib/testdata/daemon-health-writer.js +8 -0
  152. package/dist/lib/traces/backend.js +13 -2
  153. package/dist/lib/traces/sync.d.ts +7 -0
  154. package/dist/lib/traces/sync.js +9 -0
  155. package/dist/lib/usage-refresh.d.ts +8 -2
  156. package/dist/lib/usage-refresh.js +3 -3
  157. package/dist/lib/worktree/held.d.ts +166 -0
  158. package/dist/lib/worktree/held.js +368 -0
  159. package/package.json +2 -2
  160. package/dist/lib/account-state-service.d.ts +0 -21
  161. package/dist/lib/account-state-service.js +0 -60
@@ -27,6 +27,7 @@ import { randomUUID } from 'crypto';
27
27
  import { isSessionTrackedAgent } from '../lib/session/types.js';
28
28
  import { applyActiveRulesPresetAtRun } from '../lib/rules/run-sync.js';
29
29
  import { handleBroadcast } from './run-broadcast.js';
30
+ import { bootMark } from '../lib/boot-profile.js';
30
31
  /** Distinguish a terminal account-picker marker from an explicit @version pin. */
31
32
  export function parseRunAccountPickerRequest(agentSpec) {
32
33
  const requested = agentSpec.endsWith('@');
@@ -516,6 +517,7 @@ export function registerRunCommand(program) {
516
517
  .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
517
518
  .option('--name <slug>', 'Name the run — seeds the session label so it shows up as `<name>` in `agents sessions` and resolves by it (and `agents hosts logs <name>` for --device runs) instead of an opaque id. An agent-generated title later refines the label; your name shows until then. Optional.')
518
519
  .option('--notify', 'Post a desktop notification when a headless run finishes. Fired by this process on exit, so it survives whatever launched the run (the menu bar dispatching it, a terminal you closed).')
520
+ .option('--no-trace-sync', 'Skip the run-exit trace auto-sync for this run. Auto-sync fires by default only for local runs and only once you have run `agents traces sync` at least once (also silenced by AGENTS_NO_TRACE_SYNC=1).')
519
521
  .option('--terminal [backend]', "Open this run in a real terminal tab instead of here. Without a value the terminal is detected from your live sessions (`agents sessions --active` host), so it lands where you already work — Ghostty for a Ghostty user, iTerm for an iTerm user. Name one to force it: iterm | ghostty | terminal | tmux | vscodium-agent. This is how the menu bar's New Session opens.")
520
522
  .option('--verbose', 'Show detailed execution logs')
521
523
  .option('--raw', 'Keep this interactive run direct when the device has opted into tmux wrapping. A no-op under the default tmux-off configuration; equivalent to AGENTS_NO_TMUX=1.')
@@ -689,6 +691,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
689
691
  `,
690
692
  });
691
693
  runCmd.action(async (agentSpec, prompt, options, command) => {
694
+ bootMark('run-action:enter');
692
695
  // Capture everything after -- as passthrough args forwarded verbatim to the
693
696
  // underlying CLI. Commander strips the literal `--` and folds what follows
694
697
  // into the positional operands (so `agents run codex -- --yolo` would parse
@@ -1340,6 +1343,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1340
1343
  // --device/--on/--computer: offload this run onto a registered agent host
1341
1344
  // over SSH instead of running locally. The three flags are aliases.
1342
1345
  const hostGiven = hostTargetGiven(options);
1346
+ // Auto-sync this run's redacted trajectory when it exits, so the console is
1347
+ // never stale and a session link/QR resolves the moment the run finishes
1348
+ // (PHNX-3628). Armed HERE — after hostGiven is finalized — so every
1349
+ // remote-placement signal is already reflected in options: --device/--on/
1350
+ // --computer and --where device: (expanded to options.host above), AND the
1351
+ // --resume-to-peer redirect that sets options.host during resume
1352
+ // resolution. A run that will dispatch remotely (hostGiven non-empty, or
1353
+ // --lease/--box) has its trajectory on that box and never arms a local
1354
+ // sync; --cloud returned earlier. The module additionally gates on opt-in
1355
+ // (signed in + already synced once) and is best-effort.
1356
+ if (prompt !== undefined &&
1357
+ hostGiven.length === 0 &&
1358
+ !options.lease &&
1359
+ !options.box) {
1360
+ const { armRunFinishTraceSync } = await import('../lib/run-trace-sync.js');
1361
+ armRunFinishTraceSync({ disabled: options.traceSync === false });
1362
+ }
1343
1363
  // --project <slug>[@worktree]: resolve the projects-root shorthand into a
1344
1364
  // cwd. On a host run it resolves home-relative (`~/…`, so the host expands
1345
1365
  // it); locally it becomes an absolute path. It owns the working directory,
@@ -1788,6 +1808,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1788
1808
  import('../lib/capabilities.js'),
1789
1809
  import('../lib/share/config.js'),
1790
1810
  ]);
1811
+ bootMark('run-deps:imported');
1791
1812
  const isValidAgent = (agent) => ALL_AGENT_IDS.includes(agent);
1792
1813
  // Parse agent@version#label. The label selects native auth without pinning
1793
1814
  // the binary version; --account remains the equivalent flag form.
@@ -2327,6 +2348,25 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2327
2348
  process.exit(1);
2328
2349
  }
2329
2350
  version = resolvedRecoveryTarget.version;
2351
+ // Account rotation (PHNX-3626 / PHNX-3674): recovery picked a healthy
2352
+ // provider of the SAME harness. Inject that credential through the
2353
+ // `--account` path so spawn does not authenticate as the version home's
2354
+ // native login (the exhausted origin when the continue pick is a
2355
+ // provider). An explicit --account (configuredAccount) always wins.
2356
+ const rotatedAccount = resolvedRecoveryTarget.account;
2357
+ if (rotatedAccount && !configuredAccount) {
2358
+ try {
2359
+ const picked = resolveSpawnAccount(rotatedAccount.providerAccount, agent, version, readMeta(), { useDefault: false });
2360
+ if (picked?.kind === 'provider')
2361
+ accountEnv = picked.env;
2362
+ }
2363
+ catch {
2364
+ // Account-registry errors can carry provider credential material;
2365
+ // never relay them onto the terminal from this automatic path.
2366
+ console.error(chalk.red('Could not prepare the rotated provider account for session recovery.'));
2367
+ process.exit(1);
2368
+ }
2369
+ }
2330
2370
  if (resolvedRecoveryTarget.mode === 'native') {
2331
2371
  version = session.version;
2332
2372
  resumeNative = true;
@@ -2337,6 +2377,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2337
2377
  // differ from the earlier cwd that selected projects/<cwd-key>.
2338
2378
  if (!options.cwd && resolvedRecoveryTarget.cwd)
2339
2379
  options.cwd = resolvedRecoveryTarget.cwd;
2380
+ if (rotatedAccount && !configuredAccount && !options.quiet) {
2381
+ process.stderr.write(chalk.gray(`[agents] origin ${agent} account limited → rotated to ${rotatedAccount.label} (native resume)\n`));
2382
+ }
2340
2383
  if (!options.quiet)
2341
2384
  process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''} in ${options.cwd ?? cwd}\n`));
2342
2385
  }
@@ -2346,6 +2389,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2346
2389
  prompt = buildContinuePrompt(session.id, prompt);
2347
2390
  if (prompt.trim() === `/continue ${session.id}`)
2348
2391
  forceInteractive = true;
2392
+ if (rotatedAccount && !configuredAccount && !options.quiet) {
2393
+ process.stderr.write(chalk.gray(`[agents] origin ${agent} account limited → rotated to ${rotatedAccount.label} (/continue)\n`));
2394
+ }
2349
2395
  if (!options.quiet)
2350
2396
  process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)${version ? ` @${version}` : ''}\n`));
2351
2397
  }
@@ -2403,7 +2449,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2403
2449
  // ones sitting in a version home (RUSH-3182). Run-path only — the
2404
2450
  // picker's other callers keep the native-only collector.
2405
2451
  const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2452
+ bootMark('resolve-version:start');
2406
2453
  const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun);
2454
+ bootMark('resolve-version:done');
2407
2455
  if (resolved.exhausted) {
2408
2456
  // Zero healthy accounts splits two ways, and conflating them is what
2409
2457
  // stranded a logged-out harness with no way in at all (RUSH-2334):
@@ -2570,6 +2618,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2570
2618
  version = healed;
2571
2619
  }
2572
2620
  }
2621
+ bootMark('ensure-runnable:done');
2573
2622
  // The harness may simply not be on this machine. The self-heal above only
2574
2623
  // runs when a managed version resolved, so with nothing installed we used
2575
2624
  // to fall through and spawn the bare `cliCommand`, which dies as
@@ -2606,6 +2655,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2606
2655
  if (defaultVersion) {
2607
2656
  applyActiveRulesPresetAtRun(agent, defaultVersion, getVersionHomePath(agent, defaultVersion));
2608
2657
  }
2658
+ bootMark('rules-sync:done');
2609
2659
  // Login preflight (advisory, warn + continue). On a local INTERACTIVE
2610
2660
  // launch, probe whether this agent's account has a credential and print a
2611
2661
  // one-line warning if it looks logged out — so you find out BEFORE the TUI
@@ -5,7 +5,8 @@ import { projectKeyFromCwd } from '../lib/project-key.js';
5
5
  import { postFeedStatus } from '../lib/feed-post.js';
6
6
  import { linearIssueUrl } from '../lib/session/linear.js';
7
7
  import { parseFeedPostLevel, planFeedBroadcast, runFeedBroadcast, effectiveBroadcastConfig, withDesktopNotify, blockBroadcastContext, blockDeliveryFailure, } from '../lib/feed-broadcast.js';
8
- import { getSessionById } from '../lib/session/db.js';
8
+ import { getSessionById, resolveFullSessionId } from '../lib/session/db.js';
9
+ import { fireTraceSyncInBackground } from '../lib/run-trace-sync.js';
9
10
  import { readMeta } from '../lib/state.js';
10
11
  import { enrichBlocksFromSessions, groupBlocksByOutcome, isUnambiguousOutcomeAnswer, openBlocksForOutcome, stampBlockOutcomes, } from '../lib/feed-outcome.js';
11
12
  import { classifyBlock, filterBlocksForFeed, suppressionDigest, } from '../lib/ask-classifier.js';
@@ -692,7 +693,10 @@ async function broadcastPostedEvent(event, level, meta, notify = false) {
692
693
  const config = withDesktopNotify(effectiveBroadcastConfig(meta.feed?.broadcast, level, meta), notify);
693
694
  if (!config)
694
695
  return [];
695
- const ticket = getSessionById(event.sessionId)?.ticketId;
696
+ // Upgrade an 8-char footer crumb to the full indexed id so the console session
697
+ // URL resolves instead of 404ing, and so the ticket join hits the right row.
698
+ const session = resolveFullSessionId(event.sessionId) ?? event.sessionId;
699
+ const ticket = getSessionById(session)?.ticketId;
696
700
  const planned = planFeedBroadcast(config, {
697
701
  title: event.title,
698
702
  text: event.detail ?? '',
@@ -702,11 +706,16 @@ async function broadcastPostedEvent(event, level, meta, notify = false) {
702
706
  project: event.project,
703
707
  agent: event.agent,
704
708
  host: event.host,
705
- session: event.sessionId,
709
+ session,
706
710
  links: (event.attachments ?? [])
707
711
  .map((a) => a.href)
708
712
  .filter((href) => /^https?:\/\//i.test(href)),
709
- });
713
+ }, meta);
714
+ // An important post links the session's console page; fire the trace sync now
715
+ // so that page exists when the owner taps it (trace sync otherwise waits for
716
+ // run exit — PHNX-3628/PHNX-3698). Gated + best-effort, never blocks the post.
717
+ if (level === 'important')
718
+ fireTraceSyncInBackground();
710
719
  return runFeedBroadcast(planned, meta);
711
720
  }
712
721
  /**
@@ -722,9 +731,13 @@ async function broadcastBlock(block, extras, meta, notify = false) {
722
731
  const config = withDesktopNotify(effectiveBroadcastConfig(meta.feed?.broadcast, 'important', meta), notify);
723
732
  if (!config)
724
733
  return [];
725
- const ticket = getSessionById(block.sessionId)?.ticketId;
726
- const ctx = blockBroadcastContext({ ...block, ticket: block.ticket ?? ticket }, extras);
727
- return runFeedBroadcast(planFeedBroadcast(config, ctx), meta);
734
+ const sessionId = resolveFullSessionId(block.sessionId) ?? block.sessionId;
735
+ const ticket = getSessionById(sessionId)?.ticketId;
736
+ const ctx = blockBroadcastContext({ ...block, sessionId, ticket: block.ticket ?? ticket }, extras);
737
+ // A block is always important and links the session's console page — fire the
738
+ // trace sync so the page exists when tapped (best-effort, gated, non-blocking).
739
+ fireTraceSyncInBackground();
740
+ return runFeedBroadcast(planFeedBroadcast(config, ctx, meta), meta);
728
741
  }
729
742
  /** One line per sink that ran. Silent when nothing is configured. */
730
743
  function reportBroadcast(outcomes) {
@@ -248,10 +248,10 @@ function ensureDaemonRunning() {
248
248
  const result = startDaemon();
249
249
  if (result.pid) {
250
250
  console.log(chalk.green(`Daemon started (PID: ${result.pid}). It will watch monitors in the background.`));
251
- console.log(chalk.gray('Stop anytime with: agents routines stop'));
251
+ console.log(chalk.gray('Disable only monitor polling with: agents daemon services disable monitors'));
252
252
  }
253
253
  else {
254
- stderrLine(chalk.yellow('Could not start the daemon. Start it manually with: agents routines start'));
254
+ stderrLine(chalk.yellow('Could not start the daemon. Start it manually with: agents daemon start'));
255
255
  }
256
256
  return true;
257
257
  }
@@ -947,6 +947,9 @@ export function registerMonitorsCommands(program) {
947
947
  console.log(observation.raw.split('\n').slice(0, 20).map((l) => ` ${l}`).join('\n'));
948
948
  if (observation.meta)
949
949
  console.log(chalk.gray(` meta: ${JSON.stringify(observation.meta)}`));
950
+ if (observation.failed) {
951
+ console.log(chalk.yellow(` poll failed (${observation.failureReason ?? 'observation failure'}) — not a value change; skipped`));
952
+ }
950
953
  console.log('');
951
954
  console.log(`Would fire: ${wouldFire ? chalk.green('yes') : chalk.gray('no')}`);
952
955
  if (decision?.event) {
@@ -13,13 +13,33 @@ import { type LinearMilestone } from '../lib/linear-project-counts.js';
13
13
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
14
14
  export declare const PROJECTS_NO_FANOUT_ENV = "AGENTS_PROJECTS_LOCAL";
15
15
  /**
16
- * Render `agents projects for-cwd`'s output for a resolved (or absent)
17
- * project name — `--json` always prints `{"name": ...}` even on no match, so
18
- * a scripted caller can distinguish "ran and found nothing" from a crash;
19
- * the plain-text form prints nothing on no match. Returns '' when nothing
20
- * should be printed.
16
+ * A path-shaped `agents projects view` argument — `.`, `..`, a `~`-prefixed
17
+ * value, or anything containing a path separator — means "auto-detect the
18
+ * project for this DIRECTORY", not "a project named literally this". A bare
19
+ * token (no separators) stays a project name, exactly as before.
21
20
  */
22
- export declare function formatForCwdOutput(name: string | undefined, json: boolean): string;
21
+ export declare function looksLikePath(token: string): boolean;
22
+ /** Machine-readable shape of `agents projects view <path> --json`. */
23
+ export interface ViewDetection {
24
+ /** Detected project name, or null when no definition contains the path. */
25
+ name: string | null;
26
+ /** The detected project's Linear binding; fields are null when unbound. */
27
+ linear: {
28
+ name: string | null;
29
+ projectId: string | null;
30
+ };
31
+ /** The detected project's repo/monorepo root (home-relative), or null. */
32
+ root: string | null;
33
+ }
34
+ /**
35
+ * Best-effort cwd->project detection for `agents projects view <path>`. Delegates
36
+ * to the shared {@link projectNameForCwd} (def-root containment, longest wins),
37
+ * then reads the matched definition's Linear binding and root so ONE call yields
38
+ * the name AND the Linear projectId. Fail-open: an all-null shape when nothing
39
+ * contains the path — never throws, so a scripted caller stays unscoped rather
40
+ * than crashing. This subsumes the removed `agents projects for-cwd`.
41
+ */
42
+ export declare function detectProjectForPath(cwd: string, defs: ProjectDef[]): ViewDetection;
23
43
  /**
24
44
  * One compact trailing note for peers that didn't answer the `--fleet`
25
45
  * fan-out — unreachable, running an agents-cli too old to carry `projects
@@ -35,16 +35,33 @@ export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
35
35
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
36
36
  const SKIPPED_NAME_LIMIT = 4;
37
37
  /**
38
- * Render `agents projects for-cwd`'s output for a resolved (or absent)
39
- * project name — `--json` always prints `{"name": ...}` even on no match, so
40
- * a scripted caller can distinguish "ran and found nothing" from a crash;
41
- * the plain-text form prints nothing on no match. Returns '' when nothing
42
- * should be printed.
38
+ * A path-shaped `agents projects view` argument — `.`, `..`, a `~`-prefixed
39
+ * value, or anything containing a path separator — means "auto-detect the
40
+ * project for this DIRECTORY", not "a project named literally this". A bare
41
+ * token (no separators) stays a project name, exactly as before.
43
42
  */
44
- export function formatForCwdOutput(name, json) {
45
- if (json)
46
- return JSON.stringify({ name: name ?? null });
47
- return name ?? '';
43
+ export function looksLikePath(token) {
44
+ return token === '.' || token === '..' || token.startsWith('~') || token.includes('/');
45
+ }
46
+ /**
47
+ * Best-effort cwd->project detection for `agents projects view <path>`. Delegates
48
+ * to the shared {@link projectNameForCwd} (def-root containment, longest wins),
49
+ * then reads the matched definition's Linear binding and root so ONE call yields
50
+ * the name AND the Linear projectId. Fail-open: an all-null shape when nothing
51
+ * contains the path — never throws, so a scripted caller stays unscoped rather
52
+ * than crashing. This subsumes the removed `agents projects for-cwd`.
53
+ */
54
+ export function detectProjectForPath(cwd, defs) {
55
+ const name = projectNameForCwd(cwd, defs);
56
+ const def = name ? defs.find((d) => d.name === name) : undefined;
57
+ return {
58
+ name: name ?? null,
59
+ linear: {
60
+ name: def?.linear?.name ?? null,
61
+ projectId: def?.linear?.projectId ?? null,
62
+ },
63
+ root: def?.root ?? null,
64
+ };
48
65
  }
49
66
  /**
50
67
  * One compact trailing note for peers that didn't answer the `--fleet`
@@ -570,17 +587,6 @@ export function registerProjectsCommands(program) {
570
587
  console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))}${agentsSuffix}`);
571
588
  }
572
589
  });
573
- // ---- for-cwd ----
574
- projects
575
- .command('for-cwd [cwd]')
576
- .description('Resolve a directory to its defined project name (root or a repos[].path/subpath match). Defaults to the current directory.')
577
- .option('--json', 'Machine-readable output: {"name": string | null}')
578
- .action((cwdArg, opts) => {
579
- const name = projectNameForCwd(cwdArg ?? process.cwd(), listProjectDefs());
580
- const out = formatForCwdOutput(name, !!opts.json);
581
- if (out)
582
- console.log(out);
583
- });
584
590
  // ---- add ----
585
591
  projects
586
592
  .command('add <name>')
@@ -763,15 +769,42 @@ export function registerProjectsCommands(program) {
763
769
  process.stdout.write(formatFleetSkippedNote(fleetSkipped));
764
770
  }
765
771
  projects
766
- .command('status [name]')
772
+ .command('status [nameOrPath]')
767
773
  .alias('view')
768
- .description('Progress card for every project across the whole fleet, or one named project (alias: view). Named form also prints every milestone and the stored definition.')
774
+ .description('Progress card for every project across the whole fleet, or one named project (alias: view). Named form also prints every milestone and the stored definition. A path argument (., .., a ~-prefixed value, a /-containing value, or --path) auto-detects the project that CONTAINS that directory.')
769
775
  .option('--json', 'Machine-readable output')
776
+ .option('--path [dir]', 'Treat the argument as a DIRECTORY and auto-detect the project that contains it (bare --path uses the cwd). With --json prints {name, linear:{name,projectId}, root}, all-null when nothing matches (fail-open, exit 0).')
770
777
  .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
771
778
  .option('--no-remote', 'Skip the GitHub and Linear lookups; faster, offline')
772
779
  .option('--device <name...>', 'Scope fleet status to one or more devices (repeatable)')
773
780
  .option('--devices <names>', 'Scope fleet status to a comma-separated list of devices')
774
781
  .action(async (name, rawOpts) => {
782
+ // Path mode: an explicit --path, or a path-shaped positional (., .., a
783
+ // ~-prefixed value, or a /-containing value) means "auto-detect the
784
+ // project for this DIRECTORY" rather than "a project named literally
785
+ // this". projectNameForCwd resolves the dir (expandLocalHome +
786
+ // path.resolve), so `.`, `~/…`, and relative paths all normalize. This
787
+ // subsumes the removed `agents projects for-cwd`.
788
+ let detectDir;
789
+ if (typeof rawOpts.path === 'string')
790
+ detectDir = rawOpts.path;
791
+ else if (rawOpts.path === true)
792
+ detectDir = process.cwd();
793
+ else if (name !== undefined && looksLikePath(name))
794
+ detectDir = name;
795
+ if (detectDir !== undefined) {
796
+ const detection = detectProjectForPath(detectDir, listProjectDefs());
797
+ if (rawOpts.json) {
798
+ console.log(JSON.stringify(detection));
799
+ return;
800
+ }
801
+ if (!detection.name) {
802
+ console.log(chalk.gray(`No defined project contains ${detectDir}`));
803
+ return;
804
+ }
805
+ // Non-JSON: fall through and render the full card for the detected project.
806
+ name = detection.name;
807
+ }
775
808
  // Named invocation = `view` depth (all milestones + definition). Unnamed
776
809
  // stays the scannable multi-project rollup. `view` is a commander alias
777
810
  // of this same command, so there is only one implementation.
@@ -57,6 +57,41 @@ function syncMarketplacesForDefaults() {
57
57
  catch { /* best-effort */ }
58
58
  }
59
59
  }
60
+ /** Write this device's account metadata before the user repo is committed. */
61
+ async function publishUserRepoAccountState(provisionAuth) {
62
+ const { publishUsageSnapshotToSharedStore } = await import('../lib/accounting/usage-sync.js');
63
+ const usage = publishUsageSnapshotToSharedStore();
64
+ if (usage.error)
65
+ console.error(chalk.yellow(`Usage snapshot: ${usage.error}`));
66
+ if (provisionAuth) {
67
+ const { syncReservedAuthBundle } = await import('../lib/secrets/reserved-sync.js');
68
+ const auth = await syncReservedAuthBundle();
69
+ if (auth.pushed.length > 0)
70
+ console.log(chalk.gray(`Auth bundle: pushed to ${auth.pushed.join(', ')}`));
71
+ for (const err of auth.errors)
72
+ console.error(chalk.yellow(`Auth bundle: ${err.device}: ${err.message}`));
73
+ return;
74
+ }
75
+ const { publishReservedAuthVerdict } = await import('../lib/secrets/reserved-sync.js');
76
+ const auth = publishReservedAuthVerdict();
77
+ if (auth.error)
78
+ console.error(chalk.yellow(`Auth verdict: ${auth.device}: ${auth.error}`));
79
+ }
80
+ /** Consume freshly pulled usage/auth metadata from the local user-repo checkout. */
81
+ async function consumeUserRepoAccountState() {
82
+ const { consumeUsageSnapshotsFromSharedStore } = await import('../lib/accounting/usage-sync.js');
83
+ const usage = consumeUsageSnapshotsFromSharedStore();
84
+ if (usage.merged > 0)
85
+ console.log(chalk.gray(`Usage snapshot: merged ${usage.merged} row(s) from ${usage.sources.join(', ')}`));
86
+ for (const err of usage.errors)
87
+ console.error(chalk.yellow(`Usage snapshot: ${err.device}: ${err.message}`));
88
+ const { syncReservedAuthBundle } = await import('../lib/secrets/reserved-sync.js');
89
+ const auth = await syncReservedAuthBundle();
90
+ if (auth.pushed.length > 0)
91
+ console.log(chalk.gray(`Auth bundle: pushed to ${auth.pushed.join(', ')}`));
92
+ for (const err of auth.errors)
93
+ console.error(chalk.yellow(`Auth bundle: ${err.device}: ${err.message}`));
94
+ }
60
95
  const ALIAS_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
61
96
  /** Derive a default alias from a source URL (e.g. gh:foo/.agents-work -> agents-work). */
62
97
  function deriveAlias(source) {
@@ -1052,14 +1087,7 @@ export function registerRepoCommands(program) {
1052
1087
  ].filter(Boolean);
1053
1088
  if (parts.length > 0)
1054
1089
  console.log(chalk.gray(`Device policy: ${parts.join(' · ')}`));
1055
- const { syncReservedAuthBundle } = await import('../lib/secrets/reserved-sync.js');
1056
- const auth = syncReservedAuthBundle();
1057
- if (auth.pushed.length > 0) {
1058
- console.log(chalk.gray(`Auth bundle: pushed to ${auth.pushed.join(', ')}`));
1059
- }
1060
- for (const err of auth.errors) {
1061
- console.error(chalk.yellow(`Auth bundle: ${err.device}: ${err.message}`));
1062
- }
1090
+ await consumeUserRepoAccountState();
1063
1091
  }
1064
1092
  });
1065
1093
  repoCmd
@@ -1095,20 +1123,14 @@ export function registerRepoCommands(program) {
1095
1123
  return;
1096
1124
  }
1097
1125
  for (const t of pushable) {
1126
+ // Account snapshots must exist BEFORE commitAndPush or this push ships
1127
+ // the previous tick's state and delays propagation by one repo cycle.
1128
+ if (t.alias === 'user')
1129
+ await publishUserRepoAccountState(true);
1098
1130
  const spinner = ora(`Pushing ${formatRepoTarget(t.alias, t.dir)}...`).start();
1099
1131
  const result = await commitAndPush(t.dir, options.message);
1100
1132
  if (result.success) {
1101
1133
  spinner.succeed(`${formatRepoTarget(t.alias, t.dir, result.branch)}: ${result.detail ?? 'pushed'}`);
1102
- if (t.alias === 'user') {
1103
- const { syncReservedAuthBundle } = await import('../lib/secrets/reserved-sync.js');
1104
- const auth = syncReservedAuthBundle();
1105
- if (auth.pushed.length > 0) {
1106
- console.log(chalk.gray(`Auth bundle: pushed to ${auth.pushed.join(', ')}`));
1107
- }
1108
- for (const err of auth.errors) {
1109
- console.error(chalk.yellow(`Auth bundle: ${err.device}: ${err.message}`));
1110
- }
1111
- }
1112
1134
  }
1113
1135
  else {
1114
1136
  spinner.fail(`${formatRepoTarget(t.alias, t.dir)}: ${result.error}`);
@@ -1173,8 +1195,23 @@ Examples:
1173
1195
  }
1174
1196
  }
1175
1197
  const push = t.alias !== 'system';
1198
+ // syncRepoGit pulls then pushes. Seed our conflict-free owner file first
1199
+ // so the same transaction carries it; consume peers after the pull.
1200
+ if (t.alias === 'user')
1201
+ await publishUserRepoAccountState(false);
1176
1202
  const spinner = ora(`Syncing ${formatRepoTarget(t.alias, t.dir)}...`).start();
1177
- const result = await syncRepoGit(t.dir, { push });
1203
+ const result = t.alias === 'user'
1204
+ ? await (async () => {
1205
+ const { syncFleetSharedStateRepo } = await import('../lib/fleet-shared-repo-sync.js');
1206
+ const synced = await syncFleetSharedStateRepo({ userAgentsDir: t.dir });
1207
+ return {
1208
+ success: synced.success,
1209
+ commit: synced.commit ?? '',
1210
+ pushed: synced.pushed,
1211
+ error: synced.error ?? synced.skipped ?? undefined,
1212
+ };
1213
+ })()
1214
+ : await syncRepoGit(t.dir, { push });
1178
1215
  if (result.success) {
1179
1216
  const pushed = result.pushed ? ' (pushed)' : '';
1180
1217
  spinner.succeed(`${formatRepoTarget(t.alias, t.dir)}: ${result.commit}${pushed}`);
@@ -1184,6 +1221,7 @@ Examples:
1184
1221
  recordUserRepoRemote(t.dir, u);
1185
1222
  const { reconcileDeviceDiscoveryPolicies } = await import('../lib/devices/discovery-policy.js');
1186
1223
  await reconcileDeviceDiscoveryPolicies();
1224
+ await consumeUserRepoAccountState();
1187
1225
  }
1188
1226
  }
1189
1227
  else {
@@ -1,4 +1,20 @@
1
+ import type { SessionMeta } from '../lib/session/types.js';
1
2
  export declare const RESUME_SOURCE_ENV = "AGENTS_RESUME_SOURCE_JSON";
3
+ /**
4
+ * The source a dead-remote local fallback resumes from: the same session, but
5
+ * with `machine` rewritten to THIS box so the delegated `agents run --resume`
6
+ * resolves recovery locally (`sessionRecoveryPeer` returns undefined) instead of
7
+ * bouncing back to the unreachable owner. Because no local version home owns the
8
+ * peer's transcript, that local recovery lands on a labelled `/continue` replay
9
+ * from the synced mirror — the only way to continue a session whose owning device
10
+ * is gone. Owner-approved prefer-device, fall-back-local (PHNX-3626).
11
+ *
12
+ * Safe against the RUSH-2022 "silent local resume forks live state" hazard by
13
+ * PRECONDITION: this is reached only after `runOnPeer` proved the owner
14
+ * unreachable, so there is no live process on the peer to fork, and the fallback
15
+ * is announced with a log line — never silent.
16
+ */
17
+ export declare function resumeLocalFallbackSource(session: SessionMeta, self?: string): SessionMeta;
2
18
  export interface StrictResumeOptions {
3
19
  mode?: string;
4
20
  interactive?: boolean;
@@ -7,7 +7,25 @@ import { spawn } from 'child_process';
7
7
  import chalk from 'chalk';
8
8
  import { resolveSessionMetadataValue } from './sessions.js';
9
9
  import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
10
+ import { machineId } from '../lib/machine-id.js';
10
11
  export const RESUME_SOURCE_ENV = 'AGENTS_RESUME_SOURCE_JSON';
12
+ /**
13
+ * The source a dead-remote local fallback resumes from: the same session, but
14
+ * with `machine` rewritten to THIS box so the delegated `agents run --resume`
15
+ * resolves recovery locally (`sessionRecoveryPeer` returns undefined) instead of
16
+ * bouncing back to the unreachable owner. Because no local version home owns the
17
+ * peer's transcript, that local recovery lands on a labelled `/continue` replay
18
+ * from the synced mirror — the only way to continue a session whose owning device
19
+ * is gone. Owner-approved prefer-device, fall-back-local (PHNX-3626).
20
+ *
21
+ * Safe against the RUSH-2022 "silent local resume forks live state" hazard by
22
+ * PRECONDITION: this is reached only after `runOnPeer` proved the owner
23
+ * unreachable, so there is no live process on the peer to fork, and the fallback
24
+ * is announced with a log line — never silent.
25
+ */
26
+ export function resumeLocalFallbackSource(session, self = machineId()) {
27
+ return { ...session, machine: self };
28
+ }
11
29
  /**
12
30
  * The argv to re-run this resume on the machine that owns the session.
13
31
  *
@@ -165,14 +183,30 @@ export async function runStrictResume(sessionId, prompt, options) {
165
183
  },
166
184
  sessionId: outcome.session.id,
167
185
  });
168
- if (rc === 'no-target') {
169
- console.error(chalk.red(`Session ${outcome.session.shortId} lives on ${owner}, which isn't a reachable device right now.`));
170
- console.error(chalk.gray(`Register/wake it (agents devices), or run there: agents ssh ${owner}`));
171
- process.exitCode = 1;
186
+ if (rc === 'no-target' || rc === 'unreachable') {
187
+ // Prefer-device, fall back to local (PHNX-3626): the owning device is
188
+ // unreachable — either not a dialable registered device ('no-target') or
189
+ // registered but offline/asleep so the SSH connection itself failed
190
+ // ('unreachable'). Either way there is no live harness to reach OR to fork,
191
+ // so continue the session HERE from its synced mirror rather than
192
+ // dead-ending. The local recovery resolves this to a labelled `/continue`
193
+ // replay (no local home owns the peer's transcript), the honest degradation.
194
+ if (!options.quiet) {
195
+ process.stderr.write(chalk.yellow(`[agents] session ${outcome.session.shortId} belongs to ${owner}, which is unreachable → resuming locally (/continue replay from the synced transcript)\n`));
196
+ }
197
+ process.exitCode = await delegateLocalResume(resumeLocalFallbackSource(outcome.session), prompt, options);
172
198
  }
173
199
  return;
174
200
  }
175
- const args = buildResumeRunArgs(outcome.session, prompt, options);
201
+ process.exitCode = await delegateLocalResume(outcome.session, prompt, options);
202
+ }
203
+ /**
204
+ * Spawn the delegated local `agents run --resume` for a session this box owns
205
+ * (or is falling back to). The run command remains the sole executor; recovery
206
+ * (native vs `/continue`) is resolved there. Returns the child's exit code.
207
+ */
208
+ async function delegateLocalResume(session, prompt, options) {
209
+ const args = buildResumeRunArgs(session, prompt, options);
176
210
  const child = spawn(process.execPath, [process.argv[1], ...args], {
177
211
  stdio: 'inherit',
178
212
  env: {
@@ -180,12 +214,11 @@ export async function runStrictResume(sessionId, prompt, options) {
180
214
  // Avoid repeating the fleet lookup in the delegated local `run`
181
215
  // process. The value is metadata-only and is not forwarded over SSH;
182
216
  // the owner performs its own local SQLite lookup.
183
- [RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
217
+ [RESUME_SOURCE_ENV]: JSON.stringify(session),
184
218
  },
185
219
  });
186
- const exitCode = await new Promise((resolve) => {
220
+ return new Promise((resolve) => {
187
221
  child.once('error', () => resolve(127));
188
222
  child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
189
223
  });
190
- process.exitCode = exitCode;
191
224
  }