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
@@ -14,9 +14,11 @@ import { fileURLToPath } from "node:url";
14
14
  * harnery repo at the *matching version tag* into a cache dir and install the
15
15
  * web app's deps. Subsequent runs reuse the cache.
16
16
  *
17
- * The only thing `web/` pulls from `src/` is `harnery/core/journal` (a single,
18
- * dependency-free module), so the install is `web/` deps only: no root install,
19
- * no Playwright browser download.
17
+ * `web/` imports readers from `src/`, and `web/next.config.ts` resolves the
18
+ * root `playwright` package, so the install covers the repository root and
19
+ * then `web/`. Installing the `playwright` package downloads no browsers.
20
+ * Installing `web/` alone left `harn web up` failing with
21
+ * `Cannot find module 'playwright/package.json'`.
20
22
  */
21
23
  const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
22
24
  /** harnery's own package root: src/commands/ -> ../.. ; valid in both src/ and dist/. */
@@ -46,8 +48,8 @@ function webCacheRoot(ref) {
46
48
  return path.join(base, "harnery", "web", safeRef);
47
49
  }
48
50
  /** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
49
- export function webRunner() {
50
- const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
51
+ export function webRunner(spawn = spawnSync) {
52
+ const r = spawn("bun", ["--version"], { stdio: "ignore" });
51
53
  return r.status === 0 ? "bun" : "npm";
52
54
  }
53
55
  /**
@@ -55,13 +57,14 @@ export function webRunner() {
55
57
  * its path, fetching (clone + install) on first use. Returns null on failure
56
58
  * (after emitting an actionable error).
57
59
  */
58
- export function lazyFetchWebRoot(emit) {
60
+ export function lazyFetchWebRoot(emit, spawn = spawnSync) {
59
61
  const { version, repoUrl } = readPkg();
60
62
  const ref = resolveRef(version);
61
63
  const cacheRoot = webCacheRoot(ref);
62
64
  const webDir = path.join(cacheRoot, "web");
63
- // node_modules in the cached web/ is the "fetched + installed" marker.
64
- if (existsSync(path.join(webDir, "node_modules"))) {
65
+ // node_modules in both the cached root and web/ is the "fetched + installed" marker.
66
+ if (existsSync(path.join(cacheRoot, "node_modules")) &&
67
+ existsSync(path.join(webDir, "node_modules"))) {
65
68
  emit.log(`harn web · using cached dashboard (${ref})`, "info");
66
69
  return webDir;
67
70
  }
@@ -71,7 +74,7 @@ export function lazyFetchWebRoot(emit) {
71
74
  if (existsSync(cacheRoot))
72
75
  rmSync(cacheRoot, { recursive: true, force: true });
73
76
  mkdirSync(path.dirname(cacheRoot), { recursive: true });
74
- const clone = spawnSync("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
77
+ const clone = spawn("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
75
78
  stdio: "inherit",
76
79
  });
77
80
  if (clone.status !== 0) {
@@ -84,16 +87,19 @@ export function lazyFetchWebRoot(emit) {
84
87
  });
85
88
  return null;
86
89
  }
87
- const runner = webRunner();
88
- emit.log(`harn web · installing dashboard deps (${runner} install in web/)`, "info");
89
- const install = spawnSync(runner, ["install"], { cwd: webDir, stdio: "inherit" });
90
- if (install.status !== 0) {
91
- emit.error({
92
- code: "web_fetch_failed",
93
- message: `${runner} install in ${webDir} failed (status ${install.status})`,
94
- hint: `Try manually: cd ${webDir} && ${runner} install`,
95
- });
96
- return null;
90
+ const runner = webRunner(spawn);
91
+ for (const dir of [cacheRoot, webDir]) {
92
+ const where = dir === webDir ? "web/" : "the harnery root";
93
+ emit.log(`harn web · installing dashboard deps (${runner} install in ${where})`, "info");
94
+ const install = spawn(runner, ["install"], { cwd: dir, stdio: "inherit" });
95
+ if (install.status !== 0) {
96
+ emit.error({
97
+ code: "web_fetch_failed",
98
+ message: `${runner} install in ${dir} failed (status ${install.status})`,
99
+ hint: `Try manually: cd ${dir} && ${runner} install`,
100
+ });
101
+ return null;
102
+ }
97
103
  }
98
104
  emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
99
105
  return webDir;
@@ -19,7 +19,13 @@ export declare function resolveMaxOldSpaceMb(flag?: string): number;
19
19
  * wins without clobbering flags the caller set (source maps, inspector, …).
20
20
  */
21
21
  export declare function nodeOptionsWithHeapCap(mb: number): string | undefined;
22
- /** Preload the request diagnostics hook without replacing Next's HTTP server. */
22
+ /**
23
+ * Preload the dashboard's server hooks (keep-alive and request diagnostics)
24
+ * without replacing Next's HTTP server. NODE_OPTIONS reaches the child that
25
+ * `next dev` forks, but Next re-parses every `--import` into one value, so
26
+ * this names the single combined preload; a second, different `--import`
27
+ * would collapse into an unloadable path (web/server-preload.mjs).
28
+ */
23
29
  export declare function nodeOptionsWithWebDiagnostics(webDir: string, mb: number): string;
24
30
  export declare function webPerformanceLogDestination(coordRoot: string, env?: Readonly<Record<string, string | undefined>>): string;
25
31
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/commands/web.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA8CnD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAM1D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKrE;AAED,iFAAiF;AACjF,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAKhF;AAED,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAe,GAC9D,MAAM,CAQR;AAmDD;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAevE;AAsBD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CA2N5E"}
1
+ {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/commands/web.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA8CnD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAM1D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKrE;AAED;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAKhF;AAED,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAe,GAC9D,MAAM,CAQR;AAmDD;;;;GAIG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAevE;AAsBD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CA2N5E"}
@@ -71,12 +71,18 @@ export function nodeOptionsWithHeapCap(mb) {
71
71
  return inherited;
72
72
  return `${inherited} --max-old-space-size=${mb}`.trim();
73
73
  }
74
- /** Preload the request diagnostics hook without replacing Next's HTTP server. */
74
+ /**
75
+ * Preload the dashboard's server hooks (keep-alive and request diagnostics)
76
+ * without replacing Next's HTTP server. NODE_OPTIONS reaches the child that
77
+ * `next dev` forks, but Next re-parses every `--import` into one value, so
78
+ * this names the single combined preload; a second, different `--import`
79
+ * would collapse into an unloadable path (web/server-preload.mjs).
80
+ */
75
81
  export function nodeOptionsWithWebDiagnostics(webDir, mb) {
76
82
  const inherited = nodeOptionsWithHeapCap(mb) ?? "";
77
- if (inherited.includes("server-performance.mjs"))
83
+ if (inherited.includes("server-preload.mjs"))
78
84
  return inherited;
79
- const preload = pathToFileURL(path.join(webDir, "server-performance.mjs")).href;
85
+ const preload = pathToFileURL(path.join(webDir, "server-preload.mjs")).href;
80
86
  return `${inherited} --import=${preload}`.trim();
81
87
  }
82
88
  export function webPerformanceLogDestination(coordRoot, env = process.env) {
@@ -1 +1 @@
1
- {"version":3,"file":"prompt-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/prompt-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAgDH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;8EAC0E;IAC1E,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;kFAC8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;kFAC8E;IAC9E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;uEACmE;IACnE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;wBAIoB;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkFnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA6CtF"}
1
+ {"version":3,"file":"prompt-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/prompt-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAiDH,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;8EAC0E;IAC1E,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;kFAC8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;kFAC8E;IAC9E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;uEACmE;IACnE,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;wBAIoB;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkFnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA6CtF"}
@@ -43,6 +43,7 @@ import { drainMailbox, formatMailboxDelivery } from "../mailbox.js";
43
43
  import { sessionNameDisplayBlock, sessionNameDisplayPending } from "../session-name-display.js";
44
44
  import { heartbeatPath } from "../state/heartbeat-reader.js";
45
45
  import { readLiveCoordinationRow, readLiveCoordinationRows, } from "../state/live-coordination-view.js";
46
+ import { forkRelationLabels } from "../state/names.js";
46
47
  import { formatPendingCouncils, recordDeliveredMessages } from "./session-context.js";
47
48
  /**
48
49
  * Build the combined UserPromptSubmit additionalContext string. Returns "" when
@@ -337,7 +338,7 @@ function computePeerTableIfChanged(coordRoot, selfInstanceId, selfSessionIdFallb
337
338
  if (oldHash && oldHash === newHash)
338
339
  return "";
339
340
  // Render peer table via the same formatter used at SessionStart.
340
- const local = formatPeerTable(peers, mySessionId);
341
+ const local = formatPeerTable(peers, mySessionId, forkRelationLabels(coordRoot, selfInstanceId, new Set(peers.map((p) => p.instance_id ?? ""))));
341
342
  const remoteTable = formatRemoteTable(remote);
342
343
  const table = [local, remoteTable].filter(Boolean).join("\n\n");
343
344
  if (!table)
@@ -458,7 +459,7 @@ function writeHashFile(path, value) {
458
459
  * intentionally co-located here so the prompt-context renderer is self-contained
459
460
  * (no cross-file imports of formatting internals). If both renderers diverge,
460
461
  * pull the shared bits into a small util module. */
461
- function formatPeerTable(peers, mySessionId) {
462
+ function formatPeerTable(peers, mySessionId, relations = new Map()) {
462
463
  if (peers.length === 0)
463
464
  return "";
464
465
  const nowSec = Math.floor(Date.now() / 1000);
@@ -474,9 +475,13 @@ function formatPeerTable(peers, mySessionId) {
474
475
  .map((p) => {
475
476
  const folded = fold[p.instance_id ?? ""] ?? [];
476
477
  const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
477
- return { ...p, display_files: display };
478
+ const relation = relations.get(p.instance_id ?? "");
479
+ return { ...p, display_files: display, ...(relation ? { relation } : {}) };
478
480
  });
479
- const blocking = rows.filter((p) => p.session_id !== mySessionId).sort(byStartedAt);
481
+ // Related sessions sort first so a fork parent never hides in "+N more".
482
+ const blocking = rows
483
+ .filter((p) => p.session_id !== mySessionId)
484
+ .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
480
485
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
481
486
  const out = [];
482
487
  const blk = renderSubtable(blocking, "Other agent groups active (their files block you):", nowSec);
@@ -508,7 +513,8 @@ function formatRow(r, nowSec) {
508
513
  // Prefer a short instance_id over a bare "unknown" so an incomplete row is
509
514
  // still identifiable.
510
515
  const label = r.name ?? (r.instance_id ? r.instance_id.slice(0, 8) : "unknown");
511
- return ` - agent-${label}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
516
+ const relation = r.relation ? ` [${r.relation}]` : "";
517
+ return ` - agent-${label}${relation}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
512
518
  }
513
519
  function formatState(r) {
514
520
  const activity = r.activity ?? "unknown";
@@ -1 +1 @@
1
- {"version":3,"file":"session-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/session-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAyC,QAAQ,EAAE,MAAM,SAAS,CAAC;AAM1E,OAAO,EAAuC,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAoB3F,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;+BAC2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAyF7D;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,SAAS,gBAAgB,EAAE,GACpC,IAAI,CAUN;AAkID;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAwBvD;AA0DD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAiDlF;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,iBAAW,CAAC"}
1
+ {"version":3,"file":"session-context.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/render/session-context.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAyC,QAAQ,EAAE,MAAM,SAAS,CAAC;AAM1E,OAAO,EAAuC,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAoB3F,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;+BAC2B;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAiG7D;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,SAAS,gBAAgB,EAAE,GACpC,IAAI,CAUN;AA6LD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAwBvD;AA0DD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAiDlF;AAED;;;GAGG;AACH,eAAO,MAAM,uBAAuB,iBAAW,CAAC"}
@@ -15,7 +15,7 @@ import { appendEntry } from "../../journal/index.js";
15
15
  import { readRemoteMachines } from "../../presence/index.js";
16
16
  import { drainMailbox, formatMailboxDelivery } from "../mailbox.js";
17
17
  import { readLiveCoordinationRows } from "../state/live-coordination-view.js";
18
- import { readForkParent } from "../state/names.js";
18
+ import { forkRelationLabels, readForkState } from "../state/names.js";
19
19
  /**
20
20
  * Build the combined SessionStart systemMessage. Returns the additionalContext
21
21
  * string (or "" if there's nothing to say).
@@ -25,7 +25,13 @@ export function renderSessionContext(opts) {
25
25
  const messages = [];
26
26
  // 1. Self-name line + peer table (folded if peers present)
27
27
  const peers = readActivePeers(coordRoot, instanceId);
28
- const localTable = formatPeerTable(peers, sessionId);
28
+ const forkState = readForkState(coordRoot, instanceId);
29
+ const forkParent = forkState?.parent ?? null;
30
+ const parentRow = forkParent
31
+ ? peers.find((peer) => peer.instance_id === forkParent.instance_id)
32
+ : undefined;
33
+ const relations = forkRelationLabels(coordRoot, instanceId, new Set(peers.map((peer) => peer.instance_id ?? "")));
34
+ const localTable = formatPeerTable(peers, sessionId, relations);
29
35
  // Cross-machine presence (ADR 0016): sessions on other machines (advisory).
30
36
  const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
31
37
  if (agentName) {
@@ -35,13 +41,11 @@ export function renderSessionContext(opts) {
35
41
  // asserts the parent's name, claims, and task. When recorded fork lineage
36
42
  // (.name-history forked_from) names the parent, say so specifically — the
37
43
  // fork is about to read a transcript full of that exact name. Otherwise
38
- // fall back to the generic clause, which needs no fork detection.
39
- const forkParent = readForkParent(coordRoot, instanceId);
40
- const authority = forkParent?.name
41
- ? `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.`
42
- : forkParent
43
- ? `This conversation was branched from another session (${forkParent.instance_id}); any agent name in earlier context belongs to that session, not to you.`
44
- : "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
44
+ // fall back to the generic clause, which needs no fork detection. A live
45
+ // parent is the dangerous case (2026-10-07: a fork read its running
46
+ // parent as its own earlier self and offered to close it), so the notice
47
+ // names what the parent is doing and forbids ending it.
48
+ const authority = forkAuthorityClause(forkParent, forkState?.fork === true, parentRow);
45
49
  const selfLine = `You are agent-${agentName}${suffix}. ${authority}`;
46
50
  messages.push(peerTable ? `${selfLine}\n\n${peerTable}` : selfLine);
47
51
  }
@@ -151,13 +155,48 @@ function formatRemoteMachines(coordRoot) {
151
155
  function readActivePeers(coordRoot, selfInstanceId) {
152
156
  return readLiveCoordinationRows(coordRoot).filter((heartbeat) => heartbeat.instance_id && heartbeat.instance_id !== selfInstanceId);
153
157
  }
158
+ const DO_NOT_END = "Do not end, release, or finalize that session unless the operator explicitly tells you to.";
159
+ function forkAuthorityClause(parent, fork, parentRow) {
160
+ if (parent?.name && parentRow) {
161
+ const files = parentRow.files_touched ?? [];
162
+ const task = parentRow.task ? ` on "${parentRow.task.slice(0, 80)}"` : "";
163
+ const holds = files.length > 0
164
+ ? ` and holds ${files.length} file${files.length === 1 ? "" : "s"} (${fmtFileList(files)})`
165
+ : "";
166
+ const owned = files.length > 0
167
+ ? parentRow.task
168
+ ? "That name, task, and those files"
169
+ : "That name and those files"
170
+ : parentRow.task
171
+ ? "That name and task"
172
+ : "That name";
173
+ return (`This conversation was forked from agent-${parent.name}, which is still running as a separate session${task}${holds}. ` +
174
+ `${owned} belong${owned === "That name" ? "s" : ""} to agent-${parent.name}, not to you. ${DO_NOT_END}`);
175
+ }
176
+ if (parent?.name) {
177
+ 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.`;
178
+ }
179
+ if (parent) {
180
+ return `This conversation was branched from another session (${parent.instance_id}); any agent name in earlier context belongs to that session, not to you.`;
181
+ }
182
+ if (fork) {
183
+ 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}`;
184
+ }
185
+ return "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
186
+ }
187
+ function fmtFileList(files) {
188
+ const sorted = [...files].sort();
189
+ return sorted.length <= 3
190
+ ? sorted.join(", ")
191
+ : `${sorted.slice(0, 3).join(", ")}, +${sorted.length - 3} more`;
192
+ }
154
193
  /**
155
194
  * Renders the peer table as
156
195
  * two subsections: "Other agent groups active" (blocking) and "Your group"
157
196
  * (subagents/siblings, no mutual block). Folds transient subagents' files
158
197
  * into their parent session.
159
198
  */
160
- function formatPeerTable(peers, mySessionId) {
199
+ function formatPeerTable(peers, mySessionId, relations = new Map()) {
161
200
  if (peers.length === 0)
162
201
  return "";
163
202
  const nowSec = Math.floor(Date.now() / 1000);
@@ -174,9 +213,13 @@ function formatPeerTable(peers, mySessionId) {
174
213
  .map((p) => {
175
214
  const folded = fold[p.instance_id ?? ""] ?? [];
176
215
  const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
177
- return { ...p, display_files: display };
216
+ const relation = relations.get(p.instance_id ?? "");
217
+ return { ...p, display_files: display, ...(relation ? { relation } : {}) };
178
218
  });
179
- const blocking = rows.filter((p) => p.session_id !== mySessionId).sort(byStartedAt);
219
+ // Related sessions sort first so the parent never hides in "+N more".
220
+ const blocking = rows
221
+ .filter((p) => p.session_id !== mySessionId)
222
+ .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
180
223
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
181
224
  const sections = [];
182
225
  const blockingSection = renderSubtable(blocking, "Other agent groups active (their files block you):", nowSec);
@@ -204,7 +247,8 @@ function formatRow(r, nowSec) {
204
247
  const reason = lifecycle === "blocked" && r.task_state_reason ? `: ${r.task_state_reason.slice(0, 80)}` : "";
205
248
  const ageFrom = fmtAge(nowSec - parseIsoSec(r.started_at));
206
249
  const filesPart = fmtFiles(r.display_files);
207
- return ` - agent-${r.name ?? "unknown"}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`;
250
+ const relation = r.relation ? ` [${r.relation}]` : "";
251
+ return ` - agent-${r.name ?? "unknown"}${relation}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`;
208
252
  }
209
253
  function fmtFiles(files) {
210
254
  if (files.length === 0)
@@ -43,13 +43,20 @@ export interface NameHistoryRow {
43
43
  ts: string;
44
44
  /** Durable persona UUID. Present after `agents identity assume`. */
45
45
  agent_id?: string;
46
- /** Audit marker distinguishing an explicit role adoption from pool assignment. */
47
- source?: "pool" | "identity.assume";
46
+ /** Audit marker distinguishing an explicit role adoption from pool assignment,
47
+ * and a lineage row appended after the name was already assigned. */
48
+ source?: "pool" | "identity.assume" | "fork.detected";
48
49
  previous_name?: string;
49
50
  /** Instance this session was forked/branched from (recorded fork lineage).
50
51
  * Stamped only on the row that first assigns this instance, when the adapter
51
52
  * layer detected or supplied a parent conversation. */
52
53
  forked_from?: string;
54
+ /** The adapter declared or inherited-history proved this instance is a fork,
55
+ * even when its parent could not be resolved yet. */
56
+ fork?: true;
57
+ /** Fork detection has run against this instance's transcript; the tool-path
58
+ * heal skips instances carrying it so the scan runs once per instance. */
59
+ fork_checked?: true;
53
60
  }
54
61
  /**
55
62
  * Resolve (name, kind) for an existing owner without consuming a counter
@@ -112,10 +119,43 @@ export declare function readLiveNames(coordRoot: string, opts: {
112
119
  export declare function assignName(coordRoot: string, instanceId: string, kind: NameKind, opts: {
113
120
  freshnessSecs: number;
114
121
  forkedFrom?: string;
122
+ fork?: boolean;
123
+ forkChecked?: boolean;
115
124
  nowMs?: number;
116
125
  }): string;
126
+ export interface ForkState {
127
+ /** True when the instance is a recorded fork, parent known or not. */
128
+ fork: boolean;
129
+ /** Detection already ran for this instance. */
130
+ checked: boolean;
131
+ parent: {
132
+ instance_id: string;
133
+ name: string | null;
134
+ } | null;
135
+ }
136
+ /** Recorded fork state for <instanceId>, or null when it has no history row. */
137
+ export declare function readForkState(coordRoot: string, instanceId: string): ForkState | null;
138
+ /**
139
+ * Record fork detection that ran after the instance was already named (the
140
+ * tool-path heal). Appends a copy of the latest identity row, so the name,
141
+ * kind, and persona are unchanged, plus `fork_checked` and any lineage found.
142
+ * A no-op for an instance with no history row or one already checked.
143
+ */
144
+ export declare function recordForkDetection(coordRoot: string, instanceId: string, detection: {
145
+ fork: boolean;
146
+ forkedFrom?: string;
147
+ }): boolean;
148
+ /**
149
+ * Peer labels for recorded fork relations, keyed by instance id: this
150
+ * session's fork parent and its direct forks. `liveIds` marks which of them
151
+ * are running, so a fork can tell its live parent from its own earlier self.
152
+ */
153
+ export declare function forkRelationLabels(coordRoot: string, instanceId: string, liveIds: Set<string>): Map<string, string>;
154
+ /** Instance ids recorded as direct forks of <instanceId>. */
155
+ export declare function readForkChildren(coordRoot: string, instanceId: string): Set<string>;
117
156
  /** One step of recorded fork lineage: the latest row for <instanceId> that
118
- * carries `forked_from` (latest-row-wins, matching resolveName). */
157
+ * carries `forked_from`. Later rows without lineage (an identity assumption)
158
+ * do not erase it: a session never stops being a fork. */
119
159
  export declare function readForkParent(coordRoot: string, instanceId: string): {
120
160
  instance_id: string;
121
161
  name: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"names.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/names.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAYH,qBAAqB;AACrB,eAAO,MAAM,WAAW,85EA+Qd,CAAC;AAMX,MAAM,MAAM,QAAQ,GAAG,SAAS,GAAG,UAAU,GAAG,WAAW,GAAG,gBAAgB,CAAC;AAE/E,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,QAAQ,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,GAAG,iBAAiB,CAAC;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;2DAEuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAsCD;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,SAAS,CAAC,EAAE,MAAM,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA4B5D;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,QAAQ,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACrE,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,QAAQ,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,IAAI,GAAE,QAAoB,GACzB,oBAAoB,CAoBtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9C,GAAG,CAAC,MAAM,CAAC,CA4Bb;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CACxB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,QAAQ,EACd,IAAI,EAAE;IAAE,aAAa,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACnE,MAAM,CA4CR;AAED;oEACoE;AACpE,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAUrD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,KAAK,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAYrD"}
1
+ {"version":3,"file":"names.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/names.ts"],"names":[],"mappings":"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"}
@@ -496,19 +496,103 @@ export function assignName(coordRoot, instanceId, kind, opts) {
496
496
  kind,
497
497
  source: "pool",
498
498
  ...(forkedFrom && forkedFrom !== instanceId ? { forked_from: forkedFrom } : {}),
499
+ ...(opts.fork || (forkedFrom && forkedFrom !== instanceId) ? { fork: true } : {}),
500
+ ...(opts.forkChecked ? { fork_checked: true } : {}),
499
501
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
500
502
  });
501
503
  return name;
502
504
  }
505
+ /** Recorded fork state for <instanceId>, or null when it has no history row. */
506
+ export function readForkState(coordRoot, instanceId) {
507
+ const history = readHistory(coordRoot);
508
+ let seen = false;
509
+ let fork = false;
510
+ let checked = false;
511
+ let parentId;
512
+ for (const row of history) {
513
+ if (row.instance_id !== instanceId)
514
+ continue;
515
+ seen = true;
516
+ if (row.fork || row.forked_from)
517
+ fork = true;
518
+ if (row.fork_checked)
519
+ checked = true;
520
+ if (row.forked_from && row.forked_from !== instanceId)
521
+ parentId = row.forked_from;
522
+ }
523
+ if (!seen)
524
+ return null;
525
+ const parent = parentId
526
+ ? { instance_id: parentId, name: resolveName(coordRoot, parentId)?.name ?? null }
527
+ : null;
528
+ return { fork, checked, parent };
529
+ }
530
+ /**
531
+ * Record fork detection that ran after the instance was already named (the
532
+ * tool-path heal). Appends a copy of the latest identity row, so the name,
533
+ * kind, and persona are unchanged, plus `fork_checked` and any lineage found.
534
+ * A no-op for an instance with no history row or one already checked.
535
+ */
536
+ export function recordForkDetection(coordRoot, instanceId, detection) {
537
+ const history = readHistory(coordRoot);
538
+ let latest;
539
+ for (const row of history) {
540
+ if (row.instance_id !== instanceId)
541
+ continue;
542
+ if (row.fork_checked)
543
+ return false;
544
+ latest = row;
545
+ }
546
+ if (!latest)
547
+ return false;
548
+ const forkedFrom = detection.forkedFrom && detection.forkedFrom !== instanceId ? detection.forkedFrom : undefined;
549
+ appendHistory(coordRoot, {
550
+ instance_id: instanceId,
551
+ name: latest.name,
552
+ kind: latest.kind,
553
+ ...(latest.agent_id ? { agent_id: latest.agent_id } : {}),
554
+ source: "fork.detected",
555
+ ...(forkedFrom ? { forked_from: forkedFrom } : {}),
556
+ ...(detection.fork || forkedFrom || latest.fork ? { fork: true } : {}),
557
+ fork_checked: true,
558
+ ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
559
+ });
560
+ return true;
561
+ }
562
+ /**
563
+ * Peer labels for recorded fork relations, keyed by instance id: this
564
+ * session's fork parent and its direct forks. `liveIds` marks which of them
565
+ * are running, so a fork can tell its live parent from its own earlier self.
566
+ */
567
+ export function forkRelationLabels(coordRoot, instanceId, liveIds) {
568
+ const out = new Map();
569
+ const parent = readForkParent(coordRoot, instanceId);
570
+ if (parent) {
571
+ out.set(parent.instance_id, liveIds.has(parent.instance_id) ? "your fork parent, live" : "your fork parent");
572
+ }
573
+ for (const child of readForkChildren(coordRoot, instanceId))
574
+ out.set(child, "your fork");
575
+ return out;
576
+ }
577
+ /** Instance ids recorded as direct forks of <instanceId>. */
578
+ export function readForkChildren(coordRoot, instanceId) {
579
+ const out = new Set();
580
+ for (const row of readHistory(coordRoot)) {
581
+ if (row.forked_from === instanceId && row.instance_id !== instanceId)
582
+ out.add(row.instance_id);
583
+ }
584
+ return out;
585
+ }
503
586
  /** One step of recorded fork lineage: the latest row for <instanceId> that
504
- * carries `forked_from` (latest-row-wins, matching resolveName). */
587
+ * carries `forked_from`. Later rows without lineage (an identity assumption)
588
+ * do not erase it: a session never stops being a fork. */
505
589
  export function readForkParent(coordRoot, instanceId) {
506
590
  const history = readHistory(coordRoot);
507
591
  for (let i = history.length - 1; i >= 0; i--) {
508
592
  const row = history[i];
509
- if (row.instance_id !== instanceId)
593
+ if (row.instance_id !== instanceId || !row.forked_from)
510
594
  continue;
511
- if (!row.forked_from)
595
+ if (row.forked_from === instanceId)
512
596
  return null;
513
597
  const parent = resolveName(coordRoot, row.forked_from);
514
598
  return { instance_id: row.forked_from, name: parent?.name ?? null };
@@ -1 +1 @@
1
- {"version":3,"file":"pack-reader.d.ts","sourceRoot":"","sources":["../../../../../src/core/events/v3/support-storage/pack-reader.ts"],"names":[],"mappings":"AAOA,OAAO,EAEL,KAAK,0BAA0B,EAC/B,KAAK,wBAAwB,EAI9B,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,wBAAwB;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,sBAAsB,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE;AAED,MAAM,WAAW,+BACf,SAAQ,IAAI,CAAC,wBAAwB,EAAE,gBAAgB,CAAC;IACxD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,0BAA0B,CAAC;IACrC,OAAO,EAAE,+BAA+B,EAAE,CAAC;CAC5C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,0BAA0B,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAUD,wBAAsB,8BAA8B,CAClD,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC,0BAA0B,CAAC,CAarC;AAED,4FAA4F;AAC5F,wBAAuB,+BAA+B,CACpD,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,cAAc,CAAC,+BAA+B,CAAC,CAGjD;AA2GD,wBAAsB,0BAA0B,CAC9C,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC,2BAA2B,CAAC,CAWtC;AAED,yFAAyF;AACzF,wBAAsB,wBAAwB,CAC5C,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC,0BAA0B,CAAC,CAarC;AAED,0FAA0F;AAC1F,wBAAsB,wBAAwB,CAC5C,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CA0BhE"}
1
+ {"version":3,"file":"pack-reader.d.ts","sourceRoot":"","sources":["../../../../../src/core/events/v3/support-storage/pack-reader.ts"],"names":[],"mappings":"AASA,OAAO,EAEL,KAAK,0BAA0B,EAC/B,KAAK,wBAAwB,EAI9B,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,wBAAwB;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,CAAC,QAAQ,EAAE,sBAAsB,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE;AAED,MAAM,WAAW,+BACf,SAAQ,IAAI,CAAC,wBAAwB,EAAE,gBAAgB,CAAC;IACxD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,0BAA0B,CAAC;IACrC,OAAO,EAAE,+BAA+B,EAAE,CAAC;CAC5C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,EAAE,0BAA0B,CAAC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAUD,wBAAsB,8BAA8B,CAClD,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC,0BAA0B,CAAC,CAarC;AAED,4FAA4F;AAC5F,wBAAuB,+BAA+B,CACpD,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,cAAc,CAAC,+BAA+B,CAAC,CAGjD;AAoGD,wBAAsB,0BAA0B,CAC9C,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC,2BAA2B,CAAC,CAWtC;AAED,yFAAyF;AACzF,wBAAsB,wBAAwB,CAC5C,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC,0BAA0B,CAAC,CAarC;AAED,0FAA0F;AAC1F,wBAAsB,wBAAwB,CAC5C,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,MAAM,GAAE,wBAA6B,GACpC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CA0BhE"}
@@ -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.js";
@@ -44,18 +46,12 @@ async function* streamEventV3SupportPackRecordsForManifest(manifestPath, manifes
44
46
  throw new Error("event_v3_support_payload_length_mismatch");
45
47
  if (payloadStat.size > bounded.max_payload_bytes)
46
48
  throw new Error("event_v3_support_payload_too_large");
47
- if ((await hashHandle(payloadPath, payloadHandle)) !== manifest.payload.digest) {
49
+ if ((await hashHandle(payloadHandle)) !== manifest.payload.digest) {
48
50
  throw new Error("event_v3_support_payload_digest_mismatch");
49
51
  }
50
52
  limits.fault?.("after_payload_digest", payloadPath);
51
- await assertCanonicalSingleGzipMember(payloadPath, payloadHandle, payloadStat.size, bounded.max_decompressed_bytes);
53
+ await assertCanonicalSingleGzipMember(payloadHandle, payloadStat.size, bounded.max_decompressed_bytes);
52
54
  const gunzip = createGunzip();
53
- const source = createReadStream(payloadPath, {
54
- fd: payloadHandle.fd,
55
- autoClose: false,
56
- start: 0,
57
- });
58
- source.pipe(gunzip);
59
55
  const decoder = new StringDecoder("utf8");
60
56
  let pending = "";
61
57
  let decompressedBytes = 0;
@@ -64,7 +60,7 @@ async function* streamEventV3SupportPackRecordsForManifest(manifestPath, manifes
64
60
  let priorPath;
65
61
  const entries = [];
66
62
  const seen = new Set();
67
- for await (const current of gunzip) {
63
+ for await (const current of decompressHandle(payloadHandle, 0, gunzip)) {
68
64
  const chunk = current;
69
65
  decompressedBytes += chunk.length;
70
66
  if (decompressedBytes > bounded.max_decompressed_bytes) {
@@ -176,20 +172,51 @@ export async function unpackEventV3SupportPack(manifestPath, destination, limits
176
172
  throw error;
177
173
  }
178
174
  }
179
- async function hashHandle(path, handle) {
175
+ async function hashHandle(handle) {
180
176
  const hash = createHash("sha256");
181
- const stream = createReadStream(path, { fd: handle.fd, autoClose: false, start: 0 });
182
- for await (const current of stream)
177
+ for await (const current of handleChunks(handle, 0))
183
178
  hash.update(current);
184
179
  return `sha256:${hash.digest("hex")}`;
185
180
  }
181
+ async function* handleChunks(handle, position) {
182
+ while (true) {
183
+ const buffer = Buffer.alloc(64 * 1024);
184
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
185
+ if (bytesRead === 0)
186
+ return;
187
+ position += bytesRead;
188
+ yield buffer.subarray(0, bytesRead);
189
+ }
190
+ }
191
+ async function* decompressHandle(handle, start, decoder) {
192
+ const source = Readable.from(handleChunks(handle, start));
193
+ // Observe failures immediately, then settle source reads before the caller
194
+ // can close and reuse the verified handle, including on early rejection.
195
+ const completed = finished(source, { cleanup: true }).then(() => undefined, (error) => error);
196
+ source.once("error", (error) => decoder.destroy(error));
197
+ source.pipe(decoder);
198
+ try {
199
+ for await (const chunk of decoder)
200
+ yield chunk;
201
+ source.unpipe(decoder);
202
+ source.resume();
203
+ const error = await completed;
204
+ if (error)
205
+ throw error;
206
+ }
207
+ finally {
208
+ source.destroy();
209
+ decoder.destroy();
210
+ await completed;
211
+ }
212
+ }
186
213
  async function realDirectory(path) {
187
214
  const stat = await lstat(path);
188
215
  if (!stat.isDirectory() || stat.isSymbolicLink())
189
216
  throw new Error("event_v3_support_pack_root_invalid");
190
217
  return realpath(path);
191
218
  }
192
- async function assertCanonicalSingleGzipMember(path, handle, payloadBytes, maximumDecompressedBytes) {
219
+ async function assertCanonicalSingleGzipMember(handle, payloadBytes, maximumDecompressedBytes) {
193
220
  if (payloadBytes < 18)
194
221
  throw new Error("event_v3_support_gzip_truncated");
195
222
  const header = Buffer.alloc(10);
@@ -203,9 +230,8 @@ async function assertCanonicalSingleGzipMember(path, handle, payloadBytes, maxim
203
230
  throw new Error("event_v3_support_gzip_header_noncanonical");
204
231
  }
205
232
  const inflate = createInflateRaw();
206
- createReadStream(path, { fd: handle.fd, autoClose: false, start: 10 }).pipe(inflate);
207
233
  let decompressed = 0;
208
- for await (const current of inflate) {
234
+ for await (const current of decompressHandle(handle, 10, inflate)) {
209
235
  decompressed += current.length;
210
236
  if (decompressed > maximumDecompressedBytes) {
211
237
  inflate.destroy();