@phnx-labs/agents-cli 1.22.21 → 1.22.23

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 (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +8 -2
  3. package/dist/commands/doctor.js +15 -7
  4. package/dist/commands/exec.js +20 -6
  5. package/dist/commands/focus.d.ts +76 -4
  6. package/dist/commands/focus.js +219 -40
  7. package/dist/commands/fork.d.ts +20 -2
  8. package/dist/commands/fork.js +91 -64
  9. package/dist/commands/go.d.ts +25 -0
  10. package/dist/commands/go.js +63 -2
  11. package/dist/commands/harness-wizard.d.ts +206 -0
  12. package/dist/commands/harness-wizard.js +403 -0
  13. package/dist/commands/harness.d.ts +12 -0
  14. package/dist/commands/harness.js +97 -101
  15. package/dist/commands/insights.d.ts +32 -0
  16. package/dist/commands/insights.js +478 -0
  17. package/dist/commands/resume.js +10 -3
  18. package/dist/commands/secrets.js +25 -30
  19. package/dist/commands/sessions-picker.d.ts +2 -0
  20. package/dist/commands/sessions-picker.js +1 -0
  21. package/dist/commands/sessions-resume.d.ts +21 -3
  22. package/dist/commands/sessions-resume.js +50 -11
  23. package/dist/commands/sessions.d.ts +81 -5
  24. package/dist/commands/sessions.js +331 -66
  25. package/dist/commands/watchdog.js +13 -2
  26. package/dist/index.js +3 -1
  27. package/dist/lib/agents.d.ts +1 -1
  28. package/dist/lib/agents.js +132 -0
  29. package/dist/lib/codex-policy.d.ts +17 -0
  30. package/dist/lib/codex-policy.js +48 -0
  31. package/dist/lib/crabbox/lease.d.ts +25 -0
  32. package/dist/lib/crabbox/lease.js +62 -0
  33. package/dist/lib/daemon.js +70 -0
  34. package/dist/lib/exec.d.ts +4 -0
  35. package/dist/lib/exec.js +88 -54
  36. package/dist/lib/feed-broadcast.d.ts +1 -20
  37. package/dist/lib/feed-broadcast.js +31 -1
  38. package/dist/lib/hooks.js +12 -2
  39. package/dist/lib/hosts/passthrough.js +1 -0
  40. package/dist/lib/mcp.js +44 -0
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  44. package/dist/lib/models.d.ts +0 -5
  45. package/dist/lib/models.js +48 -0
  46. package/dist/lib/picker.d.ts +45 -0
  47. package/dist/lib/picker.js +75 -6
  48. package/dist/lib/plugin-marketplace.js +9 -0
  49. package/dist/lib/pricing/prices.json +119 -92
  50. package/dist/lib/pricing/table.js +13 -0
  51. package/dist/lib/remote-agents-json.d.ts +29 -1
  52. package/dist/lib/remote-agents-json.js +47 -10
  53. package/dist/lib/resources/mcp.js +2 -0
  54. package/dist/lib/resources/permissions.js +3 -0
  55. package/dist/lib/resources/types.d.ts +2 -1
  56. package/dist/lib/runner.js +28 -19
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  59. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  60. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  61. package/dist/lib/secrets/index.d.ts +15 -1
  62. package/dist/lib/secrets/index.js +118 -49
  63. package/dist/lib/secrets/reaper.d.ts +87 -0
  64. package/dist/lib/secrets/reaper.js +184 -0
  65. package/dist/lib/secrets/remote.d.ts +29 -0
  66. package/dist/lib/secrets/remote.js +37 -1
  67. package/dist/lib/session/active.d.ts +36 -1
  68. package/dist/lib/session/active.js +60 -19
  69. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  70. package/dist/lib/session/actor-sidecar.js +67 -4
  71. package/dist/lib/session/db.d.ts +35 -1
  72. package/dist/lib/session/db.js +132 -1
  73. package/dist/lib/session/digest.d.ts +3 -0
  74. package/dist/lib/session/digest.js +3 -3
  75. package/dist/lib/session/discover.js +168 -0
  76. package/dist/lib/session/insights.d.ts +126 -0
  77. package/dist/lib/session/insights.js +330 -0
  78. package/dist/lib/session/parse.d.ts +29 -2
  79. package/dist/lib/session/parse.js +111 -5
  80. package/dist/lib/session/remote-list.d.ts +10 -1
  81. package/dist/lib/session/remote-list.js +2 -8
  82. package/dist/lib/session/remote.d.ts +57 -3
  83. package/dist/lib/session/remote.js +90 -26
  84. package/dist/lib/session/resume-command.d.ts +6 -0
  85. package/dist/lib/session/resume-command.js +8 -0
  86. package/dist/lib/session/session-cache.d.ts +173 -0
  87. package/dist/lib/session/session-cache.js +399 -0
  88. package/dist/lib/session/types.d.ts +2 -2
  89. package/dist/lib/session/types.js +1 -1
  90. package/dist/lib/session/width.d.ts +1 -1
  91. package/dist/lib/session/width.js +12 -2
  92. package/dist/lib/shims.d.ts +2 -2
  93. package/dist/lib/shims.js +40 -5
  94. package/dist/lib/staleness/writers/hooks.js +1 -1
  95. package/dist/lib/startup/command-registry.d.ts +1 -0
  96. package/dist/lib/startup/command-registry.js +2 -0
  97. package/dist/lib/types.d.ts +1 -1
  98. package/dist/lib/usage.d.ts +13 -0
  99. package/dist/lib/usage.js +254 -3
  100. package/dist/lib/versions.js +13 -2
  101. package/package.json +1 -1
  102. package/dist/bin/agents +0 -0
  103. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  104. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  105. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Behavioural facets of a coding session, and the cross-session rollup built from them.
3
+ *
4
+ * This is the engine behind `agents insights`. It answers "how do you work" rather than
5
+ * "what did you spend" (`agents cost`) or "what shipped" (`agents output`), and it is
6
+ * the only surface that splits any of it by the account that produced the work.
7
+ *
8
+ * Everything here is a pure function of a parsed `SessionEvent[]`, so it is testable
9
+ * without a database and cheap to re-run. The expensive part is parsing the transcript,
10
+ * which is why results are cached per session in `session_insights` and recomputed only
11
+ * when the file's (mtime, size) changes — the same staleness contract as `scan_ledger`.
12
+ *
13
+ * Prior art: Claude Code's own `/insights`, which computes a comparable set from
14
+ * `~/.claude/projects` alone. Two deliberate differences:
15
+ *
16
+ * - It sees ONE account's directory. This reads every indexed session, across every
17
+ * Claude account and every other harness, and reports them apart.
18
+ * - It collapses conversation branches before counting. agents-cli is file-per-session
19
+ * throughout (`discover.ts` keys on the transcript's basename), so session counts
20
+ * here will read slightly higher than `/insights` on the same machine. Reported as
21
+ * the raw file count rather than quietly differing.
22
+ */
23
+ import { computeSummaryStats, shortenModel } from './render.js';
24
+ import { classifyFileChanges, EDIT_TOOLS, WRITE_TOOLS } from './digest.js';
25
+ /** File extension → language label. Mirrors the set `/insights` attributes by. */
26
+ const LANGUAGE_BY_EXT = {
27
+ '.ts': 'TypeScript', '.tsx': 'TypeScript', '.mts': 'TypeScript', '.cts': 'TypeScript',
28
+ '.js': 'JavaScript', '.jsx': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript',
29
+ '.py': 'Python', '.go': 'Go', '.rs': 'Rust', '.rb': 'Ruby', '.java': 'Java',
30
+ '.kt': 'Kotlin', '.swift': 'Swift', '.c': 'C', '.h': 'C', '.cc': 'C++', '.cpp': 'C++',
31
+ '.hpp': 'C++', '.cs': 'C#', '.php': 'PHP', '.sh': 'Shell', '.bash': 'Shell',
32
+ '.zsh': 'Shell', '.fish': 'Shell', '.sql': 'SQL', '.css': 'CSS', '.scss': 'CSS',
33
+ '.html': 'HTML', '.vue': 'Vue', '.svelte': 'Svelte', '.md': 'Markdown',
34
+ '.json': 'JSON', '.yaml': 'YAML', '.yml': 'YAML', '.toml': 'TOML',
35
+ };
36
+ /**
37
+ * Tool-failure categories, matched in order against the result text (lowercased).
38
+ * Substring rules, not judgement calls — the same shape `/insights` uses, so the
39
+ * buckets stay comparable. First match wins; anything unmatched is "Other".
40
+ */
41
+ const ERROR_CATEGORIES = [
42
+ [['string to replace not found', 'no changes to make'], 'Edit Failed'],
43
+ [['has been modified since', 'modified since read'], 'File Changed'],
44
+ [['exceeds maximum', 'too large', 'too long'], 'File Too Large'],
45
+ [['file not found', 'does not exist', 'no such file'], 'File Not Found'],
46
+ [['rejected', "doesn't want to proceed", 'user doesn’t want'], 'User Rejected'],
47
+ [['exit code', 'command failed', 'error:'], 'Command Failed'],
48
+ ];
49
+ /**
50
+ * Gaps longer than this are someone leaving and coming back, not a reply latency.
51
+ * Counted separately rather than silently dropped.
52
+ */
53
+ const GAP_CEILING_SECONDS = 3600;
54
+ /** Response-gap buckets, in ascending order. Upper bound is exclusive. */
55
+ const GAP_BUCKETS = [
56
+ ['<10s', 10], ['10-30s', 30], ['30s-1m', 60], ['1-2m', 120],
57
+ ['2-5m', 300], ['5-15m', 900], ['15-60m', Infinity],
58
+ ];
59
+ function emptyFacets() {
60
+ return {
61
+ toolCounts: {}, models: {}, languages: {}, slashCommands: {}, errorCategories: {},
62
+ interruptions: 0, responseGaps: [], gapsOverCeiling: 0,
63
+ linesTouchedBefore: 0, linesTouchedAfter: 0, editingToolCalls: 0,
64
+ filesCreated: 0, filesModified: 0, filesDeleted: 0, gitCommits: 0, gitPushes: 0,
65
+ shellCommandsSeen: 0,
66
+ messageHours: new Array(24).fill(0), userTurns: 0, assistantTurns: 0,
67
+ toolCount: 0, errorCount: 0,
68
+ };
69
+ }
70
+ function bump(map, key, by = 1) {
71
+ map[key] = (map[key] ?? 0) + by;
72
+ }
73
+ /**
74
+ * Lines in a string, 0 for empty. A trailing newline terminates the last line rather
75
+ * than starting a new one, so `"a\nb\n"` is 2 — `split('\n').length` would say 3 and
76
+ * over-count every newline-terminated Write by one.
77
+ */
78
+ function lineCount(text) {
79
+ if (typeof text !== 'string' || text === '')
80
+ return 0;
81
+ const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
82
+ return trimmed.split('\n').length;
83
+ }
84
+ function categorizeError(text) {
85
+ const lower = text.toLowerCase();
86
+ for (const [needles, label] of ERROR_CATEGORIES) {
87
+ if (needles.some((n) => lower.includes(n)))
88
+ return label;
89
+ }
90
+ return 'Other';
91
+ }
92
+ /**
93
+ * Count invocations of a git subcommand in a shell command line.
94
+ *
95
+ * Matches `git commit`, `git -C /repo commit`, and the same again after a `&&`, `||`
96
+ * or `;`, without trying to parse shell. The subcommand must be its own whitespace-
97
+ * separated token, so `git-commit` (a different binary) does not count.
98
+ *
99
+ * Known limitation, shared with the `/insights` implementation this mirrors: a git
100
+ * command quoted inside another command (`echo "run git commit later"`) still counts.
101
+ * Distinguishing that needs a real shell parse, which is not worth it for a rollup.
102
+ */
103
+ function countGitOp(command, op) {
104
+ const re = new RegExp(`(?:^|[\\s&|;(])git(?:\\s+[^\\s&|;]+)*?\\s+${op}\\b`, 'g');
105
+ return (command.match(re) ?? []).length;
106
+ }
107
+ /**
108
+ * Compute every behavioural facet of one session from its parsed events.
109
+ *
110
+ * Pure: no I/O, no clock, no filesystem. `timezoneOffsetMinutes` is injected rather
111
+ * than read from the environment so the hour histogram is deterministic in tests and
112
+ * can be re-bucketed for a different display timezone without re-parsing.
113
+ */
114
+ export function computeInsightFacets(events, timezoneOffsetMinutes = new Date().getTimezoneOffset()) {
115
+ const f = emptyFacets();
116
+ const stats = computeSummaryStats(events);
117
+ f.toolCounts = stats.toolCounts;
118
+ f.userTurns = stats.userTurns;
119
+ f.assistantTurns = stats.assistantTurns;
120
+ f.toolCount = stats.toolCount;
121
+ f.errorCount = stats.errorCount;
122
+ const changes = classifyFileChanges(events);
123
+ for (const c of changes) {
124
+ if (c.op === 'created')
125
+ f.filesCreated++;
126
+ else if (c.op === 'modified')
127
+ f.filesModified++;
128
+ else
129
+ f.filesDeleted++;
130
+ const dot = c.path.lastIndexOf('.');
131
+ if (dot > 0) {
132
+ const lang = LANGUAGE_BY_EXT[c.path.slice(dot).toLowerCase()];
133
+ if (lang)
134
+ bump(f.languages, lang);
135
+ }
136
+ }
137
+ // Response gap: assistant goes quiet, user speaks again.
138
+ //
139
+ // No lower bound. /insights drops gaps under 2s, which sounds harmless and is not:
140
+ // measured over 782 real transcripts it censors 28.4% of the sample and inflates the
141
+ // reported p50 by 63% (143s against a true 88s), because fast replies are common and
142
+ // dropping them all shifts the median right. A 0-second reply is a real reply.
143
+ //
144
+ // The upper bound stays: past an hour the user went away and came back, which is not
145
+ // a reply latency. It censors 5.5% of gaps, and `gapsOverCeiling` reports how many so
146
+ // the number is never quietly truncated.
147
+ let lastAssistantTs = null;
148
+ for (const e of events) {
149
+ const ts = new Date(e.timestamp).getTime();
150
+ const hasTs = !Number.isNaN(ts);
151
+ switch (e.type) {
152
+ case 'interrupt':
153
+ f.interruptions++;
154
+ break;
155
+ case 'usage':
156
+ // shortenModel so the label matches `agents sessions <id>` and `trends`
157
+ // rather than printing the raw id beside their shortened one.
158
+ if (e.model)
159
+ bump(f.models, shortenModel(e.model));
160
+ break;
161
+ case 'error':
162
+ bump(f.errorCategories, categorizeError(e.content ?? e.output ?? ''));
163
+ break;
164
+ case 'message':
165
+ if (e.role === 'assistant') {
166
+ if (hasTs)
167
+ lastAssistantTs = ts;
168
+ break;
169
+ }
170
+ if (e.role !== 'user')
171
+ break;
172
+ if (hasTs) {
173
+ // Local-time hour. parse.ts falls back to `new Date()` for a record with no
174
+ // timestamp; those are indistinguishable here, but they are rare and would
175
+ // only smear the histogram toward the scan time, never invent a session.
176
+ const local = new Date(ts - timezoneOffsetMinutes * 60_000);
177
+ f.messageHours[local.getUTCHours()]++;
178
+ if (lastAssistantTs !== null) {
179
+ const gap = (ts - lastAssistantTs) / 1000;
180
+ // >= 0 because clock skew between records can produce a negative gap
181
+ // (one of -8.662s in a real corpus). Removing the old 2s floor removed
182
+ // this guard with it; a negative reply latency is not a data point.
183
+ if (gap >= 0 && gap < GAP_CEILING_SECONDS)
184
+ f.responseGaps.push(gap);
185
+ else if (gap >= GAP_CEILING_SECONDS)
186
+ f.gapsOverCeiling++;
187
+ }
188
+ }
189
+ lastAssistantTs = null;
190
+ if (e.slashCommand)
191
+ bump(f.slashCommands, e.slashCommand);
192
+ break;
193
+ case 'tool_use': {
194
+ if (e._local)
195
+ break;
196
+ if (hasTs)
197
+ lastAssistantTs = ts;
198
+ const args = e.args ?? {};
199
+ const toolName = e.tool ?? '';
200
+ // Keyed on the SHARED cross-harness vocabulary, not Claude's literals. Keying
201
+ // on 'Edit'|'MultiEdit'|'Write' meant codex (whose vocabulary is exec /
202
+ // exec_command / write_stdin) reported 5,197 tool calls and exactly zero lines
203
+ // touched, rendered under the same column heading as a real number.
204
+ if (EDIT_TOOLS.has(toolName)) {
205
+ f.linesTouchedBefore += lineCount(args.old_string);
206
+ f.linesTouchedAfter += lineCount(args.new_string);
207
+ for (const edit of Array.isArray(args.edits) ? args.edits : []) {
208
+ f.linesTouchedBefore += lineCount(edit?.old_string);
209
+ f.linesTouchedAfter += lineCount(edit?.new_string);
210
+ }
211
+ f.editingToolCalls++;
212
+ }
213
+ else if (WRITE_TOOLS.has(toolName)) {
214
+ f.linesTouchedAfter += lineCount(args.content);
215
+ f.editingToolCalls++;
216
+ }
217
+ if (e.command) {
218
+ f.shellCommandsSeen++;
219
+ f.gitCommits += countGitOp(e.command, 'commit');
220
+ f.gitPushes += countGitOp(e.command, 'push');
221
+ }
222
+ break;
223
+ }
224
+ default:
225
+ if (hasTs && e.role === 'assistant')
226
+ lastAssistantTs = ts;
227
+ }
228
+ }
229
+ return f;
230
+ }
231
+ /** Percentile of a numeric sample, nearest-rank. Returns 0 for an empty sample. */
232
+ export function percentile(values, p) {
233
+ if (values.length === 0)
234
+ return 0;
235
+ const sorted = [...values].sort((a, b) => a - b);
236
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
237
+ return sorted[idx];
238
+ }
239
+ /** Bucket response gaps for display. Returns every bucket, including empty ones. */
240
+ export function bucketGaps(gaps) {
241
+ const out = GAP_BUCKETS.map(([bucket]) => ({ bucket, count: 0 }));
242
+ for (const g of gaps) {
243
+ for (let i = 0; i < GAP_BUCKETS.length; i++) {
244
+ if (g < GAP_BUCKETS[i][1]) {
245
+ out[i].count++;
246
+ break;
247
+ }
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+ /**
253
+ * Detect concurrent sessions by interval intersection.
254
+ *
255
+ * This is the metric that makes the account split legible rather than academic: a
256
+ * cross-account overlap is `balanced` rotation actively running two orgs' quota at the
257
+ * same moment. `/insights` has a comparable "multi-clauding" count, but with one
258
+ * account it can only ever report the same-account case.
259
+ *
260
+ * Sweep in start order, keeping only spans that could still intersect, so this is
261
+ * O(n log n + pairs) rather than O(n^2) over ~3k sessions.
262
+ */
263
+ export function detectOverlap(spans) {
264
+ const usable = spans
265
+ .filter((s) => Number.isFinite(s.startMs) && Number.isFinite(s.endMs) && s.endMs > s.startMs)
266
+ .sort((a, b) => a.startMs - b.startMs);
267
+ let overlappingPairs = 0;
268
+ let crossAccountPairs = 0;
269
+ const involved = new Set();
270
+ const active = [];
271
+ for (const span of usable) {
272
+ // Drop spans that ended before this one started; they cannot intersect it or
273
+ // anything after it.
274
+ for (let i = active.length - 1; i >= 0; i--) {
275
+ if (active[i].endMs <= span.startMs)
276
+ active.splice(i, 1);
277
+ }
278
+ for (const other of active) {
279
+ overlappingPairs++;
280
+ if (other.accountKey !== span.accountKey)
281
+ crossAccountPairs++;
282
+ involved.add(other.id);
283
+ involved.add(span.id);
284
+ }
285
+ active.push(span);
286
+ }
287
+ return { overlappingPairs, crossAccountPairs, sessionsInvolved: involved.size };
288
+ }
289
+ /** Merge a session's facets into a running total. */
290
+ export function mergeFacets(into, add) {
291
+ for (const [k, v] of Object.entries(add.toolCounts))
292
+ bump(into.toolCounts, k, v);
293
+ for (const [k, v] of Object.entries(add.models))
294
+ bump(into.models, k, v);
295
+ for (const [k, v] of Object.entries(add.languages))
296
+ bump(into.languages, k, v);
297
+ for (const [k, v] of Object.entries(add.slashCommands))
298
+ bump(into.slashCommands, k, v);
299
+ for (const [k, v] of Object.entries(add.errorCategories))
300
+ bump(into.errorCategories, k, v);
301
+ into.interruptions += add.interruptions;
302
+ into.responseGaps.push(...add.responseGaps);
303
+ into.gapsOverCeiling += add.gapsOverCeiling;
304
+ into.linesTouchedBefore += add.linesTouchedBefore;
305
+ into.linesTouchedAfter += add.linesTouchedAfter;
306
+ into.editingToolCalls += add.editingToolCalls;
307
+ into.filesCreated += add.filesCreated;
308
+ into.filesModified += add.filesModified;
309
+ into.filesDeleted += add.filesDeleted;
310
+ into.gitCommits += add.gitCommits;
311
+ into.gitPushes += add.gitPushes;
312
+ into.shellCommandsSeen += add.shellCommandsSeen;
313
+ into.userTurns += add.userTurns;
314
+ into.assistantTurns += add.assistantTurns;
315
+ into.toolCount += add.toolCount;
316
+ into.errorCount += add.errorCount;
317
+ for (let i = 0; i < 24; i++)
318
+ into.messageHours[i] += add.messageHours[i];
319
+ }
320
+ /** A fresh zeroed accumulator, for callers folding many sessions together. */
321
+ export function newFacetAccumulator() {
322
+ return emptyFacets();
323
+ }
324
+ /** Top-N entries of a count map, highest first, ties broken by name for determinism. */
325
+ export function topEntries(counts, limit) {
326
+ return Object.entries(counts)
327
+ .map(([name, count]) => ({ name, count }))
328
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
329
+ .slice(0, limit);
330
+ }
@@ -34,6 +34,23 @@ export declare function safeReadSessionFile(filePath: string, maxBytes?: number)
34
34
  export interface ParseSessionOptions {
35
35
  /** Keep normalized tool results compact by default; renderers can request full output. */
36
36
  maxToolOutputChars?: number;
37
+ /**
38
+ * Emit an `interrupt` event where the transcript records `[Request interrupted`.
39
+ *
40
+ * OFF by default, deliberately. That marker is not a user message, and the default
41
+ * event array is a versioned consumer contract: `agents sessions <id> --json`
42
+ * serializes it verbatim (see render.ts, issue #743), `computeSummaryStats` folds
43
+ * every event's timestamp into the session duration, and the live-state reader and
44
+ * tail renderer inspect fixed-size windows of the last N events. Emitting it
45
+ * unconditionally changed all four — a measured 12x duration swing on one real
46
+ * transcript, a new object in a published payload, and an eviction from the
47
+ * 12-event rate-limit window whose trigger shape (a trailing interrupt) is exactly
48
+ * a session the user just cancelled.
49
+ *
50
+ * `agents insights` opts in: an interruption is a real friction signal, and dropping
51
+ * it outright is what made it unrecoverable.
52
+ */
53
+ includeInterrupts?: boolean;
37
54
  }
38
55
  export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
39
56
  /** Infer the agent type from a session file path using known directory conventions. */
@@ -54,14 +71,14 @@ export declare function isCompletedTodoStatus(status: unknown): boolean;
54
71
  */
55
72
  export declare function summarizeToolUse(tool: string, args?: Record<string, any>): string;
56
73
  /** Parse a Claude JSONL session file into normalized events. */
57
- export declare function parseClaude(filePath: string): SessionEvent[];
74
+ export declare function parseClaude(filePath: string, opts?: ParseSessionOptions): SessionEvent[];
58
75
  /**
59
76
  * Parse Claude JSONL *content* (already read into a string) into normalized
60
77
  * events. Split from `parseClaude` so the tail reader can parse just the last
61
78
  * chunk of a file without re-reading the whole thing. Malformed leading lines
62
79
  * (a tail that starts mid-line) are skipped by the per-line try/catch below.
63
80
  */
64
- export declare function parseClaudeContent(content: string): SessionEvent[];
81
+ export declare function parseClaudeContent(content: string, opts?: ParseSessionOptions): SessionEvent[];
65
82
  /** Parse a Codex JSONL session file into normalized events. */
66
83
  export declare function parseCodex(filePath: string): SessionEvent[];
67
84
  /**
@@ -116,6 +133,16 @@ export declare function parseGrok(filePath: string): SessionEvent[];
116
133
  export declare function parseOpenCode(filePath: string): SessionEvent[];
117
134
  /** Parse a Rush JSONL session file into normalized events. */
118
135
  export declare function parseRush(filePath: string): SessionEvent[];
136
+ /**
137
+ * Muse Code session.jsonl → normalized events.
138
+ *
139
+ * Muse records an append-only event log. We map:
140
+ * - runtime.command_intake.received / turn_submit → user message
141
+ * - assistant_message_committed → assistant message
142
+ * - model_completed.usage → usage
143
+ * - tool-related events when present
144
+ */
145
+ export declare function parseMuse(filePath: string): SessionEvent[];
119
146
  /** Parse a Hermes session JSON file into normalized events. */
120
147
  export declare function parseHermes(filePath: string): SessionEvent[];
121
148
  /** Parse a Kimi session state.json file by reading its agents/main/wire.jsonl. */
@@ -125,7 +125,7 @@ export function parseSession(filePath, agent, opts = {}) {
125
125
  let events;
126
126
  switch (detected) {
127
127
  case 'claude':
128
- events = parseClaude(filePath);
128
+ events = parseClaude(filePath, opts);
129
129
  break;
130
130
  case 'codex':
131
131
  events = parseCodex(filePath);
@@ -160,6 +160,9 @@ export function parseSession(filePath, agent, opts = {}) {
160
160
  case 'cursor':
161
161
  events = parseCursor(filePath);
162
162
  break;
163
+ case 'muse':
164
+ events = parseMuse(filePath);
165
+ break;
163
166
  }
164
167
  // Chokepoint: every string field that originated in an untrusted session
165
168
  // file gets stripped of terminal escapes here, so renderers downstream can
@@ -206,6 +209,13 @@ export function detectAgent(filePath) {
206
209
  return 'droid';
207
210
  if (filePath.includes('/.cursor/') || filePath.includes('\\.cursor\\'))
208
211
  return 'cursor';
212
+ // Muse sessions: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl
213
+ if (filePath.includes('/muse/sessions/') ||
214
+ filePath.includes('\\muse\\sessions\\') ||
215
+ filePath.includes('/.local/share/muse/') ||
216
+ filePath.includes('\\.local\\share\\muse\\')) {
217
+ return 'muse';
218
+ }
209
219
  // Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
210
220
  const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
211
221
  if (cloudMatch)
@@ -311,8 +321,8 @@ function shortenPath(p) {
311
321
  // Claude parser
312
322
  // ---------------------------------------------------------------------------
313
323
  /** Parse a Claude JSONL session file into normalized events. */
314
- export function parseClaude(filePath) {
315
- return parseClaudeContent(safeReadSessionFile(filePath));
324
+ export function parseClaude(filePath, opts = {}) {
325
+ return parseClaudeContent(safeReadSessionFile(filePath), opts);
316
326
  }
317
327
  /**
318
328
  * Parse Claude JSONL *content* (already read into a string) into normalized
@@ -320,7 +330,7 @@ export function parseClaude(filePath) {
320
330
  * chunk of a file without re-reading the whole thing. Malformed leading lines
321
331
  * (a tail that starts mid-line) are skipped by the per-line try/catch below.
322
332
  */
323
- export function parseClaudeContent(content) {
333
+ export function parseClaudeContent(content, opts = {}) {
324
334
  const lines = content.split('\n').filter(l => l.trim());
325
335
  const events = [];
326
336
  // Map tool_use id -> {tool, args} for correlating with tool_result
@@ -436,7 +446,15 @@ export function parseClaudeContent(content) {
436
446
  for (const block of contentBlocks) {
437
447
  if (block.type === 'text') {
438
448
  const text = (block.text || '').trim();
439
- if (text && !text.startsWith('[Request interrupted')) {
449
+ if (text.startsWith('[Request interrupted')) {
450
+ // The harness's marker for a turn the user cut short, not a user
451
+ // message. Surfaced only on request — see includeInterrupts for why
452
+ // the default stream must stay byte-identical.
453
+ if (opts.includeInterrupts) {
454
+ events.push({ type: 'interrupt', agent: 'claude', timestamp, content: text });
455
+ }
456
+ }
457
+ else if (text) {
440
458
  events.push({
441
459
  type: 'message',
442
460
  agent: 'claude',
@@ -1496,6 +1514,94 @@ export function parseRush(filePath) {
1496
1514
  // system_prompt, message_count, messages: [{role, content}, ...] }
1497
1515
  // Content may be a string or an array of text parts.
1498
1516
  // ---------------------------------------------------------------------------
1517
+ /**
1518
+ * Muse Code session.jsonl → normalized events.
1519
+ *
1520
+ * Muse records an append-only event log. We map:
1521
+ * - runtime.command_intake.received / turn_submit → user message
1522
+ * - assistant_message_committed → assistant message
1523
+ * - model_completed.usage → usage
1524
+ * - tool-related events when present
1525
+ */
1526
+ export function parseMuse(filePath) {
1527
+ const content = safeReadSessionFile(filePath);
1528
+ const lines = content.split('\n').filter((l) => l.trim());
1529
+ const events = [];
1530
+ const museTs = (raw) => {
1531
+ if (typeof raw?.recorded_at === 'number') {
1532
+ const v = raw.recorded_at;
1533
+ const ms = v > 1e14 ? Math.floor(v / 1000) : v;
1534
+ return new Date(ms).toISOString();
1535
+ }
1536
+ return new Date().toISOString();
1537
+ };
1538
+ for (const line of lines) {
1539
+ let raw;
1540
+ try {
1541
+ raw = JSON.parse(line);
1542
+ }
1543
+ catch {
1544
+ continue;
1545
+ }
1546
+ const timestamp = museTs(raw);
1547
+ const payloadType = raw.payload_type;
1548
+ const payload = raw.payload;
1549
+ const event = payload?.event ?? payload;
1550
+ if (payloadType === 'runtime.command_intake.received') {
1551
+ const cmd = payload?.record?.command;
1552
+ if (cmd?.kind === 'turn_submit' && typeof cmd.prompt === 'string' && cmd.prompt.trim()) {
1553
+ events.push({
1554
+ type: 'message',
1555
+ agent: 'muse',
1556
+ timestamp,
1557
+ role: 'user',
1558
+ content: cmd.prompt.trim(),
1559
+ });
1560
+ }
1561
+ continue;
1562
+ }
1563
+ if (event?.kind === 'assistant_message_committed' && typeof event.text === 'string') {
1564
+ events.push({
1565
+ type: 'message',
1566
+ agent: 'muse',
1567
+ timestamp,
1568
+ role: 'assistant',
1569
+ content: event.text,
1570
+ });
1571
+ continue;
1572
+ }
1573
+ if (event?.kind === 'model_completed' && event.usage && typeof event.usage === 'object') {
1574
+ const u = event.usage;
1575
+ events.push({
1576
+ type: 'usage',
1577
+ agent: 'muse',
1578
+ timestamp,
1579
+ content: JSON.stringify({
1580
+ input_tokens: u.input_tokens ?? 0,
1581
+ output_tokens: u.output_tokens ?? 0,
1582
+ cached_tokens: u.cached_tokens ?? 0,
1583
+ reasoning_tokens: u.reasoning_tokens ?? 0,
1584
+ }),
1585
+ });
1586
+ continue;
1587
+ }
1588
+ // Tool call / result shapes when Muse records them under task events
1589
+ if (event?.kind === 'tool_call' || event?.kind === 'tool.called') {
1590
+ const tool = event.tool || event.name || event.tool_name;
1591
+ if (typeof tool === 'string') {
1592
+ events.push({
1593
+ type: 'tool_use',
1594
+ agent: 'muse',
1595
+ timestamp,
1596
+ tool,
1597
+ args: typeof event.args === 'object' ? event.args : undefined,
1598
+ callId: typeof event.call_id === 'string' ? event.call_id : undefined,
1599
+ });
1600
+ }
1601
+ }
1602
+ }
1603
+ return events;
1604
+ }
1499
1605
  /** Parse a Hermes session JSON file into normalized events. */
1500
1606
  export function parseHermes(filePath) {
1501
1607
  let session;
@@ -73,7 +73,16 @@ export declare function isAutomaticSessionPeer(d: DeviceProfile, self: string):
73
73
  * address. `forwardedArgs` are the caller's own sessions args (query + filters,
74
74
  * already `--json`) so every peer returns the same slice this machine asked for.
75
75
  */
76
- export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[]): Promise<RemoteListResult>;
76
+ export interface GatherRemoteListOptions {
77
+ /**
78
+ * Opt-in early-exit for a globally-unique id lookup (a full UUID): the first
79
+ * peer to return the matching row resolves the fan-out and cancels the rest.
80
+ * Omitted for browse/label/prefix sweeps, which must wait for every peer to
81
+ * know whether the match is unique or conflicting.
82
+ */
83
+ isDefinitive?: (session: SessionMeta, machine: string) => boolean;
84
+ }
85
+ export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[], opts?: GatherRemoteListOptions): Promise<RemoteListResult>;
77
86
  export interface RemoteToolSearchResult {
78
87
  envelopes: Array<{
79
88
  machine: string;
@@ -191,19 +191,13 @@ export function isAutomaticSessionPeer(d, self) {
191
191
  return false;
192
192
  return d.platform === 'windows' || d.platform === 'linux' || d.platform === 'macos';
193
193
  }
194
- /**
195
- * Gather listing sessions from other machines. With an explicit `hosts` list
196
- * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
197
- * online devices from `ag devices`, excluding this machine and any without an
198
- * address. `forwardedArgs` are the caller's own sessions args (query + filters,
199
- * already `--json`) so every peer returns the same slice this machine asked for.
200
- */
201
- export async function gatherRemoteList(forwardedArgs, hosts) {
194
+ export async function gatherRemoteList(forwardedArgs, hosts, opts) {
202
195
  const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
203
196
  const result = await gatherRemoteAgentsJson({
204
197
  args: forwardedArgs,
205
198
  noFanoutEnv: NO_FANOUT_ENV,
206
199
  hosts,
200
+ earlyExit: opts?.isDefinitive ? { isDefinitive: opts.isDefinitive } : undefined,
207
201
  parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
208
202
  });
209
203
  return {
@@ -54,20 +54,74 @@ export declare function classifySshFailure(res: {
54
54
  error?: Error | null;
55
55
  status: number | null;
56
56
  }): SshOutcome;
57
+ /**
58
+ * How long a successful remote fetch may be served without re-SSHing.
59
+ * Short on purpose: session listings must stay near-live (RUSH-2062). Match the
60
+ * active-session snapshot window so surfaces share one freshness model.
61
+ */
62
+ export declare const REMOTE_CACHE_MAX_AGE_MS = 15000;
57
63
  /**
58
64
  * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
59
65
  * are hashed so distinct queries cache independently; the host stays readable in
60
66
  * the filename (sanitised so `user@host` and aliases are filesystem-safe).
61
67
  */
62
68
  export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
69
+ /**
70
+ * Pure freshness check for a remote-sessions cache entry. A reachable host
71
+ * skips SSH only while this returns true; unreachable fallback ignores age.
72
+ */
73
+ export declare function isRemoteCacheFresh(mtimeMs: number, nowMs: number, maxAgeMs?: number): boolean;
74
+ export interface RemoteCacheHit {
75
+ output: string;
76
+ mtimeMs: number;
77
+ }
78
+ /**
79
+ * Read a cached remote fetch. When `maxAgeMs` is set, returns null if the
80
+ * entry is older than the window (cache-first path for reachable hosts).
81
+ * Omit `maxAgeMs` to accept any age (unreachable fallback).
82
+ */
83
+ export declare function readRemoteCache(host: string, forwardedArgs: string[], opts?: {
84
+ maxAgeMs?: number;
85
+ nowMs?: number;
86
+ }): RemoteCacheHit | null;
63
87
  /** Banner shown above replayed cache rows when the peer is offline. */
64
88
  export declare function formatStaleBanner(host: string, mtimeMs: number): string;
65
89
  /** Message shown when a host is unreachable and there is no cache to fall back to. */
66
90
  export declare function formatUnreachable(host: string): string;
91
+ /** Persist a successful fetch for later cache-first / offline replay.
92
+ * Best-effort: a cache write must never break the live query. Exported for tests. */
93
+ export declare function writeRemoteCache(host: string, forwardedArgs: string[], output: string): void;
94
+ /**
95
+ * Serve a *fresh* cache entry for a reachable-host skip (no banner — the data
96
+ * is still within the freshness window). Returns false when missing/stale so
97
+ * the caller SSHes. RUSH-2062: without this, a reachable host never skipped SSH
98
+ * even when the cache was just written.
99
+ */
100
+ export declare function serveWarmRemoteCache(host: string, forwardedArgs: string[], opts?: {
101
+ maxAgeMs?: number;
102
+ nowMs?: number;
103
+ }): boolean;
104
+ /** Replay a cached fetch for an unreachable host (any age). Banner goes to
105
+ * stderr (so a piped stdout stays exactly the cached rows); returns false when
106
+ * nothing is cached for this exact (host, query). */
107
+ export declare function replayRemoteCache(host: string, forwardedArgs: string[]): boolean;
108
+ export interface RunRemoteSessionsOptions {
109
+ /** Skip warm cache and SSH every host (force-refresh). */
110
+ forceRefresh?: boolean;
111
+ /** Override freshness window for the warm path. */
112
+ maxAgeMs?: number;
113
+ /** Clock (tests). */
114
+ nowMs?: number;
115
+ }
67
116
  /**
68
117
  * Run the current `agents sessions` invocation on one or more remote machines over
69
- * SSH, writing each remote's output to the terminal. A successful fetch is cached;
70
- * an unreachable host falls back to that cache (with a stale banner) when present.
118
+ * SSH, writing each remote's output to the terminal.
119
+ *
120
+ * Cache policy (RUSH-2062):
121
+ * - **Default:** serve a fresh cache hit without SSH; SSH only on miss/stale.
122
+ * - **`forceRefresh`:** always SSH, then rewrite the cache.
123
+ * - **Unreachable:** fall back to any cached output (with a stale banner).
124
+ *
71
125
  * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
72
126
  * Reads the invocation from `process.argv` (override via `argv` for testing).
73
127
  *
@@ -75,4 +129,4 @@ export declare function formatUnreachable(host: string): string;
75
129
  * Session output is small and the remote returns quickly, so buffering is
76
130
  * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
77
131
  */
78
- export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;
132
+ export declare function runRemoteSessions(hosts: string[], argv?: string[], opts?: RunRemoteSessionsOptions): void;