@phnx-labs/agents-cli 1.22.59 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.60
4
+
5
+ - **Detect stranded uncommitted work in `agents teams status` (PHNX-2951).** A teammate that exits `COMPLETED` with no PR and uncommitted changes in its local worktree now reports `STRANDED` instead of `done`, and the status line names the worktree path so the work can be rescued before cleanup. The stranded count is computed over the full team even when `--filter` narrows the displayed agents, so `agents teams status <team> --filter running` still surfaces stranded completed teammates. Team rollups and `teams list --status` classify these teams as `stranded`, not `done`. Source: `cli/src/lib/teams/delivery.ts`, `cli/src/lib/teams/api.ts`, `cli/src/commands/teams.ts`.
6
+
7
+ - **Track agent-launch boot cost: persist + surface the `startup` phase (PHNX-3468).** `agent.run` already timed a `startup` phase (entering `spawnAgent` → child spawn) but only the total run duration reached the perf warehouse, so boot cost was untrackable across the fleet. The timer now persists its sub-phase marks into each `perf.timing` sample's `meta_json`, `aggregateSamples` folds them into a per-label `phases` break-out (p50/p90 over the samples that carried each phase), and `agents insights perf run` renders a `└ startup: p50 … p90 … (n=…)` sub-line under `agent.run`. This is the measurement the boot-perf work is graded against. Source: `cli/src/lib/feed/events.ts`, `cli/src/lib/perf/db.ts`, `cli/src/lib/perf/types.ts`, `cli/src/commands/perf.ts`.
8
+
9
+ - **`agents sessions preview <id>` now pulls the transcript digest from the
10
+ owning device for host-dispatched sessions (PHNX-3481).** A host dispatch
11
+ leaves a synthetic local index row with `machine = <peer>` and an empty
12
+ `filePath`, but that row does not carry the live fan-out's `_remote` marker.
13
+ Full-UUID preview resolved the local row and then treated it as a local live
14
+ session, rendering "full transcript not indexed here" without making any SSH
15
+ hop. Transcript reads now follow one file-aware predicate: an explicit remote
16
+ row still reads on its peer; a row naming another machine also reads there
17
+ when no transcript exists on this disk; and a synced mirror with a real local
18
+ file still renders locally. The direct preview, `sessions <id>`, picker view,
19
+ and picker/browser digest pane all share that rule. Live-registry metadata now
20
+ also stamps `_remote` when its execution machine differs from this box, and an
21
+ unreachable owner remains a loud error instead of a local placeholder.
22
+ Source: `cli/src/commands/sessions-picker.ts`, `cli/src/commands/sessions.ts`,
23
+ `cli/src/lib/session/live-metadata.ts`.
24
+
3
25
  ## 1.22.59
4
26
 
5
27
  - **`agents devices disable/prefer` now actually change `--device auto` placement (PHNX-2092).**
@@ -132,6 +132,16 @@ function renderLabelTable(title, rows, warnMs, limit) {
132
132
  formatMs(r.maxMs),
133
133
  formatRateColumn(r),
134
134
  ]), sliced.map((r) => r.p99Ms > warnMs));
135
+ // Sub-phase break-out (agent.run's `startup` = spawn overhead before the child
136
+ // runs, PHNX-3468). Indented under the row so a slow boot is visible next to
137
+ // the total without adding columns that are empty for phaseless labels.
138
+ for (const r of sliced) {
139
+ if (!r.phases)
140
+ continue;
141
+ for (const [name, ph] of Object.entries(r.phases)) {
142
+ console.log(chalk.gray(` └ ${name}: p50 ${formatMs(ph.p50Ms)} p90 ${formatMs(ph.p90Ms)} (n=${ph.n})`));
143
+ }
144
+ }
135
145
  }
136
146
  /**
137
147
  * Prefer SQLite samples; fall back to the legacy daily JSONL so existing
@@ -3,6 +3,19 @@ import { fetchPeerPreviewDigest } from '../lib/session/remote-list.js';
3
3
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
4
4
  import type { FileChange } from '../lib/session/digest.js';
5
5
  import { extractArtifacts, extractHooks, extractLinks, extractSkills } from '../lib/session/highlights.js';
6
+ /** A session whose transcript FILE is on another machine: its `filePath` is on
7
+ * that peer's disk, so the preview can't parse it locally — it fetches the
8
+ * peer's digest over SSH and shows metadata + a "resume there" note until that
9
+ * lands. `_remote` is an explicit signal; a peer `machine` plus no readable
10
+ * local file covers host-dispatch index rows and live-registry rows that were
11
+ * synthesized outside the fan-out. A locally-readable synced mirror stays
12
+ * local even when its recorded owner is another machine.
13
+ *
14
+ * This is the READ rule and only the read rule. Whether a session may be
15
+ * RESUMED here is a different question with a different answer — a mirror is
16
+ * readable but not resumable — and `sessionOwnerDevice`
17
+ * (lib/session/resume-owner.ts) is the single place that answers it (RUSH-2022). */
18
+ export declare function transcriptOnPeerOf(session: SessionMeta): string | undefined;
6
19
  /**
7
20
  * Compact checklist tally for list rows and previews (RUSH-2045).
8
21
  * Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
@@ -25,19 +25,28 @@ import { createMemoryCache } from '../lib/memory-cache.js';
25
25
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
26
26
  import { extractArtifacts, extractHooks, extractLinks, extractRepos, extractSkills, harnessTracksBackgroundShells, isBackgroundShellStart, isSubAgentTool, } from '../lib/session/highlights.js';
27
27
  import { getSessionPlugins, readSessionPreviewCache, writeSessionPreviewCache, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
28
- /** A session whose transcript FILE is on another machine (folded in over the
29
- * live cross-machine fan-out): its `filePath` is on that peer's disk, so the
30
- * preview can't parse it locally — it fetches the peer's digest over SSH and
31
- * shows metadata + a "resume there" note until that lands. Keys off `_remote`,
32
- * not the machine tag, so locally-readable synced mirrors still parse their
33
- * file normally.
28
+ import { machineId } from '../lib/session/sync/config.js';
29
+ /** A session whose transcript FILE is on another machine: its `filePath` is on
30
+ * that peer's disk, so the preview can't parse it locally — it fetches the
31
+ * peer's digest over SSH and shows metadata + a "resume there" note until that
32
+ * lands. `_remote` is an explicit signal; a peer `machine` plus no readable
33
+ * local file covers host-dispatch index rows and live-registry rows that were
34
+ * synthesized outside the fan-out. A locally-readable synced mirror stays
35
+ * local even when its recorded owner is another machine.
34
36
  *
35
37
  * This is the READ rule and only the read rule. Whether a session may be
36
38
  * RESUMED here is a different question with a different answer — a mirror is
37
39
  * readable but not resumable — and `sessionOwnerDevice`
38
40
  * (lib/session/resume-owner.ts) is the single place that answers it (RUSH-2022). */
39
- function transcriptOnPeerOf(session) {
40
- return session._remote ? session.machine : undefined;
41
+ export function transcriptOnPeerOf(session) {
42
+ if (session._remote)
43
+ return session.machine;
44
+ if (session.machine
45
+ && session.machine !== machineId()
46
+ && (!session.filePath || !fs.existsSync(session.filePath))) {
47
+ return session.machine;
48
+ }
49
+ return undefined;
41
50
  }
42
51
  /**
43
52
  * Compact checklist tally for list rows and previews (RUSH-2045).
@@ -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;
@@ -3873,11 +3874,11 @@ export async function handlePickedSession(picked) {
3873
3874
  }
3874
3875
  // Reading and resuming are on DIFFERENT machines' terms, and conflating them
3875
3876
  // is what RUSH-2022 was. Reading follows the FILE: a synced mirror sits on
3876
- // this disk, so only a live fan-out row (`_remote`) has to be read on the
3877
- // peer. Resuming follows the HARNESS STATE, which is on the owning machine
3878
- // 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.
3879
3880
  if (picked.action === 'view') {
3880
- const readFrom = picked.session._remote ? picked.session.machine : undefined;
3881
+ const readFrom = transcriptOnPeerOf(picked.session);
3881
3882
  if (readFrom) {
3882
3883
  const rc = await runOnPeer(['sessions', picked.session.shortId, '--markdown'], readFrom);
3883
3884
  if (rc === 'no-target')
@@ -4503,7 +4504,8 @@ async function renderOneSession(query, mode, scope) {
4503
4504
  }
4504
4505
  if (outcome.kind === 'resolved') {
4505
4506
  const resolved = outcome.session;
4506
- if (resolved._remote && resolved.machine && resolved.machine !== machineId()) {
4507
+ const transcriptPeer = transcriptOnPeerOf(resolved);
4508
+ if (transcriptPeer) {
4507
4509
  const args = ['sessions', resolved.id, '--local'];
4508
4510
  const flag = modeFlag(mode);
4509
4511
  if (flag)
@@ -4518,9 +4520,9 @@ async function renderOneSession(query, mode, scope) {
4518
4520
  args.push('--last', String(scope.filter.last));
4519
4521
  if (scope.redact === false)
4520
4522
  args.push('--no-redact');
4521
- const rendered = await runOnPeer(args, resolved.machine);
4523
+ const rendered = await runOnPeer(args, transcriptPeer);
4522
4524
  if (rendered === 'no-target') {
4523
- 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.`));
4524
4526
  process.exit(1);
4525
4527
  }
4526
4528
  return;
@@ -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
  }
@@ -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;
@@ -32,10 +32,26 @@ import { sshTargetFor } from '../devices/connect.js';
32
32
  import { isHostPinned } from '../devices/known-hosts.js';
33
33
  import { machineId, normalizeHost } from '../session/sync/config.js';
34
34
  import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
35
- import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, readClaudeUsageCache, } from './usage.js';
35
+ import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
36
36
  /** How long a single peer push may take before it is abandoned for this tick. */
37
37
  export const USAGE_PUSH_DEADLINE_MS = 20_000;
38
38
  export const USAGE_PULL_DEADLINE_MS = 20_000;
39
+ /**
40
+ * A worker's newest local usage row older than this triggers a pull from the
41
+ * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
42
+ * RUSH-2392), so its only source of a fresh row is the daemon push every
43
+ * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
44
+ * cycle is tolerated before the worker actively pulls. Bounds worker staleness
45
+ * to ~30m instead of the days-old caches this replaces.
46
+ */
47
+ export const USAGE_SYNC_MAX_AGE_MS = 30 * 60_000;
48
+ /** Epoch ms of a cache row's capture time, or null when unparseable/absent. */
49
+ function capturedAtMs(capturedAt) {
50
+ if (!capturedAt)
51
+ return null;
52
+ const ms = Date.parse(capturedAt);
53
+ return Number.isFinite(ms) ? ms : null;
54
+ }
39
55
  /**
40
56
  * Decide, per peer, whether to push the local usage snapshot. Pure.
41
57
  *
@@ -109,12 +125,24 @@ export function pullUsageFromPrimary(deps = {}) {
109
125
  return result;
110
126
  }
111
127
  const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
112
- const readRow = deps.readRow ?? readClaudeUsageCache;
113
128
  const localEntries = Object.entries(localRows);
114
- if (localEntries.length > 0 && localEntries.every(([key, row]) => {
115
- const fresh = readRow(key);
116
- return fresh !== null && fresh.windows.length === row.windows.length;
117
- })) {
129
+ // Fresh means RECENT, not "the cache equals itself". The prior check read the
130
+ // SAME on-disk cache twice (exportRows() and readRow()) and compared window
131
+ // COUNT, so `fresh.windows.length === row.windows.length` was always true and
132
+ // any non-empty worker cache skipped the pull forever — which is how a worker
133
+ // served a days-old snapshot indefinitely and balanced launched into an
134
+ // account already at its weekly cap. A worker cannot self-read usage, so any
135
+ // row older than the sync cadence is stale and must trigger a pull; the merge
136
+ // is newest-wins + idempotent, so pulling when the primary is no fresher is a
137
+ // harmless no-op.
138
+ const now = (deps.now ?? Date.now)();
139
+ const staleCutoff = now - USAGE_SYNC_MAX_AGE_MS;
140
+ const allFresh = localEntries.length > 0 &&
141
+ localEntries.every(([, row]) => {
142
+ const ms = capturedAtMs(row.capturedAt);
143
+ return ms !== null && ms >= staleCutoff;
144
+ });
145
+ if (allFresh) {
118
146
  result.skipped = 'local usage cache is fresh';
119
147
  return result;
120
148
  }
@@ -22,6 +22,8 @@ import { stampProvenance, resetEventProvenanceForTest } from '../event-provenanc
22
22
  /** Lazy perf warehouse write — avoids a hard cycle at module load. */
23
23
  function recordPerfTiming(payload) {
24
24
  try {
25
+ const phases = payload.phases && Object.keys(payload.phases).length > 0 ? payload.phases : undefined;
26
+ const metaJson = phases ? JSON.stringify({ phases }) : undefined;
25
27
  // Dynamic import keeps events.ts free of a load-time dependency on perf/db.
26
28
  void import('../perf/spool.js').then(({ recordSample }) => {
27
29
  recordSample({
@@ -33,6 +35,7 @@ function recordPerfTiming(payload) {
33
35
  agentVersion: payload.version,
34
36
  sessionId: payload.sessionId,
35
37
  cwd: payload.cwd,
38
+ metaJson,
36
39
  });
37
40
  }).catch(() => { });
38
41
  }
@@ -693,6 +696,7 @@ export function createTimer(label, payload = {}) {
693
696
  version: merged.version,
694
697
  sessionId: merged.sessionId,
695
698
  cwd: merged.cwd,
699
+ phases: marks,
696
700
  });
697
701
  },
698
702
  };
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import Database from '../sqlite.js';
8
8
  import type { AggregateOptions, PerfAggregateRow } from './types.js';
9
- export type { AggregateOptions, PerfAggregateRow, PerfSample } from './types.js';
9
+ export type { AggregateOptions, PerfAggregateRow, PerfPhaseStat, PerfSample } from './types.js';
10
10
  export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
11
11
  export { percentile } from '../percentile.js';
12
12
  export declare const PERF_SCHEMA_VERSION = 1;
@@ -12,6 +12,31 @@ import { localMachineId } from '../origin-machine.js';
12
12
  import { resolveProjectKey } from '../project-key.js';
13
13
  import { percentile } from '../percentile.js';
14
14
  import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
15
+ /**
16
+ * Parse the `phases` map from a sample's meta_json. Fail-soft: a row with no
17
+ * meta_json, malformed JSON, or a non-numeric phase value contributes nothing
18
+ * rather than throwing (the warehouse must survive any writer's shape).
19
+ */
20
+ function parsePhases(metaJson) {
21
+ if (!metaJson)
22
+ return undefined;
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(metaJson);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ const phases = parsed?.phases;
31
+ if (!phases || typeof phases !== 'object')
32
+ return undefined;
33
+ const out = {};
34
+ for (const [name, val] of Object.entries(phases)) {
35
+ if (typeof val === 'number' && Number.isFinite(val))
36
+ out[name] = val;
37
+ }
38
+ return Object.keys(out).length > 0 ? out : undefined;
39
+ }
15
40
  export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
16
41
  export { percentile } from '../percentile.js';
17
42
  export const PERF_SCHEMA_VERSION = 1;
@@ -222,7 +247,7 @@ export function aggregateSamples(opts = {}) {
222
247
  clauses.push('agent = ?');
223
248
  params.push(opts.agent);
224
249
  }
225
- const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd
250
+ const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd, meta_json
226
251
  FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
227
252
  // Memoize cwd -> project key: resolveProjectKey walks the filesystem for
228
253
  // a repo root, and many rows in one warehouse query share the same cwd.
@@ -244,10 +269,21 @@ export function aggregateSamples(opts = {}) {
244
269
  const key = `${r.kind}\0${r.label}`;
245
270
  let b = map.get(key);
246
271
  if (!b) {
247
- b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0 };
272
+ b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0, phases: new Map() };
248
273
  map.set(key, b);
249
274
  }
250
275
  b.durations.push(Number(r.duration_ms));
276
+ const phases = parsePhases(r.meta_json);
277
+ if (phases) {
278
+ for (const [name, ms] of Object.entries(phases)) {
279
+ let arr = b.phases.get(name);
280
+ if (!arr) {
281
+ arr = [];
282
+ b.phases.set(name, arr);
283
+ }
284
+ arr.push(ms);
285
+ }
286
+ }
251
287
  if (r.cache === 'hit')
252
288
  b.hits++;
253
289
  else if (r.cache === 'stale-prefetch')
@@ -301,6 +337,21 @@ export function aggregateSamples(opts = {}) {
301
337
  }
302
338
  if (b.timeouts > 0)
303
339
  row.timeoutRate = Math.round((b.timeouts / n) * 1000) / 1000;
340
+ if (b.phases.size > 0) {
341
+ const phases = {};
342
+ for (const [name, durs] of b.phases) {
343
+ if (durs.length === 0)
344
+ continue;
345
+ const ps = durs.slice().sort((a, c) => a - c);
346
+ phases[name] = {
347
+ n: ps.length,
348
+ p50Ms: Math.round(percentile(ps, 50)),
349
+ p90Ms: Math.round(percentile(ps, 90)),
350
+ };
351
+ }
352
+ if (Object.keys(phases).length > 0)
353
+ row.phases = phases;
354
+ }
304
355
  if (opts.project)
305
356
  row.project = opts.project;
306
357
  out.push(row);
@@ -21,6 +21,13 @@ export interface PerfSample {
21
21
  status?: string;
22
22
  metaJson?: string;
23
23
  }
24
+ /** Percentiles for one sub-phase (e.g. agent.run's `startup`) across a bucket. */
25
+ export interface PerfPhaseStat {
26
+ /** Samples that carried this phase (may be < the row's `n`). */
27
+ n: number;
28
+ p50Ms: number;
29
+ p90Ms: number;
30
+ }
24
31
  export interface PerfAggregateRow {
25
32
  kind: string;
26
33
  label: string;
@@ -31,6 +38,13 @@ export interface PerfAggregateRow {
31
38
  meanMs: number;
32
39
  maxMs: number;
33
40
  minMs: number;
41
+ /**
42
+ * Per-sub-phase percentiles parsed from each sample's meta_json `phases`.
43
+ * Present only when at least one sample in the bucket recorded a phase
44
+ * (agent.run records `startup` — time from spawnAgent entry to child spawn —
45
+ * so boot cost is trackable independently of the total run, PHNX-3468).
46
+ */
47
+ phases?: Record<string, PerfPhaseStat>;
34
48
  cacheHitPct?: number;
35
49
  cacheStalePct?: number;
36
50
  cacheMissPct?: number;
@@ -15,7 +15,8 @@
15
15
  // exist. It parses no transcript and renders nothing — it only reshapes process
16
16
  // state the live registry already computed. When the transcript IS on disk, the
17
17
  // synthesized row carries its path, so the downstream `buildPreview` renders the
18
- // real digest; otherwise it renders the header + a "not indexed here" note.
18
+ // real digest. Without a path, a peer-owned row fetches its digest from that peer;
19
+ // a genuinely local row renders the header + a "not indexed here" note.
19
20
  import { deriveShortId } from '../text/short-id.js';
20
21
  import { isSessionTrackedAgent } from './types.js';
21
22
  /**
@@ -50,9 +51,8 @@ export function activeSessionToSessionMeta(active, self, nowMs) {
50
51
  topic: active.topic,
51
52
  version: active.version,
52
53
  messageCount: undefined,
53
- // The row runs HERE — the local live registry is machine-local, so a match
54
- // resolves without an SSH hop back to a peer.
55
54
  machine: active.machine ?? self,
55
+ _remote: (active.machine ?? self) !== self,
56
56
  ticketId: active.ticket?.id,
57
57
  prUrl: active.pr?.url,
58
58
  prNumber: active.pr?.number,
@@ -62,6 +62,8 @@ export interface AgentStatusDetail {
62
62
  task_type?: TaskType | null;
63
63
  /** Device name the teammate runs on for a distributed (--on) teammate; null for local. */
64
64
  host?: string | null;
65
+ /** Absolute path to the teammate's worktree, when known. */
66
+ workspace_dir?: string | null;
65
67
  /** Sanitized evidence observed at the lifecycle boundary that failed. */
66
68
  failure?: TeammateFailure | null;
67
69
  }
@@ -73,6 +75,7 @@ export interface TaskStatusResult {
73
75
  pending: number;
74
76
  running: number;
75
77
  completed: number;
78
+ stranded: number;
76
79
  failed: number;
77
80
  stopped: number;
78
81
  };
@@ -126,6 +129,8 @@ export interface AgentStatusSummary {
126
129
  /** Device name for a distributed (--on) teammate; null for local. */
127
130
  host: string | null;
128
131
  failure: TeammateFailure | null;
132
+ /** Absolute path to the teammate's worktree, when known. */
133
+ workspace_dir?: string | null;
129
134
  /** ISO timestamp — feed back via --since for delta polling. */
130
135
  cursor: string;
131
136
  }
@@ -137,6 +142,7 @@ export interface TaskStatusSummaryResult {
137
142
  pending: number;
138
143
  running: number;
139
144
  completed: number;
145
+ stranded: number;
140
146
  failed: number;
141
147
  stopped: number;
142
148
  };
@@ -164,6 +170,8 @@ export interface TaskInfo {
164
170
  pending: number;
165
171
  running: number;
166
172
  completed: number;
173
+ /** Completed teammates with uncommitted work and no PR (PHNX-2951). */
174
+ stranded: number;
167
175
  failed: number;
168
176
  stopped: number;
169
177
  workspace_dir: string | null;
@@ -3,6 +3,7 @@ import { getDelta } from './summarizer.js';
3
3
  import { debug } from './debug.js';
4
4
  import { buildClaudeLabelMap } from '../session/discover.js';
5
5
  import { resolveTeammateDelivery } from './delivery.js';
6
+ import { hasUncommittedChanges } from './worktree.js';
6
7
  /**
7
8
  * Truncate a bash command for status output.
8
9
  * Handles heredocs specially - shows the redirect target instead of contents.
@@ -126,6 +127,7 @@ export function toAgentStatusSummary(detail) {
126
127
  .map((m) => trimMessage(m)),
127
128
  host: detail.host ?? null,
128
129
  failure: detail.failure ?? null,
130
+ workspace_dir: detail.workspace_dir ?? null,
129
131
  cursor: detail.cursor,
130
132
  };
131
133
  }
@@ -207,7 +209,7 @@ parentSessionId) {
207
209
  ? allAgents
208
210
  : allAgents.filter((a) => a.status === effectiveFilter);
209
211
  const agentStatuses = [];
210
- const counts = { pending: 0, running: 0, completed: 0, failed: 0, stopped: 0 };
212
+ const counts = { pending: 0, running: 0, completed: 0, stranded: 0, failed: 0, stopped: 0 };
211
213
  // Count ALL agents for summary (not just filtered)
212
214
  for (const agent of allAgents) {
213
215
  if (agent.status === AgentStatus.PENDING)
@@ -221,6 +223,30 @@ parentSessionId) {
221
223
  else if (agent.status === AgentStatus.STOPPED)
222
224
  counts.stopped++;
223
225
  }
226
+ // Stranded count is computed over ALL agents, not the filter-narrowed view,
227
+ // so a `--filter running` status still reports teammates that completed dirty.
228
+ // Cache the probe result so we can reuse it when building details for the
229
+ // filtered subset without probing the worktree twice.
230
+ const uncommittedCache = new Map();
231
+ for (const agent of allAgents) {
232
+ if (agent.status !== AgentStatus.COMPLETED)
233
+ continue;
234
+ const shouldProbeWorktree = !agent.prUrl?.trim() &&
235
+ !agent.hostName &&
236
+ Boolean(agent.workspaceDir);
237
+ const hasUncommitted = shouldProbeWorktree
238
+ ? await hasUncommittedChanges(agent.workspaceDir)
239
+ : false;
240
+ uncommittedCache.set(agent.agentId, hasUncommitted);
241
+ const delivery = resolveTeammateDelivery({
242
+ status: agent.status,
243
+ prUrl: agent.prUrl,
244
+ hasUncommittedChanges: hasUncommitted,
245
+ });
246
+ if (delivery === 'stranded') {
247
+ counts.stranded++;
248
+ }
249
+ }
224
250
  // Build details only for filtered agents
225
251
  let maxTimestamp = since || new Date(0).toISOString(); // Track max timestamp for cursor
226
252
  const claudeLabels = allAgents.some((agent) => agent.agentType === 'claude')
@@ -237,6 +263,12 @@ parentSessionId) {
237
263
  if (agentTimestamp > maxTimestamp) {
238
264
  maxTimestamp = agentTimestamp;
239
265
  }
266
+ const hasUncommitted = uncommittedCache.get(agent.agentId) ?? false;
267
+ const delivery = resolveTeammateDelivery({
268
+ status: agent.status,
269
+ prUrl: agent.prUrl,
270
+ hasUncommittedChanges: hasUncommitted,
271
+ });
240
272
  const detail = {
241
273
  agent_id: agent.agentId,
242
274
  agent_type: agent.agentType,
@@ -254,15 +286,13 @@ parentSessionId) {
254
286
  after: agent.after,
255
287
  task_type: agent.taskType,
256
288
  host: agent.hostName,
289
+ workspace_dir: agent.workspaceDir,
257
290
  failure: agent.failure,
258
291
  mode: agent.mode,
259
292
  cloud_session_id: agent.cloudSessionId,
260
293
  cloud_provider: agent.cloudProvider,
261
294
  pr_url: agent.prUrl,
262
- delivery: resolveTeammateDelivery({
263
- status: agent.status,
264
- prUrl: agent.prUrl,
265
- }),
295
+ delivery,
266
296
  files_created: delta.new_files_created,
267
297
  files_modified: delta.new_files_modified,
268
298
  files_read: delta.new_files_read,
@@ -297,7 +327,7 @@ export async function handleTasks(manager, limit = 10) {
297
327
  }
298
328
  const tasks = [];
299
329
  for (const [taskName, agents] of taskMap) {
300
- let pending = 0, running = 0, completed = 0, failed = 0, stopped = 0;
330
+ let pending = 0, running = 0, completed = 0, stranded = 0, failed = 0, stopped = 0;
301
331
  let earliestStart = null;
302
332
  let latestActivity = null;
303
333
  let workspaceDir = null;
@@ -313,6 +343,19 @@ export async function handleTasks(manager, limit = 10) {
313
343
  failed++;
314
344
  else if (agent.status === AgentStatus.STOPPED)
315
345
  stopped++;
346
+ // Stranded = completed, no PR, local worktree still dirty. Probes the real
347
+ // worktree so `teams tasks` and `teams list --status` don't classify lost
348
+ // work as done (PHNX-2951).
349
+ if (agent.status === AgentStatus.COMPLETED && !agent.prUrl?.trim() && !agent.hostName && agent.workspaceDir) {
350
+ const delivery = resolveTeammateDelivery({
351
+ status: agent.status,
352
+ prUrl: agent.prUrl,
353
+ hasUncommittedChanges: await hasUncommittedChanges(agent.workspaceDir),
354
+ });
355
+ if (delivery === 'stranded') {
356
+ stranded++;
357
+ }
358
+ }
316
359
  // Track earliest start (created_at)
317
360
  if (!earliestStart || agent.startedAt < earliestStart) {
318
361
  earliestStart = agent.startedAt;
@@ -336,6 +379,7 @@ export async function handleTasks(manager, limit = 10) {
336
379
  pending,
337
380
  running,
338
381
  completed,
382
+ stranded,
339
383
  failed,
340
384
  stopped,
341
385
  workspace_dir: workspaceDir,
@@ -12,13 +12,17 @@
12
12
  */
13
13
  import { AgentStatus } from './agents.js';
14
14
  /** Postcondition-facing delivery of a teammate's work. */
15
- export type TeammateDelivery = 'pending' | 'in_progress' | 'pr_open' | 'pr_merged' | 'no_pr' | 'failed' | 'stopped';
15
+ export type TeammateDelivery = 'pending' | 'in_progress' | 'pr_open' | 'pr_merged' | 'no_pr' | 'stranded' | 'failed' | 'stopped';
16
16
  /**
17
17
  * Derive delivery from process status and PR state.
18
18
  *
19
19
  * When the process completed with a `prUrl` and merge is unknown or false,
20
20
  * delivery is `pr_open` — pessimistic: assume open until proven merged so an
21
21
  * orchestrator never mistakes "agent stopped" for "work on main".
22
+ *
23
+ * When the process completed with no PR and uncommitted changes remain in the
24
+ * worktree, delivery is `stranded` — the work exists only locally and will be
25
+ * lost if the worktree is cleaned up (PHNX-2951).
22
26
  */
23
27
  export declare function resolveTeammateDelivery(opts: {
24
28
  status: AgentStatus | string;
@@ -28,14 +32,20 @@ export declare function resolveTeammateDelivery(opts: {
28
32
  * `null`/`undefined` = unknown; with a `prUrl` that means `pr_open`.
29
33
  */
30
34
  prMerged?: boolean | null;
35
+ /**
36
+ * Whether the teammate's worktree has uncommitted changes. Only consulted
37
+ * when the process completed without a PR URL.
38
+ */
39
+ hasUncommittedChanges?: boolean | null;
31
40
  }): TeammateDelivery;
32
41
  /**
33
42
  * Human label for `teams status` rows. Replaces bare COMPLETED with PR OPEN
34
- * when delivery is still pending merge.
43
+ * when delivery is still pending merge, and with STRANDED when uncommitted
44
+ * work is stranded in the worktree.
35
45
  */
36
46
  export declare function deliveryDisplayLabel(delivery: TeammateDelivery, processStatus: AgentStatus | string): string;
37
47
  /**
38
- * Color key for statusColor-style switches. `pr_open` is its own key so the
39
- * row is visually distinct from green COMPLETED.
48
+ * Color key for statusColor-style switches. `pr_open` and `stranded` get their
49
+ * own keys so the rows are visually distinct from green COMPLETED.
40
50
  */
41
51
  export declare function deliveryColorKey(delivery: TeammateDelivery, processStatus: string): string;
@@ -17,6 +17,10 @@ import { AgentStatus } from './agents.js';
17
17
  * When the process completed with a `prUrl` and merge is unknown or false,
18
18
  * delivery is `pr_open` — pessimistic: assume open until proven merged so an
19
19
  * orchestrator never mistakes "agent stopped" for "work on main".
20
+ *
21
+ * When the process completed with no PR and uncommitted changes remain in the
22
+ * worktree, delivery is `stranded` — the work exists only locally and will be
23
+ * lost if the worktree is cleaned up (PHNX-2951).
20
24
  */
21
25
  export function resolveTeammateDelivery(opts) {
22
26
  const status = String(opts.status);
@@ -30,8 +34,9 @@ export function resolveTeammateDelivery(opts) {
30
34
  return 'stopped';
31
35
  if (status === AgentStatus.COMPLETED || status === 'completed') {
32
36
  const prUrl = opts.prUrl?.trim();
33
- if (!prUrl)
34
- return 'no_pr';
37
+ if (!prUrl) {
38
+ return opts.hasUncommittedChanges ? 'stranded' : 'no_pr';
39
+ }
35
40
  if (opts.prMerged === true)
36
41
  return 'pr_merged';
37
42
  return 'pr_open';
@@ -40,21 +45,26 @@ export function resolveTeammateDelivery(opts) {
40
45
  }
41
46
  /**
42
47
  * Human label for `teams status` rows. Replaces bare COMPLETED with PR OPEN
43
- * when delivery is still pending merge.
48
+ * when delivery is still pending merge, and with STRANDED when uncommitted
49
+ * work is stranded in the worktree.
44
50
  */
45
51
  export function deliveryDisplayLabel(delivery, processStatus) {
46
52
  if (delivery === 'pr_open')
47
53
  return 'PR OPEN';
48
54
  if (delivery === 'pr_merged')
49
55
  return 'COMPLETED';
56
+ if (delivery === 'stranded')
57
+ return 'STRANDED';
50
58
  return String(processStatus).toUpperCase();
51
59
  }
52
60
  /**
53
- * Color key for statusColor-style switches. `pr_open` is its own key so the
54
- * row is visually distinct from green COMPLETED.
61
+ * Color key for statusColor-style switches. `pr_open` and `stranded` get their
62
+ * own keys so the rows are visually distinct from green COMPLETED.
55
63
  */
56
64
  export function deliveryColorKey(delivery, processStatus) {
57
65
  if (delivery === 'pr_open')
58
66
  return 'pr_open';
67
+ if (delivery === 'stranded')
68
+ return 'stranded';
59
69
  return String(processStatus);
60
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.59",
3
+ "version": "1.22.60",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",