harnery 0.42.0 → 0.43.0

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 (77) hide show
  1. package/dist/commander.d.ts +16 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +18 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +54 -4
  6. package/dist/commands/tunnel.d.ts +65 -0
  7. package/dist/commands/tunnel.d.ts.map +1 -1
  8. package/dist/commands/tunnel.js +290 -101
  9. package/dist/commands/web-fetch.d.ts +3 -2
  10. package/dist/commands/web-fetch.d.ts.map +1 -1
  11. package/dist/commands/web-fetch.js +25 -19
  12. package/dist/commands/web.d.ts +7 -1
  13. package/dist/commands/web.d.ts.map +1 -1
  14. package/dist/commands/web.js +9 -3
  15. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  16. package/dist/core/agents/render/prompt-context.js +11 -5
  17. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  18. package/dist/core/agents/render/session-context.js +57 -13
  19. package/dist/core/agents/state/names.d.ts +43 -3
  20. package/dist/core/agents/state/names.d.ts.map +1 -1
  21. package/dist/core/agents/state/names.js +87 -3
  22. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  23. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  24. package/dist/core/hooks/cli.js +87 -18
  25. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  26. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  27. package/dist/core/hooks/fork-lineage.js +62 -0
  28. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  29. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  30. package/dist/core/hooks/resolve/transcript.js +84 -0
  31. package/dist/core/hooks/shell-rm.d.ts +2 -0
  32. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  33. package/dist/core/hooks/shell-rm.js +105 -0
  34. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  35. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  36. package/dist/core/hooks/shell-tokens.js +97 -0
  37. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  38. package/dist/core/hooks/shell-waiter.js +3 -93
  39. package/dist/core/semantic/contract.d.ts +5 -5
  40. package/dist/core/storage/process-log.d.ts +12 -0
  41. package/dist/core/storage/process-log.d.ts.map +1 -1
  42. package/dist/core/storage/process-log.js +10 -2
  43. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  44. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  45. package/dist/lib/tunnel/allowlist.js +204 -0
  46. package/dist/lib/tunnel/current-address.d.ts +41 -0
  47. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  48. package/dist/lib/tunnel/current-address.js +123 -0
  49. package/dist/lib/tunnel/gate.js +13 -6
  50. package/dist/lib/tunnel/processes.d.ts +39 -0
  51. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  52. package/dist/lib/tunnel/processes.js +121 -0
  53. package/dist/lib/tunnel/state.d.ts +28 -3
  54. package/dist/lib/tunnel/state.d.ts.map +1 -1
  55. package/dist/lib/tunnel/state.js +98 -27
  56. package/package.json +2 -1
  57. package/src/commander.ts +32 -0
  58. package/src/commands/agents.ts +97 -11
  59. package/src/commands/tunnel.ts +394 -116
  60. package/src/commands/web-fetch.ts +30 -19
  61. package/src/commands/web.ts +9 -3
  62. package/src/core/agents/render/prompt-context.ts +25 -8
  63. package/src/core/agents/render/session-context.ts +83 -16
  64. package/src/core/agents/state/names.ts +117 -6
  65. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  66. package/src/core/hooks/cli.ts +106 -17
  67. package/src/core/hooks/fork-lineage.ts +82 -0
  68. package/src/core/hooks/resolve/transcript.ts +88 -0
  69. package/src/core/hooks/shell-rm.ts +103 -0
  70. package/src/core/hooks/shell-tokens.ts +96 -0
  71. package/src/core/hooks/shell-waiter.ts +4 -91
  72. package/src/core/storage/process-log.ts +17 -2
  73. package/src/lib/tunnel/allowlist.ts +208 -0
  74. package/src/lib/tunnel/current-address.ts +158 -0
  75. package/src/lib/tunnel/gate.ts +16 -8
  76. package/src/lib/tunnel/processes.ts +149 -0
  77. package/src/lib/tunnel/state.ts +123 -27
@@ -16,9 +16,11 @@ import type { EmitContext } from "../commander.ts";
16
16
  * harnery repo at the *matching version tag* into a cache dir and install the
17
17
  * web app's deps. Subsequent runs reuse the cache.
18
18
  *
19
- * The only thing `web/` pulls from `src/` is `harnery/core/journal` (a single,
20
- * dependency-free module), so the install is `web/` deps only: no root install,
21
- * no Playwright browser download.
19
+ * `web/` imports readers from `src/`, and `web/next.config.ts` resolves the
20
+ * root `playwright` package, so the install covers the repository root and
21
+ * then `web/`. Installing the `playwright` package downloads no browsers.
22
+ * Installing `web/` alone left `harn web up` failing with
23
+ * `Cannot find module 'playwright/package.json'`.
22
24
  */
23
25
 
24
26
  const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
@@ -53,8 +55,8 @@ function webCacheRoot(ref: string): string {
53
55
  }
54
56
 
55
57
  /** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
56
- export function webRunner(): "bun" | "npm" {
57
- const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
58
+ export function webRunner(spawn: typeof spawnSync = spawnSync): "bun" | "npm" {
59
+ const r = spawn("bun", ["--version"], { stdio: "ignore" });
58
60
  return r.status === 0 ? "bun" : "npm";
59
61
  }
60
62
 
@@ -63,14 +65,20 @@ export function webRunner(): "bun" | "npm" {
63
65
  * its path, fetching (clone + install) on first use. Returns null on failure
64
66
  * (after emitting an actionable error).
65
67
  */
66
- export function lazyFetchWebRoot(emit: EmitContext): string | null {
68
+ export function lazyFetchWebRoot(
69
+ emit: EmitContext,
70
+ spawn: typeof spawnSync = spawnSync,
71
+ ): string | null {
67
72
  const { version, repoUrl } = readPkg();
68
73
  const ref = resolveRef(version);
69
74
  const cacheRoot = webCacheRoot(ref);
70
75
  const webDir = path.join(cacheRoot, "web");
71
76
 
72
- // node_modules in the cached web/ is the "fetched + installed" marker.
73
- if (existsSync(path.join(webDir, "node_modules"))) {
77
+ // node_modules in both the cached root and web/ is the "fetched + installed" marker.
78
+ if (
79
+ existsSync(path.join(cacheRoot, "node_modules")) &&
80
+ existsSync(path.join(webDir, "node_modules"))
81
+ ) {
74
82
  emit.log(`harn web · using cached dashboard (${ref})`, "info");
75
83
  return webDir;
76
84
  }
@@ -85,7 +93,7 @@ export function lazyFetchWebRoot(emit: EmitContext): string | null {
85
93
  if (existsSync(cacheRoot)) rmSync(cacheRoot, { recursive: true, force: true });
86
94
  mkdirSync(path.dirname(cacheRoot), { recursive: true });
87
95
 
88
- const clone = spawnSync("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
96
+ const clone = spawn("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
89
97
  stdio: "inherit",
90
98
  });
91
99
  if (clone.status !== 0) {
@@ -100,16 +108,19 @@ export function lazyFetchWebRoot(emit: EmitContext): string | null {
100
108
  return null;
101
109
  }
102
110
 
103
- const runner = webRunner();
104
- emit.log(`harn web · installing dashboard deps (${runner} install in web/)`, "info");
105
- const install = spawnSync(runner, ["install"], { cwd: webDir, stdio: "inherit" });
106
- if (install.status !== 0) {
107
- emit.error({
108
- code: "web_fetch_failed",
109
- message: `${runner} install in ${webDir} failed (status ${install.status})`,
110
- hint: `Try manually: cd ${webDir} && ${runner} install`,
111
- });
112
- return null;
111
+ const runner = webRunner(spawn);
112
+ for (const dir of [cacheRoot, webDir]) {
113
+ const where = dir === webDir ? "web/" : "the harnery root";
114
+ emit.log(`harn web · installing dashboard deps (${runner} install in ${where})`, "info");
115
+ const install = spawn(runner, ["install"], { cwd: dir, stdio: "inherit" });
116
+ if (install.status !== 0) {
117
+ emit.error({
118
+ code: "web_fetch_failed",
119
+ message: `${runner} install in ${dir} failed (status ${install.status})`,
120
+ hint: `Try manually: cd ${dir} && ${runner} install`,
121
+ });
122
+ return null;
123
+ }
113
124
  }
114
125
 
115
126
  emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
@@ -82,11 +82,17 @@ export function nodeOptionsWithHeapCap(mb: number): string | undefined {
82
82
  return `${inherited} --max-old-space-size=${mb}`.trim();
83
83
  }
84
84
 
85
- /** Preload the request diagnostics hook without replacing Next's HTTP server. */
85
+ /**
86
+ * Preload the dashboard's server hooks (keep-alive and request diagnostics)
87
+ * without replacing Next's HTTP server. NODE_OPTIONS reaches the child that
88
+ * `next dev` forks, but Next re-parses every `--import` into one value, so
89
+ * this names the single combined preload; a second, different `--import`
90
+ * would collapse into an unloadable path (web/server-preload.mjs).
91
+ */
86
92
  export function nodeOptionsWithWebDiagnostics(webDir: string, mb: number): string {
87
93
  const inherited = nodeOptionsWithHeapCap(mb) ?? "";
88
- if (inherited.includes("server-performance.mjs")) return inherited;
89
- const preload = pathToFileURL(path.join(webDir, "server-performance.mjs")).href;
94
+ if (inherited.includes("server-preload.mjs")) return inherited;
95
+ const preload = pathToFileURL(path.join(webDir, "server-preload.mjs")).href;
90
96
  return `${inherited} --import=${preload}`.trim();
91
97
  }
92
98
 
@@ -57,6 +57,7 @@ import {
57
57
  readLiveCoordinationRow,
58
58
  readLiveCoordinationRows,
59
59
  } from "../state/live-coordination-view.ts";
60
+ import { forkRelationLabels } from "../state/names.ts";
60
61
  import type { AgentActivity, TaskState } from "../state/session-state.ts";
61
62
  import { formatPendingCouncils, recordDeliveredMessages } from "./session-context.ts";
62
63
 
@@ -441,7 +442,11 @@ function computePeerTableIfChanged(
441
442
  if (oldHash && oldHash === newHash) return "";
442
443
 
443
444
  // Render peer table via the same formatter used at SessionStart.
444
- const local = formatPeerTable(peers, mySessionId);
445
+ const local = formatPeerTable(
446
+ peers,
447
+ mySessionId,
448
+ forkRelationLabels(coordRoot, selfInstanceId, new Set(peers.map((p) => p.instance_id ?? ""))),
449
+ );
445
450
  const remoteTable = formatRemoteTable(remote);
446
451
  const table = [local, remoteTable].filter(Boolean).join("\n\n");
447
452
  if (!table) return "";
@@ -580,7 +585,11 @@ function writeHashFile(path: string, value: string): void {
580
585
  * (no cross-file imports of formatting internals). If both renderers diverge,
581
586
  * pull the shared bits into a small util module. */
582
587
 
583
- function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
588
+ function formatPeerTable(
589
+ peers: HeartbeatRow[],
590
+ mySessionId: string,
591
+ relations: Map<string, string> = new Map(),
592
+ ): string {
584
593
  if (peers.length === 0) return "";
585
594
  const nowSec = Math.floor(Date.now() / 1000);
586
595
 
@@ -592,16 +601,20 @@ function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
592
601
  }
593
602
  }
594
603
 
595
- type RowExt = HeartbeatRow & { display_files: string[] };
604
+ type RowExt = HeartbeatRow & { display_files: string[]; relation?: string };
596
605
  const rows: RowExt[] = peers
597
606
  .filter((p) => (p.kind ?? "unknown") !== "transient")
598
607
  .map((p) => {
599
608
  const folded = fold[p.instance_id ?? ""] ?? [];
600
609
  const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
601
- return { ...p, display_files: display };
610
+ const relation = relations.get(p.instance_id ?? "");
611
+ return { ...p, display_files: display, ...(relation ? { relation } : {}) };
602
612
  });
603
613
 
604
- const blocking = rows.filter((p) => p.session_id !== mySessionId).sort(byStartedAt);
614
+ // Related sessions sort first so a fork parent never hides in "+N more".
615
+ const blocking = rows
616
+ .filter((p) => p.session_id !== mySessionId)
617
+ .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
605
618
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
606
619
 
607
620
  const out: string[] = [];
@@ -625,7 +638,7 @@ function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number {
625
638
  }
626
639
 
627
640
  function renderSubtable(
628
- rows: Array<HeartbeatRow & { display_files: string[] }>,
641
+ rows: Array<HeartbeatRow & { display_files: string[]; relation?: string }>,
629
642
  header: string,
630
643
  nowSec: number,
631
644
  ): string {
@@ -635,7 +648,10 @@ function renderSubtable(
635
648
  return `${header}\n${first.join("\n")}${overflow}`;
636
649
  }
637
650
 
638
- function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number): string {
651
+ function formatRow(
652
+ r: HeartbeatRow & { display_files: string[]; relation?: string },
653
+ nowSec: number,
654
+ ): string {
639
655
  const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
640
656
  const statePart = formatState(r);
641
657
  // Fall back started_at → last_heartbeat; if neither is a valid timestamp,
@@ -646,7 +662,8 @@ function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number
646
662
  // Prefer a short instance_id over a bare "unknown" so an incomplete row is
647
663
  // still identifiable.
648
664
  const label = r.name ?? (r.instance_id ? r.instance_id.slice(0, 8) : "unknown");
649
- return ` - agent-${label}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
665
+ const relation = r.relation ? ` [${r.relation}]` : "";
666
+ return ` - agent-${label}${relation}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
650
667
  }
651
668
 
652
669
  function formatState(r: HeartbeatRow): string {
@@ -16,7 +16,7 @@ import { appendEntry } from "../../journal/index.ts";
16
16
  import { readRemoteMachines } from "../../presence/index.ts";
17
17
  import { drainMailbox, formatMailboxDelivery, type MailboxMessageV1 } from "../mailbox.ts";
18
18
  import { readLiveCoordinationRows } from "../state/live-coordination-view.ts";
19
- import { readForkParent } from "../state/names.ts";
19
+ import { forkRelationLabels, readForkState } from "../state/names.ts";
20
20
  import type { AgentActivity, TaskState } from "../state/session-state.ts";
21
21
 
22
22
  interface HeartbeatRow {
@@ -54,7 +54,17 @@ export function renderSessionContext(opts: RenderOpts): string {
54
54
 
55
55
  // 1. Self-name line + peer table (folded if peers present)
56
56
  const peers = readActivePeers(coordRoot, instanceId);
57
- const localTable = formatPeerTable(peers, sessionId);
57
+ const forkState = readForkState(coordRoot, instanceId);
58
+ const forkParent = forkState?.parent ?? null;
59
+ const parentRow = forkParent
60
+ ? peers.find((peer) => peer.instance_id === forkParent.instance_id)
61
+ : undefined;
62
+ const relations = forkRelationLabels(
63
+ coordRoot,
64
+ instanceId,
65
+ new Set(peers.map((peer) => peer.instance_id ?? "")),
66
+ );
67
+ const localTable = formatPeerTable(peers, sessionId, relations);
58
68
  // Cross-machine presence (ADR 0016): sessions on other machines (advisory).
59
69
  const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
60
70
  if (agentName) {
@@ -64,13 +74,11 @@ export function renderSessionContext(opts: RenderOpts): string {
64
74
  // asserts the parent's name, claims, and task. When recorded fork lineage
65
75
  // (.name-history forked_from) names the parent, say so specifically — the
66
76
  // fork is about to read a transcript full of that exact name. Otherwise
67
- // fall back to the generic clause, which needs no fork detection.
68
- const forkParent = readForkParent(coordRoot, instanceId);
69
- const authority = forkParent?.name
70
- ? `This conversation was branched from agent-${forkParent.name}'s session: earlier context showing agent-${forkParent.name}'s name, task, or file claims belongs to the pre-fork session, not to you.`
71
- : forkParent
72
- ? `This conversation was branched from another session (${forkParent.instance_id}); any agent name in earlier context belongs to that session, not to you.`
73
- : "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
77
+ // fall back to the generic clause, which needs no fork detection. A live
78
+ // parent is the dangerous case (2026-10-07: a fork read its running
79
+ // parent as its own earlier self and offered to close it), so the notice
80
+ // names what the parent is doing and forbids ending it.
81
+ const authority = forkAuthorityClause(forkParent, forkState?.fork === true, parentRow);
74
82
  const selfLine = `You are agent-${agentName}${suffix}. ${authority}`;
75
83
  messages.push(peerTable ? `${selfLine}\n\n${peerTable}` : selfLine);
76
84
  } else if (peerTable) {
@@ -194,13 +202,64 @@ function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRo
194
202
  );
195
203
  }
196
204
 
205
+ const DO_NOT_END =
206
+ "Do not end, release, or finalize that session unless the operator explicitly tells you to.";
207
+
208
+ function forkAuthorityClause(
209
+ parent: { instance_id: string; name: string | null } | null,
210
+ fork: boolean,
211
+ parentRow: HeartbeatRow | undefined,
212
+ ): string {
213
+ if (parent?.name && parentRow) {
214
+ const files = parentRow.files_touched ?? [];
215
+ const task = parentRow.task ? ` on "${parentRow.task.slice(0, 80)}"` : "";
216
+ const holds =
217
+ files.length > 0
218
+ ? ` and holds ${files.length} file${files.length === 1 ? "" : "s"} (${fmtFileList(files)})`
219
+ : "";
220
+ const owned =
221
+ files.length > 0
222
+ ? parentRow.task
223
+ ? "That name, task, and those files"
224
+ : "That name and those files"
225
+ : parentRow.task
226
+ ? "That name and task"
227
+ : "That name";
228
+ return (
229
+ `This conversation was forked from agent-${parent.name}, which is still running as a separate session${task}${holds}. ` +
230
+ `${owned} belong${owned === "That name" ? "s" : ""} to agent-${parent.name}, not to you. ${DO_NOT_END}`
231
+ );
232
+ }
233
+ if (parent?.name) {
234
+ return `This conversation was branched from agent-${parent.name}'s session: earlier context showing agent-${parent.name}'s name, task, or file claims belongs to the pre-fork session, not to you.`;
235
+ }
236
+ if (parent) {
237
+ return `This conversation was branched from another session (${parent.instance_id}); any agent name in earlier context belongs to that session, not to you.`;
238
+ }
239
+ if (fork) {
240
+ return `This conversation was forked from another session, which may still be running. Any agent name, task, or file claim in earlier context belongs to that session, not to you. ${DO_NOT_END}`;
241
+ }
242
+ return "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
243
+ }
244
+
245
+ function fmtFileList(files: string[]): string {
246
+ const sorted = [...files].sort();
247
+ return sorted.length <= 3
248
+ ? sorted.join(", ")
249
+ : `${sorted.slice(0, 3).join(", ")}, +${sorted.length - 3} more`;
250
+ }
251
+
197
252
  /**
198
253
  * Renders the peer table as
199
254
  * two subsections: "Other agent groups active" (blocking) and "Your group"
200
255
  * (subagents/siblings, no mutual block). Folds transient subagents' files
201
256
  * into their parent session.
202
257
  */
203
- function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
258
+ function formatPeerTable(
259
+ peers: HeartbeatRow[],
260
+ mySessionId: string,
261
+ relations: Map<string, string> = new Map(),
262
+ ): string {
204
263
  if (peers.length === 0) return "";
205
264
  const nowSec = Math.floor(Date.now() / 1000);
206
265
 
@@ -214,16 +273,20 @@ function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
214
273
  }
215
274
 
216
275
  // Build rows with display_files (own files + folded transient files).
217
- type RowExt = HeartbeatRow & { display_files: string[] };
276
+ type RowExt = HeartbeatRow & { display_files: string[]; relation?: string };
218
277
  const rows: RowExt[] = peers
219
278
  .filter((p) => (p.kind ?? "unknown") !== "transient")
220
279
  .map((p) => {
221
280
  const folded = fold[p.instance_id ?? ""] ?? [];
222
281
  const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
223
- return { ...p, display_files: display };
282
+ const relation = relations.get(p.instance_id ?? "");
283
+ return { ...p, display_files: display, ...(relation ? { relation } : {}) };
224
284
  });
225
285
 
226
- const blocking = rows.filter((p) => p.session_id !== mySessionId).sort(byStartedAt);
286
+ // Related sessions sort first so the parent never hides in "+N more".
287
+ const blocking = rows
288
+ .filter((p) => p.session_id !== mySessionId)
289
+ .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
227
290
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
228
291
 
229
292
  const sections: string[] = [];
@@ -248,7 +311,7 @@ function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number {
248
311
  }
249
312
 
250
313
  function renderSubtable(
251
- rows: Array<HeartbeatRow & { display_files: string[] }>,
314
+ rows: Array<HeartbeatRow & { display_files: string[]; relation?: string }>,
252
315
  header: string,
253
316
  nowSec: number,
254
317
  ): string {
@@ -258,7 +321,10 @@ function renderSubtable(
258
321
  return `${header}\n${first.join("\n")}${overflow}`;
259
322
  }
260
323
 
261
- function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number): string {
324
+ function formatRow(
325
+ r: HeartbeatRow & { display_files: string[]; relation?: string },
326
+ nowSec: number,
327
+ ): string {
262
328
  const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
263
329
  const activity = r.activity ?? "unknown";
264
330
  const lifecycle = r.task_state ?? "active";
@@ -266,7 +332,8 @@ function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number
266
332
  lifecycle === "blocked" && r.task_state_reason ? `: ${r.task_state_reason.slice(0, 80)}` : "";
267
333
  const ageFrom = fmtAge(nowSec - parseIsoSec(r.started_at));
268
334
  const filesPart = fmtFiles(r.display_files);
269
- return ` - agent-${r.name ?? "unknown"}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`;
335
+ const relation = r.relation ? ` [${r.relation}]` : "";
336
+ return ` - agent-${r.name ?? "unknown"}${relation}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`;
270
337
  }
271
338
 
272
339
  function fmtFiles(files: string[]): string {
@@ -331,13 +331,20 @@ export interface NameHistoryRow {
331
331
  ts: string;
332
332
  /** Durable persona UUID. Present after `agents identity assume`. */
333
333
  agent_id?: string;
334
- /** Audit marker distinguishing an explicit role adoption from pool assignment. */
335
- source?: "pool" | "identity.assume";
334
+ /** Audit marker distinguishing an explicit role adoption from pool assignment,
335
+ * and a lineage row appended after the name was already assigned. */
336
+ source?: "pool" | "identity.assume" | "fork.detected";
336
337
  previous_name?: string;
337
338
  /** Instance this session was forked/branched from (recorded fork lineage).
338
339
  * Stamped only on the row that first assigns this instance, when the adapter
339
340
  * layer detected or supplied a parent conversation. */
340
341
  forked_from?: string;
342
+ /** The adapter declared or inherited-history proved this instance is a fork,
343
+ * even when its parent could not be resolved yet. */
344
+ fork?: true;
345
+ /** Fork detection has run against this instance's transcript; the tool-path
346
+ * heal skips instances carrying it so the scan runs once per instance. */
347
+ fork_checked?: true;
341
348
  }
342
349
 
343
350
  function atomicWrite(path: string, content: string): void {
@@ -516,7 +523,13 @@ export function assignName(
516
523
  coordRoot: string,
517
524
  instanceId: string,
518
525
  kind: NameKind,
519
- opts: { freshnessSecs: number; forkedFrom?: string; nowMs?: number },
526
+ opts: {
527
+ freshnessSecs: number;
528
+ forkedFrom?: string;
529
+ fork?: boolean;
530
+ forkChecked?: boolean;
531
+ nowMs?: number;
532
+ },
520
533
  ): string {
521
534
  // Check 1: existing history row → original name. A resume re-enters here,
522
535
  // which also makes fork stamping naturally idempotent: lineage lands only on
@@ -558,13 +571,111 @@ export function assignName(
558
571
  kind,
559
572
  source: "pool",
560
573
  ...(forkedFrom && forkedFrom !== instanceId ? { forked_from: forkedFrom } : {}),
574
+ ...(opts.fork || (forkedFrom && forkedFrom !== instanceId) ? { fork: true as const } : {}),
575
+ ...(opts.forkChecked ? { fork_checked: true as const } : {}),
561
576
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
562
577
  });
563
578
  return name;
564
579
  }
565
580
 
581
+ export interface ForkState {
582
+ /** True when the instance is a recorded fork, parent known or not. */
583
+ fork: boolean;
584
+ /** Detection already ran for this instance. */
585
+ checked: boolean;
586
+ parent: { instance_id: string; name: string | null } | null;
587
+ }
588
+
589
+ /** Recorded fork state for <instanceId>, or null when it has no history row. */
590
+ export function readForkState(coordRoot: string, instanceId: string): ForkState | null {
591
+ const history = readHistory(coordRoot);
592
+ let seen = false;
593
+ let fork = false;
594
+ let checked = false;
595
+ let parentId: string | undefined;
596
+ for (const row of history) {
597
+ if (row.instance_id !== instanceId) continue;
598
+ seen = true;
599
+ if (row.fork || row.forked_from) fork = true;
600
+ if (row.fork_checked) checked = true;
601
+ if (row.forked_from && row.forked_from !== instanceId) parentId = row.forked_from;
602
+ }
603
+ if (!seen) return null;
604
+ const parent = parentId
605
+ ? { instance_id: parentId, name: resolveName(coordRoot, parentId)?.name ?? null }
606
+ : null;
607
+ return { fork, checked, parent };
608
+ }
609
+
610
+ /**
611
+ * Record fork detection that ran after the instance was already named (the
612
+ * tool-path heal). Appends a copy of the latest identity row, so the name,
613
+ * kind, and persona are unchanged, plus `fork_checked` and any lineage found.
614
+ * A no-op for an instance with no history row or one already checked.
615
+ */
616
+ export function recordForkDetection(
617
+ coordRoot: string,
618
+ instanceId: string,
619
+ detection: { fork: boolean; forkedFrom?: string },
620
+ ): boolean {
621
+ const history = readHistory(coordRoot);
622
+ let latest: NameHistoryRow | undefined;
623
+ for (const row of history) {
624
+ if (row.instance_id !== instanceId) continue;
625
+ if (row.fork_checked) return false;
626
+ latest = row;
627
+ }
628
+ if (!latest) return false;
629
+ const forkedFrom =
630
+ detection.forkedFrom && detection.forkedFrom !== instanceId ? detection.forkedFrom : undefined;
631
+ appendHistory(coordRoot, {
632
+ instance_id: instanceId,
633
+ name: latest.name,
634
+ kind: latest.kind,
635
+ ...(latest.agent_id ? { agent_id: latest.agent_id } : {}),
636
+ source: "fork.detected",
637
+ ...(forkedFrom ? { forked_from: forkedFrom } : {}),
638
+ ...(detection.fork || forkedFrom || latest.fork ? { fork: true as const } : {}),
639
+ fork_checked: true,
640
+ ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
641
+ });
642
+ return true;
643
+ }
644
+
645
+ /**
646
+ * Peer labels for recorded fork relations, keyed by instance id: this
647
+ * session's fork parent and its direct forks. `liveIds` marks which of them
648
+ * are running, so a fork can tell its live parent from its own earlier self.
649
+ */
650
+ export function forkRelationLabels(
651
+ coordRoot: string,
652
+ instanceId: string,
653
+ liveIds: Set<string>,
654
+ ): Map<string, string> {
655
+ const out = new Map<string, string>();
656
+ const parent = readForkParent(coordRoot, instanceId);
657
+ if (parent) {
658
+ out.set(
659
+ parent.instance_id,
660
+ liveIds.has(parent.instance_id) ? "your fork parent, live" : "your fork parent",
661
+ );
662
+ }
663
+ for (const child of readForkChildren(coordRoot, instanceId)) out.set(child, "your fork");
664
+ return out;
665
+ }
666
+
667
+ /** Instance ids recorded as direct forks of <instanceId>. */
668
+ export function readForkChildren(coordRoot: string, instanceId: string): Set<string> {
669
+ const out = new Set<string>();
670
+ for (const row of readHistory(coordRoot)) {
671
+ if (row.forked_from === instanceId && row.instance_id !== instanceId) out.add(row.instance_id);
672
+ }
673
+ return out;
674
+ }
675
+
566
676
  /** One step of recorded fork lineage: the latest row for <instanceId> that
567
- * carries `forked_from` (latest-row-wins, matching resolveName). */
677
+ * carries `forked_from`. Later rows without lineage (an identity assumption)
678
+ * do not erase it: a session never stops being a fork. */
568
679
  export function readForkParent(
569
680
  coordRoot: string,
570
681
  instanceId: string,
@@ -572,8 +683,8 @@ export function readForkParent(
572
683
  const history = readHistory(coordRoot);
573
684
  for (let i = history.length - 1; i >= 0; i--) {
574
685
  const row = history[i]!;
575
- if (row.instance_id !== instanceId) continue;
576
- if (!row.forked_from) return null;
686
+ if (row.instance_id !== instanceId || !row.forked_from) continue;
687
+ if (row.forked_from === instanceId) return null;
577
688
  const parent = resolveName(coordRoot, row.forked_from);
578
689
  return { instance_id: row.forked_from, name: parent?.name ?? null };
579
690
  }
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { constants, createReadStream } from "node:fs";
2
+ import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, realpath, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, isAbsolute, relative, resolve } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { finished } from "node:stream/promises";
5
7
  import { StringDecoder } from "node:string_decoder";
6
8
  import { createGunzip, createInflateRaw } from "node:zlib";
7
9
  import { stateDirMode, stateFileMode } from "../../../storage/modes.ts";
@@ -89,24 +91,17 @@ async function* streamEventV3SupportPackRecordsForManifest(
89
91
  throw new Error("event_v3_support_payload_length_mismatch");
90
92
  if (payloadStat.size > bounded.max_payload_bytes)
91
93
  throw new Error("event_v3_support_payload_too_large");
92
- if ((await hashHandle(payloadPath, payloadHandle)) !== manifest.payload.digest) {
94
+ if ((await hashHandle(payloadHandle)) !== manifest.payload.digest) {
93
95
  throw new Error("event_v3_support_payload_digest_mismatch");
94
96
  }
95
97
  limits.fault?.("after_payload_digest", payloadPath);
96
98
  await assertCanonicalSingleGzipMember(
97
- payloadPath,
98
99
  payloadHandle,
99
100
  payloadStat.size,
100
101
  bounded.max_decompressed_bytes,
101
102
  );
102
103
 
103
104
  const gunzip = createGunzip();
104
- const source = createReadStream(payloadPath, {
105
- fd: payloadHandle.fd,
106
- autoClose: false,
107
- start: 0,
108
- });
109
- source.pipe(gunzip);
110
105
  const decoder = new StringDecoder("utf8");
111
106
  let pending = "";
112
107
  let decompressedBytes = 0;
@@ -116,7 +111,7 @@ async function* streamEventV3SupportPackRecordsForManifest(
116
111
  const entries: EventV3LogicalAuthorityEntry[] = [];
117
112
  const seen = new Set<string>();
118
113
 
119
- for await (const current of gunzip) {
114
+ for await (const current of decompressHandle(payloadHandle, 0, gunzip)) {
120
115
  const chunk = current as Buffer;
121
116
  decompressedBytes += chunk.length;
122
117
  if (decompressedBytes > bounded.max_decompressed_bytes) {
@@ -247,16 +242,49 @@ export async function unpackEventV3SupportPack(
247
242
  }
248
243
  }
249
244
 
250
- async function hashHandle(
251
- path: string,
252
- handle: Awaited<ReturnType<typeof open>>,
253
- ): Promise<`sha256:${string}`> {
245
+ async function hashHandle(handle: Awaited<ReturnType<typeof open>>): Promise<`sha256:${string}`> {
254
246
  const hash = createHash("sha256");
255
- const stream = createReadStream(path, { fd: handle.fd, autoClose: false, start: 0 });
256
- for await (const current of stream) hash.update(current as Buffer);
247
+ for await (const current of handleChunks(handle, 0)) hash.update(current);
257
248
  return `sha256:${hash.digest("hex")}`;
258
249
  }
259
250
 
251
+ async function* handleChunks(handle: Awaited<ReturnType<typeof open>>, position: number) {
252
+ while (true) {
253
+ const buffer = Buffer.alloc(64 * 1024);
254
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
255
+ if (bytesRead === 0) return;
256
+ position += bytesRead;
257
+ yield buffer.subarray(0, bytesRead);
258
+ }
259
+ }
260
+
261
+ async function* decompressHandle(
262
+ handle: Awaited<ReturnType<typeof open>>,
263
+ start: number,
264
+ decoder: ReturnType<typeof createGunzip>,
265
+ ): AsyncGenerator<Buffer> {
266
+ const source = Readable.from(handleChunks(handle, start));
267
+ // Observe failures immediately, then settle source reads before the caller
268
+ // can close and reuse the verified handle, including on early rejection.
269
+ const completed = finished(source, { cleanup: true }).then(
270
+ () => undefined,
271
+ (error: unknown) => error,
272
+ );
273
+ source.once("error", (error) => decoder.destroy(error));
274
+ source.pipe(decoder);
275
+ try {
276
+ for await (const chunk of decoder) yield chunk as Buffer;
277
+ source.unpipe(decoder);
278
+ source.resume();
279
+ const error = await completed;
280
+ if (error) throw error;
281
+ } finally {
282
+ source.destroy();
283
+ decoder.destroy();
284
+ await completed;
285
+ }
286
+ }
287
+
260
288
  async function realDirectory(path: string): Promise<string> {
261
289
  const stat = await lstat(path);
262
290
  if (!stat.isDirectory() || stat.isSymbolicLink())
@@ -265,7 +293,6 @@ async function realDirectory(path: string): Promise<string> {
265
293
  }
266
294
 
267
295
  async function assertCanonicalSingleGzipMember(
268
- path: string,
269
296
  handle: Awaited<ReturnType<typeof open>>,
270
297
  payloadBytes: number,
271
298
  maximumDecompressedBytes: number,
@@ -284,9 +311,8 @@ async function assertCanonicalSingleGzipMember(
284
311
  throw new Error("event_v3_support_gzip_header_noncanonical");
285
312
  }
286
313
  const inflate = createInflateRaw();
287
- createReadStream(path, { fd: handle.fd, autoClose: false, start: 10 }).pipe(inflate);
288
314
  let decompressed = 0;
289
- for await (const current of inflate) {
315
+ for await (const current of decompressHandle(handle, 10, inflate)) {
290
316
  decompressed += (current as Buffer).length;
291
317
  if (decompressed > maximumDecompressedBytes) {
292
318
  inflate.destroy();