@phnx-labs/agents-cli 1.22.24 → 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.
- package/CHANGELOG.md +350 -1
- package/README.md +31 -6
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/cloud.js +9 -5
- package/dist/commands/doctor.d.ts +24 -0
- package/dist/commands/doctor.js +108 -9
- package/dist/commands/exec.js +19 -16
- package/dist/commands/feed.d.ts +5 -0
- package/dist/commands/feed.js +21 -1
- package/dist/commands/focus.js +2 -2
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/menubar.js +8 -0
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.d.ts +3 -0
- package/dist/commands/routines.js +72 -49
- package/dist/commands/run-cloud.js +1 -1
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-browser.d.ts +1 -1
- package/dist/commands/sessions-browser.js +27 -7
- package/dist/commands/sessions-resume.js +3 -2
- package/dist/commands/sessions.d.ts +13 -1
- package/dist/commands/sessions.js +28 -2
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup-watchdog.js +5 -10
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +106 -47
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -2
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/commands/watchdog.d.ts +3 -4
- package/dist/commands/watchdog.js +26 -66
- package/dist/index.js +37 -2
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +155 -23
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/cloud/cursor.d.ts +79 -0
- package/dist/lib/cloud/cursor.js +228 -0
- package/dist/lib/cloud/registry.js +2 -0
- package/dist/lib/cloud/types.d.ts +7 -2
- package/dist/lib/cloud/types.js +14 -0
- package/dist/lib/crabbox/cli.d.ts +2 -2
- package/dist/lib/crabbox/config.d.ts +7 -8
- package/dist/lib/crabbox/config.js +14 -14
- package/dist/lib/crabbox/lease.d.ts +11 -4
- package/dist/lib/crabbox/lease.js +40 -8
- package/dist/lib/crabbox/setup-copy.d.ts +5 -0
- package/dist/lib/crabbox/setup-copy.js +17 -1
- package/dist/lib/daemon.js +27 -1
- package/dist/lib/device-config.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +13 -1
- package/dist/lib/devices/doctor-findings.js +73 -2
- package/dist/lib/events.d.ts +9 -0
- package/dist/lib/events.js +58 -0
- package/dist/lib/exec.d.ts +3 -3
- package/dist/lib/exec.js +24 -10
- package/dist/lib/feed-outcome.d.ts +3 -0
- package/dist/lib/feed-outcome.js +18 -10
- package/dist/lib/feed.d.ts +4 -0
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +44 -0
- package/dist/lib/hosts/passthrough.js +84 -12
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/mcp.js +5 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/snapshot.d.ts +15 -0
- package/dist/lib/menubar/snapshot.js +40 -0
- package/dist/lib/plugins.js +13 -1
- package/dist/lib/resources/mcp.js +3 -0
- package/dist/lib/routine-process-cleanup.d.ts +9 -0
- package/dist/lib/routine-process-cleanup.js +73 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +6 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.js +1 -20
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +13 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
- package/dist/lib/secrets/rc-hygiene.js +14 -1
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/active.d.ts +4 -0
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +229 -15
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/remote-list.d.ts +2 -0
- package/dist/lib/session/remote-list.js +1 -0
- package/dist/lib/session/session-cache.d.ts +4 -4
- package/dist/lib/session/session-cache.js +4 -4
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/shims.js +21 -1
- package/dist/lib/signin-badge.js +2 -0
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +16 -0
- package/dist/lib/startup/command-registry.js +46 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +76 -8
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/versions.js +16 -1
- package/dist/lib/watchdog/service.d.ts +17 -0
- package/dist/lib/watchdog/service.js +39 -0
- package/package.json +1 -1
|
@@ -65,6 +65,7 @@ function emptyFacets() {
|
|
|
65
65
|
shellCommandsSeen: 0,
|
|
66
66
|
messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
|
|
67
67
|
toolCount: 0, errorCount: 0,
|
|
68
|
+
frictionSignals: {}, correctionSignals: {}, automationSignals: {},
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
function bump(map, key, by = 1) {
|
|
@@ -145,6 +146,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
145
146
|
// a reply latency. It censors 5.5% of gaps, and `gapsOverCeiling` reports how many so
|
|
146
147
|
// the number is never quietly truncated.
|
|
147
148
|
let lastAssistantTs = null;
|
|
149
|
+
let lastFailedTool = null;
|
|
148
150
|
for (const e of events) {
|
|
149
151
|
const ts = new Date(e.timestamp).getTime();
|
|
150
152
|
const hasTs = !Number.isNaN(ts);
|
|
@@ -153,13 +155,21 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
153
155
|
f.interruptions++;
|
|
154
156
|
break;
|
|
155
157
|
case 'usage':
|
|
156
|
-
// shortenModel so the label matches `agents sessions <id>` and `
|
|
158
|
+
// shortenModel so the label matches `agents sessions <id>` and `insights mix`
|
|
157
159
|
// rather than printing the raw id beside their shortened one.
|
|
158
160
|
if (e.model)
|
|
159
161
|
bump(f.models, shortenModel(e.model));
|
|
160
162
|
break;
|
|
161
163
|
case 'error':
|
|
162
164
|
bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
|
|
165
|
+
if (e.tool && e.tool === lastFailedTool)
|
|
166
|
+
bump(f.frictionSignals, `failed tool loop: ${e.tool}`);
|
|
167
|
+
lastFailedTool = e.tool ?? null;
|
|
168
|
+
classifyFriction(e.content ?? e.output ?? '', f.frictionSignals);
|
|
169
|
+
break;
|
|
170
|
+
case 'tool_result':
|
|
171
|
+
if (e.success !== false && e.outcome !== 'error')
|
|
172
|
+
lastFailedTool = null;
|
|
163
173
|
break;
|
|
164
174
|
case 'message':
|
|
165
175
|
if (e.role === 'assistant') {
|
|
@@ -169,6 +179,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
169
179
|
}
|
|
170
180
|
if (e.role !== 'user')
|
|
171
181
|
break;
|
|
182
|
+
if (!e._synthetic)
|
|
183
|
+
classifyCorrection(e.content ?? '', f.correctionSignals);
|
|
172
184
|
if (hasTs) {
|
|
173
185
|
// Local-time hour. parse.ts falls back to `new Date()` for a record with no
|
|
174
186
|
// timestamp; those are indistinguishable here, but they are rare and would
|
|
@@ -197,6 +209,8 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
197
209
|
lastAssistantTs = ts;
|
|
198
210
|
const args = e.args ?? {};
|
|
199
211
|
const toolName = e.tool ?? '';
|
|
212
|
+
if (/askuserquestion/i.test(toolName))
|
|
213
|
+
classifyAskStall(args, f.correctionSignals);
|
|
200
214
|
// Keyed on the SHARED cross-harness vocabulary, not Claude's literals. Keying
|
|
201
215
|
// on 'Edit'|'MultiEdit'|'Write' meant codex (whose vocabulary is exec /
|
|
202
216
|
// exec_command / write_stdin) reported 5,197 tool calls and exactly zero lines
|
|
@@ -218,6 +232,7 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
218
232
|
f.shellCommandsSeen++;
|
|
219
233
|
f.gitCommits += countGitOp(e.command, 'commit');
|
|
220
234
|
f.gitPushes += countGitOp(e.command, 'push');
|
|
235
|
+
classifyAutomation(e.command, f.automationSignals);
|
|
221
236
|
}
|
|
222
237
|
break;
|
|
223
238
|
}
|
|
@@ -228,6 +243,59 @@ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().
|
|
|
228
243
|
}
|
|
229
244
|
return f;
|
|
230
245
|
}
|
|
246
|
+
const FRICTION_PATTERNS = [
|
|
247
|
+
[/blocked by|guard(?:rail)? (?:blocked|denied)|permission denied/i, 'blocked guard'],
|
|
248
|
+
[/\b(?:ci|check|workflow)\b.*\b(?:red|fail(?:ed|ure)?)\b|\b(?:red|failed)\b.*\bci\b/i, 'CI red loop'],
|
|
249
|
+
[/merge conflict|conflict in |CONFLICT \(/i, 'merge conflict'],
|
|
250
|
+
];
|
|
251
|
+
const CORRECTION_PATTERNS = [
|
|
252
|
+
[/\b(?:continue|keep going|don'?t stop)\b/i, 'continue / keep going'],
|
|
253
|
+
[/\b(?:yes|go ahead|do it|merge it)\b/i, 'approval repeated'],
|
|
254
|
+
[/\b(?:are we done|done end.to.end|what(?:'s| is) left)\b/i, 'done end-to-end?'],
|
|
255
|
+
[/\b(?:did you merge|merged\??)\b/i, 'did you merge?'],
|
|
256
|
+
[/\bwhat(?:'s| is) next\??\b/i, "what's next?"],
|
|
257
|
+
[/\b(?:check now|check again|try now|did it work)\b/i, 'check now'],
|
|
258
|
+
[/\b(?:don'?t ask|just do it|run what)\b/i, "don't ask / just do it"],
|
|
259
|
+
];
|
|
260
|
+
const AUTOMATION_PATTERNS = [
|
|
261
|
+
[/\bgh pr (?:checks|view|merge)\b/i, 'PR babysitting'],
|
|
262
|
+
[/\bagents secrets (?:list|exec|unlock|export)\b/i, 'secrets unlock dance'],
|
|
263
|
+
[/\bgit (?:fetch|rebase|merge|push)\b/i, 'git reconcile recipe'],
|
|
264
|
+
[/\b(?:scp|rsync|agents ssh)\b/i, 'fleet file transfer'],
|
|
265
|
+
[/\b(?:release\.sh|deploy\.sh|npm publish)\b/i, 'release / deploy recipe'],
|
|
266
|
+
];
|
|
267
|
+
function classifyFriction(text, counts) {
|
|
268
|
+
for (const [pattern, label] of FRICTION_PATTERNS)
|
|
269
|
+
if (pattern.test(text))
|
|
270
|
+
bump(counts, label);
|
|
271
|
+
}
|
|
272
|
+
function classifyCorrection(text, counts) {
|
|
273
|
+
const normalized = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
274
|
+
for (const [pattern, label] of CORRECTION_PATTERNS)
|
|
275
|
+
if (pattern.test(normalized))
|
|
276
|
+
bump(counts, label);
|
|
277
|
+
}
|
|
278
|
+
function classifyAskStall(args, counts) {
|
|
279
|
+
const text = JSON.stringify(args).toLowerCase();
|
|
280
|
+
const categories = [
|
|
281
|
+
[/release|ship|deploy|publish/, 'Ask stall: release / ship / deploy'],
|
|
282
|
+
[/what'?s next|next step|next move/, "Ask stall: what's next?"],
|
|
283
|
+
[/merge|reconcile|rebase/, 'Ask stall: merge / reconcile'],
|
|
284
|
+
[/direction|approach|implementation/, 'Ask stall: direction / approach'],
|
|
285
|
+
];
|
|
286
|
+
for (const [pattern, label] of categories) {
|
|
287
|
+
if (pattern.test(text)) {
|
|
288
|
+
bump(counts, label);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
bump(counts, 'AskUserQuestion');
|
|
293
|
+
}
|
|
294
|
+
function classifyAutomation(command, counts) {
|
|
295
|
+
for (const [pattern, label] of AUTOMATION_PATTERNS)
|
|
296
|
+
if (pattern.test(command))
|
|
297
|
+
bump(counts, label);
|
|
298
|
+
}
|
|
231
299
|
/** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
|
|
232
300
|
export function percentile(values, p) {
|
|
233
301
|
if (values.length === 0)
|
|
@@ -298,6 +366,12 @@ export function mergeFacets(into, add) {
|
|
|
298
366
|
bump(into.slashCommands, k, v);
|
|
299
367
|
for (const [k, v] of Object.entries(add.errorCategories))
|
|
300
368
|
bump(into.errorCategories, k, v);
|
|
369
|
+
for (const [k, v] of Object.entries(add.frictionSignals ?? {}))
|
|
370
|
+
bump(into.frictionSignals, k, v);
|
|
371
|
+
for (const [k, v] of Object.entries(add.correctionSignals ?? {}))
|
|
372
|
+
bump(into.correctionSignals, k, v);
|
|
373
|
+
for (const [k, v] of Object.entries(add.automationSignals ?? {}))
|
|
374
|
+
bump(into.automationSignals, k, v);
|
|
301
375
|
into.interruptions += add.interruptions;
|
|
302
376
|
into.responseGaps.push(...add.responseGaps);
|
|
303
377
|
into.gapsOverCeiling += add.gapsOverCeiling;
|
|
@@ -328,3 +402,71 @@ export function topEntries(counts, limit) {
|
|
|
328
402
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
|
329
403
|
.slice(0, limit);
|
|
330
404
|
}
|
|
405
|
+
/** Build a stable, evidence-backed action list without exposing transcript text. */
|
|
406
|
+
export function buildInsightActions(sessions) {
|
|
407
|
+
const specs = [
|
|
408
|
+
{ source: 'correctionSignals', label: 'continue / keep going', category: 'rule', action: 'Keep working through the delivery chain without waiting for another “continue”.' },
|
|
409
|
+
{ source: 'correctionSignals', label: 'approval repeated', category: 'rule', action: 'Treat the original build or ship request as authorization for routine follow-through.' },
|
|
410
|
+
{ source: 'correctionSignals', label: 'done end-to-end?', category: 'rule', action: 'Verify the user-visible outcome before declaring the task complete.' },
|
|
411
|
+
{ source: 'correctionSignals', label: 'did you merge?', category: 'automation', action: 'Automate PR review, CI watching, and merge-on-green as one durable workflow.' },
|
|
412
|
+
{ source: 'correctionSignals', label: "what's next?", category: 'rule', action: 'State and execute the next in-scope step instead of asking the owner to steer implementation.' },
|
|
413
|
+
{ source: 'correctionSignals', label: 'check now', category: 'automation', action: 'Add bounded status polling with a terminal success or failure signal.' },
|
|
414
|
+
{ source: 'correctionSignals', label: "don't ask / just do it", category: 'rule', action: 'Reserve questions for genuine product or scope choices.' },
|
|
415
|
+
{ source: 'correctionSignals', label: 'Ask stall: release / ship / deploy', category: 'skill', action: 'Teach release workflows to carry publish, tag, rollout, and live verification as one chain.' },
|
|
416
|
+
{ source: 'correctionSignals', label: "Ask stall: what's next?", category: 'rule', action: 'Remove workflow-stall “what next?” prompts from agent guidance.' },
|
|
417
|
+
{ source: 'correctionSignals', label: 'Ask stall: merge / reconcile', category: 'skill', action: 'Encode the safe merge and reconcile path in the git workflow skill.' },
|
|
418
|
+
{ source: 'correctionSignals', label: 'Ask stall: direction / approach', category: 'rule', action: 'Let agents choose implementation details after scope is clear.' },
|
|
419
|
+
{ source: 'frictionSignals', label: 'blocked guard', category: 'product', action: 'Make guard failures return the safe next command and exact blocked operation.' },
|
|
420
|
+
{ source: 'frictionSignals', label: 'CI red loop', category: 'automation', action: 'Deduplicate CI watchers and turn repeated red checks into one stateful wait.' },
|
|
421
|
+
{ source: 'frictionSignals', label: 'merge conflict', category: 'skill', action: 'Standardize conflict diagnosis and fix-forward reconciliation.' },
|
|
422
|
+
{ source: 'automationSignals', label: 'PR babysitting', category: 'automation', action: 'Bundle PR checks, review collection, comment handling, and merge-on-green.' },
|
|
423
|
+
{ source: 'automationSignals', label: 'secrets unlock dance', category: 'product', action: 'Provide one headless secrets-backed command path for repeated credential operations.' },
|
|
424
|
+
{ source: 'automationSignals', label: 'git reconcile recipe', category: 'skill', action: 'Promote repeated git reconciliation commands into the canonical workflow.' },
|
|
425
|
+
{ source: 'automationSignals', label: 'fleet file transfer', category: 'product', action: 'Add a first-class fleet file transfer command with host/path validation.' },
|
|
426
|
+
{ source: 'automationSignals', label: 'release / deploy recipe', category: 'automation', action: 'Turn repeated release shell recipes into a checked-in release script.' },
|
|
427
|
+
];
|
|
428
|
+
const actions = [];
|
|
429
|
+
for (const spec of specs) {
|
|
430
|
+
let evidenceCount = 0;
|
|
431
|
+
const ids = [];
|
|
432
|
+
for (const session of sessions) {
|
|
433
|
+
const count = session.facets[spec.source]?.[spec.label] ?? 0;
|
|
434
|
+
if (count <= 0)
|
|
435
|
+
continue;
|
|
436
|
+
evidenceCount += count;
|
|
437
|
+
if (ids.length < 3)
|
|
438
|
+
ids.push(session.id.slice(0, 8));
|
|
439
|
+
}
|
|
440
|
+
if (evidenceCount === 0)
|
|
441
|
+
continue;
|
|
442
|
+
actions.push({
|
|
443
|
+
priority: evidenceCount >= 10 ? 'high' : evidenceCount >= 3 ? 'medium' : 'low',
|
|
444
|
+
category: spec.category,
|
|
445
|
+
action: spec.action,
|
|
446
|
+
evidenceCount,
|
|
447
|
+
sampleSessionIds: ids,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
let failedLoopCount = 0;
|
|
451
|
+
const failedLoopIds = [];
|
|
452
|
+
for (const session of sessions) {
|
|
453
|
+
const count = Object.entries(session.facets.frictionSignals ?? {})
|
|
454
|
+
.filter(([label]) => label.startsWith('failed tool loop:'))
|
|
455
|
+
.reduce((sum, [, value]) => sum + value, 0);
|
|
456
|
+
if (count <= 0)
|
|
457
|
+
continue;
|
|
458
|
+
failedLoopCount += count;
|
|
459
|
+
if (failedLoopIds.length < 3)
|
|
460
|
+
failedLoopIds.push(session.id.slice(0, 8));
|
|
461
|
+
}
|
|
462
|
+
if (failedLoopCount > 0) {
|
|
463
|
+
actions.push({
|
|
464
|
+
priority: failedLoopCount >= 10 ? 'high' : failedLoopCount >= 3 ? 'medium' : 'low',
|
|
465
|
+
category: 'automation',
|
|
466
|
+
action: 'Detect repeated failures of the same tool and stop the retry loop with a different recovery path.',
|
|
467
|
+
evidenceCount: failedLoopCount,
|
|
468
|
+
sampleSessionIds: failedLoopIds,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return actions.sort((a, b) => b.evidenceCount - a.evidenceCount || a.action.localeCompare(b.action));
|
|
472
|
+
}
|
|
@@ -81,6 +81,8 @@ export interface GatherRemoteListOptions {
|
|
|
81
81
|
* know whether the match is unique or conflicting.
|
|
82
82
|
*/
|
|
83
83
|
isDefinitive?: (session: SessionMeta, machine: string) => boolean;
|
|
84
|
+
/** Per-peer deadline for slower indexed browse queries. */
|
|
85
|
+
timeoutMs?: number;
|
|
84
86
|
}
|
|
85
87
|
export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[], opts?: GatherRemoteListOptions): Promise<RemoteListResult>;
|
|
86
88
|
export interface RemoteToolSearchResult {
|
|
@@ -197,6 +197,7 @@ export async function gatherRemoteList(forwardedArgs, hosts, opts) {
|
|
|
197
197
|
args: forwardedArgs,
|
|
198
198
|
noFanoutEnv: NO_FANOUT_ENV,
|
|
199
199
|
hosts,
|
|
200
|
+
timeoutMs: opts?.timeoutMs,
|
|
200
201
|
earlyExit: opts?.isDefinitive ? { isDefinitive: opts.isDefinitive } : undefined,
|
|
201
202
|
parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
|
|
202
203
|
});
|
|
@@ -4,11 +4,11 @@ import type { ActiveSession } from './active.js';
|
|
|
4
4
|
* Short on purpose: live status (running/idle/waiting) must not go stale.
|
|
5
5
|
* The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
|
|
6
6
|
*/
|
|
7
|
-
export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS
|
|
7
|
+
export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS: number;
|
|
8
8
|
/** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
|
|
9
|
-
export declare const SESSION_CACHE_WARM_INTERVAL_MS
|
|
10
|
-
/** Kick off the first warm
|
|
11
|
-
export declare const SESSION_CACHE_WARM_KICKOFF_MS =
|
|
9
|
+
export declare const SESSION_CACHE_WARM_INTERVAL_MS: number;
|
|
10
|
+
/** Kick off the first warm 30s after daemon start (staggered off other ticks). */
|
|
11
|
+
export declare const SESSION_CACHE_WARM_KICKOFF_MS = 30000;
|
|
12
12
|
/** Snapshot scope: this host only, or a fleet-wide merge written by a reader. */
|
|
13
13
|
export type ActiveCacheScope = 'local' | 'fleet';
|
|
14
14
|
export interface ActiveSessionsSnapshot {
|
|
@@ -34,11 +34,11 @@ const IMMUTABLE_FILE = '.active-session-immutable.json';
|
|
|
34
34
|
* Short on purpose: live status (running/idle/waiting) must not go stale.
|
|
35
35
|
* The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
|
|
36
36
|
*/
|
|
37
|
-
export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS =
|
|
37
|
+
export const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 4 * 60_000;
|
|
38
38
|
/** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
|
|
39
|
-
export const SESSION_CACHE_WARM_INTERVAL_MS =
|
|
40
|
-
/** Kick off the first warm
|
|
41
|
-
export const SESSION_CACHE_WARM_KICKOFF_MS =
|
|
39
|
+
export const SESSION_CACHE_WARM_INTERVAL_MS = 3 * 60_000;
|
|
40
|
+
/** Kick off the first warm 30s after daemon start (staggered off other ticks). */
|
|
41
|
+
export const SESSION_CACHE_WARM_KICKOFF_MS = 30_000;
|
|
42
42
|
/** Keys stored in the immutable memo (transcript-stable). */
|
|
43
43
|
export const IMMUTABLE_FIELD_KEYS = [
|
|
44
44
|
'topic',
|
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
203
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
},
|
|
39
|
+
}, options?: PersistToolCallsOptions): void;
|