@phnx-labs/agents-cli 1.22.26 → 1.22.27

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 (131) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +18 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +17 -16
  8. package/dist/commands/insights.js +39 -5
  9. package/dist/commands/output.js +100 -25
  10. package/dist/commands/resume.d.ts +11 -0
  11. package/dist/commands/resume.js +51 -0
  12. package/dist/commands/sessions-picker.js +12 -7
  13. package/dist/commands/sessions.d.ts +47 -1
  14. package/dist/commands/sessions.js +345 -37
  15. package/dist/commands/ssh.js +184 -11
  16. package/dist/commands/teams.js +3 -3
  17. package/dist/index.js +16 -20
  18. package/dist/lib/auth-health.d.ts +47 -3
  19. package/dist/lib/auth-health.js +74 -15
  20. package/dist/lib/bench/index.d.ts +4 -0
  21. package/dist/lib/bench/index.js +4 -0
  22. package/dist/lib/bench/runner.d.ts +16 -0
  23. package/dist/lib/bench/runner.js +111 -0
  24. package/dist/lib/bench/schema.d.ts +5 -0
  25. package/dist/lib/bench/schema.js +91 -0
  26. package/dist/lib/bench/storage.d.ts +5 -0
  27. package/dist/lib/bench/storage.js +32 -0
  28. package/dist/lib/bench/types.d.ts +40 -0
  29. package/dist/lib/bench/types.js +1 -0
  30. package/dist/lib/claude-account-token.d.ts +2 -0
  31. package/dist/lib/claude-account-token.js +27 -1
  32. package/dist/lib/crabbox/cli.d.ts +2 -0
  33. package/dist/lib/crabbox/cli.js +2 -0
  34. package/dist/lib/crabbox/lease.js +7 -1
  35. package/dist/lib/daemon.js +20 -0
  36. package/dist/lib/devices/connect.d.ts +2 -0
  37. package/dist/lib/devices/connect.js +7 -0
  38. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  39. package/dist/lib/devices/doctor-findings.js +15 -1
  40. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  41. package/dist/lib/devices/harness-inventory.js +0 -0
  42. package/dist/lib/devices/registry.d.ts +2 -0
  43. package/dist/lib/devices/ssh-config.js +3 -0
  44. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  45. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  46. package/dist/lib/exec.bench.d.ts +1 -0
  47. package/dist/lib/exec.bench.js +186 -0
  48. package/dist/lib/exec.js +18 -8
  49. package/dist/lib/fleet/apply.js +9 -7
  50. package/dist/lib/fleet/remote-login.d.ts +4 -3
  51. package/dist/lib/fleet/remote-login.js +11 -9
  52. package/dist/lib/gemini-settings.d.ts +0 -1
  53. package/dist/lib/gemini-settings.js +12 -7
  54. package/dist/lib/hooks.d.ts +8 -5
  55. package/dist/lib/hooks.js +10 -6
  56. package/dist/lib/hosts/dispatch.js +7 -3
  57. package/dist/lib/hosts/passthrough.d.ts +22 -0
  58. package/dist/lib/hosts/passthrough.js +14 -8
  59. package/dist/lib/hosts/progress.d.ts +2 -1
  60. package/dist/lib/hosts/progress.js +4 -3
  61. package/dist/lib/hosts/providers/devices.js +1 -0
  62. package/dist/lib/hosts/ready.d.ts +4 -2
  63. package/dist/lib/hosts/ready.js +24 -8
  64. package/dist/lib/hosts/reconcile.d.ts +1 -1
  65. package/dist/lib/hosts/reconcile.js +16 -7
  66. package/dist/lib/hosts/reconnect.d.ts +45 -12
  67. package/dist/lib/hosts/reconnect.js +90 -34
  68. package/dist/lib/hosts/registry.d.ts +2 -2
  69. package/dist/lib/hosts/registry.js +3 -5
  70. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  71. package/dist/lib/hosts/remote-cmd.js +29 -0
  72. package/dist/lib/hosts/tasks.d.ts +2 -0
  73. package/dist/lib/hosts/types.d.ts +1 -0
  74. package/dist/lib/hosts/types.js +3 -0
  75. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  76. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  77. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  78. package/dist/lib/menubar/install-menubar.js +20 -9
  79. package/dist/lib/models.d.ts +30 -1
  80. package/dist/lib/models.js +42 -144
  81. package/dist/lib/pricing/cost.d.ts +9 -0
  82. package/dist/lib/pricing/cost.js +24 -0
  83. package/dist/lib/pricing/index.d.ts +1 -1
  84. package/dist/lib/pricing/index.js +1 -1
  85. package/dist/lib/redact.js +8 -3
  86. package/dist/lib/remote-agents-json.d.ts +32 -0
  87. package/dist/lib/remote-agents-json.js +47 -16
  88. package/dist/lib/resource-profiles.js +1 -2
  89. package/dist/lib/routine-notify-owner.d.ts +102 -0
  90. package/dist/lib/routine-notify-owner.js +232 -0
  91. package/dist/lib/routines.d.ts +10 -0
  92. package/dist/lib/runner.d.ts +9 -1
  93. package/dist/lib/runner.js +182 -17
  94. package/dist/lib/sandbox.d.ts +0 -2
  95. package/dist/lib/sandbox.js +2 -19
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.js +3 -2
  99. package/dist/lib/secrets/lease.d.ts +25 -0
  100. package/dist/lib/secrets/lease.js +44 -0
  101. package/dist/lib/self-update.d.ts +20 -5
  102. package/dist/lib/self-update.js +93 -16
  103. package/dist/lib/session/db.d.ts +19 -2
  104. package/dist/lib/session/db.js +54 -5
  105. package/dist/lib/session/discover.d.ts +21 -1
  106. package/dist/lib/session/discover.js +124 -32
  107. package/dist/lib/session/insights.d.ts +19 -0
  108. package/dist/lib/session/insights.js +78 -10
  109. package/dist/lib/session/recovery.d.ts +17 -1
  110. package/dist/lib/session/recovery.js +111 -4
  111. package/dist/lib/session/remote-list.d.ts +10 -9
  112. package/dist/lib/session/remote-list.js +25 -23
  113. package/dist/lib/session/resume-owner.d.ts +55 -0
  114. package/dist/lib/session/resume-owner.js +69 -0
  115. package/dist/lib/session/team-filter.d.ts +65 -0
  116. package/dist/lib/session/team-filter.js +98 -3
  117. package/dist/lib/session/types.d.ts +33 -0
  118. package/dist/lib/smart-launch.d.ts +31 -5
  119. package/dist/lib/smart-launch.js +43 -7
  120. package/dist/lib/ssh-exec.d.ts +27 -0
  121. package/dist/lib/ssh-exec.js +34 -1
  122. package/dist/lib/ssh-tunnel.d.ts +3 -2
  123. package/dist/lib/ssh-tunnel.js +25 -16
  124. package/dist/lib/startup/command-registry.d.ts +1 -0
  125. package/dist/lib/startup/command-registry.js +2 -0
  126. package/dist/lib/teams/placement-probe.d.ts +1 -1
  127. package/dist/lib/teams/placement-probe.js +24 -24
  128. package/dist/lib/tmux/session.d.ts +8 -0
  129. package/dist/lib/tmux/session.js +22 -0
  130. package/dist/lib/types.d.ts +2 -0
  131. package/package.json +1 -1
@@ -1,8 +1,11 @@
1
- import { AGENTS } from '../agents.js';
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { AGENTS, agentConfigDirName } from '../agents.js';
2
4
  import { isSelfHost } from '../devices/self-host.js';
3
5
  import { nativeResume } from '../exec.js';
4
6
  import { machineId, normalizeHost } from '../machine-id.js';
5
7
  import { collectRunCandidates, formatNoHealthyAccountError, pickBalancedCandidate, readinessFromCandidate, } from '../rotate.js';
8
+ import { getVersionHomePath } from '../versions.js';
6
9
  export class SessionRecoveryError extends Error {
7
10
  constructor(message) {
8
11
  super(message);
@@ -41,6 +44,99 @@ function sourceReason(session, candidates) {
41
44
  ? `origin ${session.agent}@${session.version} has no native resume form`
42
45
  : `origin ${session.agent}@${session.version} is ${readiness.reason}`;
43
46
  }
47
+ function isPathInside(candidate, dir) {
48
+ const rel = path.relative(dir, candidate);
49
+ return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
50
+ }
51
+ function existingDirectory(dir) {
52
+ if (!dir)
53
+ return undefined;
54
+ try {
55
+ return fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ /** Read the launch cwd Claude used to choose its projects/<cwd-key> directory.
62
+ * Claude can record attachment envelopes before the first user turn, and those
63
+ * envelopes retain the actual launch cwd even after the session changes dirs. */
64
+ function readClaudeLaunchCwd(filePath) {
65
+ const maxBytes = 2 * 1024 * 1024;
66
+ let fd;
67
+ try {
68
+ fd = fs.openSync(filePath, 'r');
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ try {
74
+ const chunk = Buffer.alloc(maxBytes);
75
+ const bytesRead = fs.readSync(fd, chunk, 0, maxBytes, 0);
76
+ const lines = chunk.toString('utf8', 0, bytesRead).split('\n');
77
+ for (const line of lines) {
78
+ if (!line.trim())
79
+ continue;
80
+ try {
81
+ const parsed = JSON.parse(line);
82
+ if (typeof parsed?.cwd !== 'string' || !path.isAbsolute(parsed.cwd))
83
+ continue;
84
+ if (existingDirectory(parsed.cwd))
85
+ return parsed.cwd;
86
+ }
87
+ catch {
88
+ // A malformed line or vanished cwd cannot identify a usable native home.
89
+ }
90
+ }
91
+ }
92
+ finally {
93
+ fs.closeSync(fd);
94
+ }
95
+ return undefined;
96
+ }
97
+ /**
98
+ * Prove that the indexed transcript is reachable from the exact active version
99
+ * home that would receive native resume. Retained trash/backup transcripts are
100
+ * intentionally rejected here: they remain readable by `/continue`, but a new
101
+ * installation with the same version number must not native-resume an empty
102
+ * isolated home.
103
+ */
104
+ export function inspectNativeResumeSession(session, versionHome) {
105
+ let realFile;
106
+ try {
107
+ realFile = fs.realpathSync(session.filePath);
108
+ }
109
+ catch {
110
+ return { available: false, reason: 'the indexed transcript is no longer present in the origin home' };
111
+ }
112
+ const roots = [versionHome, path.join(versionHome, agentConfigDirName(session.agent))];
113
+ const owned = roots.some((root) => {
114
+ try {
115
+ return isPathInside(realFile, fs.realpathSync(root));
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ });
121
+ if (!owned) {
122
+ return {
123
+ available: false,
124
+ reason: `the indexed transcript is retained outside the active ${session.agent}@${session.version ?? 'unknown'} home`,
125
+ };
126
+ }
127
+ if (session.agent === 'claude') {
128
+ const cwd = readClaudeLaunchCwd(realFile);
129
+ if (!cwd) {
130
+ return {
131
+ available: false,
132
+ reason: 'the Claude transcript does not identify an existing original project directory',
133
+ };
134
+ }
135
+ return { available: true, cwd };
136
+ }
137
+ const cwd = existingDirectory(session.cwd);
138
+ return { available: true, cwd };
139
+ }
44
140
  /**
45
141
  * Decide how a durable session resumes on the device that owns it.
46
142
  *
@@ -49,7 +145,7 @@ function sourceReason(session, candidates) {
49
145
  * same harness and uses `/continue`, whose indexed transcript reader can reach
50
146
  * retained version trash. No healthy same-harness account is a loud failure.
51
147
  */
52
- export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume) {
148
+ export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
53
149
  const agent = runnableSessionAgent(session);
54
150
  const device = sessionOriginDevice(session);
55
151
  const source = session.version
@@ -69,11 +165,22 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
69
165
  }
70
166
  const version = selection.picked.version;
71
167
  if (session.version === version && supportsNative(agent, version)) {
168
+ const inspection = nativeInspection
169
+ ?? inspectNativeResumeSession(session, getVersionHomePath(agent, version));
170
+ if (inspection.available) {
171
+ return {
172
+ mode: 'native',
173
+ agent,
174
+ version,
175
+ cwd: inspection.cwd,
176
+ reason: `origin ${agent}@${version} is installed, healthy, and owns the indexed transcript`,
177
+ };
178
+ }
72
179
  return {
73
- mode: 'native',
180
+ mode: 'continue',
74
181
  agent,
75
182
  version,
76
- reason: `origin ${agent}@${version} is installed and healthy`,
183
+ reason: `${inspection.reason}; continuing with healthy ${agent}@${version}`,
77
184
  };
78
185
  }
79
186
  return {
@@ -2,20 +2,12 @@ import { type DeviceProfile } from '../devices/registry.js';
2
2
  import { type RemoteAgentsJsonParseResult } from '../remote-agents-json.js';
3
3
  import type { SessionMeta } from './types.js';
4
4
  import { type ToolProgramCountEnvelope, type ToolSearchEnvelope } from './tool-index.js';
5
- export declare const REMOTE_STDOUT_MAX_BYTES: number;
5
+ export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
6
6
  export declare const REMOTE_TOOL_AGGREGATE_MAX_BYTES: number;
7
7
  export interface RemoteToolByteBudget {
8
8
  remainingBytes: number;
9
9
  exhausted: boolean;
10
10
  }
11
- /** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
12
- export declare class RemoteUtf8Accumulator {
13
- private readonly decoder;
14
- private value;
15
- write(chunk: Buffer): void;
16
- end(): string;
17
- current(): string;
18
- }
19
11
  /** Claim received bytes against one fleet-query budget before retaining them. */
20
12
  export declare function consumeRemoteToolByteBudget(budget: RemoteToolByteBudget, bytes: number): boolean;
21
13
  /** Charge sanitized, machine-stamped evidence because redaction may expand it. */
@@ -130,7 +122,16 @@ export declare function resolvePeerTarget(machine: string): Promise<{
130
122
  * not via a local `--host` hop, which would discover locally and dead-end for a
131
123
  * session that exists only on the peer. Resolves 'no-target' when the machine
132
124
  * isn't a dialable registered device; the caller surfaces a clear message.
125
+ *
126
+ * `opts.env` adds variables to the remote command. It deliberately does NOT
127
+ * carry `AGENTS_FLEET_REMOTE` the way the `--host` passthrough does: that marker
128
+ * gates consent-sensitive actions on the far side
129
+ * (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
130
+ * that would inherit it for its whole life — `agents browser start` inside it
131
+ * would then be refused as a cross-machine drive. A one-shot `--host` command
132
+ * can carry the marker; a session cannot.
133
133
  */
134
134
  export declare function runOnPeer(args: string[], machine: string, opts?: {
135
135
  tty?: boolean;
136
+ env?: Record<string, string>;
136
137
  }): Promise<'ok' | 'no-target'>;
@@ -14,13 +14,12 @@
14
14
  * never fatal — one asleep laptop must not blank the list.
15
15
  */
16
16
  import { spawn } from 'child_process';
17
- import { StringDecoder } from 'string_decoder';
18
17
  import chalk from 'chalk';
19
- import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
18
+ import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from '../ssh-exec.js';
20
19
  import { sshTargetFor } from '../devices/connect.js';
21
20
  import { resolveExplicitTargetSet } from '../devices/resolve-target.js';
22
21
  import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
23
- import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
22
+ import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from '../hosts/remote-cmd.js';
24
23
  import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
25
24
  import { machineId, normalizeHost } from './sync/config.js';
26
25
  import { NO_FANOUT_ENV } from './remote-active.js';
@@ -30,23 +29,12 @@ import { mapBounded } from '../concurrency.js';
30
29
  import { TOOL_QUERY_MAX_CLAUSE_BYTES, TOOL_QUERY_MAX_CALL_ROWS, TOOL_QUERY_MAX_CLAUSES, TOOL_QUERY_MAX_RESULT_SESSIONS, TOOL_QUERY_MAX_SERIALIZED_BYTES, serializedToolSearchEnvelopeBytes, } from './tool-index.js';
31
30
  import { TOOL_ERROR_OUTPUT_MAX_BYTES, TOOL_INPUT_MAX_BYTES, TOOL_SUCCESS_OUTPUT_MAX_BYTES, sanitizeToolEvidenceText, } from './tool-calls.js';
32
31
  const REMOTE_TOOL_TIMEOUT_MS = 60_000;
33
- export const REMOTE_STDOUT_MAX_BYTES = 16 * 1024 * 1024;
32
+ // The per-peer stdout ceiling and the UTF-8-safe accumulator live in ssh-exec.ts
33
+ // (the shared SSH transport both this reader and the top-level `remote-agents-json`
34
+ // fan-out import), so the bound is defined once. Re-exported here for the existing
35
+ // consumers/tests that reach them through this module.
36
+ export { REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator } from '../ssh-exec.js';
34
37
  export const REMOTE_TOOL_AGGREGATE_MAX_BYTES = TOOL_QUERY_MAX_SERIALIZED_BYTES;
35
- /** Preserve UTF-8 code points when SSH splits them across stdout chunks. */
36
- export class RemoteUtf8Accumulator {
37
- decoder = new StringDecoder('utf8');
38
- value = '';
39
- write(chunk) {
40
- this.value += this.decoder.write(chunk);
41
- }
42
- end() {
43
- this.value += this.decoder.end();
44
- return this.value;
45
- }
46
- current() {
47
- return this.value;
48
- }
49
- }
50
38
  /** Claim received bytes against one fleet-query budget before retaining them. */
51
39
  export function consumeRemoteToolByteBudget(budget, bytes) {
52
40
  if (budget.exhausted || bytes > budget.remainingBytes) {
@@ -91,7 +79,7 @@ export function remoteListCommand(forwardedArgs, os) {
91
79
  export function parseRemoteList(stdout, machine) {
92
80
  let parsed;
93
81
  try {
94
- parsed = JSON.parse(stdout);
82
+ parsed = JSON.parse(stripClixml(stdout));
95
83
  }
96
84
  catch {
97
85
  return [];
@@ -117,7 +105,7 @@ function isSafeResolverRow(value) {
117
105
  export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
118
106
  let parsed;
119
107
  try {
120
- parsed = JSON.parse(stdout);
108
+ parsed = JSON.parse(stripClixml(stdout));
121
109
  }
122
110
  catch {
123
111
  return { items: [], valid: false };
@@ -375,6 +363,10 @@ function parseRemoteToolSession(value, machine) {
375
363
  };
376
364
  }
377
365
  export function parseRemoteToolSearch(stdout, machine, expectedClauses) {
366
+ // The fleet tool-search fan-out reads a raw sshCapture (not the stripped
367
+ // gatherRemoteAgentsJson wrapper), so a Windows peer's PowerShell CLIXML banner
368
+ // must be removed here too or the box reads as "no envelope" (RUSH-2286).
369
+ stdout = stripClixml(stdout);
378
370
  if (Buffer.byteLength(stdout) > REMOTE_STDOUT_MAX_BYTES)
379
371
  return undefined;
380
372
  try {
@@ -540,6 +532,14 @@ export async function resolvePeerTarget(machine) {
540
532
  * not via a local `--host` hop, which would discover locally and dead-end for a
541
533
  * session that exists only on the peer. Resolves 'no-target' when the machine
542
534
  * isn't a dialable registered device; the caller surfaces a clear message.
535
+ *
536
+ * `opts.env` adds variables to the remote command. It deliberately does NOT
537
+ * carry `AGENTS_FLEET_REMOTE` the way the `--host` passthrough does: that marker
538
+ * gates consent-sensitive actions on the far side
539
+ * (lib/browser/remote-control.ts), and a resumed agent is a long-lived session
540
+ * that would inherit it for its whole life — `agents browser start` inside it
541
+ * would then be refused as a cross-machine drive. A one-shot `--host` command
542
+ * can carry the marker; a session cannot.
543
543
  */
544
544
  export async function runOnPeer(args, machine, opts = {}) {
545
545
  const peer = await resolvePeerTarget(machine);
@@ -547,9 +547,11 @@ export async function runOnPeer(args, machine, opts = {}) {
547
547
  return 'no-target';
548
548
  assertValidSshTarget(peer.target); // registry-sourced, but validate like the fan-out does
549
549
  const cols = terminalWidth();
550
+ const env = { ...(cols > 0 ? { COLUMNS: String(cols) } : {}), ...opts.env };
551
+ const assignments = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
550
552
  const remoteCmd = remoteShellFor(peer.os) === 'powershell'
551
- ? buildWindowsAgentsCommand({ args, env: cols > 0 ? { COLUMNS: String(cols) } : undefined })
552
- : `bash -lc ${shellQuote((cols > 0 ? [`COLUMNS=${cols}`] : []).concat(['agents', ...args].map(shellQuote)).join(' '))}`;
553
+ ? buildWindowsAgentsCommand({ args, env: assignments.length ? env : undefined })
554
+ : `bash -lc ${shellQuote(assignments.concat(['agents', ...args].map(shellQuote)).join(' '))}`;
553
555
  const sshArgs = [...SSH_OPTS, ...controlOpts()];
554
556
  if (opts.tty)
555
557
  sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Where a session may be resumed — the one place that answers "does this
3
+ * transcript belong to another machine?" and, when it does, runs the resume
4
+ * THERE instead of here.
5
+ *
6
+ * A session's agent state lives on the box that produced it: the harness keeps
7
+ * its own conversation store under that machine's home, and `machine` on a
8
+ * SessionMeta records where the transcript originated (see session/types.ts).
9
+ * A row can reach this box two ways and BOTH are remote-owned:
10
+ *
11
+ * - a **synced mirror** — locally readable at `backups/<agent>/<machine>/…`,
12
+ * which is exactly why the old code could not tell: the transcript file is
13
+ * right there, so nothing failed until the harness was asked to resume a
14
+ * conversation it has never seen;
15
+ * - a **live fan-out row** (`_remote`), whose `filePath` is on the peer's disk.
16
+ *
17
+ * Resuming either one locally starts the harness against state it does not
18
+ * have. Before RUSH-2022 that happened silently — `sessions-resume.ts` even fell
19
+ * back to `process.cwd()` when the recorded cwd did not exist locally, so a
20
+ * remote session resumed in whatever directory the user happened to be in.
21
+ *
22
+ * Callers act on the answer differently, and the difference is deliberate:
23
+ * `agents resume` and `agents sessions attach` HOP to the owner over SSH, while
24
+ * `resumeSessionInPlace` — the local takeover every routed caller reaches only
25
+ * after deciding — REFUSES. The batch `sessions resume` needs no check of its
26
+ * own: each of its tabs runs the canonical `agents resume <id>`
27
+ * (lib/session/resume-command.ts), which routes itself.
28
+ */
29
+ import type { SessionMeta } from './types.js';
30
+ /**
31
+ * Set on the SSH hop that sends a resume to its owning device: the far side must
32
+ * run it, never route again.
33
+ *
34
+ * An env var rather than a flag, deliberately. The fleet is mixed-version — a
35
+ * peer on the released CLI would die on an unknown `--here` with
36
+ * `error: unknown option '--here'`, breaking the very hop this feature adds. An
37
+ * unrecognized exported variable is inert on every version, so routing works
38
+ * against old and new peers alike. The far side deletes it after reading
39
+ * ({@link consumeResumePinned}) so it cannot leak into the agent's own children.
40
+ */
41
+ export declare const RESUME_PINNED_ENV = "AGENTS_RESUME_PINNED";
42
+ /**
43
+ * Whether this process was handed a resume by its owner-routing hop — read once,
44
+ * then cleared so a nested `agents resume` inside the running agent still routes
45
+ * normally.
46
+ */
47
+ export declare function consumeResumePinned(): boolean;
48
+ /**
49
+ * The peer that owns `session`'s state, or `undefined` when this machine does.
50
+ *
51
+ * Undefined is also the answer for an untagged row (`machine` unset — a session
52
+ * obtained outside `discoverSessions`): there is nothing to route to, so the
53
+ * caller keeps the local behaviour rather than inventing a target.
54
+ */
55
+ export declare function sessionOwnerDevice(session: Pick<SessionMeta, 'machine'>): string | undefined;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where a session may be resumed — the one place that answers "does this
3
+ * transcript belong to another machine?" and, when it does, runs the resume
4
+ * THERE instead of here.
5
+ *
6
+ * A session's agent state lives on the box that produced it: the harness keeps
7
+ * its own conversation store under that machine's home, and `machine` on a
8
+ * SessionMeta records where the transcript originated (see session/types.ts).
9
+ * A row can reach this box two ways and BOTH are remote-owned:
10
+ *
11
+ * - a **synced mirror** — locally readable at `backups/<agent>/<machine>/…`,
12
+ * which is exactly why the old code could not tell: the transcript file is
13
+ * right there, so nothing failed until the harness was asked to resume a
14
+ * conversation it has never seen;
15
+ * - a **live fan-out row** (`_remote`), whose `filePath` is on the peer's disk.
16
+ *
17
+ * Resuming either one locally starts the harness against state it does not
18
+ * have. Before RUSH-2022 that happened silently — `sessions-resume.ts` even fell
19
+ * back to `process.cwd()` when the recorded cwd did not exist locally, so a
20
+ * remote session resumed in whatever directory the user happened to be in.
21
+ *
22
+ * Callers act on the answer differently, and the difference is deliberate:
23
+ * `agents resume` and `agents sessions attach` HOP to the owner over SSH, while
24
+ * `resumeSessionInPlace` — the local takeover every routed caller reaches only
25
+ * after deciding — REFUSES. The batch `sessions resume` needs no check of its
26
+ * own: each of its tabs runs the canonical `agents resume <id>`
27
+ * (lib/session/resume-command.ts), which routes itself.
28
+ */
29
+ import { isSelfHost } from '../devices/self-host.js';
30
+ /**
31
+ * Set on the SSH hop that sends a resume to its owning device: the far side must
32
+ * run it, never route again.
33
+ *
34
+ * An env var rather than a flag, deliberately. The fleet is mixed-version — a
35
+ * peer on the released CLI would die on an unknown `--here` with
36
+ * `error: unknown option '--here'`, breaking the very hop this feature adds. An
37
+ * unrecognized exported variable is inert on every version, so routing works
38
+ * against old and new peers alike. The far side deletes it after reading
39
+ * ({@link consumeResumePinned}) so it cannot leak into the agent's own children.
40
+ */
41
+ export const RESUME_PINNED_ENV = 'AGENTS_RESUME_PINNED';
42
+ /**
43
+ * Whether this process was handed a resume by its owner-routing hop — read once,
44
+ * then cleared so a nested `agents resume` inside the running agent still routes
45
+ * normally.
46
+ */
47
+ export function consumeResumePinned() {
48
+ const pinned = process.env[RESUME_PINNED_ENV] === '1';
49
+ delete process.env[RESUME_PINNED_ENV];
50
+ return pinned;
51
+ }
52
+ /**
53
+ * The peer that owns `session`'s state, or `undefined` when this machine does.
54
+ *
55
+ * Undefined is also the answer for an untagged row (`machine` unset — a session
56
+ * obtained outside `discoverSessions`): there is nothing to route to, so the
57
+ * caller keeps the local behaviour rather than inventing a target.
58
+ */
59
+ export function sessionOwnerDevice(session) {
60
+ const owner = session.machine?.trim();
61
+ if (!owner)
62
+ return undefined;
63
+ // `isSelfHost` matches every identity this box answers to — `machineId()`
64
+ // itself, the tailnet dnsName and its short form, loopback (devices/
65
+ // self-host.ts `selfAliases`) — so a mirror tagged with this machine's tailnet
66
+ // name is local, not a peer (cf. RUSH-2114). No separate `machineId()`
67
+ // comparison: it is one of those aliases.
68
+ return isSelfHost(owner) ? undefined : owner;
69
+ }
@@ -51,3 +51,68 @@ export interface FilterResult {
51
51
  * excluded and counted in `hiddenCount`.
52
52
  */
53
53
  export declare function filterTeamSessions(sessions: SessionMeta[], showTeams: boolean): FilterResult;
54
+ /**
55
+ * Whether a team-origin session is a real `agents teams` teammate or a bare SDK
56
+ * spawn with no teammate record. The two share the `sdk-cli` entrypoint that
57
+ * sets `isTeamOrigin`, so they are told apart by whether a `meta.json` teammate
58
+ * record backs the origin ({@link TeamOrigin.source} === `'meta'`).
59
+ *
60
+ * `source` is newer than the rest of {@link TeamOrigin}, so a row that arrived
61
+ * from a pre-`source` peer over the `--host` fan-out carries none. There the
62
+ * meta-only fields settle it: the entrypoint fallback is a bare `{ handle }`, so
63
+ * any `team` / `mode` / `parentSessionId` / `startedAt` means a real teammate
64
+ * record produced it — otherwise a genuine teammate from an unupgraded peer
65
+ * would be misfiled into the no-team bucket, the exact conflation this splits.
66
+ */
67
+ export declare function teamRowKind(origin: TeamOrigin | undefined): 'teammate' | 'subagent';
68
+ /** A team-origin session with its resolved {@link TeamOrigin} guaranteed present. */
69
+ type TeamSession = SessionMeta & {
70
+ teamOrigin: TeamOrigin;
71
+ };
72
+ /**
73
+ * Key/label for the residual bucket — sessions flagged `isTeamOrigin` (an SDK /
74
+ * `sdk-cli` entrypoint) that carry no teammate `meta.json`. In practice this is
75
+ * headless `agents run` spawns and teammates whose team record aged out; a true
76
+ * `Task` sub-agent's transcript is never indexed, so it cannot land here. Named
77
+ * "(no team)" rather than "(sub-agents)" so the label matches what it holds.
78
+ */
79
+ export declare const NO_TEAM_GROUP_KEY = "(no team)";
80
+ /** Key/label for teammates whose record carries no team name (`task_name`). */
81
+ export declare const UNNAMED_TEAM_KEY = "(unnamed team)";
82
+ /** One team's sessions, grouped for the `agents sessions --teams` view. */
83
+ export interface TeamSessionGroup {
84
+ /**
85
+ * The grouping key: the team name, {@link UNNAMED_TEAM_KEY} for teammates that
86
+ * carry no `task_name`, or {@link NO_TEAM_GROUP_KEY} for the residual bucket.
87
+ */
88
+ key: string;
89
+ /** A named team of `agents teams` teammates vs the catch-all no-team bucket. */
90
+ kind: 'team' | 'noTeam';
91
+ /** Team name when this is a named team, else undefined. */
92
+ team?: string;
93
+ /**
94
+ * The orchestrator session id that spawned this team (`parent_session_id`),
95
+ * when every teammate agrees on one. Undefined for the no-team bucket, an
96
+ * unparented team, or a group whose teammates disagree.
97
+ */
98
+ spawnerSessionId?: string;
99
+ /** The team's rows, newest-active first. */
100
+ sessions: TeamSession[];
101
+ /** Most-recent activity across the group, for ordering groups. */
102
+ maxTs: string;
103
+ /** Earliest spawn time across the group (`started_at`, else `timestamp`). */
104
+ firstSpawnTs: string;
105
+ }
106
+ /**
107
+ * Group team-origin sessions for the `--teams` view: each named team becomes one
108
+ * group of its teammates, and every bare SDK spawn with no teammate record falls
109
+ * into a single trailing {@link NO_TEAM_GROUP_KEY} bucket, so a real teammate and
110
+ * a bare spawn are never shown as the same thing.
111
+ *
112
+ * Only rows with a resolved {@link TeamOrigin} participate (the caller has
113
+ * already run {@link filterTeamSessions}); any non-team session is ignored.
114
+ * Groups are ordered newest-active first; sub-agents always sort last. Pure —
115
+ * unit-tested.
116
+ */
117
+ export declare function groupSessionsByTeam(sessions: SessionMeta[]): TeamSessionGroup[];
118
+ export {};
@@ -35,7 +35,7 @@ export function classifyTeamSession(session) {
35
35
  if (origin)
36
36
  return origin;
37
37
  if (session.isTeamOrigin) {
38
- return { handle: session.id.slice(0, 8) };
38
+ return { handle: session.id.slice(0, 8), source: 'entrypoint' };
39
39
  }
40
40
  return null;
41
41
  }
@@ -54,12 +54,16 @@ function readTeamOrigin(metaPath, agentId) {
54
54
  mode: str(meta.mode),
55
55
  team: str(meta.task_name),
56
56
  parentSessionId: str(meta.parent_session_id),
57
+ startedAt: str(meta.started_at),
58
+ source: 'meta',
57
59
  },
58
60
  sessionId: str(meta.remote_session_id),
59
61
  };
60
62
  }
61
63
  catch {
62
- return { origin: { handle: agentId.slice(0, 8) } };
64
+ // An unreadable meta.json still lives under the teams agents dir, so the
65
+ // session IS a teammate — mark the source accordingly, just without fields.
66
+ return { origin: { handle: agentId.slice(0, 8), source: 'meta' } };
63
67
  }
64
68
  }
65
69
  /**
@@ -133,7 +137,8 @@ export function enrichTeamOrigins(sessions) {
133
137
  // here would find no local record and downgrade a named teammate to a bare id.
134
138
  if (session.teamOrigin)
135
139
  return session;
136
- const origin = index.get(session.id) ?? (session.isTeamOrigin ? { handle: session.id.slice(0, 8) } : null);
140
+ const origin = index.get(session.id) ??
141
+ (session.isTeamOrigin ? { handle: session.id.slice(0, 8), source: 'entrypoint' } : null);
137
142
  return origin ? { ...session, teamOrigin: origin } : session;
138
143
  });
139
144
  }
@@ -162,3 +167,93 @@ export function filterTeamSessions(sessions, showTeams) {
162
167
  }
163
168
  return { visible, hiddenCount };
164
169
  }
170
+ /**
171
+ * Whether a team-origin session is a real `agents teams` teammate or a bare SDK
172
+ * spawn with no teammate record. The two share the `sdk-cli` entrypoint that
173
+ * sets `isTeamOrigin`, so they are told apart by whether a `meta.json` teammate
174
+ * record backs the origin ({@link TeamOrigin.source} === `'meta'`).
175
+ *
176
+ * `source` is newer than the rest of {@link TeamOrigin}, so a row that arrived
177
+ * from a pre-`source` peer over the `--host` fan-out carries none. There the
178
+ * meta-only fields settle it: the entrypoint fallback is a bare `{ handle }`, so
179
+ * any `team` / `mode` / `parentSessionId` / `startedAt` means a real teammate
180
+ * record produced it — otherwise a genuine teammate from an unupgraded peer
181
+ * would be misfiled into the no-team bucket, the exact conflation this splits.
182
+ */
183
+ export function teamRowKind(origin) {
184
+ if (!origin)
185
+ return 'subagent';
186
+ if (origin.source === 'meta')
187
+ return 'teammate';
188
+ if (origin.source === 'entrypoint')
189
+ return 'subagent';
190
+ return origin.team || origin.mode || origin.parentSessionId || origin.startedAt
191
+ ? 'teammate'
192
+ : 'subagent';
193
+ }
194
+ /**
195
+ * Key/label for the residual bucket — sessions flagged `isTeamOrigin` (an SDK /
196
+ * `sdk-cli` entrypoint) that carry no teammate `meta.json`. In practice this is
197
+ * headless `agents run` spawns and teammates whose team record aged out; a true
198
+ * `Task` sub-agent's transcript is never indexed, so it cannot land here. Named
199
+ * "(no team)" rather than "(sub-agents)" so the label matches what it holds.
200
+ */
201
+ export const NO_TEAM_GROUP_KEY = '(no team)';
202
+ /** Key/label for teammates whose record carries no team name (`task_name`). */
203
+ export const UNNAMED_TEAM_KEY = '(unnamed team)';
204
+ /** Recency signal for a row: last activity, else the creation timestamp. */
205
+ function rowTs(s) {
206
+ return s.lastActivity ?? s.timestamp;
207
+ }
208
+ /** Spawn time for a row: the meta `started_at`, else the transcript creation time. */
209
+ function spawnTs(s) {
210
+ return s.teamOrigin.startedAt ?? s.timestamp;
211
+ }
212
+ /**
213
+ * Group team-origin sessions for the `--teams` view: each named team becomes one
214
+ * group of its teammates, and every bare SDK spawn with no teammate record falls
215
+ * into a single trailing {@link NO_TEAM_GROUP_KEY} bucket, so a real teammate and
216
+ * a bare spawn are never shown as the same thing.
217
+ *
218
+ * Only rows with a resolved {@link TeamOrigin} participate (the caller has
219
+ * already run {@link filterTeamSessions}); any non-team session is ignored.
220
+ * Groups are ordered newest-active first; sub-agents always sort last. Pure —
221
+ * unit-tested.
222
+ */
223
+ export function groupSessionsByTeam(sessions) {
224
+ const byKey = new Map();
225
+ for (const s of sessions) {
226
+ if (!s.teamOrigin)
227
+ continue;
228
+ const kind = teamRowKind(s.teamOrigin);
229
+ const key = kind === 'subagent'
230
+ ? NO_TEAM_GROUP_KEY
231
+ : safeTeamText(s.teamOrigin.team) ?? UNNAMED_TEAM_KEY;
232
+ const ts = s;
233
+ (byKey.get(key) ?? byKey.set(key, []).get(key)).push(ts);
234
+ }
235
+ const groups = [];
236
+ for (const [key, rows] of byKey) {
237
+ rows.sort((a, b) => (rowTs(a) < rowTs(b) ? 1 : rowTs(a) > rowTs(b) ? -1 : 0));
238
+ const kind = key === NO_TEAM_GROUP_KEY ? 'noTeam' : 'team';
239
+ // A single agreed-on spawner names the orchestrator for the whole team; a
240
+ // mix (or none) leaves it unset rather than claiming a wrong parent.
241
+ const parents = new Set(rows.map((r) => r.teamOrigin.parentSessionId).filter((p) => !!p));
242
+ groups.push({
243
+ key,
244
+ kind,
245
+ team: kind === 'team' && key !== UNNAMED_TEAM_KEY ? key : undefined,
246
+ spawnerSessionId: parents.size === 1 ? [...parents][0] : undefined,
247
+ sessions: rows,
248
+ maxTs: rowTs(rows[0]),
249
+ firstSpawnTs: rows.map(spawnTs).sort()[0],
250
+ });
251
+ }
252
+ // Newest-active team first; the no-team bucket always sinks to the bottom.
253
+ groups.sort((a, b) => {
254
+ if (a.kind !== b.kind)
255
+ return a.kind === 'noTeam' ? 1 : -1;
256
+ return a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key);
257
+ });
258
+ return groups;
259
+ }
@@ -117,6 +117,24 @@ export interface TeamOrigin {
117
117
  * meta dir has aged past the teams cleanup window.
118
118
  */
119
119
  parentSessionId?: string;
120
+ /**
121
+ * Spawn time (`started_at` in the teammate meta.json) — when `agents teams`
122
+ * launched this teammate. Distinct from the session's own `timestamp` (first
123
+ * transcript line), and present even before the harness writes a transcript.
124
+ * Absent for the entrypoint-only fallback (no meta record).
125
+ */
126
+ startedAt?: string;
127
+ /**
128
+ * How this origin was established, which is what separates a real
129
+ * `agents teams` teammate from a plain SDK sub-agent (a `Task` / `Agent()`
130
+ * spawn). Both carry the `sdk-cli` entrypoint that sets `isTeamOrigin`, so the
131
+ * entrypoint flag alone cannot tell them apart — only a teammate has a
132
+ * `meta.json` under the teams agents dir.
133
+ * - `'meta'` — a teammate: read from its `meta.json` record.
134
+ * - `'entrypoint'` — an SDK spawn with no team record (a sub-agent, or a
135
+ * teammate whose meta dir aged past the cleanup window).
136
+ */
137
+ source?: 'meta' | 'entrypoint';
120
138
  }
121
139
  /** Lightweight metadata for a discovered session, used in listings and pickers. */
122
140
  export interface SessionMeta {
@@ -144,8 +162,23 @@ export interface SessionMeta {
144
162
  tokenCount?: number;
145
163
  /** Real generated (output) tokens — excludes cache-read/-write context (issue: `agents output`). */
146
164
  outputTokens?: number;
165
+ /**
166
+ * Uncached input tokens, cache-read tokens, and cache-write (cache-creation)
167
+ * tokens — the burn split `agents output` reports, kept only for harnesses that
168
+ * record a per-message cache split (Claude/Codex/Gemini/Droid). Undefined for
169
+ * harnesses that expose no split (RUSH-2287).
170
+ */
171
+ inputTokens?: number;
172
+ cacheReadTokens?: number;
173
+ cacheWriteTokens?: number;
147
174
  /** Total USD cost, computed at scan time from per-model token usage (issue #323). */
148
175
  costUsd?: number;
176
+ /**
177
+ * USD cost priced as if caching were off — cache read/write billed at the full
178
+ * input rate. Backs `agents output --pricing no-cache` (RUSH-2287). Undefined
179
+ * when the harness records no cache split (then no-cache == actual by definition).
180
+ */
181
+ costUsdNoCache?: number;
149
182
  /** Wall-clock duration in ms (lastTs − firstTs), persisted at scan time. */
150
183
  durationMs?: number;
151
184
  /** Underlying LLM model observed in the transcript, when the agent records one. */