@phnx-labs/agents-cli 1.22.26 → 1.22.28

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 (183) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +80 -27
  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 +71 -26
  8. package/dist/commands/hosts.js +4 -3
  9. package/dist/commands/insights.js +39 -5
  10. package/dist/commands/output.js +100 -25
  11. package/dist/commands/perf.d.ts +10 -0
  12. package/dist/commands/perf.js +14 -6
  13. package/dist/commands/projects.js +24 -10
  14. package/dist/commands/resume.d.ts +11 -0
  15. package/dist/commands/resume.js +51 -0
  16. package/dist/commands/routines.js +31 -4
  17. package/dist/commands/run-account-picker.d.ts +37 -0
  18. package/dist/commands/run-account-picker.js +101 -17
  19. package/dist/commands/secrets.js +86 -2
  20. package/dist/commands/sessions-picker.js +12 -7
  21. package/dist/commands/sessions.d.ts +73 -7
  22. package/dist/commands/sessions.js +391 -49
  23. package/dist/commands/ssh.js +184 -11
  24. package/dist/commands/teams.js +22 -11
  25. package/dist/index.js +16 -20
  26. package/dist/lib/activity.js +45 -99
  27. package/dist/lib/auth-health.d.ts +47 -3
  28. package/dist/lib/auth-health.js +74 -15
  29. package/dist/lib/bench/index.d.ts +4 -0
  30. package/dist/lib/bench/index.js +4 -0
  31. package/dist/lib/bench/runner.d.ts +16 -0
  32. package/dist/lib/bench/runner.js +111 -0
  33. package/dist/lib/bench/schema.d.ts +5 -0
  34. package/dist/lib/bench/schema.js +91 -0
  35. package/dist/lib/bench/storage.d.ts +5 -0
  36. package/dist/lib/bench/storage.js +32 -0
  37. package/dist/lib/bench/types.d.ts +40 -0
  38. package/dist/lib/bench/types.js +1 -0
  39. package/dist/lib/claude-account-token.d.ts +2 -0
  40. package/dist/lib/claude-account-token.js +56 -3
  41. package/dist/lib/cloud/host.js +1 -0
  42. package/dist/lib/crabbox/cli.d.ts +2 -0
  43. package/dist/lib/crabbox/cli.js +2 -0
  44. package/dist/lib/crabbox/lease.js +7 -1
  45. package/dist/lib/daemon.js +20 -0
  46. package/dist/lib/devices/connect.d.ts +2 -0
  47. package/dist/lib/devices/connect.js +7 -0
  48. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  49. package/dist/lib/devices/doctor-findings.js +15 -1
  50. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  51. package/dist/lib/devices/harness-inventory.js +0 -0
  52. package/dist/lib/devices/registry.d.ts +2 -0
  53. package/dist/lib/devices/resolve-target.d.ts +1 -0
  54. package/dist/lib/devices/resolve-target.js +9 -2
  55. package/dist/lib/devices/ssh-config.js +3 -0
  56. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  57. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  58. package/dist/lib/events.d.ts +1 -1
  59. package/dist/lib/events.js +2 -2
  60. package/dist/lib/exec.js +18 -8
  61. package/dist/lib/fleet/apply.js +9 -7
  62. package/dist/lib/fleet/remote-login.d.ts +4 -3
  63. package/dist/lib/fleet/remote-login.js +11 -9
  64. package/dist/lib/gemini-settings.d.ts +0 -1
  65. package/dist/lib/gemini-settings.js +12 -7
  66. package/dist/lib/git.d.ts +9 -3
  67. package/dist/lib/git.js +39 -22
  68. package/dist/lib/hooks/profile.d.ts +12 -1
  69. package/dist/lib/hooks/profile.js +6 -1
  70. package/dist/lib/hooks.d.ts +8 -5
  71. package/dist/lib/hooks.js +14 -7
  72. package/dist/lib/hosts/dispatch.d.ts +10 -0
  73. package/dist/lib/hosts/dispatch.js +85 -25
  74. package/dist/lib/hosts/logs.js +11 -1
  75. package/dist/lib/hosts/passthrough.d.ts +27 -0
  76. package/dist/lib/hosts/passthrough.js +19 -10
  77. package/dist/lib/hosts/progress.d.ts +12 -1
  78. package/dist/lib/hosts/progress.js +41 -8
  79. package/dist/lib/hosts/providers/devices.js +1 -0
  80. package/dist/lib/hosts/ready.d.ts +45 -4
  81. package/dist/lib/hosts/ready.js +124 -20
  82. package/dist/lib/hosts/reconcile.d.ts +2 -1
  83. package/dist/lib/hosts/reconcile.js +24 -7
  84. package/dist/lib/hosts/reconnect.d.ts +45 -12
  85. package/dist/lib/hosts/reconnect.js +94 -36
  86. package/dist/lib/hosts/registry.d.ts +2 -2
  87. package/dist/lib/hosts/registry.js +4 -5
  88. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  89. package/dist/lib/hosts/remote-cmd.js +29 -0
  90. package/dist/lib/hosts/tasks.d.ts +3 -0
  91. package/dist/lib/hosts/types.d.ts +1 -0
  92. package/dist/lib/hosts/types.js +3 -0
  93. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  94. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  95. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  96. package/dist/lib/menubar/install-menubar.js +20 -9
  97. package/dist/lib/menubar/snapshot.js +15 -2
  98. package/dist/lib/models.d.ts +42 -6
  99. package/dist/lib/models.js +55 -153
  100. package/dist/lib/perf/db.js +16 -3
  101. package/dist/lib/perf/types.d.ts +12 -1
  102. package/dist/lib/pricing/cost.d.ts +9 -0
  103. package/dist/lib/pricing/cost.js +24 -0
  104. package/dist/lib/pricing/index.d.ts +1 -1
  105. package/dist/lib/pricing/index.js +1 -1
  106. package/dist/lib/project-probe.d.ts +22 -3
  107. package/dist/lib/project-probe.js +105 -17
  108. package/dist/lib/project-status.d.ts +9 -0
  109. package/dist/lib/project-status.js +15 -0
  110. package/dist/lib/redact.js +8 -3
  111. package/dist/lib/refresh.js +22 -11
  112. package/dist/lib/remote-agents-json.d.ts +32 -0
  113. package/dist/lib/remote-agents-json.js +47 -16
  114. package/dist/lib/resource-profiles.js +1 -2
  115. package/dist/lib/rotate.d.ts +22 -0
  116. package/dist/lib/rotate.js +26 -0
  117. package/dist/lib/routine-notify-owner.d.ts +102 -0
  118. package/dist/lib/routine-notify-owner.js +232 -0
  119. package/dist/lib/routines.d.ts +10 -0
  120. package/dist/lib/runner.d.ts +9 -1
  121. package/dist/lib/runner.js +182 -17
  122. package/dist/lib/sandbox.d.ts +0 -2
  123. package/dist/lib/sandbox.js +2 -19
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  126. package/dist/lib/secrets/agent.d.ts +19 -3
  127. package/dist/lib/secrets/agent.js +61 -22
  128. package/dist/lib/secrets/audit.d.ts +1 -1
  129. package/dist/lib/secrets/audit.js +2 -0
  130. package/dist/lib/secrets/bundles.d.ts +3 -3
  131. package/dist/lib/secrets/bundles.js +15 -5
  132. package/dist/lib/secrets/filestore.d.ts +2 -0
  133. package/dist/lib/secrets/filestore.js +4 -0
  134. package/dist/lib/secrets/lease.d.ts +25 -0
  135. package/dist/lib/secrets/lease.js +44 -0
  136. package/dist/lib/secrets/session-store.d.ts +7 -0
  137. package/dist/lib/secrets/session-store.js +21 -0
  138. package/dist/lib/self-update.d.ts +20 -5
  139. package/dist/lib/self-update.js +93 -16
  140. package/dist/lib/session/active.d.ts +49 -0
  141. package/dist/lib/session/active.js +175 -11
  142. package/dist/lib/session/bash-command.d.ts +16 -0
  143. package/dist/lib/session/bash-command.js +65 -0
  144. package/dist/lib/session/db.d.ts +23 -2
  145. package/dist/lib/session/db.js +86 -20
  146. package/dist/lib/session/discover.d.ts +21 -1
  147. package/dist/lib/session/discover.js +124 -32
  148. package/dist/lib/session/insights.d.ts +19 -0
  149. package/dist/lib/session/insights.js +78 -10
  150. package/dist/lib/session/recovery.d.ts +17 -1
  151. package/dist/lib/session/recovery.js +111 -4
  152. package/dist/lib/session/remote-bundle.js +1 -1
  153. package/dist/lib/session/remote-list.d.ts +10 -9
  154. package/dist/lib/session/remote-list.js +25 -23
  155. package/dist/lib/session/resume-owner.d.ts +55 -0
  156. package/dist/lib/session/resume-owner.js +69 -0
  157. package/dist/lib/session/team-filter.d.ts +65 -0
  158. package/dist/lib/session/team-filter.js +98 -3
  159. package/dist/lib/session/types.d.ts +33 -0
  160. package/dist/lib/smart-launch.d.ts +31 -5
  161. package/dist/lib/smart-launch.js +43 -7
  162. package/dist/lib/ssh-exec.d.ts +27 -0
  163. package/dist/lib/ssh-exec.js +34 -1
  164. package/dist/lib/ssh-tunnel.d.ts +3 -2
  165. package/dist/lib/ssh-tunnel.js +25 -16
  166. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  167. package/dist/lib/staleness/detectors/skills.js +17 -2
  168. package/dist/lib/staleness/index.d.ts +10 -1
  169. package/dist/lib/staleness/index.js +28 -3
  170. package/dist/lib/startup/command-registry.d.ts +1 -0
  171. package/dist/lib/startup/command-registry.js +2 -0
  172. package/dist/lib/teams/agents.d.ts +1 -0
  173. package/dist/lib/teams/agents.js +38 -11
  174. package/dist/lib/teams/placement-probe.d.ts +1 -1
  175. package/dist/lib/teams/placement-probe.js +24 -24
  176. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  177. package/dist/lib/teams/remoteWorktree.js +29 -27
  178. package/dist/lib/tmux/session.d.ts +8 -0
  179. package/dist/lib/tmux/session.js +22 -0
  180. package/dist/lib/types.d.ts +2 -0
  181. package/dist/lib/versions.d.ts +1 -0
  182. package/dist/lib/versions.js +40 -22
  183. package/package.json +3 -1
@@ -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. */
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Device affinity for `--device auto` / `--host auto`.
2
+ * Device placement for `--device auto` / `--host auto`.
3
3
  *
4
- * Picks a host from 14d session usage (launches by machine), weighted sample
5
- * among online devices. Most-used has highest probability — not a hard lock.
4
+ * Explicit auto placement probes live fleet health and harness readiness.
5
+ * Historical affinity remains for generic host resolution callers.
6
6
  * Account selection is separate (`--strategy balanced` / rotate.ts).
7
7
  */
8
8
  import { type AffinityRow } from './session/db.js';
9
+ import { type DevicePlacementSignal } from './teams/scheduler.js';
10
+ import type { AgentType } from './teams/agents.js';
9
11
  /** Peakiness of usage weights; >1 amplifies the most-used option. */
10
12
  export declare const DEFAULT_AFFINITY_ALPHA = 1.3;
11
13
  export interface WeightedCandidate {
@@ -45,6 +47,29 @@ export interface DeviceAffinityPlan {
45
47
  deviceCandidates: WeightedCandidate[];
46
48
  pickedDeviceKey: string | null;
47
49
  }
50
+ /** Live placement plan used by the explicit `--device auto` surface. */
51
+ export interface DeviceAutoPlan {
52
+ /** null means the local machine won the comparison. */
53
+ host: string | null;
54
+ candidates: Array<{
55
+ key: string;
56
+ loadPercent?: number;
57
+ installed?: boolean;
58
+ signedIn?: boolean;
59
+ }>;
60
+ pickedDeviceKey: string;
61
+ }
62
+ /**
63
+ * Pick the least-loaded healthy device that can run `agent` when the harness is
64
+ * known. `run auto` omits the agent and ranks the same live health/load signals
65
+ * without an installed-harness filter. The local machine participates in the
66
+ * same probe; a fully unusable fleet degrades to local instead of stranding a run.
67
+ */
68
+ export declare function resolveDeviceAuto(agent?: string, opts?: {
69
+ eligibleHosts?: string[];
70
+ localMachine?: string;
71
+ probe?: (pool: string[], agent?: AgentType) => Promise<Map<string, DevicePlacementSignal>>;
72
+ }): Promise<DeviceAutoPlan>;
48
73
  /**
49
74
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
50
75
  */
@@ -81,6 +106,7 @@ export type DeviceAutoApplyResult = {
81
106
  * returns `skipped` — never throws; callers must not hard-exit.
82
107
  */
83
108
  export declare function applyDeviceAutoToOptions(options: DeviceAutoHostOptions, deps?: {
84
- resolve?: () => DeviceAffinityPlan;
109
+ resolve?: () => DeviceAutoPlan | Promise<DeviceAutoPlan>;
110
+ agent?: string;
85
111
  accountPickerRequested?: boolean;
86
- }): DeviceAutoApplyResult;
112
+ }): Promise<DeviceAutoApplyResult>;
@@ -1,14 +1,16 @@
1
1
  /**
2
- * Device affinity for `--device auto` / `--host auto`.
2
+ * Device placement for `--device auto` / `--host auto`.
3
3
  *
4
- * Picks a host from 14d session usage (launches by machine), weighted sample
5
- * among online devices. Most-used has highest probability — not a hard lock.
4
+ * Explicit auto placement probes live fleet health and harness readiness.
5
+ * Historical affinity remains for generic host resolution callers.
6
6
  * Account selection is separate (`--strategy balanced` / rotate.ts).
7
7
  */
8
8
  import { queryAffinityRollup } from './session/db.js';
9
9
  import { localMachineId } from './session/origin-machine.js';
10
10
  import { loadDevicesSync } from './devices/registry.js';
11
11
  import { normalizeHost } from './machine-id.js';
12
+ import { probePoolSignals } from './teams/placement-probe.js';
13
+ import { pickBestDevice } from './teams/scheduler.js';
12
14
  /** Peakiness of usage weights; >1 amplifies the most-used option. */
13
15
  export const DEFAULT_AFFINITY_ALPHA = 1.3;
14
16
  /**
@@ -62,6 +64,39 @@ export function listOnlineDeviceNames(localName = localMachineId()) {
62
64
  }
63
65
  return [...names];
64
66
  }
67
+ /**
68
+ * Pick the least-loaded healthy device that can run `agent` when the harness is
69
+ * known. `run auto` omits the agent and ranks the same live health/load signals
70
+ * without an installed-harness filter. The local machine participates in the
71
+ * same probe; a fully unusable fleet degrades to local instead of stranding a run.
72
+ */
73
+ export async function resolveDeviceAuto(agent, opts = {}) {
74
+ const local = normalizeHost(opts.localMachine ?? localMachineId());
75
+ const pool = [...new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost))];
76
+ if (!pool.includes(local))
77
+ pool.push(local);
78
+ try {
79
+ const signals = await (opts.probe ?? probePoolSignals)(pool, agent);
80
+ const picked = pickBestDevice(pool, [], { signals, agentLabel: agent });
81
+ return {
82
+ host: picked === local ? null : picked,
83
+ pickedDeviceKey: picked,
84
+ candidates: pool.map((key) => ({
85
+ key,
86
+ loadPercent: signals.get(key)?.loadPercent,
87
+ installed: signals.get(key)?.installed,
88
+ signedIn: signals.get(key)?.signedIn,
89
+ })),
90
+ };
91
+ }
92
+ catch {
93
+ return {
94
+ host: null,
95
+ pickedDeviceKey: local,
96
+ candidates: [{ key: local }],
97
+ };
98
+ }
99
+ }
65
100
  /**
66
101
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
67
102
  */
@@ -120,7 +155,7 @@ const HOST_SLOTS = ['host', 'device', 'on', 'computer'];
120
155
  * Mutates `options` in place. On affinity failure, clears auto slots (local) and
121
156
  * returns `skipped` — never throws; callers must not hard-exit.
122
157
  */
123
- export function applyDeviceAutoToOptions(options, deps = {}) {
158
+ export async function applyDeviceAutoToOptions(options, deps = {}) {
124
159
  let deprecationSmart = false;
125
160
  if (options.smart) {
126
161
  const anyHost = HOST_SLOTS.some((k) => typeof options[k] === 'string' && options[k].trim() !== '');
@@ -133,7 +168,8 @@ export function applyDeviceAutoToOptions(options, deps = {}) {
133
168
  return { attempted: false, deprecationSmart };
134
169
  }
135
170
  try {
136
- const plan = (deps.resolve ?? (() => resolveDeviceAffinity({})))();
171
+ const resolve = deps.resolve ?? (() => resolveDeviceAuto(deps.agent));
172
+ const plan = await resolve();
137
173
  const concrete = plan.host; // null = local
138
174
  for (const k of HOST_SLOTS) {
139
175
  if (isDeviceAuto(options[k])) {
@@ -145,9 +181,9 @@ export function applyDeviceAutoToOptions(options, deps = {}) {
145
181
  options.balanced = true;
146
182
  }
147
183
  const hostLabel = concrete ?? 'local';
148
- const deviceHint = plan.deviceCandidates
184
+ const deviceHint = plan.candidates
149
185
  .slice(0, 4)
150
- .map((c) => `${c.key}:${c.launches}`)
186
+ .map((c) => `${c.key}:${c.loadPercent === undefined ? '?' : `${Math.round(c.loadPercent)}%`}`)
151
187
  .join(', ');
152
188
  const acctNote = accountPickerRequested ? 'accounts=picker' : 'accounts=balanced';
153
189
  return {
@@ -28,6 +28,31 @@ export declare function shellQuote(s: string): string;
28
28
  * long-lived `-N` tunnel can't leave a zombie ssh + socket pinned on the laptop.
29
29
  */
30
30
  export declare const SSH_OPTS: readonly string[];
31
+ /**
32
+ * Hard ceiling on the stdout one peer may return before its capture is aborted.
33
+ * A cross-machine fan-out streams every peer's `agents … --json` into memory in
34
+ * parallel, so an unbounded buffer means one runaway peer (a corrupt or
35
+ * pathologically large payload) can exhaust the caller's heap and take the whole
36
+ * sweep down with it — RUSH-2065 observed ~170MB retained across a single
37
+ * `--active` gather. 16 MiB is far above any legitimate JSON listing yet small
38
+ * enough that N peers in flight stay bounded. A peer that overflows is treated as
39
+ * unreachable rather than trusted with partial output.
40
+ */
41
+ export declare const REMOTE_STDOUT_MAX_BYTES: number;
42
+ /**
43
+ * Accumulate SSH stdout as UTF-8 without corrupting a multi-byte code point that
44
+ * a chunk boundary splits — the streaming `on('data')` callbacks hand us raw
45
+ * `Buffer`s, and a naive `buf.toString()` per chunk mangles any character the
46
+ * kernel cut in half. `StringDecoder` holds the trailing partial bytes until the
47
+ * next chunk completes them.
48
+ */
49
+ export declare class RemoteUtf8Accumulator {
50
+ private readonly decoder;
51
+ private value;
52
+ write(chunk: Buffer): void;
53
+ end(): string;
54
+ current(): string;
55
+ }
31
56
  export declare function controlOpts(): string[];
32
57
  /**
33
58
  * Compose an ssh connection-option prefix.
@@ -128,6 +153,8 @@ export interface SshStreamOptions {
128
153
  * interactive credential-copy path (RUSH-1767).
129
154
  */
130
155
  hostKeyOpts?: string[];
156
+ /** Additional OpenSSH argv placed before the target (for example `-i <path>`). */
157
+ extraSshArgs?: string[];
131
158
  }
132
159
  /**
133
160
  * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
@@ -8,6 +8,7 @@
8
8
  * canonical definition; `commands/secrets.ts` re-exports it.
9
9
  */
10
10
  import { spawn, spawnSync } from 'child_process';
11
+ import { StringDecoder } from 'string_decoder';
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
13
14
  import { getCacheDir } from './state.js';
@@ -46,6 +47,38 @@ export const SSH_OPTS = [
46
47
  '-o', 'ServerAliveInterval=15',
47
48
  '-o', 'ServerAliveCountMax=3',
48
49
  ];
50
+ /**
51
+ * Hard ceiling on the stdout one peer may return before its capture is aborted.
52
+ * A cross-machine fan-out streams every peer's `agents … --json` into memory in
53
+ * parallel, so an unbounded buffer means one runaway peer (a corrupt or
54
+ * pathologically large payload) can exhaust the caller's heap and take the whole
55
+ * sweep down with it — RUSH-2065 observed ~170MB retained across a single
56
+ * `--active` gather. 16 MiB is far above any legitimate JSON listing yet small
57
+ * enough that N peers in flight stay bounded. A peer that overflows is treated as
58
+ * unreachable rather than trusted with partial output.
59
+ */
60
+ export const REMOTE_STDOUT_MAX_BYTES = 16 * 1024 * 1024;
61
+ /**
62
+ * Accumulate SSH stdout as UTF-8 without corrupting a multi-byte code point that
63
+ * a chunk boundary splits — the streaming `on('data')` callbacks hand us raw
64
+ * `Buffer`s, and a naive `buf.toString()` per chunk mangles any character the
65
+ * kernel cut in half. `StringDecoder` holds the trailing partial bytes until the
66
+ * next chunk completes them.
67
+ */
68
+ export class RemoteUtf8Accumulator {
69
+ decoder = new StringDecoder('utf8');
70
+ value = '';
71
+ write(chunk) {
72
+ this.value += this.decoder.write(chunk);
73
+ }
74
+ end() {
75
+ this.value += this.decoder.end();
76
+ return this.value;
77
+ }
78
+ current() {
79
+ return this.value;
80
+ }
81
+ }
49
82
  /**
50
83
  * OpenSSH connection-multiplexing options. The first connection to a host opens
51
84
  * a control socket; subsequent connections (even from a *separate* `agents`
@@ -292,7 +325,7 @@ export function sshStream(target, remoteCmd, opts = {}) {
292
325
  assertValidSshTarget(target);
293
326
  const mux = opts.multiplex === false ? [] : controlOpts();
294
327
  const tty = opts.tty ? ['-tt'] : [];
295
- const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...tty, target, remoteCmd];
328
+ const args = [...sshConnectOpts(mux, opts.hostKeyOpts), ...(opts.extraSshArgs ?? []), ...tty, target, remoteCmd];
296
329
  const res = spawnSync('ssh', args, { stdio: 'inherit' });
297
330
  if (typeof res.status === 'number')
298
331
  return res.status;
@@ -27,13 +27,14 @@ export interface StartTunnelOptions {
27
27
  * of one CDP session and kills it on cleanup.
28
28
  */
29
29
  detached?: boolean;
30
+ extraSshArgs?: string[];
30
31
  }
31
32
  /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
32
33
  *
33
34
  * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
34
35
  * so the tunnel inherits the same options — crucially the keepalive, which lets
35
36
  * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
36
- export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number): string[];
37
+ export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number, extraSshArgs?: string[]): string[];
37
38
  /**
38
39
  * Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
39
40
  *
@@ -151,7 +152,7 @@ export declare function scpRemotePath(remotePath: string): string;
151
152
  * assert the real binary copy path keeps BatchMode and does not route bytes
152
153
  * through a PowerShell decoder.
153
154
  */
154
- export declare function buildScpArgs(target: string, remotePath: string, filePath: string): string[];
155
+ export declare function buildScpArgs(target: string, remotePath: string, filePath: string, extraSshArgs?: string[]): string[];
155
156
  /**
156
157
  * `setup --host`: push the exe, then register + start the LOGON task. Remote
157
158
  * PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening