@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -99,31 +99,136 @@ export function toolHistogram(toolCounts, top = 8) {
99
99
  .sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
100
100
  .slice(0, top);
101
101
  }
102
- /** Recognized test/build runners → the label we show. */
102
+ /**
103
+ * Recognized test/build runners → the label we show.
104
+ *
105
+ * These match a *real invocation* of a runner, not any command that merely
106
+ * contains a runner token. In particular we must NOT fire on npm-script
107
+ * sub-targets like `bun test:setup`, `npm run test:watch`, or `pnpm test:ci` —
108
+ * those are setup/watch/CI-orchestration scripts, not a test run whose output we
109
+ * can scrape for a pass/fail verdict. A `test` token followed by `:something` is
110
+ * a distinct script name and is excluded via the `(?![:\w-])` guard.
111
+ */
103
112
  const TEST_RUNNERS = [
104
- { re: /\b((?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test|vitest|jest)\b/, label: 'tests' },
105
- { re: /\bpytest\b/, label: 'pytest' },
106
- { re: /\bgo\s+test\b/, label: 'go test' },
107
- { re: /\bcargo\s+test\b/, label: 'cargo test' },
108
- { re: /\b(tsc|tsc\s+--noEmit)\b/, label: 'tsc' },
113
+ // Package-manager `test` script: `bun test`, `npm test`, `npm run test`,
114
+ // `yarn test`, `pnpm test`. The negative lookahead `(?![:\w-])` rejects
115
+ // `test:watch` / `test-ci` / `testfoo` — only the bare `test` target counts.
116
+ { re: /\b(?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test(?![:\w-])/, label: 'tests' },
117
+ // Direct binaries — `vitest`, `jest`, `mocha` (optionally via a runner). These
118
+ // are real invocations; a trailing subcommand like `vitest run` is fine.
119
+ { re: /\b(?:vitest|jest|mocha)(?![:\w-])/, label: 'tests' },
120
+ { re: /\bpytest(?![:\w-])/, label: 'pytest' },
121
+ { re: /\bgo\s+test(?![:\w-])/, label: 'go test' },
122
+ { re: /\bcargo\s+test(?![:\w-])/, label: 'cargo test' },
123
+ { re: /\btsc(?:\s+--noEmit)?(?![:\w-])/, label: 'tsc' },
109
124
  ];
125
+ /**
126
+ * Anchored summary-line extractors, per runner family. Each returns a pass/fail
127
+ * verdict ONLY when it matches that runner's authoritative summary construct —
128
+ * never an arbitrary `\d+ pass`-shaped substring elsewhere in stdout. This is
129
+ * what stops `442 passwords generated`, `442 files`, or `442 passes/sec` from
130
+ * being reported as `442 pass`.
131
+ */
132
+ function parseSummaryLine(output) {
133
+ const lines = output.split(/\r?\n/);
134
+ // vitest: ` Tests 1 failed | 2 passed (3)` (mixed) or ` Tests 2 passed (2)`
135
+ // (all green). Leading indent + the `Tests` / `Test Files` label + double
136
+ // space is the summary row. Prefer the ` Tests ` row (test count) over the
137
+ // ` Test Files ` row (file count).
138
+ for (const label of ['Tests', 'Test Files']) {
139
+ for (const line of lines) {
140
+ const row = new RegExp(`^\\s*${label}\\s{2,}(.+)$`).exec(line);
141
+ if (!row)
142
+ continue;
143
+ const seg = row[1];
144
+ const passed = /(\d+)\s+passed/.exec(seg);
145
+ const failed = /(\d+)\s+failed/.exec(seg);
146
+ // Only accept a vitest row that actually reports a pass/fail count.
147
+ if (passed || failed) {
148
+ return {
149
+ passed: passed ? +passed[1] : undefined,
150
+ failed: failed ? +failed[1] : 0,
151
+ };
152
+ }
153
+ }
154
+ }
155
+ // jest: `Tests: 1 failed, 2 passed, 3 total`. The `Tests:` label anchors
156
+ // it; order of failed/passed varies.
157
+ for (const line of lines) {
158
+ const row = /^\s*Tests:\s+(.+)$/.exec(line);
159
+ if (!row)
160
+ continue;
161
+ const seg = row[1];
162
+ const passed = /(\d+)\s+passed/.exec(seg);
163
+ const failed = /(\d+)\s+failed/.exec(seg);
164
+ if (passed || failed) {
165
+ return { passed: passed ? +passed[1] : undefined, failed: failed ? +failed[1] : 0 };
166
+ }
167
+ }
168
+ // pytest: the summary rule line `==== 3 passed, 1 failed in 0.12s ====`. The
169
+ // surrounding `=` rule and the ` in <dur>s` tail anchor it to the real footer,
170
+ // not a stray `N passed` in captured stdout.
171
+ for (const line of lines) {
172
+ if (!/={3,}.*\bin\s+[\d.]+s\b.*={3,}/.test(line) && !/^={3,}.*={3,}$/.test(line))
173
+ continue;
174
+ if (!/\bin\s+[\d.]+m?s\b/.test(line))
175
+ continue;
176
+ const passed = /(\d+)\s+passed/.exec(line);
177
+ const failed = /(\d+)\s+failed/.exec(line);
178
+ const errors = /(\d+)\s+error/.exec(line);
179
+ if (passed || failed || errors) {
180
+ const failCount = (failed ? +failed[1] : 0) + (errors ? +errors[1] : 0);
181
+ return { passed: passed ? +passed[1] : undefined, failed: failed || errors ? failCount : 0 };
182
+ }
183
+ }
184
+ // bun test: a summary block of ` N pass` / ` N fail` lines (each its own line,
185
+ // leading-indented), closed by `Ran N tests across M files.`. Require the
186
+ // `Ran … tests` sentinel to be present so we only read bun's real summary
187
+ // block — bun also prints inline `(pass)`/`(fail)` per-test lines we ignore.
188
+ if (/^Ran\s+\d+\s+tests?\b/m.test(output)) {
189
+ let passed;
190
+ let failed;
191
+ for (const line of lines) {
192
+ const p = /^\s*(\d+)\s+pass$/.exec(line);
193
+ const f = /^\s*(\d+)\s+fail$/.exec(line);
194
+ if (p)
195
+ passed = +p[1];
196
+ if (f)
197
+ failed = +f[1];
198
+ }
199
+ if (passed !== undefined || failed !== undefined) {
200
+ return { passed, failed: failed ?? 0 };
201
+ }
202
+ }
203
+ // mocha: ` 2 passing` / ` 1 failing` (leading-indented, own line). The
204
+ // `passing`/`failing` gerund is mocha-specific and won't match `442 passwords`.
205
+ {
206
+ let passed;
207
+ let failed;
208
+ for (const line of lines) {
209
+ const p = /^\s*(\d+)\s+passing\b/.exec(line);
210
+ const f = /^\s*(\d+)\s+failing\b/.exec(line);
211
+ if (p)
212
+ passed = +p[1];
213
+ if (f)
214
+ failed = +f[1];
215
+ }
216
+ if (passed !== undefined || failed !== undefined) {
217
+ return { passed, failed: failed ?? 0 };
218
+ }
219
+ }
220
+ return undefined;
221
+ }
110
222
  /** Parse pass/fail counts from common runner output. */
111
223
  function parseTestOutput(runner, output) {
112
- // vitest/jest/bun: "N passed", "N failed"; pytest: "N passed, N failed".
113
- // Take the LAST occurrence runners print a per-file line first, then the
114
- // authoritative aggregate ("Tests 4 failed | 294 passed") at the end.
115
- const lastNum = (re) => {
116
- let m;
117
- let val;
118
- const g = new RegExp(re.source, 'gi');
119
- while ((m = g.exec(output)) !== null)
120
- val = +m[1];
121
- return val;
122
- };
123
- const passed = lastNum(/(\d+)\s+pass(?:ed)?/);
124
- const failed = lastNum(/(\d+)\s+fail(?:ed|ures?)?/);
125
- if (passed !== undefined || failed !== undefined) {
126
- return { passed, failed, ok: true };
224
+ // Anchor to a recognized runner summary construct (vitest ` Tests `, jest
225
+ // `Tests:`, pytest `=== N passed in Xs ===`, bun's `N pass`/`N fail` block,
226
+ // mocha `N passing`) never a blanket `\d+ pass`-shaped scrape over stdout.
227
+ // That blanket scrape used to report `442 passwords`, `442 files passed
228
+ // validation`, or a `442 passes/sec` benchmark as `442 pass`.
229
+ const summary = parseSummaryLine(output);
230
+ if (summary) {
231
+ return { ...summary, ok: true };
127
232
  }
128
233
  // tsc: no news is good news; "error TSxxxx" means failure.
129
234
  if (runner === 'tsc') {
@@ -6,7 +6,8 @@
6
6
  * the last run are re-parsed. All metadata is upserted into the sessions DB so
7
7
  * subsequent queries are served entirely from the cache.
8
8
  */
9
- import type { SessionAgentId, SessionMeta } from './types.js';
9
+ import type { SessionAgentId, SessionEvent, SessionMeta, TodoProgress } from './types.js';
10
+ export { machineForSessionFile } from './origin-machine.js';
10
11
  import { type ScanStamp } from './db.js';
11
12
  /** Options controlling which sessions to discover and how to report progress. */
12
13
  export interface DiscoverOptions {
@@ -82,6 +83,8 @@ interface ClaudeSessionScan {
82
83
  spawnedTeam?: string;
83
84
  /** Plan markdown from the last ExitPlanMode tool call (Claude sessions only). */
84
85
  plan?: string;
86
+ todos?: TodoProgress;
87
+ recentDirectoriesTouched?: string[];
85
88
  }
86
89
  /** Lightweight metadata extracted from a Codex JSONL file during incremental scan. */
87
90
  interface CodexSessionScan {
@@ -105,6 +108,8 @@ interface CodexSessionScan {
105
108
  ticketId?: string;
106
109
  createdTickets?: string[];
107
110
  spawnedTeam?: string;
111
+ todos?: TodoProgress;
112
+ recentDirectoriesTouched?: string[];
108
113
  }
109
114
  /**
110
115
  * Discover sessions. Scans only files whose (mtime, size) have changed since
@@ -144,7 +149,6 @@ export declare function scanAgentsBounded<T>(items: readonly T[], run: (item: T)
144
149
  * `agents add --isolated`, where the whole point was to keep the two apart.
145
150
  */
146
151
  export declare function isManagedSessionFile(filePath: string): boolean;
147
- export declare function machineForSessionFile(filePath: string, agent: string): string;
148
152
  /**
149
153
  * Count sessions in scope without running an incremental scan. Assumes the DB
150
154
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -190,6 +194,20 @@ export declare function _normalizeCwdForTest(cwd?: string): string;
190
194
  * ids, whose charset is too permissive to distinguish from a search phrase.
191
195
  */
192
196
  export declare function isCompleteSessionId(query: string): boolean;
197
+ /**
198
+ * Whether a query should be treated as a session id rather than a search phrase
199
+ * — the one canonical id-shaped test, shared by every session-id resolver.
200
+ *
201
+ * True for a complete id (`isCompleteSessionId`) AND for a bare hex short-id or
202
+ * prefix (`d3470b57`), which the complete-id check rejects. Any id-shaped query
203
+ * resolves by id ONLY (exact -> prefix -> `findSessionsById` index) and must
204
+ * never fall back to fuzzy content search: a short id like `d3470b57` otherwise
205
+ * surfaces every transcript that merely MENTIONS the string (a resume prompt
206
+ * echoes the parent id into the body of many later sessions). The hex test
207
+ * catches the bare short-id/prefix; `isCompleteSessionId` additionally catches
208
+ * the prefixed whole ids (`session_…`, `ses_…`) that the hex test rejects.
209
+ */
210
+ export declare function looksLikeSessionId(query: string): boolean;
193
211
  /**
194
212
  * Resolve a session by full or short ID. Accepts a pre-loaded session list
195
213
  * (fast path from discoverSessions) and falls back to a DB lookup for the
@@ -369,6 +387,8 @@ export interface ClaudeParseState {
369
387
  pendingTicketTools: Set<string>;
370
388
  spawnedTeam?: string;
371
389
  plan?: string;
390
+ checklistEvents: SessionEvent[];
391
+ recentDirectoriesTouched: string[];
372
392
  }
373
393
  /** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
374
394
  export declare function initClaudeParseState(): ClaudeParseState;
@@ -427,6 +447,8 @@ export interface ClaudeParserState {
427
447
  spawnedTeam?: string;
428
448
  ticketId?: string;
429
449
  contentText?: string;
450
+ checklistEvents: SessionEvent[];
451
+ recentDirectoriesTouched: string[];
430
452
  }
431
453
  /**
432
454
  * Snapshot a live {@link ClaudeParseState} into its serializable form at
@@ -500,6 +522,8 @@ export interface CodexParseState {
500
522
  createdTickets: Set<string>;
501
523
  pendingTicketTools: Set<string>;
502
524
  spawnedTeam?: string;
525
+ checklistEvents: SessionEvent[];
526
+ recentDirectoriesTouched: string[];
503
527
  }
504
528
  /** Zero-value accumulator for a fresh (from-byte-0) Codex parse. */
505
529
  export declare function initCodexParseState(): CodexParseState;
@@ -549,6 +573,8 @@ export interface CodexParserState {
549
573
  spawnedTeam?: string;
550
574
  ticketId?: string;
551
575
  contentText?: string;
576
+ checklistEvents: SessionEvent[];
577
+ recentDirectoriesTouched: string[];
552
578
  }
553
579
  /**
554
580
  * Snapshot a live {@link CodexParseState} into its serializable form at `offset`
@@ -641,4 +667,3 @@ export declare function readGrokMeta(filePath: string, currentVersion?: string):
641
667
  } | null;
642
668
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
643
669
  export declare function parseTimeFilter(input: string): number;
644
- export {};
@@ -24,12 +24,14 @@ import { SESSION_AGENTS } from './types.js';
24
24
  import { deriveShortId } from './short-id.js';
25
25
  import { extractSessionTopic } from './prompt.js';
26
26
  import { parseAntigravity } from './parse.js';
27
- import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket } from './state.js';
27
+ import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
28
28
  import { costOfUsage } from '../pricing/index.js';
29
- import { machineId } from './sync/config.js';
29
+ import { machineForSessionFile } from './origin-machine.js';
30
+ export { machineForSessionFile } from './origin-machine.js';
30
31
  import { mapBounded } from '../concurrency.js';
31
- import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
32
+ import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, cacheLinearProject, } from './db.js';
32
33
  import { buildRunNameMap } from './run-names.js';
34
+ import { resolveLinearApiKey } from '../auto-dispatch-linear.js';
33
35
  const HOME = os.homedir();
34
36
  // Versions can live under either repo: the user repo (current canonical
35
37
  // location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
@@ -99,10 +101,49 @@ export async function discoverSessions(options) {
99
101
  }
100
102
  }
101
103
  const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
104
+ await resolveLinearProjects(sessions);
102
105
  for (const s of sessions)
103
106
  s.machine = machineForSessionFile(s.filePath, s.agent);
104
107
  return scopeToManaged(sessions, agents, options);
105
108
  }
109
+ const linearProjectCache = new Map();
110
+ async function resolveLinearProjects(sessions) {
111
+ const apiKey = resolveLinearApiKey();
112
+ if (!apiKey)
113
+ return;
114
+ await Promise.all(sessions.map(async (session) => {
115
+ if (!session.ticketId || session.linearProject)
116
+ return;
117
+ let project = linearProjectCache.get(session.ticketId);
118
+ if (project === undefined) {
119
+ try {
120
+ const response = await fetch('https://api.linear.app/graphql', {
121
+ method: 'POST',
122
+ signal: AbortSignal.timeout(3_000),
123
+ headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({
125
+ query: `query($id:String!){ issue(id:$id){ project{ name url } } }`,
126
+ variables: { id: session.ticketId },
127
+ }),
128
+ });
129
+ if (!response.ok)
130
+ throw new Error(String(response.status));
131
+ const body = await response.json();
132
+ const node = body.data?.issue?.project;
133
+ project = node?.name && node?.url ? { name: node.name, url: node.url } : null;
134
+ }
135
+ catch {
136
+ project = null;
137
+ }
138
+ linearProjectCache.set(session.ticketId, project);
139
+ }
140
+ if (!project)
141
+ return;
142
+ session.linearProject = project.name;
143
+ session.linearProjectUrl = project.url;
144
+ cacheLinearProject(session.id, project.name, project.url);
145
+ }));
146
+ }
106
147
  /**
107
148
  * Drop unmanaged rows for agents that HAVE managed versions.
108
149
  *
@@ -183,7 +224,6 @@ function dispatchAgentScan(agent, onProgress) {
183
224
  default: return Promise.resolve();
184
225
  }
185
226
  }
186
- let _localMachineId;
187
227
  /**
188
228
  * The machine a discovered session originated on. Cross-machine sync mirrors a
189
229
  * remote transcript to backups/<agent>/<machine>/<subdir>/… (see mirrorPath in
@@ -230,15 +270,6 @@ export function isManagedSessionFile(filePath) {
230
270
  return !!realRoot && real.startsWith(realRoot + path.sep);
231
271
  });
232
272
  }
233
- export function machineForSessionFile(filePath, agent) {
234
- const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
235
- if (filePath.startsWith(base)) {
236
- const seg = filePath.slice(base.length).split(path.sep)[0];
237
- if (seg)
238
- return seg;
239
- }
240
- return (_localMachineId ??= machineId());
241
- }
242
273
  /**
243
274
  * Count sessions in scope without running an incremental scan. Assumes the DB
244
275
  * is already fresh (typically true because `discoverSessions` ran first this
@@ -348,6 +379,23 @@ export function isCompleteSessionId(query) {
348
379
  const q = query.trim().toLowerCase();
349
380
  return UUID_36.test(q.replace(SESSION_UUID_PREFIX, '')) || SES_ULID.test(q);
350
381
  }
382
+ /**
383
+ * Whether a query should be treated as a session id rather than a search phrase
384
+ * — the one canonical id-shaped test, shared by every session-id resolver.
385
+ *
386
+ * True for a complete id (`isCompleteSessionId`) AND for a bare hex short-id or
387
+ * prefix (`d3470b57`), which the complete-id check rejects. Any id-shaped query
388
+ * resolves by id ONLY (exact -> prefix -> `findSessionsById` index) and must
389
+ * never fall back to fuzzy content search: a short id like `d3470b57` otherwise
390
+ * surfaces every transcript that merely MENTIONS the string (a resume prompt
391
+ * echoes the parent id into the body of many later sessions). The hex test
392
+ * catches the bare short-id/prefix; `isCompleteSessionId` additionally catches
393
+ * the prefixed whole ids (`session_…`, `ses_…`) that the hex test rejects.
394
+ */
395
+ export function looksLikeSessionId(query) {
396
+ const trimmed = query.trim();
397
+ return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
398
+ }
351
399
  /**
352
400
  * Resolve a session by full or short ID. Accepts a pre-loaded session list
353
401
  * (fast path from discoverSessions) and falls back to a DB lookup for the
@@ -968,6 +1016,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
968
1016
  createdTickets: scan.createdTickets,
969
1017
  spawnedTeam: scan.spawnedTeam,
970
1018
  plan: scan.plan,
1019
+ todos: scan.todos,
1020
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
971
1021
  };
972
1022
  }
973
1023
  else {
@@ -995,6 +1045,8 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account,
995
1045
  createdTickets: scan.createdTickets,
996
1046
  spawnedTeam: scan.spawnedTeam,
997
1047
  plan: scan.plan,
1048
+ todos: scan.todos,
1049
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
998
1050
  };
999
1051
  }
1000
1052
  return {
@@ -1288,6 +1340,8 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion, sc
1288
1340
  ticketId: scan.ticketId,
1289
1341
  createdTickets: scan.createdTickets,
1290
1342
  spawnedTeam: scan.spawnedTeam,
1343
+ todos: scan.todos,
1344
+ recentDirectoriesTouched: scan.recentDirectoriesTouched,
1291
1345
  };
1292
1346
  return {
1293
1347
  meta,
@@ -2370,8 +2424,27 @@ export function initClaudeParseState() {
2370
2424
  pendingTicketTools: new Set(),
2371
2425
  spawnedTeam: undefined,
2372
2426
  plan: undefined,
2427
+ checklistEvents: [],
2428
+ recentDirectoriesTouched: [],
2373
2429
  };
2374
2430
  }
2431
+ const CHECKLIST_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan', 'TaskCreate', 'TaskUpdate']);
2432
+ const DIRECTORY_TOOLS = new Set(['Edit', 'Write', 'edit_file', 'write_file', 'create_file', 'edit', 'write', 'Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute']);
2433
+ function foldDerivedToolState(state, event) {
2434
+ if (CHECKLIST_TOOLS.has(event.tool ?? ''))
2435
+ state.checklistEvents.push(event);
2436
+ if (!DIRECTORY_TOOLS.has(event.tool ?? ''))
2437
+ return;
2438
+ const next = extractRecentDirectoriesTouched([event], state.cwd);
2439
+ for (const dir of next ?? []) {
2440
+ const old = state.recentDirectoriesTouched.indexOf(dir);
2441
+ if (old >= 0)
2442
+ state.recentDirectoriesTouched.splice(old, 1);
2443
+ state.recentDirectoriesTouched.push(dir);
2444
+ }
2445
+ if (state.recentDirectoriesTouched.length > 10)
2446
+ state.recentDirectoriesTouched.splice(0, state.recentDirectoriesTouched.length - 10);
2447
+ }
2375
2448
  /**
2376
2449
  * Fold one parsed transcript line into the accumulator. This is the exact loop
2377
2450
  * body {@link scanClaudeSession} used to run inline — extracted verbatim,
@@ -2407,6 +2480,10 @@ export function applyClaudeLine(state, parsed) {
2407
2480
  if (p)
2408
2481
  state.plan = b.input.plan;
2409
2482
  }
2483
+ foldDerivedToolState(state, {
2484
+ type: 'tool_use', agent: 'claude', timestamp: parsed.timestamp || '', tool: b?.name, args: b?.input || {},
2485
+ path: b?.input?.file_path || b?.input?.path, command: b?.input?.command,
2486
+ });
2410
2487
  }
2411
2488
  }
2412
2489
  if (state.pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
@@ -2558,6 +2635,8 @@ export function finalizeClaudeScan(state) {
2558
2635
  createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
2559
2636
  spawnedTeam: state.spawnedTeam,
2560
2637
  plan: state.plan,
2638
+ todos: extractTodoProgressFromEvents(state.checklistEvents),
2639
+ recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
2561
2640
  };
2562
2641
  }
2563
2642
  /** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
@@ -2632,6 +2711,8 @@ export function serializeClaudeParserState(state, offset) {
2632
2711
  // not be persisted.
2633
2712
  ticketId: ticket?.id,
2634
2713
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
2714
+ checklistEvents: state.checklistEvents,
2715
+ recentDirectoriesTouched: state.recentDirectoriesTouched,
2635
2716
  };
2636
2717
  }
2637
2718
  /**
@@ -2684,6 +2765,8 @@ export function hydrateClaudeParseState(prior) {
2684
2765
  pendingTicketTools: new Set(prior.pendingTicketTools),
2685
2766
  spawnedTeam: prior.spawnedTeam,
2686
2767
  plan: prior.plan,
2768
+ checklistEvents: prior.checklistEvents ?? [],
2769
+ recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
2687
2770
  };
2688
2771
  }
2689
2772
  /**
@@ -2883,6 +2966,8 @@ export function initCodexParseState() {
2883
2966
  createdTickets: new Set(),
2884
2967
  pendingTicketTools: new Set(),
2885
2968
  spawnedTeam: undefined,
2969
+ checklistEvents: [],
2970
+ recentDirectoriesTouched: [],
2886
2971
  };
2887
2972
  }
2888
2973
  /**
@@ -2898,11 +2983,16 @@ export function applyCodexLine(state, parsed) {
2898
2983
  const p = parsed.payload || {};
2899
2984
  if (p.type === 'function_call') {
2900
2985
  let cmd = '';
2986
+ let args = {};
2901
2987
  try {
2902
- const args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
2988
+ args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
2903
2989
  cmd = String(args.command || args.cmd || '');
2904
2990
  }
2905
2991
  catch { /* non-JSON args */ }
2992
+ foldDerivedToolState(state, {
2993
+ type: 'tool_use', agent: 'codex', timestamp: parsed.timestamp || '', tool: p.name, args,
2994
+ path: args.file_path || args.path, command: cmd || undefined,
2995
+ });
2906
2996
  if (!state.prUrl && !state.sawPrCreate && isPrCreateCommand(cmd))
2907
2997
  state.sawPrCreate = true;
2908
2998
  if (!state.spawnedTeam) {
@@ -3024,6 +3114,8 @@ export function finalizeCodexScan(state) {
3024
3114
  ticketId: ticket?.id,
3025
3115
  createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
3026
3116
  spawnedTeam: state.spawnedTeam,
3117
+ todos: extractTodoProgressFromEvents(state.checklistEvents),
3118
+ recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
3027
3119
  };
3028
3120
  }
3029
3121
  /** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
@@ -3085,6 +3177,8 @@ export function serializeCodexParserState(state, offset) {
3085
3177
  // be persisted.
3086
3178
  ticketId: ticket?.id,
3087
3179
  contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
3180
+ checklistEvents: state.checklistEvents,
3181
+ recentDirectoriesTouched: state.recentDirectoriesTouched,
3088
3182
  };
3089
3183
  }
3090
3184
  /**
@@ -3118,6 +3212,8 @@ export function hydrateCodexParseState(prior) {
3118
3212
  createdTickets: new Set(prior.createdTickets),
3119
3213
  pendingTicketTools: new Set(prior.pendingTicketTools),
3120
3214
  spawnedTeam: prior.spawnedTeam,
3215
+ checklistEvents: prior.checklistEvents ?? [],
3216
+ recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
3121
3217
  };
3122
3218
  }
3123
3219
  /**
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Resolve which machine a session transcript originated on.
3
+ *
4
+ * Cross-machine sync mirrors a remote transcript to
5
+ * `backups/<agent>/<machine>/<subdir>/…`. Every other transcript is a live-home
6
+ * file on this box, so the origin is the local machine id.
7
+ *
8
+ * Kept in a leaf module (no session/db imports) so both discovery and the
9
+ * sessions DB upsert path can stamp `machine` without circular deps.
10
+ */
11
+ /** Local machine id, cached for the process lifetime. */
12
+ export declare function localMachineId(): string;
13
+ /**
14
+ * The machine a discovered session originated on.
15
+ * When the path sits under the agent's backups root, the first segment below it
16
+ * is the origin machine id; otherwise it's the local machine.
17
+ */
18
+ export declare function machineForSessionFile(filePath: string, agent: string): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Resolve which machine a session transcript originated on.
3
+ *
4
+ * Cross-machine sync mirrors a remote transcript to
5
+ * `backups/<agent>/<machine>/<subdir>/…`. Every other transcript is a live-home
6
+ * file on this box, so the origin is the local machine id.
7
+ *
8
+ * Kept in a leaf module (no session/db imports) so both discovery and the
9
+ * sessions DB upsert path can stamp `machine` without circular deps.
10
+ */
11
+ import * as path from 'path';
12
+ import { getHistoryDir } from '../state.js';
13
+ import { machineId } from '../machine-id.js';
14
+ let _localMachineId;
15
+ /** Local machine id, cached for the process lifetime. */
16
+ export function localMachineId() {
17
+ return (_localMachineId ??= machineId());
18
+ }
19
+ /**
20
+ * The machine a discovered session originated on.
21
+ * When the path sits under the agent's backups root, the first segment below it
22
+ * is the origin machine id; otherwise it's the local machine.
23
+ */
24
+ export function machineForSessionFile(filePath, agent) {
25
+ if (!filePath)
26
+ return localMachineId();
27
+ const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
28
+ if (filePath.startsWith(base)) {
29
+ const seg = filePath.slice(base.length).split(path.sep)[0];
30
+ if (seg)
31
+ return seg;
32
+ }
33
+ return localMachineId();
34
+ }
@@ -9,6 +9,7 @@ import * as fs from 'fs';
9
9
  import { truncate } from '../format.js';
10
10
  import * as path from 'path';
11
11
  import Database from '../sqlite.js';
12
+ import { isSyntheticUserMessage } from './prompt.js';
12
13
  /**
13
14
  * Largest session file we will load into memory. Above this we throw a clean
14
15
  * error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
@@ -156,9 +157,17 @@ export function parseSession(filePath, agent) {
156
157
  }
157
158
  // Chokepoint: every string field that originated in an untrusted session
158
159
  // file gets stripped of terminal escapes here, so renderers downstream can
159
- // safely splat values into chalk/console output.
160
- for (const e of events)
160
+ // safely splat values into chalk/console output. Same pass flags
161
+ // harness-injected `role=user` scaffolding (Claude `<bash-input>`/`<bash-stdout>`
162
+ // from `!`-prefix runs, `<system-reminder>`, etc.) as `_synthetic` so turn
163
+ // slicing and `--include user` count only genuine user intent — one place,
164
+ // every harness, instead of per-consumer regex.
165
+ for (const e of events) {
161
166
  sanitizeEvent(e);
167
+ if (e.type === 'message' && e.role === 'user' && isSyntheticUserMessage(e.content)) {
168
+ e._synthetic = true;
169
+ }
170
+ }
162
171
  return events;
163
172
  }
164
173
  /** Infer the agent type from a session file path using known directory conventions. */
@@ -5,6 +5,13 @@
5
5
  * wrapper tags, team-spawn boilerplate) so that only the real user intent
6
6
  * remains. Used by the session picker to show a human-readable topic line.
7
7
  */
8
+ /**
9
+ * True when a `role=user` message is harness-injected scaffolding, not a genuine
10
+ * user turn. Text-based so it is cross-harness by construction (the markers are
11
+ * the same wrappers across Claude/Codex/etc.); harnesses that already drop their
12
+ * own scaffolding at parse time simply never reach here.
13
+ */
14
+ export declare function isSyntheticUserMessage(raw: string | undefined): boolean;
8
15
  export declare const HEADLESS_PLAN_MODE_PREFIX = "You are running in HEADLESS PLAN MODE.";
9
16
  /** Strip framework noise from a raw session prompt, returning only meaningful user text. */
10
17
  export declare function cleanSessionPrompt(raw: string): string;
@@ -26,6 +26,43 @@ const NOISE_LINE_PATTERNS = [
26
26
  ];
27
27
  /** XML tag prefix used by local-command messages. */
28
28
  const LOCAL_COMMAND_PREFIX = '<local-command-caveat>';
29
+ /**
30
+ * Patterns that mark a whole `role=user` message as harness-injected scaffolding
31
+ * rather than a genuine user turn: the `!`-prefix shell echo and its output,
32
+ * slash-command wrappers, injected reminders / task-notifications, the local
33
+ * caveat, the interrupt marker, skill bodies, and hook feedback. This is the
34
+ * single cross-harness list — `isSyntheticUserMessage` is consumed by the parser
35
+ * (`parseSession`) to flag such events `_synthetic`, so `--include user` and the
36
+ * `--first`/`--last` turn split see only real intent, and every consumer (the
37
+ * session picker, `session-recall`, external hooks) gets clean user turns without
38
+ * re-implementing the stripping. Extend this list, not each caller.
39
+ */
40
+ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
41
+ /^\s*<\/?(?:bash-input|bash-stdout|bash-stderr)>/i,
42
+ /^\s*<\/?(?:command-name|command-message|command-args|command-contents)>/i,
43
+ /^\s*<\/?(?:local-command-stdout|local-command-stderr|local-command-caveat)>/i,
44
+ /^\s*<\/?system-reminder>/i,
45
+ /^\s*<\/?task-notification>/i,
46
+ /^\s*<\/?user-prompt-submit-hook>/i,
47
+ /^\s*<\/?persisted-output>/i,
48
+ /^\s*<permissions instructions>/i,
49
+ /^\s*<collaboration_mode>/i,
50
+ /^\s*Caveat: The messages below were generated by the user while running/i,
51
+ /^\s*\[Request interrupted/i,
52
+ /^\s*Base directory for this skill:/i,
53
+ /^\s*[A-Za-z][A-Za-z-]* hook feedback:/,
54
+ ];
55
+ /**
56
+ * True when a `role=user` message is harness-injected scaffolding, not a genuine
57
+ * user turn. Text-based so it is cross-harness by construction (the markers are
58
+ * the same wrappers across Claude/Codex/etc.); harnesses that already drop their
59
+ * own scaffolding at parse time simply never reach here.
60
+ */
61
+ export function isSyntheticUserMessage(raw) {
62
+ if (!raw)
63
+ return false;
64
+ return SYNTHETIC_USER_MESSAGE_PATTERNS.some(pattern => pattern.test(raw));
65
+ }
29
66
  // Prefix prepended to every Claude-in-plan-mode team spawn prompt.
30
67
  // Ends at a blank line before the real user task.
31
68
  export const HEADLESS_PLAN_MODE_PREFIX = 'You are running in HEADLESS PLAN MODE.';