@phnx-labs/agents-cli 1.22.57 → 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 (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/bootstrap.js +8 -1
  3. package/dist/commands/accounts.js +7 -3
  4. package/dist/commands/apply.js +10 -2
  5. package/dist/commands/fork.d.ts +23 -10
  6. package/dist/commands/fork.js +115 -58
  7. package/dist/commands/monitors.js +11 -0
  8. package/dist/commands/prune.js +5 -3
  9. package/dist/commands/routines.d.ts +8 -0
  10. package/dist/commands/routines.js +57 -3
  11. package/dist/commands/sessions-picker.d.ts +11 -0
  12. package/dist/commands/sessions-picker.js +16 -0
  13. package/dist/commands/sessions.js +1 -0
  14. package/dist/commands/share.d.ts +14 -0
  15. package/dist/commands/share.js +43 -2
  16. package/dist/commands/status.js +1 -1
  17. package/dist/commands/sync.js +83 -7
  18. package/dist/commands/traces.js +7 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +6 -1
  21. package/dist/lib/account-registry.d.ts +5 -1
  22. package/dist/lib/account-registry.js +47 -14
  23. package/dist/lib/accounting/capacity.d.ts +18 -7
  24. package/dist/lib/accounting/capacity.js +19 -8
  25. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  26. package/dist/lib/accounting/usage-sync.js +76 -2
  27. package/dist/lib/accounting/usage.js +7 -1
  28. package/dist/lib/auth-mint.d.ts +11 -1
  29. package/dist/lib/auth-mint.js +21 -6
  30. package/dist/lib/browser/ipc.d.ts +8 -0
  31. package/dist/lib/browser/ipc.js +87 -0
  32. package/dist/lib/browser/service.d.ts +19 -0
  33. package/dist/lib/browser/service.js +96 -11
  34. package/dist/lib/browser/sessions-list.js +10 -1
  35. package/dist/lib/daemon/runner.d.ts +3 -0
  36. package/dist/lib/daemon/runner.js +86 -45
  37. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  38. package/dist/lib/daemon/usage-sync-service.js +14 -8
  39. package/dist/lib/daemon-services.js +1 -1
  40. package/dist/lib/devices/connect.d.ts +17 -8
  41. package/dist/lib/devices/connect.js +31 -14
  42. package/dist/lib/doctor-diff.js +77 -7
  43. package/dist/lib/fleet/manifest.d.ts +17 -0
  44. package/dist/lib/fleet/manifest.js +26 -0
  45. package/dist/lib/hooks/install.d.ts +27 -11
  46. package/dist/lib/hooks/install.js +42 -17
  47. package/dist/lib/hosts/reconnect.d.ts +52 -203
  48. package/dist/lib/hosts/reconnect.js +64 -284
  49. package/dist/lib/installations/migrate.d.ts +6 -120
  50. package/dist/lib/installations/migrate.js +27 -259
  51. package/dist/lib/installations/shims.d.ts +13 -95
  52. package/dist/lib/installations/shims.js +22 -139
  53. package/dist/lib/installations/store.js +1 -1
  54. package/dist/lib/installations/versions.d.ts +26 -133
  55. package/dist/lib/installations/versions.js +41 -204
  56. package/dist/lib/plugins/skills.d.ts +8 -1
  57. package/dist/lib/plugins/skills.js +18 -2
  58. package/dist/lib/refresh.d.ts +9 -0
  59. package/dist/lib/refresh.js +3 -1
  60. package/dist/lib/routine-readiness.d.ts +15 -1
  61. package/dist/lib/routine-readiness.js +41 -0
  62. package/dist/lib/sandbox.d.ts +4 -1
  63. package/dist/lib/sandbox.js +30 -1
  64. package/dist/lib/secrets/agent.d.ts +80 -225
  65. package/dist/lib/secrets/agent.js +139 -401
  66. package/dist/lib/secrets/bundles.d.ts +73 -222
  67. package/dist/lib/secrets/bundles.js +168 -467
  68. package/dist/lib/secrets/reaper.d.ts +28 -70
  69. package/dist/lib/secrets/reaper.js +30 -85
  70. package/dist/lib/secrets/remote.d.ts +42 -129
  71. package/dist/lib/secrets/remote.js +55 -173
  72. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  73. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  74. package/dist/lib/self-heal/registry.js +2 -0
  75. package/dist/lib/self-heal/types.d.ts +1 -1
  76. package/dist/lib/self-update.d.ts +23 -0
  77. package/dist/lib/self-update.js +50 -0
  78. package/dist/lib/session/active.d.ts +13 -1
  79. package/dist/lib/session/active.js +2 -0
  80. package/dist/lib/session/db.d.ts +20 -1
  81. package/dist/lib/session/db.js +139 -9
  82. package/dist/lib/session/fork.d.ts +45 -26
  83. package/dist/lib/session/fork.js +32 -95
  84. package/dist/lib/session/tool-calls.d.ts +43 -1
  85. package/dist/lib/session/tool-calls.js +74 -44
  86. package/dist/lib/session/tool-store.d.ts +33 -2
  87. package/dist/lib/session/tool-store.js +56 -3
  88. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  89. package/dist/lib/staleness/writers/sources.js +2 -1
  90. package/dist/lib/sync-status.d.ts +22 -0
  91. package/dist/lib/sync-status.js +27 -0
  92. package/dist/lib/sync-umbrella.d.ts +9 -0
  93. package/dist/lib/sync-umbrella.js +21 -2
  94. package/dist/lib/traces/insights.d.ts +47 -14
  95. package/dist/lib/traces/insights.js +92 -21
  96. package/dist/lib/traces/phenotype.d.ts +23 -3
  97. package/dist/lib/traces/phenotype.js +72 -24
  98. package/dist/lib/traces/sync.d.ts +15 -0
  99. package/dist/lib/traces/sync.js +104 -19
  100. package/dist/lib/traces/worker-template.js +154 -1
  101. package/package.json +1 -1
@@ -145,6 +145,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
145
145
  continue;
146
146
  if (!s.version && m.version)
147
147
  s.version = m.version;
148
+ if (!s.account && m.account)
149
+ s.account = m.account;
148
150
  if (!s.label && m.label)
149
151
  s.label = m.label;
150
152
  if (!s.ticket && m.ticketId)
@@ -9,10 +9,11 @@
9
9
  import Database from '../sqlite.js';
10
10
  import type { SessionAgentId, SessionEvent, SessionMeta } from './types.js';
11
11
  import { type IndexedToolCall } from './tool-calls.js';
12
+ import { type ToolScanResumePoint } from './tool-store.js';
12
13
  /** Current schema version; bumped when migrations are added. Exported so tests
13
14
  * assert against the constant instead of hardcoding a number that every bump
14
15
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 42;
16
+ export declare const SCHEMA_VERSION = 43;
16
17
  /**
17
18
  * Bump to force the content extractor (assistant-answer text, alongside the
18
19
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -35,6 +36,8 @@ export declare const CONTENT_INDEX_VERSION = 1;
35
36
  export declare const INSIGHTS_EXTRACTOR_VERSION = 7;
36
37
  /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
37
38
  export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
39
+ /** Bump when classifyPhenotype's output changes so cached phenotypes recompute (PHNX-3327 v1). */
40
+ export declare const SESSION_PHENOTYPE_EXTRACTOR_VERSION = 1;
38
41
  /** File stat snapshot used to detect changes between scan runs. */
39
42
  export interface ScanStamp {
40
43
  fileMtimeMs: number;
@@ -249,6 +252,13 @@ export declare function upsertSessionsBatch(entries: Array<{
249
252
  toolCalls?: IndexedToolCall[];
250
253
  toolScan?: ScanStamp;
251
254
  toolIndexMode?: 'replace' | 'append';
255
+ /**
256
+ * Where the NEXT scan of this full-file-harness session may resume its tool
257
+ * index (PHNX-3411). Computed internally in the enrichment map below; not
258
+ * supplied by callers. Persisted alongside the tool ledger so an active
259
+ * session re-derives only its newly appended tool calls next tick.
260
+ */
261
+ toolResume?: ToolScanResumePoint | null;
252
262
  }>): void;
253
263
  /**
254
264
  * Sync labels for a set of sessions. For each id in the map, if the stored
@@ -377,6 +387,15 @@ export declare function writeSessionTopics<T>(entries: Array<{
377
387
  fileSize: number | null;
378
388
  topic: T;
379
389
  }>): void;
390
+ /** Read cached failure phenotypes only when their transcript byte stamps still match. */
391
+ export declare function readSessionPhenotypes<T>(ids: string[]): Map<string, T>;
392
+ /** Persist failure phenotypes against the exact transcript bytes used to classify them. */
393
+ export declare function writeSessionPhenotypes<T>(entries: Array<{
394
+ id: string;
395
+ fileMtimeMs: number | null;
396
+ fileSize: number | null;
397
+ phenotype: T;
398
+ }>): void;
380
399
  /** Read one derived preview only when it matches the transcript bytes on disk. */
381
400
  export declare function readSessionPreviewCache<T>(id: string, sourceStamp: {
382
401
  fileMtimeMs: number | null;
@@ -15,8 +15,8 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
15
15
  import { query as queryEvents, queryToolUsageForSessions } from '../feed/events.js';
16
16
  import { machineForSessionFile } from '../origin-machine.js';
17
17
  import { loadSessionActorIndex, readSessionActorRecord } from './actor-sidecar.js';
18
- import { toolCallsFromEvents } from './tool-calls.js';
19
- import { persistToolCalls, toolEvidenceSourcePath } from './tool-store.js';
18
+ import { scanEventToolCalls } from './tool-calls.js';
19
+ import { persistToolCalls, planEventToolResume, toolEvidenceSourcePath } from './tool-store.js';
20
20
  import { buildClaudeAccountIndex, resolveClaudeAccount } from './claude-accounts.js';
21
21
  import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTracksBackgroundShells, isSubAgentTool, } from './highlights.js';
22
22
  import { resolveResource } from '../resources.js';
@@ -27,7 +27,7 @@ const DB_PATH = getSessionsDbPath();
27
27
  /** Current schema version; bumped when migrations are added. Exported so tests
28
28
  * assert against the constant instead of hardcoding a number that every bump
29
29
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
30
- export const SCHEMA_VERSION = 42;
30
+ export const SCHEMA_VERSION = 43;
31
31
  /**
32
32
  * Bump to force the content extractor (assistant-answer text, alongside the
33
33
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -207,6 +207,11 @@ CREATE TABLE IF NOT EXISTS tool_calls (
207
207
  ordinal INTEGER NOT NULL,
208
208
  source_call_id TEXT,
209
209
  timestamp TEXT NOT NULL,
210
+ -- When the call's RESULT record arrived (its own end time). end_timestamp
211
+ -- minus timestamp is the call's own blocking duration, which the traces
212
+ -- insight engine attributes as a failed call's wasted time (PHNX-3437). NULL
213
+ -- for a call that never produced a result and for rows an older extractor stored.
214
+ end_timestamp TEXT,
210
215
  tool TEXT NOT NULL,
211
216
  input TEXT NOT NULL,
212
217
  outcome TEXT NOT NULL,
@@ -352,6 +357,26 @@ CREATE TABLE IF NOT EXISTS session_topics (
352
357
  topic_json TEXT NOT NULL
353
358
  );
354
359
 
360
+ -- Derived failure phenotype for traces sync (PHNX-3327). Like session_topics /
361
+ -- session_insights, this is a lazy, stamp-validated cache keyed on
362
+ -- (file_mtime_ms, file_size) and intentionally independent of SCHEMA_VERSION.
363
+ -- Classifying a phenotype needs the full derived SessionTrajectory (ordered
364
+ -- steps, gaps), which buildIndexShard does NOT have from flat tool_calls rows —
365
+ -- so it is computed per-session ONCE (parse -> trajectory -> classify) and cached
366
+ -- here, then read for the WHOLE corpus on every sync. That is what lets the
367
+ -- phenotype grouping dimension fold two identically-signatured sessions into one
368
+ -- cluster regardless of which incremental batch each was first synced in, without
369
+ -- re-parsing transcripts at 10k+ session scale. phenotype_json holds
370
+ -- { phenotype: FailurePhenotype | null } (null = no failure phenotype matched).
371
+ CREATE TABLE IF NOT EXISTS session_phenotypes (
372
+ session_id TEXT PRIMARY KEY,
373
+ file_mtime_ms INTEGER,
374
+ file_size INTEGER,
375
+ extractor_version INTEGER NOT NULL,
376
+ computed_at INTEGER NOT NULL,
377
+ phenotype_json TEXT NOT NULL
378
+ );
379
+
355
380
  -- Normalized data behind sessions preview. Like session_insights this is a
356
381
  -- lazy, stamp-validated cache: opening one session parses only that transcript,
357
382
  -- while subsequent processes reuse the derived preview until its bytes change.
@@ -437,7 +462,12 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
437
462
  export const INSIGHTS_EXTRACTOR_VERSION = 7;
438
463
  /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
439
464
  export const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
440
- const PREVIEW_EXTRACTOR_VERSION = 1;
465
+ // Bumped to 2 (PHNX-2973): the digest now carries `changedFiles` (per-file
466
+ // paths). Bumping invalidates v1 cache rows so a fresh recompute populates the
467
+ // new field instead of serving a stale digest that predates it.
468
+ const PREVIEW_EXTRACTOR_VERSION = 2;
469
+ /** Bump when classifyPhenotype's output changes so cached phenotypes recompute (PHNX-3327 v1). */
470
+ export const SESSION_PHENOTYPE_EXTRACTOR_VERSION = 1;
441
471
  let dbInstance = null;
442
472
  /**
443
473
  * Apply schema migrations from `fromVersion` → SCHEMA_VERSION. The new
@@ -1152,6 +1182,29 @@ function migrateSchema(db, fromVersion) {
1152
1182
  // Claude/Codex resumable continuation) stay intact for rows that don't
1153
1183
  // need a full reparse for any OTHER reason.
1154
1184
  }
1185
+ if (fromVersion < 43) {
1186
+ // v42 -> v43: persist a per-tool-call END timestamp (PHNX-3437). The traces
1187
+ // insight engine could only book a failed call's wasted time from the
1188
+ // bounded gap to the NEXT call — so a call that BLOCKED for minutes and was
1189
+ // the last in its session (or was followed quickly by an unrelated call)
1190
+ // registered as ~0 waste. The call's result record already carried its own
1191
+ // timestamp at ingestion; this column persists it so `end_timestamp -
1192
+ // timestamp` (the call's own blocking duration) becomes the primary
1193
+ // attribution, with the gap heuristic kept as the fallback for NULL rows.
1194
+ //
1195
+ // Additive, nullable column — no ledger flush (the v33->v34 contract that
1196
+ // adding a column keeps warm session ledgers warm). Pre-upgrade rows stay
1197
+ // NULL until re-indexed, and `insights.ts` degrades a NULL end back to the
1198
+ // bounded-gap behavior, so nothing crashes or yields NaN. The paired
1199
+ // TOOL_INDEX_VERSION bump (7 -> 8) is what re-derives it on a re-index; the
1200
+ // tool index is deliberately independent of SCHEMA_VERSION and is never
1201
+ // force-rescanned by a migration (only by the explicit tool backfill or a
1202
+ // normal incremental append), so a bare ALTER here would otherwise leave
1203
+ // existing rows dark forever.
1204
+ const cols = new Set(db.prepare(`PRAGMA table_info(tool_calls)`).all().map((c) => c.name));
1205
+ if (!cols.has('end_timestamp'))
1206
+ db.exec(`ALTER TABLE tool_calls ADD COLUMN end_timestamp TEXT`);
1207
+ }
1155
1208
  }
1156
1209
  /**
1157
1210
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -2123,6 +2176,21 @@ export function upsertSessionsBatch(entries) {
2123
2176
  // metadata fall back to exactly one normalized parse here.
2124
2177
  const events = entry.events ?? parseSession(entry.meta.filePath, entry.meta.agent);
2125
2178
  writeResourceUsage(entry.meta.id, events, entry.meta.cwd);
2179
+ // Resume the tool index from the last scan of this append-only stream when
2180
+ // it is safe to (PHNX-3411). A live session's transcript grows every tick,
2181
+ // so a full re-derive re-sanitizes its ENTIRE tool history each time — the
2182
+ // synchronous O(session) work that wedged the daemon event loop for the
2183
+ // 11 non-streaming harnesses. `planEventToolResume` returns the prior
2184
+ // snapshot only when the file is still an append of what was scanned
2185
+ // before; otherwise `prior` is null and this is a full replace from
2186
+ // event 0 (identical index, just re-derived).
2187
+ // No tool stamp (a scanner that carried no scan record) means the resume
2188
+ // point cannot be size-guarded, so full-scan rather than trust a stale
2189
+ // offset. persistToolCalls below also skips a resume-less write.
2190
+ const prior = toolScan
2191
+ ? planEventToolResume(db, entry.meta.id, toolSourcePath, toolScan, events.length)
2192
+ : null;
2193
+ const scanned = scanEventToolCalls(events, prior ?? undefined);
2126
2194
  return {
2127
2195
  ...entry,
2128
2196
  meta: {
@@ -2131,11 +2199,14 @@ export function upsertSessionsBatch(entries) {
2131
2199
  recentDirectoriesTouched: extractRecentDirectoriesTouched(events, entry.meta.cwd),
2132
2200
  ...fanOutCounts(events, entry.meta.agent),
2133
2201
  },
2134
- toolCalls: toolCallsFromEvents(events),
2202
+ // The CHANGED calls only. On a resume these are the newly appended tail
2203
+ // (append-safe upsert); on a full scan they are the whole history.
2204
+ toolCalls: scanned.calls,
2135
2205
  toolScan,
2136
- // These are complete event arrays, not an appended tail. Append would
2137
- // duplicate existing evidence even when persistToolCalls supports it.
2138
- toolIndexMode: 'replace',
2206
+ toolIndexMode: (prior ? 'append' : 'replace'),
2207
+ // Persist where the NEXT scan resumes: the collector snapshot + how many
2208
+ // events this scan folded.
2209
+ toolResume: { parserState: JSON.stringify(scanned.snapshot), parsedOffset: scanned.eventCount },
2139
2210
  };
2140
2211
  }
2141
2212
  catch {
@@ -2284,7 +2355,13 @@ export function upsertSessionsBatch(entries) {
2284
2355
  if (!toolScan || !entry.toolCalls)
2285
2356
  continue;
2286
2357
  try {
2287
- persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, { mode: entry.toolIndexMode ?? 'replace' });
2358
+ // `resume` is set only by the full-file harness path above; claude/codex
2359
+ // pass none, so their tool ledger keeps carrying no event-offset resume
2360
+ // point (their resume rides the content-scan ledger instead) — unchanged.
2361
+ persistToolCalls(db, entry.meta, entry.toolCalls, toolScan, {
2362
+ mode: entry.toolIndexMode ?? 'replace',
2363
+ resume: entry.toolResume,
2364
+ });
2288
2365
  }
2289
2366
  catch {
2290
2367
  // Boundary is intentionally retryable via tool_scan_ledger.
@@ -2950,6 +3027,59 @@ export function writeSessionTopics(entries) {
2950
3027
  }
2951
3028
  })();
2952
3029
  }
3030
+ /** Read cached failure phenotypes only when their transcript byte stamps still match. */
3031
+ export function readSessionPhenotypes(ids) {
3032
+ const db = getDB();
3033
+ const out = new Map();
3034
+ if (ids.length === 0)
3035
+ return out;
3036
+ const CHUNK = 400;
3037
+ for (let i = 0; i < ids.length; i += CHUNK) {
3038
+ const chunk = ids.slice(i, i + CHUNK);
3039
+ const placeholders = chunk.map(() => '?').join(',');
3040
+ const rows = db.prepare(`
3041
+ SELECT sp.session_id AS id, sp.phenotype_json AS phenotypeJson
3042
+ FROM session_phenotypes sp
3043
+ JOIN sessions s ON s.id = sp.session_id
3044
+ WHERE sp.session_id IN (${placeholders})
3045
+ AND sp.extractor_version = ?
3046
+ AND sp.file_mtime_ms IS s.file_mtime_ms
3047
+ AND sp.file_size IS s.file_size
3048
+ `).all(...chunk, SESSION_PHENOTYPE_EXTRACTOR_VERSION);
3049
+ for (const row of rows) {
3050
+ try {
3051
+ out.set(row.id, JSON.parse(row.phenotypeJson));
3052
+ }
3053
+ catch {
3054
+ // Invalid derived cache data is a miss and self-heals on the next write.
3055
+ }
3056
+ }
3057
+ }
3058
+ return out;
3059
+ }
3060
+ /** Persist failure phenotypes against the exact transcript bytes used to classify them. */
3061
+ export function writeSessionPhenotypes(entries) {
3062
+ if (entries.length === 0)
3063
+ return;
3064
+ const db = getDB();
3065
+ const stmt = db.prepare(`
3066
+ INSERT INTO session_phenotypes
3067
+ (session_id, file_mtime_ms, file_size, extractor_version, computed_at, phenotype_json)
3068
+ VALUES (?, ?, ?, ?, ?, ?)
3069
+ ON CONFLICT(session_id) DO UPDATE SET
3070
+ file_mtime_ms = excluded.file_mtime_ms,
3071
+ file_size = excluded.file_size,
3072
+ extractor_version = excluded.extractor_version,
3073
+ computed_at = excluded.computed_at,
3074
+ phenotype_json = excluded.phenotype_json
3075
+ `);
3076
+ const now = Date.now();
3077
+ db.transaction(() => {
3078
+ for (const entry of entries) {
3079
+ stmt.run(entry.id, entry.fileMtimeMs, entry.fileSize, SESSION_PHENOTYPE_EXTRACTOR_VERSION, now, JSON.stringify(entry.phenotype));
3080
+ }
3081
+ })();
3082
+ }
2953
3083
  /** Read one derived preview only when it matches the transcript bytes on disk. */
2954
3084
  export function readSessionPreviewCache(id, sourceStamp) {
2955
3085
  const row = getDB().prepare(`
@@ -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
  }
@@ -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;