@phnx-labs/agents-cli 1.22.58 → 1.22.60

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 (83) hide show
  1. package/CHANGELOG.md +260 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +32 -1
  4. package/dist/commands/monitors.js +187 -23
  5. package/dist/commands/perf.js +10 -0
  6. package/dist/commands/routines.test-fixture.js +5 -0
  7. package/dist/commands/send.d.ts +2 -1
  8. package/dist/commands/send.js +7 -5
  9. package/dist/commands/sessions-picker.d.ts +13 -0
  10. package/dist/commands/sessions-picker.js +17 -8
  11. package/dist/commands/sessions-stats.js +37 -5
  12. package/dist/commands/sessions.js +52 -16
  13. package/dist/commands/ssh.js +12 -1
  14. package/dist/commands/teams-picker.js +20 -6
  15. package/dist/commands/teams.d.ts +2 -2
  16. package/dist/commands/teams.js +77 -21
  17. package/dist/commands/versions.js +12 -4
  18. package/dist/commands/view.js +7 -2
  19. package/dist/lib/accounting/rotate.js +12 -4
  20. package/dist/lib/accounting/usage-sync.d.ts +12 -2
  21. package/dist/lib/accounting/usage-sync.js +34 -6
  22. package/dist/lib/auto-pull-worker.js +7 -2
  23. package/dist/lib/cloud/rush.d.ts +7 -0
  24. package/dist/lib/cloud/rush.js +29 -1
  25. package/dist/lib/daemon/daemon.d.ts +22 -0
  26. package/dist/lib/daemon/daemon.js +39 -0
  27. package/dist/lib/daemon/session-index-service.js +9 -1
  28. package/dist/lib/daemon-ticks.d.ts +15 -0
  29. package/dist/lib/daemon-ticks.js +26 -0
  30. package/dist/lib/device-config.d.ts +5 -1
  31. package/dist/lib/device-config.js +2 -2
  32. package/dist/lib/devices/health.js +5 -1
  33. package/dist/lib/devices/pool.d.ts +25 -2
  34. package/dist/lib/devices/pool.js +32 -2
  35. package/dist/lib/devices/stats-cache.d.ts +0 -6
  36. package/dist/lib/devices/stats-cache.js +2 -9
  37. package/dist/lib/doctor-diff.d.ts +14 -0
  38. package/dist/lib/doctor-diff.js +43 -2
  39. package/dist/lib/feed/events.js +4 -0
  40. package/dist/lib/git.d.ts +38 -0
  41. package/dist/lib/git.js +58 -0
  42. package/dist/lib/hosts/ready.d.ts +8 -0
  43. package/dist/lib/hosts/ready.js +13 -2
  44. package/dist/lib/installations/versions.d.ts +17 -0
  45. package/dist/lib/installations/versions.js +53 -2
  46. package/dist/lib/monitors/config.d.ts +71 -3
  47. package/dist/lib/monitors/config.js +100 -12
  48. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  49. package/dist/lib/monitors/pid-watch.js +45 -0
  50. package/dist/lib/monitors/remote.d.ts +18 -0
  51. package/dist/lib/monitors/remote.js +11 -0
  52. package/dist/lib/perf/db.d.ts +1 -1
  53. package/dist/lib/perf/db.js +53 -2
  54. package/dist/lib/perf/types.d.ts +14 -0
  55. package/dist/lib/permissions.js +7 -2
  56. package/dist/lib/plugins/plugins.d.ts +17 -3
  57. package/dist/lib/plugins/plugins.js +84 -9
  58. package/dist/lib/pty-server.d.ts +14 -0
  59. package/dist/lib/pty-server.js +49 -5
  60. package/dist/lib/secrets/drivers/rush.js +5 -0
  61. package/dist/lib/self-update.d.ts +42 -0
  62. package/dist/lib/self-update.js +88 -0
  63. package/dist/lib/session/cloud.js +5 -0
  64. package/dist/lib/session/db.d.ts +32 -6
  65. package/dist/lib/session/db.js +128 -12
  66. package/dist/lib/session/live-metadata.js +3 -3
  67. package/dist/lib/smart-launch.d.ts +6 -0
  68. package/dist/lib/smart-launch.js +5 -2
  69. package/dist/lib/staleness/writers/plugins.js +5 -2
  70. package/dist/lib/staleness/writers/subagents.js +13 -3
  71. package/dist/lib/state.d.ts +7 -4
  72. package/dist/lib/state.js +7 -4
  73. package/dist/lib/subagents.js +8 -2
  74. package/dist/lib/teams/api.d.ts +8 -0
  75. package/dist/lib/teams/api.js +50 -6
  76. package/dist/lib/teams/delivery.d.ts +14 -4
  77. package/dist/lib/teams/delivery.js +15 -5
  78. package/dist/lib/teams/scheduler.d.ts +10 -0
  79. package/dist/lib/teams/scheduler.js +8 -0
  80. package/dist/lib/traces/sync.d.ts +113 -6
  81. package/dist/lib/traces/sync.js +193 -19
  82. package/dist/lib/view-types.d.ts +12 -0
  83. package/package.json +2 -2
@@ -55,7 +55,7 @@ import { listInstalledVersions, resolveVersionAliasLoose } from '../lib/installa
55
55
  import { getAgentsInvocation } from '../lib/daemon/daemon.js';
56
56
  import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
57
57
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
58
- import { sessionPicker, buildPreview, loadSessionPreviewDigest, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
58
+ import { sessionPicker, buildPreview, loadSessionPreviewDigest, transcriptOnPeerOf, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
59
59
  import { setHelpSections } from '../lib/help.js';
60
60
  import { registerSessionsTailCommand } from './sessions-tail.js';
61
61
  import { registerSessionsResumeCommand } from './sessions-resume.js';
@@ -2017,13 +2017,14 @@ export async function renderSessionPreview(query, scope) {
2017
2017
  return;
2018
2018
  }
2019
2019
  const session = outcome.session;
2020
- if (session._remote && session.machine && session.machine !== machineId()) {
2020
+ const transcriptPeer = transcriptOnPeerOf(session);
2021
+ if (transcriptPeer) {
2021
2022
  const args = ['sessions', 'preview', session.id, '--local'];
2022
2023
  if (scope.json)
2023
2024
  args.push('--json');
2024
- const rendered = await runOnPeer(args, session.machine);
2025
+ const rendered = await runOnPeer(args, transcriptPeer);
2025
2026
  if (rendered === 'no-target') {
2026
- console.error(chalk.red(`Session ${session.id} is on ${session.machine}, but that device is not reachable.`));
2027
+ console.error(chalk.red(`Session ${session.id} is on ${transcriptPeer}, but that device is not reachable.`));
2027
2028
  process.exitCode = 1;
2028
2029
  }
2029
2030
  return;
@@ -2316,9 +2317,16 @@ limitSource) {
2316
2317
  return;
2317
2318
  }
2318
2319
  // --device / --devices values are merged into options.host for internal routing. A bare `all` / `fleet` sentinel means
2319
- // "search every peer" — which is already the default — so it resolves to no
2320
- // explicit host set rather than erroring on a device literally named "all".
2321
- const deviceTargets = [...(options.device ?? []), ...(options.devices ?? [])]
2320
+ // "search every peer" — which is already the default for `--active` and the
2321
+ // interactive listing — so it resolves to no explicit host set rather than
2322
+ // erroring on a device literally named "all".
2323
+ const rawDeviceTargets = [...(options.device ?? []), ...(options.devices ?? [])];
2324
+ // But the HISTORICAL `--json` listing does NOT fan out by default (it stays a
2325
+ // deterministic local slice for scripts), so the sentinel would otherwise be
2326
+ // silently dropped and the fleet never reached (PHNX-2673). Remember it so that
2327
+ // path can sweep every peer, exactly as the interactive listing already does.
2328
+ const fleetWide = rawDeviceTargets.some(t => t.toLowerCase() === 'all' || t.toLowerCase() === 'fleet');
2329
+ const deviceTargets = rawDeviceTargets
2322
2330
  .filter(t => t.toLowerCase() !== 'all' && t.toLowerCase() !== 'fleet');
2323
2331
  if (deviceTargets.length > 0) {
2324
2332
  options.host = [...(options.host ?? []), ...deviceTargets];
@@ -2775,16 +2783,43 @@ limitSource) {
2775
2783
  // each peer) would fall to FTS content search and return every transcript
2776
2784
  // that merely MENTIONS the id, defeating exact remote resolution. A genuine
2777
2785
  // search phrase keeps the ranked metadata+content path.
2778
- const filtered = searchQuery
2786
+ let filtered = searchQuery
2779
2787
  ? resolveSessionQuery(sessions, searchQuery, {
2780
2788
  scope: { agent: options.agent, project: options.project, routine: options.routine },
2781
2789
  }).matches
2782
2790
  : sessions;
2791
+ // `--device all`/`--fleet` on a historical `--json` query fans out to every
2792
+ // peer and merges their rows in — the SAME SSH sweep the interactive listing
2793
+ // below uses, and the whole-fleet twin of the explicit `--device <host>
2794
+ // --json` path (`runRemoteSessionsJson`). Without this the documented
2795
+ // "search the whole fleet" flag returned local rows only (PHNX-2673). Guarded
2796
+ // to the sentinel so a bare `--json` stays a deterministic local slice for
2797
+ // scripts, and skipped under `--local`/the peer recursion guard. Dead peers
2798
+ // are skipped by the fan-out, never fatal — enrichment, not a dependency.
2799
+ const forceLocalJson = options.local === true || process.env[NO_FANOUT_ENV] === '1';
2800
+ if (fleetWide && !forceLocalJson) {
2801
+ const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(options.host ?? [])));
2802
+ if (!forwarded.includes('--json'))
2803
+ forwarded.push('--json');
2804
+ const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
2805
+ try {
2806
+ const { sessions: remoteSessions } = await gatherRemoteList(forwarded, undefined);
2807
+ if (remoteSessions.length > 0) {
2808
+ filtered = mergeLocalFirst([...filtered, ...remoteSessions], machineId());
2809
+ }
2810
+ }
2811
+ catch {
2812
+ // fan-out is an enrichment, never a hard dependency
2813
+ }
2814
+ finally {
2815
+ fanSpinner?.stop();
2816
+ }
2817
+ }
2783
2818
  // JSON is the canonical picker contract: enrich the durable rows once
2784
2819
  // from the shared live cache so every consumer gets lifecycle/recovery
2785
2820
  // metadata without performing its own join or transcript scan.
2786
2821
  const live = await gatherActiveSessions({
2787
- local: options.local === true || process.env[NO_FANOUT_ENV] === '1',
2822
+ local: forceLocalJson,
2788
2823
  hosts: options.host,
2789
2824
  });
2790
2825
  process.stdout.write(serializeSessionsJson(serializeSessionPickerRows(filtered, live.sessions)));
@@ -3839,11 +3874,11 @@ export async function handlePickedSession(picked) {
3839
3874
  }
3840
3875
  // Reading and resuming are on DIFFERENT machines' terms, and conflating them
3841
3876
  // is what RUSH-2022 was. Reading follows the FILE: a synced mirror sits on
3842
- // this disk, so only a live fan-out row (`_remote`) has to be read on the
3843
- // peer. Resuming follows the HARNESS STATE, which is on the owning machine
3844
- // whatever the transcript's location — a mirror included.
3877
+ // this disk, so a peer-owned row with no readable local transcript has to be
3878
+ // read on the peer. Resuming follows the HARNESS STATE, which is on the
3879
+ // owning machine whatever the transcript's location — a mirror included.
3845
3880
  if (picked.action === 'view') {
3846
- const readFrom = picked.session._remote ? picked.session.machine : undefined;
3881
+ const readFrom = transcriptOnPeerOf(picked.session);
3847
3882
  if (readFrom) {
3848
3883
  const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], readFrom);
3849
3884
  if (rc === 'no-target')
@@ -4469,7 +4504,8 @@ async function renderOneSession(query, mode, scope) {
4469
4504
  }
4470
4505
  if (outcome.kind === 'resolved') {
4471
4506
  const resolved = outcome.session;
4472
- if (resolved._remote && resolved.machine && resolved.machine !== machineId()) {
4507
+ const transcriptPeer = transcriptOnPeerOf(resolved);
4508
+ if (transcriptPeer) {
4473
4509
  const args = ['sessions', resolved.id, '--local'];
4474
4510
  const flag = modeFlag(mode);
4475
4511
  if (flag)
@@ -4484,9 +4520,9 @@ async function renderOneSession(query, mode, scope) {
4484
4520
  args.push('--last', String(scope.filter.last));
4485
4521
  if (scope.redact === false)
4486
4522
  args.push('--no-redact');
4487
- const rendered = await runOnPeer(args, resolved.machine);
4523
+ const rendered = await runOnPeer(args, transcriptPeer);
4488
4524
  if (rendered === 'no-target') {
4489
- console.error(chalk.red(`Session ${resolved.id} is on ${resolved.machine}, but that device is not reachable.`));
4525
+ console.error(chalk.red(`Session ${resolved.id} is on ${transcriptPeer}, but that device is not reachable.`));
4490
4526
  process.exit(1);
4491
4527
  }
4492
4528
  return;
@@ -130,7 +130,11 @@ function pctCell(v, width) {
130
130
  * header — which defeats the scannability this column exists for. */
131
131
  const SPEC_WIDTH_MIN = 12;
132
132
  /** The static hardware as one compact cell — `12c 64G 1T`: cores, total RAM,
133
- * total root disk via fmtBytes. `—` only when no probe has ever seen the box.
133
+ * total root disk via fmtBytes. `—` covers two cases: no probe has ever seen
134
+ * the box, or the probe answered but yielded no usable core count (`parseNcpu`
135
+ * finding none, or the Windows `ncpu` group failing the finite-and-positive
136
+ * check) — the cell is keyed on `ncpu` because a spec string without it would
137
+ * read as a machine with zero cores.
134
138
  *
135
139
  * Deliberately NOT gated on `reachable`: hardware does not change while a
136
140
  * machine is down, so an offline device keeps rendering the spec from its last
@@ -2298,6 +2302,13 @@ box; \`agents doctor\` names it.
2298
2302
 
2299
2303
  A box whose probe cannot answer (no POSIX shell, e.g. Windows) is reported
2300
2304
  \`unverified\` rather than counted as a success.
2305
+
2306
+ The upgrade OWNS its global bin links: after installing, it verifies that
2307
+ \`<prefix>/bin/{agents,ag,browser,computer}\` resolve to the freshly-installed
2308
+ copy and RESTORES any the package manager dropped — the state that once left a
2309
+ box upgraded in place but with every \`agents\` invocation "command not found"
2310
+ (PHNX-2768). A link it cannot make resolve fails the upgrade loud, so that box
2311
+ is reported \`failed\` (exit non-zero), never a stranded \`ok\`.
2301
2312
  `)
2302
2313
  .action(async (version) => {
2303
2314
  let cmd;
@@ -9,6 +9,7 @@
9
9
  import chalk from 'chalk';
10
10
  import { relTime, truncate, humanDuration } from '../lib/format.js';
11
11
  import { itemPicker } from '../lib/picker.js';
12
+ import { deliveryDisplayLabel, deliveryColorKey } from '../lib/teams/delivery.js';
12
13
  const AGENT_LABEL = {
13
14
  claude: 'Claude',
14
15
  codex: 'Codex',
@@ -21,6 +22,7 @@ function statusColor(status) {
21
22
  case 'pending': return chalk.blue;
22
23
  case 'running': return chalk.yellow;
23
24
  case 'completed': return chalk.green;
25
+ case 'stranded': return chalk.yellow;
24
26
  case 'failed': return chalk.red;
25
27
  case 'stopped': return chalk.gray;
26
28
  default: return chalk.white;
@@ -61,10 +63,12 @@ function displayAgent(agent, version) {
61
63
  function statusCell(t) {
62
64
  if (t.agent_count === 0)
63
65
  return chalk.gray('empty');
66
+ const done = Math.max(0, t.completed - (t.stranded ?? 0));
64
67
  const states = [
65
68
  [t.pending, chalk.blue, 'pending'],
66
69
  [t.running, chalk.yellow, 'working'],
67
- [t.completed, chalk.green, 'done'],
70
+ [done, chalk.green, 'done'],
71
+ [t.stranded ?? 0, chalk.yellow, 'stranded'],
68
72
  [t.failed, chalk.red, 'failed'],
69
73
  [t.stopped, chalk.gray, 'stopped'],
70
74
  ];
@@ -76,13 +80,16 @@ function statusCell(t) {
76
80
  return nonzero.map(([n, color, label]) => color(`${n} ${label}`)).join(chalk.gray(' · '));
77
81
  }
78
82
  function statusSummaryParts(t) {
83
+ const done = Math.max(0, t.completed - (t.stranded ?? 0));
79
84
  const parts = [];
80
85
  if (t.pending)
81
86
  parts.push(chalk.blue(`${t.pending} pending`));
82
87
  if (t.running)
83
88
  parts.push(chalk.yellow(`${t.running} working`));
84
- if (t.completed)
85
- parts.push(chalk.green(`${t.completed} done`));
89
+ if (done)
90
+ parts.push(chalk.green(`${done} done`));
91
+ if (t.stranded)
92
+ parts.push(chalk.yellow(`${t.stranded} stranded`));
86
93
  if (t.failed)
87
94
  parts.push(chalk.red(`${t.failed} failed`));
88
95
  if (t.stopped)
@@ -173,7 +180,12 @@ export function buildTeamPreview(row) {
173
180
  const nameW = Math.max(6, ...row.agents.map((a) => displayHandle(a).length));
174
181
  const agentW = Math.max(8, ...row.agents.map((a) => displayAgent(a.agent_type, a.version).length));
175
182
  for (const a of row.agents) {
176
- const stat = statusColor(a.status)(a.status);
183
+ const delivery = a.delivery;
184
+ const colorKey = delivery ? deliveryColorKey(delivery, a.status) : a.status;
185
+ const label = delivery
186
+ ? deliveryDisplayLabel(delivery, a.status)
187
+ : a.status.toUpperCase();
188
+ const stat = statusColor(colorKey)(label);
177
189
  const metaParts = [];
178
190
  if (a.task_type)
179
191
  metaParts.push(chalk.magenta(a.task_type));
@@ -244,8 +256,8 @@ function searchHaystack(row) {
244
256
  parts.push(a.status);
245
257
  }
246
258
  // Also include team-level status words exposed in the row ("done",
247
- // "working", "pending", "failed", "stopped", "empty") so the search matches
248
- // what the user sees on screen.
259
+ // "working", "pending", "stranded", "failed", "stopped", "empty") so the
260
+ // search matches what the user sees on screen.
249
261
  const t = row.team;
250
262
  if (t.agent_count === 0)
251
263
  parts.push('empty');
@@ -255,6 +267,8 @@ function searchHaystack(row) {
255
267
  parts.push('working', 'running');
256
268
  if (t.completed)
257
269
  parts.push('done', 'completed');
270
+ if (t.stranded)
271
+ parts.push('stranded');
258
272
  if (t.failed)
259
273
  parts.push('failed');
260
274
  if (t.stopped)
@@ -120,11 +120,11 @@ export declare function cloudDispatchOptions(agent: Pick<AgentProcess, 'prompt'
120
120
  export declare function runOneWave(mgr: AgentManager, team: string, json: boolean): Promise<void>;
121
121
  export declare function buildTeamRowsFromSnapshots(registry: TeamRegistry, agents: TeamListAgentSnapshot[],
122
122
  /** team name -> the session that spawned it (see teamSpawners). */
123
- spawners?: Map<string, TeamSpawner>): {
123
+ spawners?: Map<string, TeamSpawner>): Promise<{
124
124
  rows: TeamRow[];
125
125
  teams: TaskInfo[];
126
126
  names: string[];
127
- };
127
+ }>;
128
128
  /**
129
129
  * `teams add --remote-cwd` is a no-op trap. `--remote-cwd` comes from the shared
130
130
  * `--device` option family (option.ts) and is meaningful for commands that *route*
@@ -62,6 +62,7 @@ function statusColor(status) {
62
62
  case 'running': return chalk.yellow;
63
63
  case 'completed': return chalk.green;
64
64
  case 'pr_open': return chalk.magenta; // RUSH-2380: process done, PR not merged
65
+ case 'stranded': return chalk.yellow; // PHNX-2951: done but work not committed
65
66
  case 'failed': return chalk.red;
66
67
  case 'stopped': return chalk.gray;
67
68
  default: return chalk.white;
@@ -150,6 +151,7 @@ function snapshotToStatusDetail(agent) {
150
151
  after: agent.after,
151
152
  task_type: agent.task_type,
152
153
  host: agent.host,
154
+ workspace_dir: agent.workspace_dir,
153
155
  };
154
156
  }
155
157
  function fullName(type, version) {
@@ -784,6 +786,9 @@ function printAgentDetail(a, session) {
784
786
  }
785
787
  if (a.has_errors)
786
788
  console.log(` ${chalk.red('! reported an error')}`);
789
+ if (delivery === 'stranded' && a.workspace_dir) {
790
+ console.log(` ${chalk.yellow('! stranded')} uncommitted work at ${a.workspace_dir}`);
791
+ }
787
792
  if (a.pr_url)
788
793
  console.log(` ${chalk.gray('PR ')}${a.pr_url}`);
789
794
  }
@@ -864,17 +869,34 @@ function printAgentSummary(s) {
864
869
  console.log(` ${chalk.gray('>')} ${truncate(firstLine, 96)}`);
865
870
  }
866
871
  }
872
+ if (delivery === 'stranded' && s.workspace_dir) {
873
+ console.log(` ${chalk.yellow('! stranded')} uncommitted work at ${s.workspace_dir}`);
874
+ }
867
875
  if (s.pr_url)
868
876
  console.log(` ${chalk.gray('PR ')} ${chalk.cyan(s.pr_url)}`);
869
877
  }
878
+ function formatTeamStatusSummary(summary) {
879
+ const done = Math.max(0, summary.completed - summary.stranded);
880
+ const parts = [];
881
+ if (summary.pending > 0)
882
+ parts.push(`${summary.pending} pending`);
883
+ if (summary.running > 0 || parts.length === 0)
884
+ parts.push(`${summary.running} working`);
885
+ if (done > 0 || summary.stranded === 0)
886
+ parts.push(`${done} done`);
887
+ if (summary.stranded > 0)
888
+ parts.push(`${summary.stranded} stranded`);
889
+ if (summary.failed > 0 || parts.length === 0)
890
+ parts.push(`${summary.failed} failed`);
891
+ if (summary.stopped > 0 || parts.length === 0)
892
+ parts.push(`${summary.stopped} stopped`);
893
+ return `(${parts.join(', ')})`;
894
+ }
870
895
  // Render a team's status in the same format the `status` subcommand uses, so
871
896
  // the interactive picker's Enter action drops the user into a familiar view.
872
897
  async function printTeamStatus(team, result) {
873
898
  const { summary, agents } = result;
874
- console.log(chalk.bold(`Team ${chalk.cyan(team)} `) +
875
- chalk.gray(summary.pending > 0
876
- ? `(${summary.pending} pending, ${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`
877
- : `(${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`));
899
+ console.log(chalk.bold(`Team ${chalk.cyan(team)} `) + chalk.gray(formatTeamStatusSummary(summary)));
878
900
  if (agents.length === 0) {
879
901
  console.log(chalk.gray(' (no teammates yet — add one with `agents teams add`)'));
880
902
  }
@@ -900,10 +922,7 @@ async function printTeamStatus(team, result) {
900
922
  // verbose/legacy layout.
901
923
  function printTeamSummary(team, result) {
902
924
  const { summary, agents } = result;
903
- console.log(chalk.bold(`Team ${chalk.cyan(team)} `) +
904
- chalk.gray(summary.pending > 0
905
- ? `(${summary.pending} pending, ${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`
906
- : `(${summary.running} working, ${summary.completed} done, ${summary.failed} failed, ${summary.stopped} stopped)`));
925
+ console.log(chalk.bold(`Team ${chalk.cyan(team)} `) + chalk.gray(formatTeamStatusSummary(summary)));
907
926
  if (agents.length === 0) {
908
927
  console.log(chalk.gray(' (no teammates yet — add one with `agents teams add`)'));
909
928
  }
@@ -925,12 +944,13 @@ function printTeamSummary(team, result) {
925
944
  console.log(chalk.gray('Raw log: agents teams logs --team ' + team + ' --teammate <name>'));
926
945
  }
927
946
  // Classify a team into a single bucket for --status filtering.
928
- // - empty: no teammates (created but nobody added yet)
929
- // - waiting: only staged teammates — call `teams start` to kick them off
930
- // - working: at least one teammate still running
931
- // - failed: at least one teammate failed or was stopped (any failure wins —
932
- // even if others finished, you want to know about the failure)
933
- // - done: everyone finished successfully, no failures
947
+ // - empty: no teammates (created but nobody added yet)
948
+ // - waiting: only staged teammates — call `teams start` to kick them off
949
+ // - working: at least one teammate still running
950
+ // - failed: at least one teammate failed or was stopped (any failure wins —
951
+ // even if others finished, you want to know about the failure)
952
+ // - stranded: everyone finished, but at least one has uncommitted work and no PR
953
+ // - done: everyone finished successfully, no failures, no stranded work
934
954
  function classifyTeamStatus(t) {
935
955
  if (t.agent_count === 0)
936
956
  return 'empty';
@@ -943,6 +963,10 @@ function classifyTeamStatus(t) {
943
963
  const accounted = t.running + t.completed + t.failed + t.stopped;
944
964
  if (accounted < t.agent_count)
945
965
  return 'waiting';
966
+ // Completed-without-delivery is not "done": the work is still in the worktree
967
+ // and will be lost on cleanup (PHNX-2951).
968
+ if ((t.stranded ?? 0) > 0)
969
+ return 'stranded';
946
970
  return 'done';
947
971
  }
948
972
  // Merge persistent team registry with tasks derived from live agents so empty
@@ -959,6 +983,7 @@ function mergeTeams(registry, tasks) {
959
983
  pending: 0,
960
984
  running: 0,
961
985
  completed: 0,
986
+ stranded: 0,
962
987
  failed: 0,
963
988
  stopped: 0,
964
989
  workspace_dir: null,
@@ -969,7 +994,7 @@ function mergeTeams(registry, tasks) {
969
994
  }
970
995
  return Array.from(byName.values()).sort((a, b) => new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime());
971
996
  }
972
- function buildTasksFromSnapshots(agents) {
997
+ async function buildTasksFromSnapshots(agents) {
973
998
  const byTeam = new Map();
974
999
  for (const agent of agents) {
975
1000
  const teamAgents = byTeam.get(agent.task_name) || [];
@@ -981,6 +1006,7 @@ function buildTasksFromSnapshots(agents) {
981
1006
  let pending = 0;
982
1007
  let running = 0;
983
1008
  let completed = 0;
1009
+ let stranded = 0;
984
1010
  let failed = 0;
985
1011
  let stopped = 0;
986
1012
  let earliestStart = null;
@@ -998,6 +1024,16 @@ function buildTasksFromSnapshots(agents) {
998
1024
  failed++;
999
1025
  else if (status === AgentStatus.STOPPED)
1000
1026
  stopped++;
1027
+ // Stranded = completed with no PR and a dirty local worktree (PHNX-2951).
1028
+ if (status === AgentStatus.COMPLETED &&
1029
+ !agent.pr_url?.trim() &&
1030
+ !agent.host &&
1031
+ agent.workspace_dir) {
1032
+ const dirty = await hasUncommittedChanges(agent.workspace_dir);
1033
+ if (dirty) {
1034
+ stranded++;
1035
+ }
1036
+ }
1001
1037
  const startedAt = parseTimestamp(agent.started_at);
1002
1038
  if (startedAt && (!earliestStart || startedAt < earliestStart)) {
1003
1039
  earliestStart = startedAt;
@@ -1017,6 +1053,7 @@ function buildTasksFromSnapshots(agents) {
1017
1053
  pending,
1018
1054
  running,
1019
1055
  completed,
1056
+ stranded,
1020
1057
  failed,
1021
1058
  stopped,
1022
1059
  workspace_dir: workspaceDir,
@@ -1026,7 +1063,7 @@ function buildTasksFromSnapshots(agents) {
1026
1063
  }
1027
1064
  return tasks.sort((a, b) => new Date(b.modified_at).getTime() - new Date(a.modified_at).getTime());
1028
1065
  }
1029
- export function buildTeamRowsFromSnapshots(registry, agents,
1066
+ export async function buildTeamRowsFromSnapshots(registry, agents,
1030
1067
  /** team name -> the session that spawned it (see teamSpawners). */
1031
1068
  spawners) {
1032
1069
  const byTeam = new Map();
@@ -1035,7 +1072,26 @@ spawners) {
1035
1072
  details.push(snapshotToStatusDetail(agent));
1036
1073
  byTeam.set(agent.task_name, details);
1037
1074
  }
1038
- const teams = mergeTeams(registry, buildTasksFromSnapshots(agents));
1075
+ const teams = mergeTeams(registry, await buildTasksFromSnapshots(agents));
1076
+ // Recompute delivery for cached snapshots so `teams list` reflects stranded
1077
+ // work discovered by probing the real worktree (PHNX-2951).
1078
+ for (const details of byTeam.values()) {
1079
+ for (const a of details) {
1080
+ const snapshot = agents.find((s) => s.agent_id === a.agent_id);
1081
+ if (snapshot &&
1082
+ normalizeTeamListStatus(a.status) === AgentStatus.COMPLETED &&
1083
+ !snapshot.pr_url?.trim() &&
1084
+ !snapshot.host &&
1085
+ snapshot.workspace_dir) {
1086
+ const dirty = await hasUncommittedChanges(snapshot.workspace_dir);
1087
+ a.delivery = resolveTeammateDelivery({
1088
+ status: a.status,
1089
+ prUrl: snapshot.pr_url,
1090
+ hasUncommittedChanges: dirty,
1091
+ });
1092
+ }
1093
+ }
1094
+ }
1039
1095
  return {
1040
1096
  teams,
1041
1097
  rows: teams.map((team) => ({
@@ -1107,7 +1163,7 @@ async function loadTeamRows(_mgr) {
1107
1163
  catch {
1108
1164
  // The index is an enrichment here — a missing/locked DB just drops the column.
1109
1165
  }
1110
- return buildTeamRowsFromSnapshots(registry, agents, spawners);
1166
+ return await buildTeamRowsFromSnapshots(registry, agents, spawners);
1111
1167
  }
1112
1168
  // Picker fallback for `teams logs` when the teammate ref is omitted. Shows a
1113
1169
  // flat list of every teammate with their team context; Enter picks one.
@@ -1271,7 +1327,7 @@ export function registerTeamsCommands(program) {
1271
1327
  .alias('ls')
1272
1328
  .description('List your teams, most recent activity first')
1273
1329
  .option('-a, --agent <agent>', 'Filter: only teams with this agent (e.g. claude or claude@2.1.112)')
1274
- .option('--status <status>', 'Filter: only teams with this status (working, done, failed, or empty)')
1330
+ .option('--status <status>', 'Filter: only teams with this status (working, done, stranded, failed, or empty)')
1275
1331
  .option('--since <time>', 'Filter: teams active after this time (e.g. "2h", "7d", or ISO date)')
1276
1332
  .option('--until <time>', 'Filter: teams active before this time (e.g. "30d", or ISO date)')
1277
1333
  .option('-n, --limit <n>', 'Show at most this many teams (default: 20)', '20')
@@ -1298,7 +1354,7 @@ export function registerTeamsCommands(program) {
1298
1354
  catch {
1299
1355
  // The session index is an enrichment here; a missing one drops the column.
1300
1356
  }
1301
- let rows = buildTeamRowsFromSnapshots(registry, everyAgent, spawners).rows;
1357
+ let rows = (await buildTeamRowsFromSnapshots(registry, everyAgent, spawners)).rows;
1302
1358
  // --- query: substring match on team name ---
1303
1359
  if (query) {
1304
1360
  const q = query.toLowerCase();
@@ -1318,7 +1374,7 @@ export function registerTeamsCommands(program) {
1318
1374
  // --- --status: classify each team, filter ---
1319
1375
  if (opts.status) {
1320
1376
  const want = opts.status.toLowerCase();
1321
- const validStatuses = ['working', 'done', 'failed', 'empty'];
1377
+ const validStatuses = ['working', 'done', 'stranded', 'failed', 'empty'];
1322
1378
  if (!validStatuses.includes(want)) {
1323
1379
  dieFriction('teams', 'invalid-status-filter', `Invalid --status '${opts.status}'. Use one of: ${validStatuses.join(', ')}`);
1324
1380
  }
@@ -11,7 +11,7 @@ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalle
11
11
  import { carryForwardSettings } from '../lib/settings-manifest.js';
12
12
  import { createShim, createVersionedAlias, supportsIsolatedInstall, isIsolationProtected, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/installations/shims.js';
13
13
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
14
- import { tryAutoPull } from '../lib/git.js';
14
+ import { tryAutoPullSystemRepo } from '../lib/git.js';
15
15
  import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
16
16
  import { setHelpSections } from '../lib/help.js';
17
17
  import { updateSessionFilePaths } from '../lib/session/db.js';
@@ -630,10 +630,18 @@ export function registerVersionsCommands(program) {
630
630
  useCmd.action(async (agentArg, versionArg, options) => {
631
631
  try {
632
632
  const skipPrompts = options.yes || !isInteractiveTerminal();
633
- // Auto-pull ~/.agents/.system if it's a git repo with remote (silent on success)
633
+ // Auto-pull ~/.agents/.system if it's a git repo tracking the EXPECTED
634
+ // system remote (silent on success). The system repo ships hooks that run
635
+ // as shell on tool events, so pulling from a repointed origin would be
636
+ // remote code execution — tryAutoPullSystemRepo refuses that (PHNX-2957).
634
637
  const agentsDir = getAgentsDir();
635
- const pullResult = await tryAutoPull(agentsDir);
636
- if (pullResult.pulled) {
638
+ const pullResult = await tryAutoPullSystemRepo(agentsDir);
639
+ if (pullResult.refused) {
640
+ console.error(chalk.red(`Refusing to auto-sync ~/.agents/.system: its origin (${pullResult.actualRemote}) is not the expected system repo.`));
641
+ console.error(chalk.gray('The system repo ships hooks that run on tool events; a fast-forward from an unexpected origin is not applied. ' +
642
+ 'Re-point it (git -C ~/.agents/.system remote set-url origin <expected>) or set AGENTS_SYSTEM_REPO, then re-run `agents setup --force`.'));
643
+ }
644
+ else if (pullResult.pulled) {
637
645
  console.log(chalk.gray('Synced ~/.agents/.system from remote'));
638
646
  }
639
647
  // Support both "claude 2.0.65" and "claude@2.0.65" formats
@@ -4,7 +4,7 @@ import { termLink } from '../lib/format.js';
4
4
  import ora from 'ora';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
- import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
7
+ import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, credentialPresence, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
8
8
  import { ambientClaudeToken, loginHint } from '../lib/signin-badge.js';
9
9
  import { machineId } from '../lib/machine-id.js';
10
10
  import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
@@ -24,7 +24,7 @@ import { listNativeAccounts } from '../lib/account-registry.js';
24
24
  import { isGitRepo, getGitSyncStatus } from '../lib/git.js';
25
25
  import { getCentralRulesFileName } from '../lib/rules/rules.js';
26
26
  import { composeRulesFromState } from '../lib/rules/compose.js';
27
- import { getConfiguredRunStrategy } from '../lib/accounting/rotate.js';
27
+ import { getConfiguredRunStrategy, isLaunchableSignedIn } from '../lib/accounting/rotate.js';
28
28
  import { resolveRunDefaults } from '../lib/run-defaults.js';
29
29
  import { resolveConfiguredModel } from '../lib/models.js';
30
30
  import { listProfiles, profileExists, profileSummary, readProfile } from '../lib/profiles.js';
@@ -1293,6 +1293,11 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1293
1293
  isolated: isVersionIsolated(agentId, version),
1294
1294
  isIsolatedDefault: getIsolatedDefault(agentId) === version,
1295
1295
  signedIn: info.signedIn,
1296
+ // The strict per-version launch truth (vs the display `signedIn` above,
1297
+ // which inherits the active/global HOME login). The same primitive
1298
+ // `collectRunCandidates` uses locally, so remote `--device auto` placement
1299
+ // is gated on identical launchability (PHNX-3466).
1300
+ launchable: isLaunchableSignedIn(info.signedIn, credentialPresence(agentId, home)),
1296
1301
  authVerdict: authCache[authCacheKey(host, agentId, version)]?.verdict ?? null,
1297
1302
  email: info.email,
1298
1303
  accountId: info.accountId,
@@ -313,7 +313,7 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
313
313
  if (!deduped.has(c))
314
314
  excluded.push(c);
315
315
  }
316
- const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity, 'representative');
316
+ const { picked, usageUnverified } = preferVerified(sorted, nowMs, (from) => weightedRandomByCapacity(from, nowMs), 'representative');
317
317
  return { picked, healthy: sorted, excluded, usageUnverified };
318
318
  }
319
319
  /**
@@ -376,8 +376,16 @@ export { PROJECTION_HORIZON_MIN, capacityWeight };
376
376
  * routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
377
377
  * near-exhausted-but-still-eligible candidate can still be picked occasionally.
378
378
  */
379
- function weightedRandomByCapacity(sorted) {
380
- const weights = sorted.map((c) => capacityWeight(getRoutingUsedPercent(c.usageSnapshot), c.usageMinutesToLimit));
379
+ function weightedRandomByCapacity(sorted, nowMs = Date.now()) {
380
+ const weights = sorted.map((c) => capacityWeight(
381
+ // An unverified (stale or absent) snapshot carries no trustworthy number,
382
+ // so it weights as UNVERIFIED_WEIGHT (the floor) instead of by its frozen
383
+ // usedPercent. Otherwise a day-old "45% used" competes as if it were live
384
+ // headroom and can win the draw over a verified-healthy account — which is
385
+ // exactly how balanced launched into an account already at its weekly cap
386
+ // on a worker whose usage refresh had stalled (PHNX-3479). A verified
387
+ // snapshot keeps its real remaining-headroom weight.
388
+ isUsageVerified(c, nowMs) ? getRoutingUsedPercent(c.usageSnapshot) : null, c.usageMinutesToLimit));
381
389
  const total = weights.reduce((sum, w) => sum + w, 0);
382
390
  if (total <= 0)
383
391
  return sorted[0];
@@ -485,7 +493,7 @@ export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
485
493
  const excluded = summaries.filter((s) => s.best === null);
486
494
  if (healthy.length === 0)
487
495
  return null;
488
- const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
496
+ const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best), nowMs);
489
497
  const picked = healthy.find((s) => s.best === pickedBest);
490
498
  return { picked, healthy, excluded };
491
499
  }
@@ -1,9 +1,18 @@
1
1
  import { type DeviceProfile } from '../devices/registry.js';
2
2
  import { type ConfiguredDeviceRole } from '../device-config.js';
3
- import { type CachedUsageSnapshot, type UsageSnapshot } from './usage.js';
3
+ import { type CachedUsageSnapshot } from './usage.js';
4
4
  /** How long a single peer push may take before it is abandoned for this tick. */
5
5
  export declare const USAGE_PUSH_DEADLINE_MS = 20000;
6
6
  export declare const USAGE_PULL_DEADLINE_MS = 20000;
7
+ /**
8
+ * A worker's newest local usage row older than this triggers a pull from the
9
+ * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
10
+ * RUSH-2392), so its only source of a fresh row is the daemon push every
11
+ * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
12
+ * cycle is tolerated before the worker actively pulls. Bounds worker staleness
13
+ * to ~30m instead of the days-old caches this replaces.
14
+ */
15
+ export declare const USAGE_SYNC_MAX_AGE_MS: number;
7
16
  /** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
8
17
  export interface UsageSyncPayload {
9
18
  v: 1;
@@ -75,8 +84,9 @@ export interface UsagePullDeps {
75
84
  listRoles?: () => Record<string, ConfiguredDeviceRole>;
76
85
  isPinned?: (name: string) => boolean;
77
86
  exportRows?: () => Record<string, CachedUsageSnapshot>;
78
- readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
79
87
  ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
88
+ /** Injectable clock for the staleness gate; defaults to `Date.now`. */
89
+ now?: () => number;
80
90
  /** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
81
91
  pull?: (device: DeviceProfile) => {
82
92
  ok: boolean;