@phnx-labs/agents-cli 1.22.59 → 1.22.61

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 (100) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/command-registry.d.ts +1 -0
  3. package/dist/cli/command-registry.js +2 -0
  4. package/dist/commands/browser.js +9 -4
  5. package/dist/commands/doctor.js +1 -1
  6. package/dist/commands/exec.js +35 -1
  7. package/dist/commands/harness-hooks.d.ts +55 -0
  8. package/dist/commands/harness-hooks.js +104 -0
  9. package/dist/commands/harness-wizard.d.ts +33 -14
  10. package/dist/commands/harness-wizard.js +53 -23
  11. package/dist/commands/harness.d.ts +14 -0
  12. package/dist/commands/harness.js +86 -5
  13. package/dist/commands/perf.js +10 -0
  14. package/dist/commands/reminders.d.ts +9 -0
  15. package/dist/commands/reminders.js +49 -0
  16. package/dist/commands/run-account-picker.d.ts +14 -0
  17. package/dist/commands/run-account-picker.js +13 -0
  18. package/dist/commands/sessions-picker.d.ts +13 -0
  19. package/dist/commands/sessions-picker.js +17 -8
  20. package/dist/commands/sessions.js +13 -11
  21. package/dist/commands/teams-picker.js +20 -6
  22. package/dist/commands/teams.d.ts +3 -3
  23. package/dist/commands/teams.js +86 -24
  24. package/dist/index.js +9 -0
  25. package/dist/lib/accounting/rotate.d.ts +63 -0
  26. package/dist/lib/accounting/rotate.js +240 -16
  27. package/dist/lib/accounting/usage-sync.d.ts +12 -2
  28. package/dist/lib/accounting/usage-sync.js +34 -6
  29. package/dist/lib/browser/drivers/local.d.ts +11 -0
  30. package/dist/lib/browser/drivers/local.js +26 -0
  31. package/dist/lib/browser/profiles.js +8 -6
  32. package/dist/lib/browser/service.d.ts +12 -8
  33. package/dist/lib/browser/service.js +38 -10
  34. package/dist/lib/claude-statusline.d.ts +14 -1
  35. package/dist/lib/claude-statusline.js +27 -2
  36. package/dist/lib/daemon/runner.js +17 -2
  37. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  38. package/dist/lib/devices/doctor-findings.js +22 -4
  39. package/dist/lib/doctor-diff.d.ts +21 -5
  40. package/dist/lib/doctor-diff.js +242 -76
  41. package/dist/lib/feed/events.d.ts +1 -1
  42. package/dist/lib/feed/events.js +28 -15
  43. package/dist/lib/github/gh-overload.d.ts +58 -0
  44. package/dist/lib/github/gh-overload.js +246 -0
  45. package/dist/lib/github/rest.d.ts +64 -0
  46. package/dist/lib/github/rest.js +111 -0
  47. package/dist/lib/harness-connection-test.d.ts +57 -0
  48. package/dist/lib/harness-connection-test.js +80 -0
  49. package/dist/lib/heal.js +8 -3
  50. package/dist/lib/installations/shims.d.ts +22 -0
  51. package/dist/lib/installations/shims.js +104 -0
  52. package/dist/lib/linear-project-counts.js +8 -0
  53. package/dist/lib/linear-rate-limit.d.ts +26 -0
  54. package/dist/lib/linear-rate-limit.js +163 -0
  55. package/dist/lib/mcp.d.ts +9 -0
  56. package/dist/lib/mcp.js +37 -1
  57. package/dist/lib/open-url.js +5 -3
  58. package/dist/lib/perf/db.d.ts +1 -1
  59. package/dist/lib/perf/db.js +53 -2
  60. package/dist/lib/perf/types.d.ts +14 -0
  61. package/dist/lib/permissions.d.ts +28 -0
  62. package/dist/lib/permissions.js +156 -1
  63. package/dist/lib/refresh.js +9 -1
  64. package/dist/lib/reminders.d.ts +29 -0
  65. package/dist/lib/reminders.js +88 -0
  66. package/dist/lib/resource-content-diff.d.ts +33 -0
  67. package/dist/lib/resource-content-diff.js +103 -0
  68. package/dist/lib/rules/compile.d.ts +7 -0
  69. package/dist/lib/rules/compile.js +7 -1
  70. package/dist/lib/session/active.d.ts +41 -4
  71. package/dist/lib/session/active.js +58 -7
  72. package/dist/lib/session/host-link.d.ts +22 -0
  73. package/dist/lib/session/host-link.js +40 -4
  74. package/dist/lib/session/live-metadata.js +3 -3
  75. package/dist/lib/session/trajectory.d.ts +42 -0
  76. package/dist/lib/session/trajectory.js +46 -27
  77. package/dist/lib/ssh-exec.d.ts +30 -0
  78. package/dist/lib/ssh-exec.js +37 -5
  79. package/dist/lib/startup/command-registry.js +1 -1
  80. package/dist/lib/subagents-registry.d.ts +18 -0
  81. package/dist/lib/subagents-registry.js +79 -0
  82. package/dist/lib/teams/agents.d.ts +12 -0
  83. package/dist/lib/teams/agents.js +51 -0
  84. package/dist/lib/teams/api.d.ts +8 -0
  85. package/dist/lib/teams/api.js +50 -6
  86. package/dist/lib/teams/delivery.d.ts +14 -4
  87. package/dist/lib/teams/delivery.js +15 -5
  88. package/dist/lib/traces/schema2-build.d.ts +85 -0
  89. package/dist/lib/traces/schema2-build.js +637 -0
  90. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  91. package/dist/lib/traces/schema2-danger.js +185 -0
  92. package/dist/lib/traces/schema2.d.ts +149 -0
  93. package/dist/lib/traces/schema2.js +20 -0
  94. package/dist/lib/traces/sync.d.ts +93 -0
  95. package/dist/lib/traces/sync.js +75 -22
  96. package/dist/lib/traces/worker-template.js +5 -0
  97. package/dist/lib/uninstall.js +10 -1
  98. package/dist/lib/workflows.d.ts +11 -0
  99. package/dist/lib/workflows.js +67 -8
  100. package/package.json +1 -1
@@ -17,7 +17,9 @@ import { listProfiles, readProfile, writeProfile, deleteProfile, profileExists,
17
17
  import { listPresets, getPreset } from '../lib/profiles-presets.js';
18
18
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName } from '../lib/agents.js';
19
19
  import { findAccount } from '../lib/account-registry.js';
20
- import { runWizardSteps, createSteps, editSteps, defaultWizardIO, } from './harness-wizard.js';
20
+ import { runWizardSteps, createSteps, editSteps, defaultWizardIO, runConnectionTest, } from './harness-wizard.js';
21
+ import { harnessHooks } from './harness-hooks.js';
22
+ import { CONNECTION_TEST_PROMPT } from '../lib/harness-connection-test.js';
21
23
  /** Short capability summary for a native harness — its supported run modes. */
22
24
  function nativeModes(id) {
23
25
  const modes = AGENTS[id]?.capabilities?.modes ?? [];
@@ -196,6 +198,73 @@ export function addNeedsWizard(name, opts) {
196
198
  return true;
197
199
  return !getPreset(name);
198
200
  }
201
+ /**
202
+ * Resolve the tri-state connection-test gate (RUSH-2221), pure so the branching
203
+ * is unit-tested with no prompt or spawn. `--test` forces it on, `--no-test`
204
+ * forces it off, and with neither flag a TTY is asked (default yes) while a
205
+ * non-interactive caller (`--key-stdin`, piped, CI) skips it — so scripting stays
206
+ * non-interactive unless it opts in with `--test`.
207
+ */
208
+ export function connectionTestGate(testFlag, interactive) {
209
+ if (testFlag === true)
210
+ return 'on';
211
+ if (testFlag === false)
212
+ return 'off';
213
+ return interactive ? 'ask' : 'off';
214
+ }
215
+ /**
216
+ * Pre-save connection test (RUSH-2221). The harness is already on disk (the test
217
+ * drives the real `agents run <name>` path, so it must be), so this runs a
218
+ * classified smoke test and — on a TTY, when it fails — offers to keep it, edit
219
+ * it, or delete-and-cancel. A test is never blocking on its own: a save is only
220
+ * discarded when the user explicitly chooses to, so a `--test` failure in a
221
+ * non-interactive shell warns and keeps rather than exiting non-zero.
222
+ */
223
+ async function preSaveConnectionTest(name, testFlag) {
224
+ const interactive = isInteractiveTerminal();
225
+ const gate = connectionTestGate(testFlag, interactive);
226
+ let shouldTest;
227
+ if (gate === 'on')
228
+ shouldTest = true;
229
+ else if (gate === 'off')
230
+ shouldTest = false;
231
+ else {
232
+ const { confirm } = await import('@inquirer/prompts');
233
+ shouldTest = await confirm({ message: `Test the connection for '${name}' now?`, default: true });
234
+ }
235
+ if (!shouldTest)
236
+ return;
237
+ console.log(chalk.gray(`Testing '${name}' — sending "${CONNECTION_TEST_PROMPT}" through agents run…`));
238
+ const result = await runConnectionTest({ mode: 'create', name }, harnessHooks());
239
+ if (!result)
240
+ return;
241
+ if (result.ok) {
242
+ console.log(chalk.green(`✓ ${result.message}`));
243
+ return;
244
+ }
245
+ console.log(chalk.yellow(`✗ ${result.message}`));
246
+ if (!interactive) {
247
+ console.log(chalk.gray(`Kept anyway. Fix and retest with: agents harness edit ${name}`));
248
+ return;
249
+ }
250
+ const { select } = await import('@inquirer/prompts');
251
+ const action = await select({
252
+ message: 'The connection test failed. What would you like to do?',
253
+ choices: [
254
+ { name: 'Keep it (the endpoint may just be down right now)', value: 'keep' },
255
+ { name: 'Edit it now', value: 'edit' },
256
+ { name: 'Delete it and cancel', value: 'delete' },
257
+ ],
258
+ });
259
+ if (action === 'edit') {
260
+ await runEditWizard(name, {});
261
+ return;
262
+ }
263
+ if (action === 'delete') {
264
+ deleteProfile(name);
265
+ throw new Error(`Harness '${name}' deleted after a failed connection test.`);
266
+ }
267
+ }
199
268
  /**
200
269
  * Shared build+persist flow for a fork — used by `agents harness fork`'s
201
270
  * flag-driven path AND by the wizard (both for `fork` and, when it falls back
@@ -223,6 +292,7 @@ async function runForkFlow(source, name, opts) {
223
292
  writeProfile(forked);
224
293
  console.log(chalk.green(`Harness '${name}' forked from ${source}.`));
225
294
  console.log(chalk.gray(`Try: agents run ${name} "hello"`));
295
+ await preSaveConnectionTest(name, opts.test);
226
296
  }
227
297
  /**
228
298
  * Interactive `agents harness add`/`fork` wizard — runs when required info is
@@ -234,7 +304,7 @@ async function runForkFlow(source, name, opts) {
234
304
  */
235
305
  async function runCreateWizard() {
236
306
  const io = await defaultWizardIO();
237
- const draft = await runWizardSteps(createSteps(), { mode: 'create' }, io);
307
+ const draft = await runWizardSteps(createSteps(), { mode: 'create' }, io, harnessHooks());
238
308
  return {
239
309
  source: draft.source,
240
310
  name: draft.name,
@@ -292,7 +362,7 @@ async function runEditWizard(name, cliOpts) {
292
362
  }
293
363
  const original = readProfile(name);
294
364
  const io = await defaultWizardIO();
295
- const draft = await runWizardSteps(editSteps(original), { mode: 'edit', original, host: original.host.agent, name }, io);
365
+ const draft = await runWizardSteps(editSteps(original), { mode: 'edit', original, host: original.host.agent, name }, io, harnessHooks());
296
366
  const opts = { ...draftToEditOptions(draft, original), keyStdin: cliOpts.keyStdin };
297
367
  if (!hasEditFlags(opts)) {
298
368
  console.log(chalk.gray(`No changes made to '${name}'.`));
@@ -308,6 +378,9 @@ async function runEditWizard(name, cliOpts) {
308
378
  writeProfile(edited);
309
379
  console.log(chalk.green(`Harness '${name}' updated.`));
310
380
  console.log(chalk.gray(`Model: ${profileModelLabel(edited)}`));
381
+ // An edit that touched host/model/endpoint/auth is exactly what can break the
382
+ // harness, so re-test after saving (RUSH-2221). cliOpts carries the --test flag.
383
+ await preSaveConnectionTest(name, cliOpts.test);
311
384
  }
312
385
  export function registerHarnessCommands(program) {
313
386
  const cmd = program
@@ -362,6 +435,8 @@ Examples:
362
435
  .option('--from-secrets <bundle>[:<key>]', 'Removed: import the value with agents accounts add, then use --account')
363
436
  .option('--key-stdin', 'Read API key from stdin instead of prompting (for scripts/CI)')
364
437
  .option('--force', 'Overwrite an existing harness with the same name')
438
+ .option('--test', 'Run a connection test before saving (default: ask on a terminal)')
439
+ .option('--no-test', 'Skip the pre-save connection test')
365
440
  .action(async (name, opts) => {
366
441
  try {
367
442
  if (opts.authProvider || opts.fromSecrets)
@@ -371,10 +446,11 @@ Examples:
371
446
  throw new Error("'agents harness add' needs --preset or --host + --model (or a name and an interactive terminal for the wizard).");
372
447
  }
373
448
  const wiz = await runCreateWizard();
374
- await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin });
449
+ await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin, test: opts.test });
375
450
  return;
376
451
  }
377
452
  await addProfile(name, opts, 'Harness');
453
+ await preSaveConnectionTest(name, opts.test);
378
454
  }
379
455
  catch (err) {
380
456
  console.error(chalk.red(err.message));
@@ -393,6 +469,8 @@ Examples:
393
469
  .option('--from-secrets <bundle>[:<key>]', 'Removed: import the value with agents accounts add, then use --account')
394
470
  .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
395
471
  .option('--force', 'Overwrite an existing harness with the same name')
472
+ .option('--test', 'Run a connection test before saving (default: ask on a terminal)')
473
+ .option('--no-test', 'Skip the pre-save connection test')
396
474
  .addHelpText('after', `
397
475
  Examples:
398
476
  # Fork OpenCode into a harness pinned to a DeepSeek model on OpenRouter
@@ -417,7 +495,7 @@ Examples:
417
495
  throw new Error("'agents harness fork' needs <source> and <name> (or an interactive terminal for the wizard).");
418
496
  }
419
497
  const wiz = await runCreateWizard();
420
- await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin });
498
+ await runForkFlow(wiz.source, wiz.name, { ...wiz.opts, force: opts.force, keyStdin: opts.keyStdin, test: opts.test });
421
499
  return;
422
500
  }
423
501
  await runForkFlow(source, name, opts);
@@ -439,6 +517,8 @@ Examples:
439
517
  .option('--fallback-model <id>', 'Secondary model retried on the same host on a rate limit (pass an empty string to clear it)')
440
518
  .option('--from-secrets <bundle>[:<key>]', 'Removed: import the value with agents accounts add, then use --account')
441
519
  .option('--key-stdin', 'Read the API key from stdin instead of prompting (for scripts/CI)')
520
+ .option('--test', 'Run a connection test after saving (default: ask on a terminal)')
521
+ .option('--no-test', 'Skip the connection test')
442
522
  .addHelpText('after', `
443
523
  Examples:
444
524
  # Swap the pinned model
@@ -479,6 +559,7 @@ Examples:
479
559
  writeProfile(edited);
480
560
  console.log(chalk.green(`Harness '${name}' updated.`));
481
561
  console.log(chalk.gray(`Model: ${profileModelLabel(edited)}`));
562
+ await preSaveConnectionTest(name, opts.test);
482
563
  }
483
564
  catch (err) {
484
565
  console.error(chalk.red(err.message));
@@ -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
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `agents reminders` — list your personal operating reminders.
3
+ *
4
+ * The reminders live in `~/.agents/reminders/reminders.yaml` and are surfaced
5
+ * succinctly in the Claude statusline — one per session, chosen from the session
6
+ * id. This command shows the full set (and the file to edit).
7
+ */
8
+ import type { Command } from 'commander';
9
+ export declare function registerRemindersCommand(program: Command): void;
@@ -0,0 +1,49 @@
1
+ import chalk from 'chalk';
2
+ import { loadReminders, remindersFilePath } from '../lib/reminders.js';
3
+ import { setHelpSections } from '../lib/help.js';
4
+ const EXAMPLES = [
5
+ 'agents reminders # list your operating reminders',
6
+ 'agents reminders --json # machine-readable output',
7
+ ].join('\n');
8
+ const NOTES = [
9
+ "Reminders live in ~/.agents/reminders/reminders.yaml and sync across the fleet via 'agents repo push'.",
10
+ 'One shows succinctly in the Claude statusline per session, chosen deterministically from the session id,',
11
+ 'so concurrent agents each show a different one and it stays stable within a session.',
12
+ ].join('\n');
13
+ export function registerRemindersCommand(program) {
14
+ const cmd = program
15
+ .command('reminders')
16
+ .description('Personal operating reminders shown in the Claude statusline')
17
+ .option('--json', 'output as JSON')
18
+ .action((opts) => {
19
+ const file = remindersFilePath();
20
+ let reminders;
21
+ try {
22
+ reminders = loadReminders();
23
+ }
24
+ catch (err) {
25
+ console.error(chalk.red(`Could not read reminders: ${err instanceof Error ? err.message : String(err)}`));
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ if (opts.json) {
30
+ process.stdout.write(`${JSON.stringify({ file, reminders }, null, 2)}\n`);
31
+ return;
32
+ }
33
+ if (reminders.length === 0) {
34
+ console.log(`No reminders yet. Add them to ${chalk.cyan(file)}`);
35
+ console.log(chalk.dim('Each shows succinctly in the Claude statusline — one per session.'));
36
+ return;
37
+ }
38
+ const count = `${reminders.length} reminder${reminders.length === 1 ? '' : 's'}`;
39
+ console.log(chalk.dim(`${count} · ${file}`));
40
+ console.log(chalk.dim('One shows in the Claude statusline per session, chosen from the session id.\n'));
41
+ for (const reminder of reminders) {
42
+ console.log(` ${chalk.cyan('◆')} ${chalk.bold(reminder.short)}`);
43
+ if (reminder.full && reminder.full !== reminder.short) {
44
+ console.log(` ${chalk.dim(reminder.full)}`);
45
+ }
46
+ }
47
+ });
48
+ setHelpSections(cmd, { examples: EXAMPLES, notes: NOTES });
49
+ }
@@ -52,6 +52,20 @@ export declare function signInLaunchDecision(input: {
52
52
  tty: boolean;
53
53
  json: boolean;
54
54
  }): 'launch' | 'fail-loud';
55
+ /**
56
+ * How a `balanced`/`available` run reacts when every account's usage is stale and
57
+ * none is verified (PHNX-2526). A human present at a real terminal gets the
58
+ * account `picker` — they can choose knowing the numbers are stale — while every
59
+ * unattended shape (`--headless`, `--json`, or no TTY) `fail-loud`s with
60
+ * NO_VERIFIED_USAGE rather than silently guess on a stale snapshot. `headless`
61
+ * joins the gate because a routine/machine dispatch can carry a TTY yet have no
62
+ * human to answer a picker; the split mirrors `signInLaunchDecision`.
63
+ */
64
+ export declare function noVerifiedUsageDecision(input: {
65
+ tty: boolean;
66
+ json: boolean;
67
+ headless: boolean;
68
+ }): 'picker' | 'fail-loud';
55
69
  /**
56
70
  * Choose which installed version to launch so the user can authenticate, when a
57
71
  * strategy found zero healthy accounts but at least one is merely signed out
@@ -221,6 +221,19 @@ export function signInLaunchDecision(input) {
221
221
  const humanPresent = input.tty && !input.json;
222
222
  return input.recoverable > 0 && humanPresent ? 'launch' : 'fail-loud';
223
223
  }
224
+ /**
225
+ * How a `balanced`/`available` run reacts when every account's usage is stale and
226
+ * none is verified (PHNX-2526). A human present at a real terminal gets the
227
+ * account `picker` — they can choose knowing the numbers are stale — while every
228
+ * unattended shape (`--headless`, `--json`, or no TTY) `fail-loud`s with
229
+ * NO_VERIFIED_USAGE rather than silently guess on a stale snapshot. `headless`
230
+ * joins the gate because a routine/machine dispatch can carry a TTY yet have no
231
+ * human to answer a picker; the split mirrors `signInLaunchDecision`.
232
+ */
233
+ export function noVerifiedUsageDecision(input) {
234
+ const humanPresent = input.tty && !input.json && !input.headless;
235
+ return humanPresent ? 'picker' : 'fail-loud';
236
+ }
224
237
  /**
225
238
  * Choose which installed version to launch so the user can authenticate, when a
226
239
  * strategy found zero healthy accounts but at least one is merely signed out
@@ -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)
@@ -108,7 +108,7 @@ export declare function staleRepoError(params: {
108
108
  * the teammate itself so we don't need the original --cloud CLI args.
109
109
  */
110
110
  export declare function wireCloudDispatcher(mgr: AgentManager): void;
111
- export declare function cloudDispatchOptions(agent: Pick<AgentProcess, 'prompt' | 'agentType' | 'cloudRepo' | 'cloudBranch' | 'model'>): DispatchOptions;
111
+ export declare function cloudDispatchOptions(agent: Pick<AgentProcess, 'prompt' | 'agentType' | 'cloudRepo' | 'cloudBranch' | 'model' | 'mode'>): DispatchOptions;
112
112
  /**
113
113
  * Single-wave start used by `teams start` without --watch. startReady()
114
114
  * terminalizes a placement/spawn/cloud/dependency failure to FAILED with
@@ -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*