@phnx-labs/agents-cli 1.22.56 → 1.22.58

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -54,14 +54,7 @@ export interface DiscoverOptions {
54
54
  idExact?: string;
55
55
  /** Session id prefix — a targeted indexed lookup with no scan (RUSH-2477). */
56
56
  idPrefix?: string;
57
- /**
58
- * Cold-miss repair: when another live process already holds the scan claim,
59
- * wait (bounded) for that in-flight scan to finish before reading the index,
60
- * instead of returning the pre-scan snapshot (RUSH-2682). A caller repairing a
61
- * "not indexed yet" miss wants the fresh result the concurrent scan is about to
62
- * write, not the stale read that just missed. Ignored when THIS process wins
63
- * the claim (it scans itself) or no scan is in progress.
64
- */
57
+ /** On a cold miss, briefly await the scan already holding the single-flight claim. */
65
58
  waitForScan?: boolean;
66
59
  }
67
60
  /** Progress report emitted during incremental scanning. */
@@ -181,41 +174,15 @@ export declare function discoverSessions(options?: DiscoverOptions): Promise<Ses
181
174
  interface IncrementalScanResult {
182
175
  /** True when this process won the single-flight claim and ran the scan. */
183
176
  claimed: boolean;
184
- /**
185
- * Transcripts parsed this scan — i.e. those whose (mtime, size) changed. Zero
186
- * is the steady state on an idle box and does NOT mean the scan was skipped;
187
- * read `claimed` for that.
188
- *
189
- * Twelve of the 13 SESSION_AGENTS contribute, including OpenCode, whose
190
- * scanner filters to sessions whose own per-session stamp changed and reports
191
- * that batch — so a tick whose only changed sessions live there no longer
192
- * reports 0 (RUSH-2691). OpenClaw is the exception and contributes nothing:
193
- * its scanner has no change detection to report (a TTL gate, a fresh stamp
194
- * every run, and an entry list rebuilt as the current inventory), so counting
195
- * it would overstate rather than measure. See `scanOpenClawIncremental`.
196
- */
177
+ /** Changed transcripts parsed; zero with `claimed: true` is a successful no-op scan. */
197
178
  scanned: number;
198
179
  }
199
- /**
200
- * The write half of {@link discoverSessions}: claim the single-flight scan slot,
201
- * incrementally index this host's transcript dirs, and report what was parsed.
202
- *
203
- * Split out so a caller that only wants the index refreshed — the daemon's warm
204
- * tick — can run it WITHOUT the listing query `discoverSessions` ends with. That
205
- * query is not free: it applies a cwd filter, runs the `archived_at`-writing
206
- * existence check, and can issue a Linear fetch, none of which index anything
207
- * (RUSH-2691). Keeping one implementation here is also what stops the tick and
208
- * the foreground path from drifting apart.
209
- */
180
+ /** Separate write half so daemon warming does not pay for listing or external enrichment. */
210
181
  export declare function scanSessionsIncremental(options?: {
211
182
  agent?: SessionAgentId;
212
183
  onProgress?: (p: ScanProgress) => void;
213
184
  }): Promise<IncrementalScanResult>;
214
- /**
215
- * Poll until no live process holds the scan claim, or the bound elapses
216
- * (RUSH-2682). Bounded so a wedged/slow scan can never hang a foreground preview.
217
- * Exported for the cold-miss repair test.
218
- */
185
+ /** Bounded wait so a wedged scan cannot hang a foreground cold-miss repair. */
219
186
  export declare function waitForScanToSettle(timeoutMs?: number, pollMs?: number): Promise<boolean>;
220
187
  /** Read the current SQLite snapshot without scanning or parsing transcript files. */
221
188
  export declare function queryIndexedSessions(options?: DiscoverOptions, indexedOptions?: {
@@ -223,27 +190,8 @@ export declare function queryIndexedSessions(options?: DiscoverOptions, indexedO
223
190
  skipExistenceCheck?: boolean;
224
191
  }): Promise<SessionMeta[]>;
225
192
  /**
226
- * Resolve a full-or-partial session id against the LOCAL SQLite index only.
227
- *
228
- * A plain WAL read through `queryIndexedSessions` — same origin-machine
229
- * attribution and managed scoping every indexed read gets — with NO incremental
230
- * discovery scan (so none of `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE`
231
- * writer lock) and NO fleet SSH fan-out. This is the crash-restart storm path
232
- * (RUSH-2477): dozens of `sessions resume <id>` at once for a known local id must
233
- * each be a cheap read, never a writer-lock contender or a dial into the
234
- * not-yet-up tailnet. Exact id first, then prefix (matching `findSessionsById`),
235
- * so a complete id never also drags in its prefix siblings. Returns `[]` on a
236
- * genuine local miss, leaving the caller to fall back to the fleet resolver.
237
- *
238
- * The existence check is left ON (`skipExistenceCheck: false`), exactly as the old
239
- * `discoverSessions` path and `findSessionsById` do (RUSH-2436): it KEEPS a
240
- * file-gone session whose user turns still live in `session_text` (flagged
241
- * archived) and SUPPRESSES a contentless phantom — so a phantom id misses here and
242
- * falls through to the fleet resolver, instead of resolving to a row with no real
243
- * transcript to resume. For a present transcript — the storm's actual case, since
244
- * the crashed tabs' files are on disk — the check does no writes, so the lock-free
245
- * guarantee holds; it only writes to (un)archive a genuinely missing or resurrected
246
- * file, which is not the 20-at-once resume path.
193
+ * Resolve locally without scanning or fleet I/O, keeping concurrent crash recovery lock-light.
194
+ * The existence check preserves archived content while rejecting transcriptless phantoms.
247
195
  */
248
196
  export declare function resolveIndexedSessionById(idQuery: string): Promise<SessionMeta[]>;
249
197
  /**
@@ -131,12 +131,7 @@ async function applyJsonlAppend(filePath, fromOffset, wasDroppingOversizedLine,
131
131
  * between `agents sessions` calls but is still being written to.
132
132
  */
133
133
  const HOT_FILE_WINDOW_MS = 600_000;
134
- /**
135
- * Kill-switch: set `AGENTS_SESSIONS_NO_DIR_LEDGER=1` to force the old full-walk
136
- * path (readdir + per-file stat every dir, every run — the pre-A-2 behavior),
137
- * skipping the dir_ledger short-circuit entirely. One env var reverts a field
138
- * regression to today's behavior.
139
- */
134
+ /** Emergency kill-switch for the directory-ledger optimization. */
140
135
  function dirLedgerDisabled() {
141
136
  const v = process.env.AGENTS_SESSIONS_NO_DIR_LEDGER;
142
137
  return v === '1' || v === 'true';
@@ -175,17 +170,7 @@ export async function discoverSessions(options) {
175
170
  skipExistenceCheck: options?.skipExistenceCheck ?? false,
176
171
  });
177
172
  }
178
- /**
179
- * The write half of {@link discoverSessions}: claim the single-flight scan slot,
180
- * incrementally index this host's transcript dirs, and report what was parsed.
181
- *
182
- * Split out so a caller that only wants the index refreshed — the daemon's warm
183
- * tick — can run it WITHOUT the listing query `discoverSessions` ends with. That
184
- * query is not free: it applies a cwd filter, runs the `archived_at`-writing
185
- * existence check, and can issue a Linear fetch, none of which index anything
186
- * (RUSH-2691). Keeping one implementation here is also what stops the tick and
187
- * the foreground path from drifting apart.
188
- */
173
+ /** Separate write half so daemon warming does not pay for listing or external enrichment. */
189
174
  export async function scanSessionsIncremental(options) {
190
175
  // Touch the DB so the schema is ready and connection is cached for this run.
191
176
  getDB();
@@ -222,11 +207,7 @@ export async function scanSessionsIncremental(options) {
222
207
  scanned += n;
223
208
  return { claimed: true, scanned };
224
209
  }
225
- /**
226
- * Poll until no live process holds the scan claim, or the bound elapses
227
- * (RUSH-2682). Bounded so a wedged/slow scan can never hang a foreground preview.
228
- * Exported for the cold-miss repair test.
229
- */
210
+ /** Bounded wait so a wedged scan cannot hang a foreground cold-miss repair. */
230
211
  export async function waitForScanToSettle(timeoutMs = WAIT_FOR_SCAN_TIMEOUT_MS, pollMs = WAIT_FOR_SCAN_POLL_MS) {
231
212
  const deadline = Date.now() + timeoutMs;
232
213
  while (scanInProgressByLivePid()) {
@@ -259,27 +240,8 @@ export async function queryIndexedSessions(options, indexedOptions = {}) {
259
240
  return scopeToManaged(sessions, agents, options);
260
241
  }
261
242
  /**
262
- * Resolve a full-or-partial session id against the LOCAL SQLite index only.
263
- *
264
- * A plain WAL read through `queryIndexedSessions` — same origin-machine
265
- * attribution and managed scoping every indexed read gets — with NO incremental
266
- * discovery scan (so none of `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE`
267
- * writer lock) and NO fleet SSH fan-out. This is the crash-restart storm path
268
- * (RUSH-2477): dozens of `sessions resume <id>` at once for a known local id must
269
- * each be a cheap read, never a writer-lock contender or a dial into the
270
- * not-yet-up tailnet. Exact id first, then prefix (matching `findSessionsById`),
271
- * so a complete id never also drags in its prefix siblings. Returns `[]` on a
272
- * genuine local miss, leaving the caller to fall back to the fleet resolver.
273
- *
274
- * The existence check is left ON (`skipExistenceCheck: false`), exactly as the old
275
- * `discoverSessions` path and `findSessionsById` do (RUSH-2436): it KEEPS a
276
- * file-gone session whose user turns still live in `session_text` (flagged
277
- * archived) and SUPPRESSES a contentless phantom — so a phantom id misses here and
278
- * falls through to the fleet resolver, instead of resolving to a row with no real
279
- * transcript to resume. For a present transcript — the storm's actual case, since
280
- * the crashed tabs' files are on disk — the check does no writes, so the lock-free
281
- * guarantee holds; it only writes to (un)archive a genuinely missing or resurrected
282
- * file, which is not the 20-at-once resume path.
243
+ * Resolve locally without scanning or fleet I/O, keeping concurrent crash recovery lock-light.
244
+ * The existence check preserves archived content while rejecting transcriptless phantoms.
283
245
  */
284
246
  export async function resolveIndexedSessionById(idQuery) {
285
247
  const q = idQuery.trim();
@@ -1,32 +1,51 @@
1
+ /**
2
+ * Session forking — branch an existing conversation into a new, independent
3
+ * sibling that continues the work, leaving the original untouched.
4
+ *
5
+ * `resume` continues the SAME conversation (same id, same file — it appends).
6
+ * `fork` launches a NEW same-harness session, load-balanced, seeded with a
7
+ * recap of the source so it picks up where the original left off. This is the
8
+ * "git branch" of conversations.
9
+ *
10
+ * The recap — not a transcript copy — is what makes fork work across every
11
+ * device and every REPL harness: the sibling is handed plain text as its opening
12
+ * input, so it never has to reach a transcript that may live on another box. The
13
+ * source is resolved cross-fleet by the same resolver `preview` uses; this module
14
+ * owns only the pure recap text the resolved data folds into.
15
+ */
1
16
  import type { SessionMeta } from './types.js';
2
- /** Agents that `fork` can branch today (see the module doc for why). */
3
- export declare const FORKABLE_AGENTS: readonly ["claude"];
4
- /** Whether a session's agent can be forked by {@link forkSession}. */
5
- export declare function isForkableAgent(agent: string): boolean;
6
- /** Outcome of a successful fork. */
7
- export interface ForkResult {
8
- /** The new session's full id. */
9
- newId: string;
10
- /** The new session's short id (first 8 chars), for display/resume. */
11
- shortId: string;
12
- /** Absolute path of the copied transcript. */
13
- filePath: string;
14
- /** The label applied to the fork. */
17
+ /** File-change tally as `sessions preview --json` serializes it (digest.changes). */
18
+ export interface ForkRecapChanges {
19
+ created: number;
20
+ modified: number;
21
+ deleted: number;
22
+ }
23
+ /** Everything the recap seed is built from — resolved cross-fleet before launch. */
24
+ export interface ForkRecapInput {
25
+ /** Source harness id — the sibling launches the same one. */
26
+ agent: string;
27
+ /** Display label for the source (label → topic → short id, resolved by the caller). */
15
28
  label: string;
29
+ /** Source working directory, so the sibling re-roots itself. */
30
+ cwd?: string;
31
+ /** Linear/GitHub ticket the source was bound to, if any. */
32
+ ticketId?: string;
33
+ /** Device that owns the source transcript, for the `/continue` escape hatch. */
34
+ machine?: string;
35
+ /** Short + full id, so the sibling can pull full history with `/continue <id>`. */
36
+ shortId: string;
37
+ id: string;
38
+ /** The source's last assistant line — the single best "where it left off" signal. */
39
+ lastAssistant?: string;
40
+ /** Changed-files tally so far. */
41
+ changes?: ForkRecapChanges;
16
42
  }
43
+ /** Resolve the human display label the caller passes in from a raw SessionMeta. */
44
+ export declare function forkLabelFor(session: Pick<SessionMeta, 'label' | 'topic' | 'shortId'>): string;
17
45
  /**
18
- * Fork a Claude session into a new, independent one.
19
- *
20
- * Copies `source.filePath` to a new `<uuid>.jsonl` beside it, rewrites the
21
- * embedded session id, registers the new session in the index, and records a
22
- * `--name`-style label. Returns the new ids/path. Throws if the source
23
- * transcript is missing.
46
+ * Build the recap-seed prompt handed to the forked sibling as its opening input.
24
47
  *
25
- * @param source The resolved metadata of the session being forked.
26
- * @param opts.name Optional explicit label; defaults to `fork of <original>`.
27
- * @param now ISO timestamp to stamp the fork with (injectable for tests).
48
+ * Pure and deterministic (unit-tested) — no filesystem, no spawn — so the launch
49
+ * orchestration in `commands/fork.ts` stays the only side-effecting layer.
28
50
  */
29
- export declare function forkSession(source: SessionMeta, opts?: {
30
- name?: string;
31
- now?: string;
32
- }): ForkResult;
51
+ export declare function buildForkRecap(input: ForkRecapInput): string;
@@ -1,102 +1,39 @@
1
- /**
2
- * Session forking — branch an existing conversation into a new, independent
3
- * session that can be continued separately, leaving the original untouched.
4
- *
5
- * `resume` continues the SAME conversation (same id, same file — it appends).
6
- * `fork` copies the transcript under a FRESH session id, so continuing the fork
7
- * diverges from the original instead of mutating it. This is the "git branch"
8
- * of conversations.
9
- *
10
- * v1 supports Claude, whose session id IS its `<id>.jsonl` filename and which
11
- * resumes natively via `--resume`. A fork is therefore: copy the transcript to
12
- * a new-uuid filename in the same directory, rewrite the embedded `sessionId`
13
- * on each line, register the new session in the index, and label it. Other
14
- * agents (codex single-file; grok/kimi multi-file; opencode DB-only) are a
15
- * natural follow-up and are refused up front for now.
16
- */
17
- import { randomUUID } from 'crypto';
18
- import * as fs from 'fs';
19
- import * as path from 'path';
20
- import { upsertSession } from './db.js';
21
- import { recordRunName } from './run-names.js';
22
- import { deriveShortId } from '../text/short-id.js';
23
- /** Agents that `fork` can branch today (see the module doc for why). */
24
- export const FORKABLE_AGENTS = ['claude'];
25
- /** Whether a session's agent can be forked by {@link forkSession}. */
26
- export function isForkableAgent(agent) {
27
- return FORKABLE_AGENTS.includes(agent);
1
+ /** Longest last-assistant excerpt carried into the seed — enough to convey intent
2
+ * without pasting a wall of text (decision: Recap, not Full digest). */
3
+ const LAST_LINE_CAP = 400;
4
+ /** Collapse whitespace and cap length so a multi-paragraph final message becomes
5
+ * one scannable recap line. */
6
+ function trimLastLine(text) {
7
+ const collapsed = text.replace(/\s+/g, ' ').trim();
8
+ if (collapsed.length <= LAST_LINE_CAP)
9
+ return collapsed;
10
+ return `${collapsed.slice(0, LAST_LINE_CAP).trimEnd()}…`;
28
11
  }
29
- /**
30
- * Rewrite the per-line `sessionId` field of a Claude JSONL transcript to a new
31
- * id. Claude resolves a conversation by its filename, so this is belt-and-braces
32
- * (keeps the in-file id consistent with the new filename); malformed lines are
33
- * passed through untouched.
34
- */
35
- function rewriteSessionId(transcript, newId) {
36
- return transcript
37
- .split('\n')
38
- .map((line) => {
39
- if (!line.trim())
40
- return line;
41
- try {
42
- const obj = JSON.parse(line);
43
- if (typeof obj.sessionId === 'string') {
44
- obj.sessionId = newId;
45
- return JSON.stringify(obj);
46
- }
47
- return line;
48
- }
49
- catch {
50
- return line;
51
- }
52
- })
53
- .join('\n');
12
+ /** Resolve the human display label the caller passes in from a raw SessionMeta. */
13
+ export function forkLabelFor(session) {
14
+ return session.label || session.topic || session.shortId;
54
15
  }
55
16
  /**
56
- * Fork a Claude session into a new, independent one.
57
- *
58
- * Copies `source.filePath` to a new `<uuid>.jsonl` beside it, rewrites the
59
- * embedded session id, registers the new session in the index, and records a
60
- * `--name`-style label. Returns the new ids/path. Throws if the source
61
- * transcript is missing.
17
+ * Build the recap-seed prompt handed to the forked sibling as its opening input.
62
18
  *
63
- * @param source The resolved metadata of the session being forked.
64
- * @param opts.name Optional explicit label; defaults to `fork of <original>`.
65
- * @param now ISO timestamp to stamp the fork with (injectable for tests).
19
+ * Pure and deterministic (unit-tested) — no filesystem, no spawn — so the launch
20
+ * orchestration in `commands/fork.ts` stays the only side-effecting layer.
66
21
  */
67
- export function forkSession(source, opts = {}) {
68
- if (!fs.existsSync(source.filePath)) {
69
- throw new Error(`transcript not found for session ${source.shortId}: ${source.filePath}`);
22
+ export function buildForkRecap(input) {
23
+ const lines = [];
24
+ lines.push(`Continue a prior ${input.agent} session ("${input.label}"). Pick up where it left off — do not restart it.`);
25
+ if (input.cwd)
26
+ lines.push(`Working directory: ${input.cwd}`);
27
+ if (input.ticketId)
28
+ lines.push(`Ticket: ${input.ticketId}`);
29
+ const last = input.lastAssistant ? trimLastLine(input.lastAssistant) : '';
30
+ if (last)
31
+ lines.push(`It last said: "${last}"`);
32
+ const chg = input.changes;
33
+ if (chg && (chg.created || chg.modified || chg.deleted)) {
34
+ lines.push(`Changes so far: +${chg.created} ~${chg.modified} -${chg.deleted}.`);
70
35
  }
71
- const newId = randomUUID();
72
- const shortId = deriveShortId(newId);
73
- const dir = path.dirname(source.filePath);
74
- const filePath = path.join(dir, `${newId}.jsonl`);
75
- const transcript = fs.readFileSync(source.filePath, 'utf-8');
76
- const rewritten = rewriteSessionId(transcript, newId);
77
- fs.writeFileSync(filePath, rewritten);
78
- const original = source.label || source.topic || source.shortId;
79
- const label = opts.name || `fork of ${original}`;
80
- // Label sidecar (seeds the DB label; survives rescans until an agent title
81
- // supersedes it), mirroring `agents run --name`.
82
- recordRunName({ sessionId: newId, name: label, agent: source.agent, cwd: source.cwd });
83
- // Register the new session so it resolves immediately (by `agents sessions resume`,
84
- // `agents sessions`, etc.) without waiting for the next scan.
85
- const stamp = opts.now ?? new Date().toISOString();
86
- const meta = {
87
- ...source,
88
- id: newId,
89
- shortId,
90
- filePath,
91
- label,
92
- timestamp: stamp,
93
- lastActivity: stamp,
94
- // The fork has not opened its own PR / team; drop origin-specific refs.
95
- prUrl: undefined,
96
- prNumber: undefined,
97
- teamOrigin: undefined,
98
- spawnedTeam: undefined,
99
- };
100
- upsertSession(meta, rewritten);
101
- return { newId, shortId, filePath, label };
36
+ const origin = input.machine ? ` on ${input.machine}` : '';
37
+ lines.push(`Source session ${input.shortId}${origin} — run \`/continue ${input.id}\` if you need the full transcript.`);
38
+ return lines.join('\n');
102
39
  }
@@ -28,28 +28,10 @@ export declare function sanitizeEvents(events: SessionEvent[]): void;
28
28
  * ERR_STRING_TOO_LONG ceiling.
29
29
  */
30
30
  export declare function safeReadSessionFile(filePath: string, maxBytes?: number): string;
31
- /**
32
- * Auto-detect agent type from file path and parse the session.
33
- */
34
31
  export interface ParseSessionOptions {
35
32
  /** Keep normalized tool results compact by default; renderers can request full output. */
36
33
  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
- */
34
+ /** Opt-in because interrupts are not user messages and would change the published event stream. */
53
35
  includeInterrupts?: boolean;
54
36
  }
55
37
  export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
@@ -117,14 +117,7 @@ function truncateNormalizedToolOutput(output, maxChars) {
117
117
  return output;
118
118
  return `${output.slice(0, maxChars)}\n\n[Output truncated: ${output.length - maxChars} characters omitted.]`;
119
119
  }
120
- /**
121
- * Registry-dispatch table: each `SessionAgentId` to its offline transcript
122
- * parser. Replaces the per-harness `switch` — the harness axis of Move 3. Kept
123
- * as its own table (not on the HarnessAdapter registry) because its id domain is
124
- * `SessionAgentId`: `rush` is a session agent with no `AgentId`, and the offline
125
- * transcript reader is a deliberately separate concern from live team events.
126
- * The `Record` is total, so a new session harness must add an entry here.
127
- */
120
+ /** Separate from HarnessAdapter because offline transcripts include session-only agents such as Rush. */
128
121
  const TRANSCRIPT_PARSERS = {
129
122
  claude: (filePath, opts) => parseClaude(filePath, opts),
130
123
  codex: (filePath) => parseCodex(filePath),
@@ -146,13 +139,7 @@ export function parseSession(filePath, agent, opts = {}) {
146
139
  throw new Error(`Cannot detect agent type from path: ${filePath}`);
147
140
  }
148
141
  const events = TRANSCRIPT_PARSERS[detected](filePath, opts);
149
- // Chokepoint: every string field that originated in an untrusted session
150
- // file gets stripped of terminal escapes here, so renderers downstream can
151
- // safely splat values into chalk/console output. Same pass flags
152
- // harness-injected `role=user` scaffolding (Claude `<bash-input>`/`<bash-stdout>`
153
- // from `!`-prefix runs, `<system-reminder>`, etc.) as `_synthetic` so turn
154
- // slicing and `--include user` count only genuine user intent — one place,
155
- // every harness, instead of per-consumer regex.
142
+ // Sanitize untrusted strings and identify synthetic user scaffolding once for every consumer.
156
143
  const maxToolOutputChars = opts.maxToolOutputChars ?? 500;
157
144
  for (const e of events) {
158
145
  if (e.type === 'tool_result' && e.output) {
@@ -10,12 +10,21 @@ export declare const TOOL_CHANGED_MAX_CALLS = 10000;
10
10
  export declare const TOOL_INDEX_LIMIT_ORDINAL: number;
11
11
  export declare const TOOL_TEXT_PROCESSING_MAX_BYTES: number;
12
12
  export declare const TOOL_SHELL_PARSE_MAX_BYTES: number;
13
- export declare const TOOL_INDEX_VERSION = 7;
13
+ export declare const TOOL_INDEX_VERSION = 8;
14
14
  export type ToolCallOutcome = 'ok' | 'error' | 'unknown';
15
15
  export interface IndexedToolCall {
16
16
  ordinal: number;
17
17
  sourceCallId?: string;
18
18
  timestamp: string;
19
+ /**
20
+ * When the call's RESULT record arrived — the call's own end time, taken from
21
+ * the tool_result transcript record at `finish()` (PHNX-3437). `timestamp` is
22
+ * the start; `endTimestamp - timestamp` is the call's own blocking duration,
23
+ * which the traces insight engine attributes as a failed call's wasted time.
24
+ * Undefined for a call that never produced a result (still pending at scan end)
25
+ * and for rows produced by an older extractor.
26
+ */
27
+ endTimestamp?: string;
19
28
  tool: string;
20
29
  programs: string[];
21
30
  programOccurrences: ShellProgramOccurrence[];
@@ -81,6 +90,39 @@ export declare class ToolCallCollector {
81
90
  private removePending;
82
91
  private markChanged;
83
92
  }
93
+ /** The prior scan's resume point for an append-only event stream. */
94
+ export interface EventToolScanResumePoint {
95
+ /** ToolCallCollector snapshot after folding `eventCount` events. */
96
+ snapshot: ToolCallCollectorSnapshot;
97
+ /** How many events had been folded when the snapshot was taken. */
98
+ eventCount: number;
99
+ }
100
+ export interface EventToolScanResult {
101
+ /** The CHANGED calls — an append-safe upsert set, not the whole history. */
102
+ calls: IndexedToolCall[];
103
+ /** Serialized-ready snapshot to persist for the next incremental scan. */
104
+ snapshot: ToolCallCollectorSnapshot;
105
+ /** Events folded so far — the next scan's resume offset into `events`. */
106
+ eventCount: number;
107
+ }
108
+ /**
109
+ * Derive tool calls from a full-file harness's normalized events, optionally
110
+ * RESUMING from a prior scan of the same append-only stream.
111
+ *
112
+ * Without `prior` this is a full parse from event 0 (identical to the legacy
113
+ * {@link toolCallsFromEvents}). With `prior`, the collector is seeded from the
114
+ * prior snapshot and only events at or after `prior.eventCount` are folded — so
115
+ * an active session that grew by a few turns re-derives (and re-redacts) only
116
+ * those new tool calls instead of re-sanitizing its entire history on every
117
+ * daemon warm tick (PHNX-3411). The ordinals continue deterministically from the
118
+ * snapshot, so folding [0..k) then [k..n) yields the same index as folding
119
+ * [0..n) once, and the CHANGED set is safe to persist with `mode: 'append'`.
120
+ *
121
+ * The caller is responsible for only supplying `prior` when the stream is still
122
+ * an append of what was scanned before (same source, un-truncated,
123
+ * `prior.eventCount <= events.length`); anything else must full-scan.
124
+ */
125
+ export declare function scanEventToolCalls(events: SessionEvent[], prior?: EventToolScanResumePoint): EventToolScanResult;
84
126
  /** Build indexed calls from the normalized parser contract used by full-file harnesses. */
85
127
  export declare function toolCallsFromEvents(events: SessionEvent[]): IndexedToolCall[];
86
128
  export declare function toolCallKey(sessionId: string, ordinal: number): string;
@@ -12,7 +12,10 @@ export const TOOL_CHANGED_MAX_CALLS = 10_000;
12
12
  export const TOOL_INDEX_LIMIT_ORDINAL = Number.MAX_SAFE_INTEGER;
13
13
  export const TOOL_TEXT_PROCESSING_MAX_BYTES = 64 * 1024;
14
14
  export const TOOL_SHELL_PARSE_MAX_BYTES = 64 * 1024;
15
- export const TOOL_INDEX_VERSION = 7;
15
+ // Bumped to 8 for the per-call end timestamp (PHNX-3437): the extractor now
16
+ // records when a call's result arrived, so a re-index re-derives it for rows
17
+ // stored by an older extractor.
18
+ export const TOOL_INDEX_VERSION = 8;
16
19
  const BASE64_BLOCK = /(?:[A-Za-z0-9+/]{256,}={0,2})/g;
17
20
  const SECRET_FIELD = /(?:token|secret|password|authorization|cookie|api[_-]?key|private[_-]?key)$/i;
18
21
  const KNOWN_SECRET_VALUES = knownSecretValuesFromEnv();
@@ -310,7 +313,7 @@ function buildCall(ordinal, timestamp, tool, args, command, sourceCallId) {
310
313
  }
311
314
  export function toolCallEvidenceBytes(call) {
312
315
  return Buffer.byteLength([
313
- call.sourceCallId, call.timestamp, call.tool, call.input, call.errorCode,
316
+ call.sourceCallId, call.timestamp, call.endTimestamp, call.tool, call.input, call.errorCode,
314
317
  call.output, call.error, call.parseError, ...call.programs,
315
318
  ...call.programOccurrences.map((occurrence) => `${occurrence.role}:${occurrence.program}`),
316
319
  ].filter((value) => typeof value === 'string').join('\0'));
@@ -363,6 +366,9 @@ export class ToolCallCollector {
363
366
  const call = this.takePending(args.sourceCallId, tool);
364
367
  if (!call)
365
368
  return undefined;
369
+ if (typeof args.timestamp === 'string' && args.timestamp.length > 0) {
370
+ call.endTimestamp = sanitizeToolEvidenceText(args.timestamp, 128);
371
+ }
366
372
  const outcome = args.outcome === 'ok' || args.outcome === 'error' || args.outcome === 'unknown'
367
373
  ? args.outcome
368
374
  : undefined;
@@ -471,50 +477,74 @@ export class ToolCallCollector {
471
477
  return true;
472
478
  }
473
479
  }
480
+ /** Fold one normalized event into a collector (the full-file harness contract). */
481
+ function foldEventIntoCollector(collector, event) {
482
+ if (event.type === 'tool_use') {
483
+ collector.start({
484
+ timestamp: event.timestamp,
485
+ tool: event.tool || 'unknown',
486
+ input: event.args,
487
+ command: event.command,
488
+ sourceCallId: event.callId,
489
+ });
490
+ }
491
+ else if (event.type === 'tool_result') {
492
+ collector.finish({
493
+ timestamp: event.timestamp,
494
+ sourceCallId: event.callId,
495
+ tool: event.tool,
496
+ success: event.success,
497
+ outcome: event.outcome,
498
+ exitCode: event.exitCode,
499
+ statusCode: event.statusCode,
500
+ errorCode: event.errorCode,
501
+ output: event.output,
502
+ });
503
+ }
504
+ else if (event.type === 'error' && event.tool) {
505
+ const structuredError = event.outcome === 'error' || event.success === false;
506
+ const evidence = event.content || event.output || 'Tool execution failed';
507
+ collector.finish({
508
+ timestamp: event.timestamp,
509
+ sourceCallId: event.callId,
510
+ tool: event.tool,
511
+ success: structuredError ? false : undefined,
512
+ outcome: event.outcome,
513
+ exitCode: event.exitCode,
514
+ statusCode: event.statusCode,
515
+ errorCode: event.errorCode,
516
+ error: structuredError ? evidence : undefined,
517
+ output: structuredError ? undefined : evidence,
518
+ });
519
+ }
520
+ }
521
+ /**
522
+ * Derive tool calls from a full-file harness's normalized events, optionally
523
+ * RESUMING from a prior scan of the same append-only stream.
524
+ *
525
+ * Without `prior` this is a full parse from event 0 (identical to the legacy
526
+ * {@link toolCallsFromEvents}). With `prior`, the collector is seeded from the
527
+ * prior snapshot and only events at or after `prior.eventCount` are folded — so
528
+ * an active session that grew by a few turns re-derives (and re-redacts) only
529
+ * those new tool calls instead of re-sanitizing its entire history on every
530
+ * daemon warm tick (PHNX-3411). The ordinals continue deterministically from the
531
+ * snapshot, so folding [0..k) then [k..n) yields the same index as folding
532
+ * [0..n) once, and the CHANGED set is safe to persist with `mode: 'append'`.
533
+ *
534
+ * The caller is responsible for only supplying `prior` when the stream is still
535
+ * an append of what was scanned before (same source, un-truncated,
536
+ * `prior.eventCount <= events.length`); anything else must full-scan.
537
+ */
538
+ export function scanEventToolCalls(events, prior) {
539
+ const startIndex = prior ? Math.min(prior.eventCount, events.length) : 0;
540
+ const collector = new ToolCallCollector(prior?.snapshot);
541
+ for (let i = startIndex; i < events.length; i++)
542
+ foldEventIntoCollector(collector, events[i]);
543
+ return { calls: collector.drainChanged(), snapshot: collector.snapshot(), eventCount: events.length };
544
+ }
474
545
  /** Build indexed calls from the normalized parser contract used by full-file harnesses. */
475
546
  export function toolCallsFromEvents(events) {
476
- const collector = new ToolCallCollector();
477
- for (const event of events) {
478
- if (event.type === 'tool_use') {
479
- collector.start({
480
- timestamp: event.timestamp,
481
- tool: event.tool || 'unknown',
482
- input: event.args,
483
- command: event.command,
484
- sourceCallId: event.callId,
485
- });
486
- }
487
- else if (event.type === 'tool_result') {
488
- collector.finish({
489
- timestamp: event.timestamp,
490
- sourceCallId: event.callId,
491
- tool: event.tool,
492
- success: event.success,
493
- outcome: event.outcome,
494
- exitCode: event.exitCode,
495
- statusCode: event.statusCode,
496
- errorCode: event.errorCode,
497
- output: event.output,
498
- });
499
- }
500
- else if (event.type === 'error' && event.tool) {
501
- const structuredError = event.outcome === 'error' || event.success === false;
502
- const evidence = event.content || event.output || 'Tool execution failed';
503
- collector.finish({
504
- timestamp: event.timestamp,
505
- sourceCallId: event.callId,
506
- tool: event.tool,
507
- success: structuredError ? false : undefined,
508
- outcome: event.outcome,
509
- exitCode: event.exitCode,
510
- statusCode: event.statusCode,
511
- errorCode: event.errorCode,
512
- error: structuredError ? evidence : undefined,
513
- output: structuredError ? undefined : evidence,
514
- });
515
- }
516
- }
517
- return collector.drainChanged();
547
+ return scanEventToolCalls(events).calls;
518
548
  }
519
549
  export function toolCallKey(sessionId, ordinal) {
520
550
  return createHash('sha256').update(`${sessionId}\0${ordinal}`).digest('hex').slice(0, 20);