@phnx-labs/agents-cli 1.22.25 → 1.22.26

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 (82) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -2
  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/browser.js +35 -0
  7. package/dist/commands/doctor.js +8 -0
  8. package/dist/commands/insights.d.ts +25 -19
  9. package/dist/commands/insights.js +107 -33
  10. package/dist/commands/reconnect.d.ts +46 -0
  11. package/dist/commands/reconnect.js +109 -0
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/secrets.d.ts +2 -8
  14. package/dist/commands/secrets.js +29 -105
  15. package/dist/commands/sessions.js +4 -0
  16. package/dist/commands/setup-secrets.d.ts +1 -0
  17. package/dist/commands/setup-secrets.js +1 -1
  18. package/dist/commands/setup.d.ts +26 -3
  19. package/dist/commands/setup.js +105 -46
  20. package/dist/commands/teams.d.ts +6 -0
  21. package/dist/commands/teams.js +43 -0
  22. package/dist/commands/trends.d.ts +8 -0
  23. package/dist/commands/trends.js +10 -156
  24. package/dist/index.js +1 -1
  25. package/dist/lib/agents.d.ts +11 -0
  26. package/dist/lib/agents.js +29 -2
  27. package/dist/lib/analytics/dashboard.d.ts +10 -6
  28. package/dist/lib/analytics/dashboard.js +6 -4
  29. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  30. package/dist/lib/analytics/mix-commands.js +229 -0
  31. package/dist/lib/analytics/recipes.d.ts +19 -14
  32. package/dist/lib/analytics/recipes.js +4 -2
  33. package/dist/lib/browser/ipc.d.ts +26 -0
  34. package/dist/lib/browser/ipc.js +139 -24
  35. package/dist/lib/browser/profiles.d.ts +11 -0
  36. package/dist/lib/browser/profiles.js +1 -1
  37. package/dist/lib/browser/stream.d.ts +14 -0
  38. package/dist/lib/browser/stream.js +71 -0
  39. package/dist/lib/channels/owner-sink.d.ts +27 -0
  40. package/dist/lib/channels/owner-sink.js +93 -0
  41. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  42. package/dist/lib/devices/doctor-findings.js +33 -1
  43. package/dist/lib/fleet/apply.d.ts +59 -3
  44. package/dist/lib/fleet/apply.js +183 -6
  45. package/dist/lib/fleet/types.d.ts +21 -2
  46. package/dist/lib/hooks/cache.js +15 -0
  47. package/dist/lib/hosts/passthrough.d.ts +23 -0
  48. package/dist/lib/hosts/passthrough.js +45 -0
  49. package/dist/lib/hosts/ready.d.ts +2 -0
  50. package/dist/lib/hosts/ready.js +10 -1
  51. package/dist/lib/hosts/reconnect.d.ts +14 -12
  52. package/dist/lib/hosts/reconnect.js +41 -40
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  54. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  55. package/dist/lib/routines.js +14 -2
  56. package/dist/lib/runner.d.ts +0 -3
  57. package/dist/lib/runner.js +1 -14
  58. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/push.d.ts +94 -0
  61. package/dist/lib/secrets/push.js +145 -0
  62. package/dist/lib/secrets/reaper.d.ts +15 -1
  63. package/dist/lib/secrets/reaper.js +30 -3
  64. package/dist/lib/session/db.d.ts +21 -3
  65. package/dist/lib/session/db.js +221 -13
  66. package/dist/lib/session/discover.d.ts +1 -0
  67. package/dist/lib/session/discover.js +115 -19
  68. package/dist/lib/session/insights.d.ts +18 -0
  69. package/dist/lib/session/insights.js +143 -1
  70. package/dist/lib/session/tool-index.js +133 -22
  71. package/dist/lib/session/tool-store.d.ts +26 -2
  72. package/dist/lib/session/tool-store.js +36 -17
  73. package/dist/lib/ssh-exec.js +8 -2
  74. package/dist/lib/startup/command-registry.d.ts +1 -0
  75. package/dist/lib/startup/command-registry.js +4 -0
  76. package/dist/lib/teams/agents.d.ts +13 -0
  77. package/dist/lib/teams/agents.js +75 -7
  78. package/dist/lib/teams/placement-probe.d.ts +21 -0
  79. package/dist/lib/teams/placement-probe.js +135 -0
  80. package/dist/lib/teams/scheduler.d.ts +74 -1
  81. package/dist/lib/teams/scheduler.js +187 -10
  82. 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,7 +117,11 @@ export function sshExec(target, remoteCmd, opts = {}) {
117
117
  stdio: ['pipe', 'pipe', 'pipe'],
118
118
  windowsHide: true,
119
119
  });
120
- const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
120
+ // Node's spawnSync with a `timeout` option kills the child via SIGTERM and sets
121
+ // res.signal (e.g. 'SIGTERM') — it does NOT set res.error.code = 'ETIMEDOUT'.
122
+ // Check both so the detection fires whether the timeout is signalled or errored.
123
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
124
+ (!!opts.timeoutMs && res.signal !== null);
121
125
  return {
122
126
  code: typeof res.status === 'number' ? res.status : null,
123
127
  stdout: res.stdout ?? '',
@@ -206,7 +210,9 @@ export function sshExecRaw(target, remoteCmd, opts = {}) {
206
210
  stdio: ['pipe', 'pipe', 'pipe'],
207
211
  windowsHide: true,
208
212
  });
209
- const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
213
+ // Same fix as sshExec: spawnSync sets res.signal, not error.code = 'ETIMEDOUT'.
214
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT') ||
215
+ (!!opts.timeoutMs && res.signal !== null);
210
216
  return {
211
217
  code: typeof res.status === 'number' ? res.status : null,
212
218
  stdout: res.stdout ?? Buffer.alloc(0),
@@ -47,6 +47,7 @@ export declare const loadMonitors: ModuleLoader;
47
47
  export declare const loadProjects: ModuleLoader;
48
48
  export declare const loadRun: ModuleLoader;
49
49
  export declare const loadResume: ModuleLoader;
50
+ export declare const loadReconnect: ModuleLoader;
50
51
  export declare const loadFork: ModuleLoader;
51
52
  export declare const loadDefaults: ModuleLoader;
52
53
  export declare const loadSet: ModuleLoader;
@@ -25,6 +25,7 @@ export const loadMonitors = async () => (await import('../../commands/monitors.j
25
25
  export const loadProjects = async () => (await import('../../commands/projects.js')).registerProjectsCommands;
26
26
  export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
27
27
  export const loadResume = async () => (await import('../../commands/resume.js')).registerResumeCommand;
28
+ export const loadReconnect = async () => (await import('../../commands/reconnect.js')).registerReconnectCommand;
28
29
  export const loadFork = async () => (await import('../../commands/fork.js')).registerForkCommand;
29
30
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
30
31
  export const loadSet = async () => (await import('../../commands/set.js')).registerSetCommand;
@@ -53,6 +54,7 @@ export const loadUsage = async () => (await import('../../commands/usage.js')).r
53
54
  export const loadCost = async () => (await import('../../commands/cost.js')).registerCostCommand;
54
55
  export const loadInsights = async () => (await import('../../commands/insights.js')).registerInsightsCommand;
55
56
  export const loadPerf = async () => (await import('../../commands/perf.js')).registerPerfCommand;
57
+ // Thin deprecated alias of `agents insights mix` — no second mix implementation.
56
58
  export const loadTrends = async () => (await import('../../commands/trends.js')).registerTrendsCommand;
57
59
  export const loadOutput = async () => (await import('../../commands/output.js')).registerOutputCommand;
58
60
  export const loadBudget = async () => (await import('../../commands/budget.js')).registerBudgetCommand;
@@ -98,6 +100,7 @@ export const loadHumans = async () => (await import('../../commands/humans.js'))
98
100
  export const LAZY_COMMAND_NAMES = new Set([
99
101
  'sessions',
100
102
  'resume',
103
+ 'reconnect',
101
104
  'roster',
102
105
  'teams',
103
106
  'cloud',
@@ -156,6 +159,7 @@ export const COMMAND_LOADERS = {
156
159
  projects: [loadProjects],
157
160
  run: [loadRun],
158
161
  resume: [loadResume],
162
+ reconnect: [loadReconnect],
159
163
  fork: [loadFork],
160
164
  defaults: [loadDefaults],
161
165
  set: [loadSet],
@@ -1,5 +1,6 @@
1
1
  import { captureProcessStartTime } from '../platform/index.js';
2
2
  import { AgentType } from './parsers.js';
3
+ import { NoViableDeviceError } from './scheduler.js';
3
4
  /**
4
5
  * Compute the Lowest Common Ancestor (LCA) of multiple file paths.
5
6
  * Returns the deepest common directory shared by all paths.
@@ -405,6 +406,18 @@ export declare class AgentManager {
405
406
  * teammate schedules identically no matter how it was fired.
406
407
  */
407
408
  private maybeSchedulePlacement;
409
+ /** Human label of a teammate's agent for the placement fail-loud message. */
410
+ private placementAgentLabel;
411
+ /**
412
+ * Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
413
+ * confirm each distinct agent among the pending, unpinned, pooled teammates
414
+ * has at least one viable device. Returns the first {@link NoViableDeviceError}
415
+ * so the command can fail loud BEFORE launching a wave, rather than silently
416
+ * leaving teammates stranded pending. A poolless team (no `--devices`) or a
417
+ * probe that could not gather positive evidence returns null — fail-loud fires
418
+ * only on proof that no device can run the agent, never on a probe miss.
419
+ */
420
+ preflightPlacement(taskName: string): Promise<NoViableDeviceError | null>;
408
421
  /**
409
422
  * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
410
423
  * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
@@ -34,7 +34,8 @@ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
34
34
  import { pullRemoteLogDelta, REMOTE_MIRROR_MAX_BYTES } from '../hosts/progress.js';
35
35
  import { createRemoteWorktree, ensureRemoteRepo } from './remoteWorktree.js';
36
36
  import { getTeam } from './registry.js';
37
- import { resolvePlacement, cappedDevices } from './scheduler.js';
37
+ import { resolvePlacement, classifyExclusions, NoViableDeviceError } from './scheduler.js';
38
+ import { probePoolSignals } from './placement-probe.js';
38
39
  import { readMaxConcurrentCaps } from '../device-config.js';
39
40
  import chalk from 'chalk';
40
41
  let lastMemoryWarnAt = 0;
@@ -1756,7 +1757,7 @@ export class AgentManager {
1756
1757
  * (immediate add-launch) and startReady() (staged launch) so an unpinned pool
1757
1758
  * teammate schedules identically no matter how it was fired.
1758
1759
  */
1759
- async maybeSchedulePlacement(agent, taskName) {
1760
+ async maybeSchedulePlacement(agent, taskName, opts = {}) {
1760
1761
  if (agent.hostName || agent.cloudProvider)
1761
1762
  return;
1762
1763
  const teamMeta = await getTeam(taskName);
@@ -1765,15 +1766,79 @@ export class AgentManager {
1765
1766
  const roster = await this.listByTask(taskName);
1766
1767
  const pool = teamMeta.devices ?? [];
1767
1768
  const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
1768
- if (maxConcurrent) {
1769
- for (const c of cappedDevices(pool, roster, maxConcurrent)) {
1770
- console.error(chalk.dim(`[placement] '${c.device}' excluded from auto-pick at its agents.max-concurrent cap (${c.running}/${c.cap} running)`));
1769
+ // On the start path (opts.probe), gather live signals so the pick is health-,
1770
+ // harness-, and load-aware (RUSH-2002); the add path stays the cap-only
1771
+ // roster count so `teams add` never blocks on an SSH fan-out. Cached per
1772
+ // (pool, agent), so a wave placing many teammates probes the pool once.
1773
+ const signals = opts.probe && pool.length > 0
1774
+ ? await probePoolSignals(pool, agent.agentType, { now: Date.now() })
1775
+ : undefined;
1776
+ const placeOpts = {
1777
+ maxConcurrent,
1778
+ signals,
1779
+ agentLabel: this.placementAgentLabel(agent),
1780
+ };
1781
+ if (signals) {
1782
+ for (const e of classifyExclusions(pool, roster, placeOpts).excluded) {
1783
+ const why = e.reason === 'capped'
1784
+ ? `at its agents.max-concurrent cap (${e.detail} running)`
1785
+ : e.reason === 'not-installed'
1786
+ ? `does not have ${this.placementAgentLabel(agent)} installed`
1787
+ : e.reason;
1788
+ console.error(chalk.dim(`[placement] '${e.device}' excluded from auto-pick — ${why}`));
1771
1789
  }
1772
1790
  }
1773
- const { device } = resolvePlacement(teamMeta, null, roster, { maxConcurrent });
1791
+ const { device } = resolvePlacement(teamMeta, null, roster, placeOpts);
1774
1792
  if (device)
1775
1793
  await this.resolveScheduledPlacement(agent, device, taskName);
1776
1794
  }
1795
+ /** Human label of a teammate's agent for the placement fail-loud message. */
1796
+ placementAgentLabel(agent) {
1797
+ return agent.version ? `${agent.agentType}@${agent.version}` : String(agent.agentType);
1798
+ }
1799
+ /**
1800
+ * Pre-flight the team pool at `teams start` (RUSH-2002): probe the pool and
1801
+ * confirm each distinct agent among the pending, unpinned, pooled teammates
1802
+ * has at least one viable device. Returns the first {@link NoViableDeviceError}
1803
+ * so the command can fail loud BEFORE launching a wave, rather than silently
1804
+ * leaving teammates stranded pending. A poolless team (no `--devices`) or a
1805
+ * probe that could not gather positive evidence returns null — fail-loud fires
1806
+ * only on proof that no device can run the agent, never on a probe miss.
1807
+ */
1808
+ async preflightPlacement(taskName) {
1809
+ await this.initialize();
1810
+ const teamMeta = await getTeam(taskName);
1811
+ const pool = teamMeta?.devices ?? [];
1812
+ if (!teamMeta || pool.length === 0)
1813
+ return null;
1814
+ const roster = await this.listByTask(taskName);
1815
+ const pending = roster.filter((a) => a.status === AgentStatus.PENDING && !a.hostName && !a.cloudProvider);
1816
+ if (pending.length === 0)
1817
+ return null;
1818
+ const maxConcurrent = pool.length > 1 ? readMaxConcurrentCaps(pool) : undefined;
1819
+ // One representative teammate per distinct agent type — the signal + gate is
1820
+ // per harness, not per teammate.
1821
+ const byAgent = new Map();
1822
+ for (const a of pending)
1823
+ if (!byAgent.has(a.agentType))
1824
+ byAgent.set(a.agentType, a);
1825
+ for (const [, rep] of byAgent) {
1826
+ const signals = await probePoolSignals(pool, rep.agentType, { now: Date.now() });
1827
+ try {
1828
+ resolvePlacement(teamMeta, null, roster, {
1829
+ maxConcurrent,
1830
+ signals,
1831
+ agentLabel: this.placementAgentLabel(rep),
1832
+ });
1833
+ }
1834
+ catch (err) {
1835
+ if (err instanceof NoViableDeviceError)
1836
+ return err;
1837
+ throw err;
1838
+ }
1839
+ }
1840
+ return null;
1841
+ }
1777
1842
  /**
1778
1843
  * One-ssh-per-host batched liveness/exit pre-pass for a team's remote teammates.
1779
1844
  * The supervisor calls this each wave BEFORE listByTask() so the per-teammate
@@ -1866,9 +1931,12 @@ export class AgentManager {
1866
1931
  // immediate-add and staged paths agree. A null pick keeps hostName null →
1867
1932
  // local spawn, unchanged. Cloud teammates never schedule.
1868
1933
  try {
1869
- await this.maybeSchedulePlacement(agent, taskName);
1934
+ await this.maybeSchedulePlacement(agent, taskName, { probe: true });
1870
1935
  }
1871
1936
  catch (err) {
1937
+ // A NoViableDeviceError means the pool cannot host this teammate right
1938
+ // now — leave it PENDING (never a silent local fallback) and surface the
1939
+ // reason; a transient device loss is retried next wave.
1872
1940
  console.error(`Could not schedule ${agent.agentId} onto the team pool:`, err);
1873
1941
  continue;
1874
1942
  }
@@ -0,0 +1,21 @@
1
+ import { type AgentType } from './agents.js';
2
+ import type { DevicePlacementSignal } from './scheduler.js';
3
+ /** Per-remote readiness probe budget — matches the health probe's short window
4
+ * (`agents view` on a warm box is sub-second; a wedged one degrades to unknown). */
5
+ export declare const READY_PROBE_TIMEOUT_MS = 8000;
6
+ /** How long a probed pool snapshot is reused within a `teams start` wave. Short
7
+ * enough that a device coming online / going overloaded is seen next wave, long
8
+ * enough that placing a wave of teammates does not re-fan-out per teammate. */
9
+ export declare const SIGNAL_TTL_MS = 15000;
10
+ /** Clear the probe cache — for tests and after a device-registry change. */
11
+ export declare function clearPlacementSignalCache(): void;
12
+ /**
13
+ * Probe every device in the team pool and return a name→signal map for the pure
14
+ * placement pick. Devices with no data at all are omitted (the pick then neither
15
+ * excludes nor prefers them). Never throws — a probe failure degrades to a
16
+ * missing/partial signal.
17
+ */
18
+ export declare function probePoolSignals(pool: string[], agent: AgentType, opts?: {
19
+ force?: boolean;
20
+ now?: number;
21
+ }): Promise<Map<string, DevicePlacementSignal>>;