@phnx-labs/agents-cli 1.22.25 → 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 (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
1
  import * as fs from 'fs';
2
2
  import { StringDecoder } from 'string_decoder';
3
- import { getDB } from './db.js';
3
+ import { getDB, maintainSessionSearchIndex } from './db.js';
4
4
  import { parseSession } from './parse.js';
5
5
  import { TOOL_INDEX_VERSION, TOOL_INDEX_LIMIT_ORDINAL, ToolCallCollector, collectClaudeToolCalls, collectCodexToolCalls, toolCallEvidenceBytes, toolCallsFromEvents, } from './tool-calls.js';
6
- import { persistToolCalls, purgeToolCalls, toolEvidenceSourcePath, } from './tool-store.js';
6
+ import { canonicalToolLedgerPath, persistToolCalls, purgeToolCalls, toolEvidenceSourcePath, } from './tool-store.js';
7
7
  const BACKFILL_MAX_FILES = 25;
8
8
  const BACKFILL_MAX_BYTES = 16 * 1024 * 1024;
9
9
  const BACKFILL_MAX_IN_MEMORY_SOURCE_BYTES = 16 * 1024 * 1024;
@@ -39,16 +39,71 @@ export function toolSearchRemoteReceiveBudget(envelope) {
39
39
  const localBytes = serializedToolSearchEnvelopeBytes(envelope);
40
40
  return Math.max(0, TOOL_QUERY_MAX_SERIALIZED_BYTES - TOOL_QUERY_MERGE_OVERHEAD_BYTES - localBytes);
41
41
  }
42
- function needsIndex(db, sessionId, stamp) {
43
- const row = db.prepare(`
44
- SELECT file_mtime_ms, file_size, extractor_version
42
+ /**
43
+ * The ledger columns every candidate session is judged on. Deliberately NOT
44
+ * `parser_state`: this runs once per session in the scan's warm path, and that
45
+ * column holds a serialized collector snapshot that can reach a megabyte. It is
46
+ * read separately, only for the sessions that turn out to need indexing.
47
+ */
48
+ function readToolLedger(db, sessionId) {
49
+ return db.prepare(`
50
+ SELECT file_path, file_mtime_ms, file_size, extractor_version, parsed_offset
45
51
  FROM tool_scan_ledger WHERE session_id = ?
46
52
  `).get(sessionId);
53
+ }
54
+ function readToolParserState(db, sessionId) {
55
+ const row = db.prepare(`SELECT parser_state FROM tool_scan_ledger WHERE session_id = ?`)
56
+ .get(sessionId);
57
+ return row?.parser_state ?? null;
58
+ }
59
+ function needsIndex(row, stamp) {
47
60
  return !row
48
61
  || row.file_mtime_ms !== stamp.fileMtimeMs
49
62
  || row.file_size !== stamp.fileSize
50
63
  || row.extractor_version !== TOOL_INDEX_VERSION;
51
64
  }
65
+ /**
66
+ * Where to start reading a session whose transcript changed.
67
+ *
68
+ * A live session's transcript is append-only, so re-reading it from byte 0 on
69
+ * every scan re-parses the entire history to discover the handful of records
70
+ * that are new — the cost that makes a large session's tool index quadratic in
71
+ * the number of scans. When the ledger carries a resume point that the current
72
+ * file still agrees with, the scan reads only the appended bytes and merges the
73
+ * result (`append`); anything else re-reads the whole file (`replace`).
74
+ *
75
+ * Each check below rejects a case where the stored prefix may no longer describe
76
+ * the file: a harness the streaming parser cannot resume, a different extractor,
77
+ * no recorded resume point, a source path the ledger row does not describe, a
78
+ * file that shrank below what was already parsed (a rewrite or truncation, not
79
+ * an append), or a snapshot that does not read back.
80
+ */
81
+ function planToolScan(db, sessionId, row, sourcePath, stamp, resumable) {
82
+ const full = { mode: 'replace', startOffset: 0 };
83
+ if (!resumable || !row)
84
+ return full;
85
+ if (row.extractor_version !== TOOL_INDEX_VERSION)
86
+ return full;
87
+ if (row.parsed_offset === null)
88
+ return full;
89
+ if (row.file_path !== canonicalToolLedgerPath(sourcePath))
90
+ return full;
91
+ if (stamp.fileSize < row.file_size || stamp.fileSize < row.parsed_offset)
92
+ return full;
93
+ const parserState = readToolParserState(db, sessionId);
94
+ if (parserState === null)
95
+ return full;
96
+ let snapshot;
97
+ try {
98
+ snapshot = JSON.parse(parserState);
99
+ }
100
+ catch {
101
+ return full;
102
+ }
103
+ if (snapshot?.v !== 1 || !Number.isSafeInteger(snapshot.nextOrdinal))
104
+ return full;
105
+ return { mode: 'append', startOffset: row.parsed_offset, snapshot };
106
+ }
52
107
  /** Read index completeness from SQLite only; never stat or parse transcripts. */
53
108
  export function readToolIndexCoverage(sessions) {
54
109
  const db = getDB();
@@ -90,14 +145,23 @@ function backfillLimitCall(session, reason) {
90
145
  };
91
146
  }
92
147
  /** Stream Claude/Codex JSONL without ever retaining an oversized record. */
93
- async function streamJsonlToolCalls(session) {
94
- const collector = new ToolCallCollector();
95
- const stream = fs.createReadStream(session.filePath, { highWaterMark: 64 * 1024 });
148
+ async function streamJsonlToolCalls(session, from = { startOffset: 0 }) {
149
+ const collector = new ToolCallCollector(from.snapshot);
150
+ const stream = fs.createReadStream(session.filePath, {
151
+ highWaterMark: 64 * 1024,
152
+ start: from.startOffset,
153
+ });
96
154
  const decoder = new StringDecoder('utf8');
97
155
  let pending = '';
98
156
  let pendingBytes = 0;
99
157
  let droppingOversizedLine = false;
100
158
  let skippedOversizedLine = false;
159
+ // Byte offset just past the last complete record applied. Only a complete,
160
+ // newline-terminated record advances it, so resuming here can never re-apply a
161
+ // record (which would mint a second ordinal for it) nor skip a partial tail.
162
+ let parsedOffset = from.startOffset;
163
+ /** Bytes of the record currently being assembled, across chunk boundaries. */
164
+ let lineBytes = 0;
101
165
  const applyLine = (line) => {
102
166
  if (!line.trim())
103
167
  return;
@@ -119,8 +183,14 @@ async function streamJsonlToolCalls(session) {
119
183
  const newline = text.indexOf('\n', start);
120
184
  const end = newline >= 0 ? newline : text.length;
121
185
  const segment = text.slice(start, end);
186
+ const segmentBytes = Buffer.byteLength(segment);
187
+ // Counted outside the drop guard and across chunk boundaries: this is the
188
+ // record's true size on disk, which is what the resume offset is measured
189
+ // in. `pendingBytes` cannot stand in for it — that one resets when an
190
+ // oversized record is dropped, and a record split over two 64 KiB reads
191
+ // would lose the part carried in from the previous chunk.
192
+ lineBytes += segmentBytes;
122
193
  if (!droppingOversizedLine) {
123
- const segmentBytes = Buffer.byteLength(segment);
124
194
  if (pendingBytes + segmentBytes <= BACKFILL_MAX_JSONL_RECORD_BYTES) {
125
195
  pending += segment;
126
196
  pendingBytes += segmentBytes;
@@ -136,6 +206,8 @@ async function streamJsonlToolCalls(session) {
136
206
  break;
137
207
  if (!droppingOversizedLine)
138
208
  applyLine(pending);
209
+ parsedOffset += lineBytes + 1; // + the newline itself
210
+ lineBytes = 0;
139
211
  pending = '';
140
212
  pendingBytes = 0;
141
213
  droppingOversizedLine = false;
@@ -145,25 +217,48 @@ async function streamJsonlToolCalls(session) {
145
217
  for await (const chunk of stream)
146
218
  consume(decoder.write(chunk));
147
219
  consume(decoder.end());
220
+ // Snapshot BEFORE the unterminated trailing record, and pair it with an offset
221
+ // that stops short of that record. The writer may be mid-append, so the record
222
+ // is indexed now (its evidence is real) but is re-read by the next scan — which
223
+ // resumes with the same next-ordinal and so re-derives the same ordinals,
224
+ // making the re-read an idempotent upsert rather than a duplicate.
225
+ const resume = skippedOversizedLine
226
+ // A dropped oversized record left the ordinals and the pending map out of
227
+ // step with the file; nothing here can be resumed from.
228
+ ? null
229
+ : { parserState: JSON.stringify(collector.snapshot()), parsedOffset };
148
230
  if (!droppingOversizedLine && pending.length > 0)
149
231
  applyLine(pending);
150
232
  const calls = collector.drainChanged();
151
233
  if (skippedOversizedLine && !calls.some((call) => call.ordinal === TOOL_INDEX_LIMIT_ORDINAL)) {
152
234
  calls.push(backfillLimitCall(session, 'At least one JSONL record exceeded the 1 MiB tool-backfill parser limit.'));
153
235
  }
154
- return calls;
236
+ return { calls, resume };
237
+ }
238
+ /** True for the harnesses whose transcript the streaming parser can resume. */
239
+ function isResumableToolSource(agent) {
240
+ return agent === 'claude' || agent === 'codex';
155
241
  }
156
- async function toolCallsForBackfill(session, sourceBytes) {
157
- if (session.agent === 'claude' || session.agent === 'codex') {
242
+ async function toolCallsForBackfill(session, sourceBytes, from = { startOffset: 0 }) {
243
+ if (isResumableToolSource(session.agent)) {
158
244
  if (sourceBytes > BACKFILL_MAX_STREAM_SOURCE_BYTES) {
159
- return [backfillLimitCall(session, 'Transcript exceeds the 64 MiB safe streaming tool-backfill limit.')];
245
+ return {
246
+ calls: [backfillLimitCall(session, 'Transcript exceeds the 64 MiB safe streaming tool-backfill limit.')],
247
+ resume: null,
248
+ };
160
249
  }
161
- return streamJsonlToolCalls(session);
250
+ return streamJsonlToolCalls(session, from);
162
251
  }
163
252
  if (sourceBytes > BACKFILL_MAX_IN_MEMORY_SOURCE_BYTES) {
164
- return [backfillLimitCall(session, 'Transcript exceeds the 16 MiB safe in-memory tool-backfill parser limit.')];
253
+ return {
254
+ calls: [backfillLimitCall(session, 'Transcript exceeds the 16 MiB safe in-memory tool-backfill parser limit.')],
255
+ resume: null,
256
+ };
165
257
  }
166
- return toolCallsFromEvents(parseSession(session.filePath, session.agent));
258
+ // Every other harness is parsed whole into memory by parseSession, which
259
+ // exposes no byte offset to resume from — so these stay full replaces and
260
+ // record no resume point, rather than storing one this path cannot honour.
261
+ return { calls: toolCallsFromEvents(parseSession(session.filePath, session.agent)), resume: null };
167
262
  }
168
263
  /**
169
264
  * Fill one bounded chunk of the independent tool index. A warm call performs
@@ -179,6 +274,7 @@ export async function ensureToolIndex(sessions, limits = {}) {
179
274
  if (!session.filePath)
180
275
  continue;
181
276
  const sourcePath = toolEvidenceSourcePath(session.filePath, session.agent);
277
+ const ledger = readToolLedger(db, session.id);
182
278
  const mustStatSource = limits.verifySourceStamps || sourcePath !== session.filePath;
183
279
  const indexed = !mustStatSource
184
280
  ? db.prepare(`
@@ -199,8 +295,15 @@ export async function ensureToolIndex(sessions, limits = {}) {
199
295
  continue;
200
296
  }
201
297
  }
202
- if (needsIndex(db, session.id, stamp))
203
- pending.push({ session, stamp });
298
+ if (!needsIndex(ledger, stamp))
299
+ continue;
300
+ const plan = planToolScan(db, session.id, ledger, sourcePath, stamp, isResumableToolSource(session.agent));
301
+ pending.push({
302
+ session,
303
+ stamp,
304
+ plan,
305
+ readBytes: Math.max(0, stamp.fileSize - plan.startOffset),
306
+ });
204
307
  }
205
308
  let indexedFiles = 0;
206
309
  let indexedCalls = 0;
@@ -212,13 +315,16 @@ export async function ensureToolIndex(sessions, limits = {}) {
212
315
  // The byte budget is a batch boundary, not a correctness boundary. Admit
213
316
  // one oversized transcript by itself so it can never wedge the ledger or
214
317
  // silently disappear from results; the next invocation resumes afterward.
215
- if (attemptedFiles > 0 && consumedBytes + item.stamp.fileSize > maxBytes)
318
+ // Budgeted on the bytes this scan reads, not the file's size: a resumed
319
+ // session costs only its appended tail, so a batch can cover far more
320
+ // growing sessions than it could when every one was re-read whole.
321
+ if (attemptedFiles > 0 && consumedBytes + item.readBytes > maxBytes)
216
322
  break;
217
323
  attemptedFiles++;
218
- consumedBytes += item.stamp.fileSize;
324
+ consumedBytes += item.readBytes;
219
325
  try {
220
- const calls = await toolCallsForBackfill(item.session, item.stamp.fileSize);
221
- persistToolCalls(db, item.session, calls, item.stamp);
326
+ const { calls, resume } = await toolCallsForBackfill(item.session, item.stamp.fileSize, item.plan);
327
+ persistToolCalls(db, item.session, calls, item.stamp, { mode: item.plan.mode, resume });
222
328
  indexedFiles++;
223
329
  indexedCalls += calls.length;
224
330
  }
@@ -226,6 +332,11 @@ export async function ensureToolIndex(sessions, limits = {}) {
226
332
  skippedFiles++;
227
333
  }
228
334
  }
335
+ // The scan just wrote a batch of FTS segments; pay a bounded slice of the
336
+ // merge they need so the index converges here instead of degrading until
337
+ // someone runs `agents sessions optimize` by hand (RUSH-2208).
338
+ if (indexedFiles > 0)
339
+ maintainSessionSearchIndex(db);
229
340
  const remainingFiles = Math.max(0, pending.length - attemptedFiles);
230
341
  const limitedSessionIds = new Set();
231
342
  const sessionIds = sessions.map((session) => session.id);
@@ -8,8 +8,32 @@ export declare function toolEvidenceSourcePath(filePath: string, agent: string):
8
8
  export declare function purgeToolCalls(db: Database.Database, sessionId: string): void;
9
9
  /** Purge deleted direct children when a transcript directory's stamp changes. */
10
10
  export declare function purgeMissingToolCallsInDirectory(db: Database.Database, dirPath: string, currentFilePaths: string[]): number;
11
- /** Persist one parser batch and its file stamp atomically. */
11
+ /** The resume point a later incremental scan starts from. */
12
+ export interface ToolScanResumePoint {
13
+ /** Serialized ToolCallCollector snapshot at `parsedOffset`. */
14
+ parserState: string;
15
+ /** Byte offset just past the last complete record consumed. */
16
+ parsedOffset: number;
17
+ }
18
+ export interface PersistToolCallsOptions {
19
+ /**
20
+ * `replace` drops the session's stored evidence first — correct for a parse
21
+ * that started at byte 0. `append` merges the batch into what is already
22
+ * stored and requires an existing ledger row; use it only for a parse that
23
+ * resumed from that row's `parsedOffset`.
24
+ */
25
+ mode?: 'replace' | 'append';
26
+ /**
27
+ * Where a later scan may resume. Omitted (or null) clears any stored resume
28
+ * point, which forces the next scan of this session to re-read from byte 0 —
29
+ * the correct outcome whenever the parse could not cover the whole prefix
30
+ * (an oversized record, a size-capped transcript, a non-streaming harness).
31
+ */
32
+ resume?: ToolScanResumePoint | null;
33
+ maxSessionBytes?: number;
34
+ }
35
+ /** Persist one parser batch, its file stamp, and its resume point atomically. */
12
36
  export declare function persistToolCalls(db: Database.Database, session: SessionMeta, calls: IndexedToolCall[], sourceStamp: {
13
37
  fileMtimeMs: number;
14
38
  fileSize: number;
15
- }, mode?: 'replace' | 'append', maxSessionBytes?: number): void;
39
+ }, options?: PersistToolCallsOptions): void;
@@ -28,11 +28,17 @@ export function toolEvidenceSourcePath(filePath, agent) {
28
28
  return filePath;
29
29
  }
30
30
  function deleteSessionCalls(db, sessionId) {
31
- const keys = db.prepare(`SELECT call_key FROM tool_calls WHERE session_id = ?`).all(sessionId);
32
- for (const { call_key } of keys) {
33
- db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`).run(call_key);
34
- db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`).run(call_key);
35
- db.prepare(`DELETE FROM tool_call_text WHERE call_key = ?`).run(call_key);
31
+ const rows = db.prepare(`SELECT rowid, call_key FROM tool_calls WHERE session_id = ?`)
32
+ .all(sessionId);
33
+ const deletePrograms = db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`);
34
+ const deleteOccurrences = db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`);
35
+ // Addressed by rowid, never by the UNINDEXED call_key see the tool_call_text
36
+ // schema comment in db.ts. A call_key predicate scans the whole FTS index.
37
+ const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE rowid = ?`);
38
+ for (const { rowid, call_key } of rows) {
39
+ deletePrograms.run(call_key);
40
+ deleteOccurrences.run(call_key);
41
+ deleteText.run(rowid);
36
42
  }
37
43
  db.prepare(`DELETE FROM tool_calls WHERE session_id = ?`).run(sessionId);
38
44
  }
@@ -61,8 +67,11 @@ export function purgeMissingToolCallsInDirectory(db, dirPath, currentFilePaths)
61
67
  }
62
68
  return purged;
63
69
  }
64
- /** Persist one parser batch and its file stamp atomically. */
65
- export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replace', maxSessionBytes = TOOL_SESSION_EVIDENCE_MAX_BYTES) {
70
+ /** Persist one parser batch, its file stamp, and its resume point atomically. */
71
+ export function persistToolCalls(db, session, calls, sourceStamp, options = {}) {
72
+ const mode = options.mode ?? 'replace';
73
+ const maxSessionBytes = options.maxSessionBytes ?? TOOL_SESSION_EVIDENCE_MAX_BYTES;
74
+ const resume = options.resume ?? null;
66
75
  const sourcePath = toolEvidenceSourcePath(session.filePath, session.agent);
67
76
  const insertCall = db.prepare(`
68
77
  INSERT INTO tool_calls (
@@ -89,16 +98,19 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
89
98
  INSERT INTO tool_program_occurrences (call_key, occurrence_ordinal, program, role)
90
99
  VALUES (?, ?, ?, ?)
91
100
  `);
92
- const insertText = db.prepare(`INSERT INTO tool_call_text (call_key, tool, input, output, error) VALUES (?, ?, ?, ?, ?)`);
101
+ // tool_call_text rows are addressed by the rowid of the tool_calls row they
102
+ // describe (db.ts schema comment): its UNINDEXED call_key cannot be seeked.
103
+ const insertText = db.prepare(`INSERT INTO tool_call_text (rowid, call_key, tool, input, output, error) VALUES (?, ?, ?, ?, ?, ?)`);
104
+ const callRowid = db.prepare(`SELECT rowid FROM tool_calls WHERE call_key = ?`);
93
105
  const deletePrograms = db.prepare(`DELETE FROM tool_call_programs WHERE call_key = ?`);
94
106
  const deleteOccurrences = db.prepare(`DELETE FROM tool_program_occurrences WHERE call_key = ?`);
95
- const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE call_key = ?`);
107
+ const deleteText = db.prepare(`DELETE FROM tool_call_text WHERE rowid = ?`);
96
108
  const deleteCall = db.prepare(`DELETE FROM tool_calls WHERE call_key = ?`);
97
109
  const writeLedger = db.prepare(`
98
110
  INSERT INTO tool_scan_ledger (
99
111
  session_id, file_path, file_mtime_ms, file_size, extractor_version, indexed_at, call_count,
100
- evidence_bytes
101
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
112
+ evidence_bytes, parser_state, parsed_offset
113
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
102
114
  ON CONFLICT(session_id) DO UPDATE SET
103
115
  file_path = excluded.file_path,
104
116
  file_mtime_ms = excluded.file_mtime_ms,
@@ -106,7 +118,9 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
106
118
  extractor_version = excluded.extractor_version,
107
119
  indexed_at = excluded.indexed_at,
108
120
  call_count = excluded.call_count,
109
- evidence_bytes = excluded.evidence_bytes
121
+ evidence_bytes = excluded.evidence_bytes,
122
+ parser_state = excluded.parser_state,
123
+ parsed_offset = excluded.parsed_offset
110
124
  `);
111
125
  const txn = db.transaction(() => {
112
126
  if (mode === 'replace')
@@ -119,7 +133,7 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
119
133
  throw new Error('Append tool evidence requires an existing tool ledger row.');
120
134
  }
121
135
  if (mode === 'append' && calls.length === 0) {
122
- writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), priorLedger.call_count, priorLedger.evidence_bytes);
136
+ writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), priorLedger.call_count, priorLedger.evidence_bytes, resume?.parserState ?? null, resume?.parsedOffset ?? null);
123
137
  return;
124
138
  }
125
139
  const limitKey = toolCallKey(session.id, TOOL_INDEX_LIMIT_ORDINAL);
@@ -129,9 +143,11 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
129
143
  const priorLimit = mode === 'append'
130
144
  ? existingSize.get(session.id, TOOL_INDEX_LIMIT_ORDINAL)
131
145
  : undefined;
146
+ const limitRow = callRowid.get(limitKey);
132
147
  deletePrograms.run(limitKey);
133
148
  deleteOccurrences.run(limitKey);
134
- deleteText.run(limitKey);
149
+ if (limitRow)
150
+ deleteText.run(limitRow.rowid);
135
151
  deleteCall.run(limitKey);
136
152
  const existingRows = mode === 'append'
137
153
  ? calls.map((call) => ({
@@ -182,17 +198,20 @@ export function persistToolCalls(db, session, calls, sourceStamp, mode = 'replac
182
198
  for (const call of accepted) {
183
199
  const key = toolCallKey(session.id, call.ordinal);
184
200
  insertCall.run(key, session.id, call.ordinal, call.sourceCallId ?? null, call.timestamp, call.tool, call.input, call.outcome, call.exitCode ?? null, call.statusCode ?? null, call.errorCode ?? null, call.output ?? null, call.error ?? null, call.parseError ?? null, toolCallEvidenceBytes(call));
201
+ // The upsert above preserves the rowid of a call it updated, so this is
202
+ // the same rowid the existing text row (if any) was written under.
203
+ const { rowid } = callRowid.get(key);
185
204
  deletePrograms.run(key);
186
205
  deleteOccurrences.run(key);
187
- deleteText.run(key);
206
+ deleteText.run(rowid);
188
207
  for (const program of call.programs)
189
208
  insertProgram.run(key, program);
190
209
  call.programOccurrences.forEach((occurrence, occurrenceOrdinal) => {
191
210
  insertOccurrence.run(key, occurrenceOrdinal, occurrence.program, occurrence.role);
192
211
  });
193
- insertText.run(key, call.tool, call.input, call.output ?? '', call.error ?? '');
212
+ insertText.run(rowid, key, call.tool, call.input, call.output ?? '', call.error ?? '');
194
213
  }
195
- writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), count, storedBytes);
214
+ writeLedger.run(session.id, ledgerPath, sourceStamp.fileMtimeMs, sourceStamp.fileSize, TOOL_INDEX_VERSION, Date.now(), count, storedBytes, resume?.parserState ?? null, resume?.parsedOffset ?? null);
196
215
  });
197
216
  txn();
198
217
  }
@@ -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