@code-yeongyu/senpi 2026.10.18-1 → 2026.10.19

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 (154) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bundle/chunks/{anthropic-messages-3CFTFCKV.js → anthropic-messages-EMTBHJMM.js} +2 -2
  3. package/dist/bundle/chunks/{args-SJWMIH2H.js → args-LWR7VFKS.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-IRKZ6FVC.js → chunk-35M5MVUZ.js} +149 -145
  5. package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
  6. package/dist/bundle/chunks/{chunk-T2BEB5SO.js → chunk-7QPFFAJT.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-4RUVN7K3.js → chunk-BOCN744P.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-DDHBG2HU.js → chunk-FM7NDZR5.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-OMY4EC25.js → chunk-FSENE7JK.js} +2 -2
  10. package/dist/bundle/chunks/{chunk-Z2LCTXS2.js → chunk-G7LLAQXI.js} +3 -3
  11. package/dist/bundle/chunks/{chunk-NRHOGI4Z.js → chunk-IM6ZWYTW.js} +2 -2
  12. package/dist/bundle/chunks/{chunk-MLKYJIUK.js → chunk-KM4FMQII.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-OOWML2KE.js → chunk-PMGEQYEP.js} +2 -2
  14. package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
  15. package/dist/bundle/chunks/{chunk-6EXDMO4S.js → chunk-QJ6MUFXY.js} +1 -1
  16. package/dist/bundle/chunks/{chunk-FZTO2ICI.js → chunk-QRGAMVEE.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-3QVP3ZVI.js → chunk-QXEWAKXA.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
  19. package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
  20. package/dist/bundle/chunks/{chunk-GAHIHXVE.js → chunk-VEKNDMUA.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-TFXUF2IL.js → chunk-VPV7ADF3.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-RELAITQ7.js → chunk-X6TWPBI6.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-PKDWFVE4.js → chunk-YUMRZIKJ.js} +6 -6
  24. package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
  25. package/dist/bundle/chunks/{cli-main-JCSB54VT.js → cli-main-EABMHWFV.js} +1 -1
  26. package/dist/bundle/chunks/{help-fast-path-FVPGRSYA.js → help-fast-path-BYWJN6YX.js} +1 -1
  27. package/dist/bundle/chunks/{host-command-X2ZZFTME.js → host-command-5T34JLBP.js} +1 -1
  28. package/dist/bundle/chunks/{host-lifecycle-OKWTQFUJ.js → host-lifecycle-GSRIQP6Z.js} +1 -1
  29. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  30. package/dist/bundle/chunks/{host-session-command-HKJKOAVS.js → host-session-command-LF6JODPX.js} +1 -1
  31. package/dist/bundle/chunks/{interactive-mode-GJERAFGV.js → interactive-mode-GMWHBIUC.js} +1 -1
  32. package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
  33. package/dist/bundle/chunks/{multi-session-host-JWMGLKAG.js → multi-session-host-3J7STKGD.js} +4 -4
  34. package/dist/bundle/chunks/{openai-codex-responses-RLYFSLK6.js → openai-codex-responses-7YOBPZ2N.js} +1 -1
  35. package/dist/bundle/chunks/{openai-completions-OWNSXMTD.js → openai-completions-HSZPYXEA.js} +1 -1
  36. package/dist/bundle/chunks/{package-manager-cli-FSVS2ND2.js → package-manager-cli-LWQZSWKL.js} +1 -1
  37. package/dist/bundle/chunks/{rotation-stream-M6QV2MMB.js → rotation-stream-QRRWL73X.js} +1 -1
  38. package/dist/bundle/chunks/{rpc-mode-TPG3J3ZJ.js → rpc-mode-6LSWRKXR.js} +1 -1
  39. package/dist/bundle/chunks/{schedule-command-PAVLVMWL.js → schedule-command-7CXMW4WK.js} +1 -1
  40. package/dist/bundle/chunks/{session-control-endpoint-NSMRMKKK.js → session-control-endpoint-E265WAIQ.js} +1 -1
  41. package/dist/bundle/chunks/{session-picker-UV6IVWJY.js → session-picker-XRB3LP5W.js} +1 -1
  42. package/dist/bundle/chunks/session-worker.js +298 -293
  43. package/dist/bundle/cli.js +2 -2
  44. package/dist/bundle/index.js +1 -1
  45. package/dist/bundle/rpc-entry.js +1 -1
  46. package/dist/bundle/runtime-manifest.json +1 -1
  47. package/dist/core/agent-run-continuation.d.ts +24 -0
  48. package/dist/core/agent-run-continuation.js +57 -0
  49. package/dist/core/agent-session.d.ts +38 -1
  50. package/dist/core/agent-session.js +122 -13
  51. package/dist/core/extension-handler-stall-report.d.ts +11 -0
  52. package/dist/core/extension-handler-stall-report.js +26 -0
  53. package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
  54. package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
  55. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
  56. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
  57. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
  58. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
  59. package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
  60. package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
  61. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
  62. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
  63. package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
  64. package/dist/core/extensions/handler-budget.d.ts +84 -0
  65. package/dist/core/extensions/handler-budget.js +136 -0
  66. package/dist/core/extensions/runner.d.ts +23 -0
  67. package/dist/core/extensions/runner.js +184 -99
  68. package/dist/core/footer-data-provider.js +2 -1
  69. package/dist/core/keybindings.d.ts +1 -1
  70. package/dist/core/keybindings.js +3 -2
  71. package/dist/core/pending-input-journal.d.ts +6 -1
  72. package/dist/core/pending-input-journal.js +90 -34
  73. package/dist/core/pending-input-public.d.ts +11 -0
  74. package/dist/core/pending-input-public.js +10 -0
  75. package/dist/core/pending-input-session-port.d.ts +15 -0
  76. package/dist/core/pending-input-session-port.js +93 -0
  77. package/dist/core/pending-input-session.d.ts +108 -0
  78. package/dist/core/pending-input-session.js +220 -0
  79. package/dist/core/pending-input-store.d.ts +32 -1
  80. package/dist/core/pending-input-store.js +74 -26
  81. package/dist/core/pending-input-types.d.ts +25 -4
  82. package/dist/core/pending-input.js +36 -18
  83. package/dist/core/session-entry-file.js +1 -0
  84. package/dist/core/session-log.js +1 -1
  85. package/dist/core/session-manager.d.ts +1 -0
  86. package/dist/core/session-manager.js +5 -1
  87. package/dist/core/session-move/bundle.d.ts +39 -0
  88. package/dist/core/session-move/bundle.js +108 -0
  89. package/dist/core/session-move/export.d.ts +18 -0
  90. package/dist/core/session-move/export.js +83 -0
  91. package/dist/core/session-move/finish.d.ts +24 -0
  92. package/dist/core/session-move/finish.js +81 -0
  93. package/dist/core/session-move/import.d.ts +32 -0
  94. package/dist/core/session-move/import.js +124 -0
  95. package/dist/core/session-move/move-ledger.d.ts +47 -0
  96. package/dist/core/session-move/move-ledger.js +151 -0
  97. package/dist/core/session-move/ownership.d.ts +61 -0
  98. package/dist/core/session-move/ownership.js +89 -0
  99. package/dist/core/session-move/requeue.d.ts +8 -0
  100. package/dist/core/session-move/requeue.js +18 -0
  101. package/dist/core/session-move/write-guard.d.ts +22 -0
  102. package/dist/core/session-move/write-guard.js +44 -0
  103. package/dist/core/settings-manager.d.ts +13 -0
  104. package/dist/core/settings-manager.js +17 -0
  105. package/dist/core/turn-deadline.d.ts +2 -0
  106. package/dist/core/turn-deadline.js +10 -3
  107. package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
  108. package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
  109. package/dist/modes/rpc/connection-handler.js +3 -0
  110. package/dist/modes/rpc/effect-commands.js +3 -4
  111. package/dist/modes/rpc/host-observe-request.js +1 -0
  112. package/dist/modes/rpc/rpc-client.d.ts +10 -0
  113. package/dist/modes/rpc/rpc-client.js +11 -0
  114. package/dist/modes/rpc/rpc-input-validation.js +8 -0
  115. package/dist/modes/rpc/rpc-types.d.ts +25 -1
  116. package/dist/modes/rpc/session-command-activity.js +1 -0
  117. package/dist/modes/rpc/session-control-actions-data.js +17 -8
  118. package/dist/modes/rpc/session-held.js +1 -0
  119. package/dist/modes/rpc/session-release.js +16 -1
  120. package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
  121. package/dist/modes/rpc/tool-child/admission.js +81 -0
  122. package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
  123. package/dist/modes/rpc/tool-child/admission.test.js +166 -0
  124. package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
  125. package/dist/modes/rpc/tool-child/codec.js +137 -0
  126. package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
  127. package/dist/modes/rpc/tool-child/codec.test.js +234 -0
  128. package/dist/modes/rpc/tool-child/context.d.ts +42 -0
  129. package/dist/modes/rpc/tool-child/context.js +23 -0
  130. package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
  131. package/dist/modes/rpc/tool-child/flow-control.js +80 -0
  132. package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
  133. package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
  134. package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
  135. package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
  136. package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
  137. package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
  138. package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
  139. package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
  140. package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
  141. package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
  142. package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
  143. package/dist/modes/rpc/tool-child/protocol.js +54 -0
  144. package/dist/utils/wsl.d.ts +2 -2
  145. package/dist/utils/wsl.js +4 -4
  146. package/docs/providers.md +2 -0
  147. package/docs/rpc.md +16 -2
  148. package/docs/settings.md +1 -0
  149. package/docs/terminal-setup.md +2 -0
  150. package/package.json +10 -6
  151. package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
  152. package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
  153. package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
  154. package/dist/bundle/chunks/chunk-QBD4LY7D.js +0 -395
@@ -0,0 +1,166 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { refFilePath, teardownRefGeneration } from "./payload-ref-paths.js";
6
+ import { TOOL_CHILD_LIMITS } from "./protocol.js";
7
+ import { isRefId } from "./protocol-guard.js";
8
+ export class PayloadRefError extends Error {
9
+ constructor(code, message, reason) {
10
+ super(`${code}: ${message}`);
11
+ this.name = "PayloadRefError";
12
+ this.code = code;
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ /**
17
+ * Writer side of an acknowledged payload reference. The writer owns a ref only until the receiver answers:
18
+ * `ref_ack` transfers ownership (the writer forgets the file and never deletes it), `ref_nack` makes the writer
19
+ * delete it. Settling the sending operation changes nothing; only generation teardown removes an unanswered ref.
20
+ */
21
+ export class PayloadRefWriter {
22
+ #root;
23
+ #now;
24
+ #pending = new Map();
25
+ #ownedBytes = 0;
26
+ constructor(options) {
27
+ this.generation = options.generation;
28
+ this.budget = options.budget ?? TOOL_CHILD_LIMITS.refsBudget;
29
+ this.#root = options.root;
30
+ this.#now = options.now ?? Date.now;
31
+ }
32
+ get ownedBytes() {
33
+ return this.#ownedBytes;
34
+ }
35
+ async create(payload) {
36
+ const size = payload.byteLength;
37
+ if (this.#ownedBytes + size > this.budget) {
38
+ throw new PayloadRefError("ref_budget_exceeded", `${size} bytes on top of ${this.#ownedBytes} owned exceeds ${this.budget}`);
39
+ }
40
+ this.#ownedBytes += size;
41
+ const ref = {
42
+ refId: randomBytes(12).toString("hex"),
43
+ generation: this.generation,
44
+ bytes: size,
45
+ sha256: createHash("sha256").update(payload).digest("hex"),
46
+ };
47
+ const path = refFilePath(this.#root, ref);
48
+ try {
49
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
50
+ await writeFile(path, payload, { flag: "wx", mode: 0o600 });
51
+ }
52
+ catch (error) {
53
+ this.#ownedBytes -= size;
54
+ await rm(path, { force: true });
55
+ throw error;
56
+ }
57
+ this.#pending.set(ref.refId, { bytes: size, createdAtMs: this.#now() });
58
+ return ref;
59
+ }
60
+ /** A `ref_ack` or `ref_nack` from the receiver; an answer for a ref this writer does not hold is ignored. */
61
+ async handle(control) {
62
+ const pending = this.#pending.get(control.refId);
63
+ if (pending === undefined)
64
+ return;
65
+ this.#pending.delete(control.refId);
66
+ this.#ownedBytes -= pending.bytes;
67
+ if (control.type === "ref_nack") {
68
+ await rm(refFilePath(this.#root, { refId: control.refId, generation: this.generation }), { force: true });
69
+ }
70
+ }
71
+ pendingIds() {
72
+ return [...this.#pending.keys()];
73
+ }
74
+ /** Refs still unanswered after `refAckMs`; reported for liveness handling, never deleted here. */
75
+ overdue() {
76
+ const deadline = this.#now() - TOOL_CHILD_LIMITS.refAckMs;
77
+ return [...this.#pending].filter(([, ref]) => ref.createdAtMs <= deadline).map(([refId]) => refId);
78
+ }
79
+ async teardown() {
80
+ this.#pending.clear();
81
+ this.#ownedBytes = 0;
82
+ await teardownRefGeneration(this.#root, this.generation);
83
+ }
84
+ }
85
+ const isMissingFile = (error) => error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
86
+ async function hashFile(path, signal) {
87
+ const hash = createHash("sha256");
88
+ for await (const chunk of createReadStream(path)) {
89
+ if (signal?.aborted)
90
+ return "cancelled";
91
+ hash.update(chunk);
92
+ }
93
+ return hash.digest("hex");
94
+ }
95
+ /**
96
+ * Receiver side. `accept` verifies size and sha256 and answers `ref_ack` (taking ownership) or `ref_nack`.
97
+ * The owner deletes the file when it consumes it; a dead owner's files go with its generation's teardown.
98
+ */
99
+ export class PayloadRefReader {
100
+ #root;
101
+ #generation;
102
+ #budget;
103
+ #send;
104
+ #owned = new Map();
105
+ #ownedBytes = 0;
106
+ constructor(options) {
107
+ this.#root = options.root;
108
+ this.#generation = options.generation;
109
+ this.#budget = options.budget ?? TOOL_CHILD_LIMITS.refsBudget;
110
+ this.#send = options.send;
111
+ }
112
+ async accept(ref, signal) {
113
+ const reason = await this.#verify(ref, signal);
114
+ if (reason !== undefined) {
115
+ this.refuse(ref, reason);
116
+ return { ok: false, reason };
117
+ }
118
+ this.#owned.set(ref.refId, ref);
119
+ this.#ownedBytes += ref.bytes;
120
+ this.#send({ type: "ref_ack", refId: ref.refId });
121
+ return { ok: true };
122
+ }
123
+ /** Answers `ref_nack` for a ref this side will not take, e.g. one whose job was cancelled before it was opened. */
124
+ refuse(ref, reason) {
125
+ if (this.#owned.has(ref.refId))
126
+ return;
127
+ this.#send({ type: "ref_nack", refId: ref.refId, reason });
128
+ }
129
+ async consume(refId) {
130
+ const ref = this.#owned.get(refId);
131
+ if (ref === undefined)
132
+ throw new PayloadRefError("ref_not_owned", `ref ${refId} is not owned by this side`);
133
+ const path = refFilePath(this.#root, ref);
134
+ const bytes = await readFile(path);
135
+ await rm(path, { force: true });
136
+ this.#owned.delete(refId);
137
+ this.#ownedBytes -= ref.bytes;
138
+ return bytes;
139
+ }
140
+ ownedIds() {
141
+ return [...this.#owned.keys()];
142
+ }
143
+ async #verify(ref, signal) {
144
+ if (signal?.aborted)
145
+ return "cancelled";
146
+ if (ref.generation !== this.#generation || !isRefId(ref.refId) || this.#owned.has(ref.refId))
147
+ return "foreign";
148
+ if (this.#ownedBytes + ref.bytes > this.#budget)
149
+ return "budget";
150
+ const path = refFilePath(this.#root, ref);
151
+ try {
152
+ if ((await stat(path)).size !== ref.bytes)
153
+ return "corrupt";
154
+ const digest = await hashFile(path, signal);
155
+ if (digest === "cancelled")
156
+ return "cancelled";
157
+ return digest === ref.sha256 ? undefined : "corrupt";
158
+ }
159
+ catch (error) {
160
+ if (isMissingFile(error))
161
+ return "missing";
162
+ throw error;
163
+ }
164
+ }
165
+ }
166
+ //# sourceMappingURL=payload-ref.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=payload-ref.test.d.ts.map
@@ -0,0 +1,254 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { encodeFrame, FrameDecoder, ToolChildCodecError } from "./codec.js";
7
+ import { PayloadRefError, PayloadRefReader, PayloadRefWriter } from "./payload-ref.js";
8
+ import { refFilePath, spoolDir, sweepRefGenerations, teardownRefGeneration } from "./payload-ref-paths.js";
9
+ import { packPayload, unpackPayload } from "./payload-wire.js";
10
+ import { TOOL_CHILD_LIMITS } from "./protocol.js";
11
+ const MIB = 1024 * 1024;
12
+ let root = "";
13
+ beforeEach(async () => {
14
+ root = await mkdtemp(join(tmpdir(), "senpi-tool-child-refs-"));
15
+ });
16
+ afterEach(async () => {
17
+ await rm(root, { recursive: true, force: true });
18
+ });
19
+ const exists = (path) => access(path).then(() => true, () => false);
20
+ function deferred() {
21
+ let resolve = () => undefined;
22
+ const promise = new Promise((done) => {
23
+ resolve = done;
24
+ });
25
+ return { promise, resolve };
26
+ }
27
+ function bytesOf(size, seed = 7) {
28
+ const bytes = new Uint8Array(size);
29
+ for (let index = 0; index < size; index += 1)
30
+ bytes[index] = (index * 31 + seed) & 0xff;
31
+ return bytes;
32
+ }
33
+ /** A fake transport: every ref control the receiver sends is delivered to the writer, in order. */
34
+ function link(writer, options = {}) {
35
+ const sent = [];
36
+ const deliveries = [];
37
+ const reader = new PayloadRefReader({
38
+ root,
39
+ generation: writer.generation,
40
+ budget: options.budget,
41
+ send: (control) => {
42
+ sent.push(control);
43
+ deliveries.push(writer.handle(control));
44
+ },
45
+ });
46
+ return { reader, sent, delivered: () => Promise.all(deliveries) };
47
+ }
48
+ function received(frame) {
49
+ const [message] = new FrameDecoder().push(frame);
50
+ if (message === undefined)
51
+ throw new Error("no frame decoded");
52
+ return message;
53
+ }
54
+ /**
55
+ * The delayed-consumption scenario: the writer sends a 5 MiB `result_ref`, its sender-side operation settles, and the
56
+ * receiver opens the file only later, behind an event gate. `onSenderSettled` is the writer's settlement behavior.
57
+ */
58
+ async function delayedConsumption(onSenderSettled) {
59
+ const writer = new PayloadRefWriter({ root, generation: 1 });
60
+ const { reader, sent, delivered } = link(writer);
61
+ const payload = bytesOf(5 * MIB);
62
+ const ref = await writer.create(payload);
63
+ const frame = received(encodeFrame({ type: "result_ref", jobId: "j1", ref }));
64
+ if (frame.type !== "result_ref")
65
+ throw new Error("expected result_ref");
66
+ const gate = deferred();
67
+ const consumer = (async () => {
68
+ await gate.promise;
69
+ const acceptance = await reader.accept(frame.ref);
70
+ await delivered();
71
+ const existsAfterAck = await exists(refFilePath(root, frame.ref));
72
+ const bytes = acceptance.ok ? await reader.consume(frame.ref.refId) : undefined;
73
+ return { acceptance, existsAfterAck, bytes };
74
+ })();
75
+ await onSenderSettled(ref);
76
+ const existsAfterSettlement = await exists(refFilePath(root, ref));
77
+ gate.resolve();
78
+ const consumed = await consumer;
79
+ return {
80
+ existsAfterSettlement,
81
+ ...consumed,
82
+ sha256Verified: consumed.bytes !== undefined && createHash("sha256").update(consumed.bytes).digest("hex") === ref.sha256,
83
+ sent,
84
+ writerPending: writer.pendingIds(),
85
+ existsAfterConsume: await exists(refFilePath(root, ref)),
86
+ };
87
+ }
88
+ const meetsOwnershipTransfer = (result) => result.existsAfterSettlement &&
89
+ result.acceptance.ok &&
90
+ result.existsAfterAck &&
91
+ result.sha256Verified &&
92
+ result.sent.length === 1 &&
93
+ result.sent[0]?.type === "ref_ack" &&
94
+ result.writerPending.length === 0 &&
95
+ !result.existsAfterConsume;
96
+ describe("payload ref lifetime", () => {
97
+ it("delayed consumption: the file outlives sender-side settlement, ownership moves on ref_ack, the owner deletes", async () => {
98
+ const result = await delayedConsumption(async () => undefined);
99
+ expect(result.existsAfterSettlement).toBe(true);
100
+ expect(result.acceptance).toEqual({ ok: true });
101
+ expect(result.sent).toEqual([{ type: "ref_ack", refId: expect.any(String) }]);
102
+ expect(result.existsAfterAck).toBe(true);
103
+ expect(result.sha256Verified).toBe(true);
104
+ expect(result.existsAfterConsume).toBe(false);
105
+ expect(meetsOwnershipTransfer(result)).toBe(true);
106
+ });
107
+ it("delayed consumption negative control: the old writer-deletes-on-settlement rule is rejected", async () => {
108
+ const oldRule = (ref) => rm(refFilePath(root, ref), { force: true });
109
+ const result = await delayedConsumption(oldRule);
110
+ expect(result.acceptance).toEqual({ ok: false, reason: "missing" });
111
+ expect(result.sent).toEqual([{ type: "ref_nack", refId: expect.any(String), reason: "missing" }]);
112
+ expect(meetsOwnershipTransfer(result)).toBe(false);
113
+ });
114
+ it("a corrupted file is nacked and the writer deletes it", async () => {
115
+ const writer = new PayloadRefWriter({ root, generation: 1 });
116
+ const { reader, sent, delivered } = link(writer);
117
+ const ref = await writer.create(bytesOf(1024));
118
+ await writeFile(refFilePath(root, ref), bytesOf(1024, 99));
119
+ expect(await reader.accept(ref)).toEqual({ ok: false, reason: "corrupt" });
120
+ await delivered();
121
+ expect(sent).toEqual([{ type: "ref_nack", refId: ref.refId, reason: "corrupt" }]);
122
+ expect(await exists(refFilePath(root, ref))).toBe(false);
123
+ expect(writer.pendingIds()).toEqual([]);
124
+ expect(writer.ownedBytes).toBe(0);
125
+ });
126
+ it("cancellation before ack sends ref_nack{cancelled} and the writer deletes", async () => {
127
+ const writer = new PayloadRefWriter({ root, generation: 1 });
128
+ const { reader, sent, delivered } = link(writer);
129
+ const first = await writer.create(bytesOf(2048));
130
+ const second = await writer.create(bytesOf(2048));
131
+ const controller = new AbortController();
132
+ controller.abort();
133
+ expect(await reader.accept(first, controller.signal)).toEqual({ ok: false, reason: "cancelled" });
134
+ reader.refuse(second, "cancelled");
135
+ await delivered();
136
+ expect(sent).toEqual([
137
+ { type: "ref_nack", refId: first.refId, reason: "cancelled" },
138
+ { type: "ref_nack", refId: second.refId, reason: "cancelled" },
139
+ ]);
140
+ expect(await exists(refFilePath(root, first))).toBe(false);
141
+ expect(await exists(refFilePath(root, second))).toBe(false);
142
+ });
143
+ it("receiver death before ack: generation teardown deletes the ref and spool/", async () => {
144
+ const writer = new PayloadRefWriter({ root, generation: 4 });
145
+ const ref = await writer.create(bytesOf(4096));
146
+ received(encodeFrame({ type: "result_ref", jobId: "j1", ref }));
147
+ await mkdir(spoolDir(root, 4), { recursive: true });
148
+ await writeFile(join(spoolDir(root, 4), "entry-1.chunk"), "spooled");
149
+ await writer.teardown();
150
+ expect(await exists(refFilePath(root, ref))).toBe(false);
151
+ expect(await exists(spoolDir(root, 4))).toBe(false);
152
+ expect(writer.pendingIds()).toEqual([]);
153
+ });
154
+ it("death before ack of an earlier process: the generation sweep removes every generation that is not live", async () => {
155
+ const stale = new PayloadRefWriter({ root, generation: 2 });
156
+ const staleRef = await stale.create(bytesOf(64));
157
+ await mkdir(spoolDir(root, 2), { recursive: true });
158
+ const live = new PayloadRefWriter({ root, generation: 3 });
159
+ const liveRef = await live.create(bytesOf(64));
160
+ expect(await sweepRefGenerations(root, new Set([3]))).toEqual([2]);
161
+ expect(await exists(refFilePath(root, staleRef))).toBe(false);
162
+ expect(await exists(spoolDir(root, 2))).toBe(false);
163
+ expect(await exists(refFilePath(root, liveRef))).toBe(true);
164
+ });
165
+ it("an acked ref whose owner dies is deleted by the teardown sweep", async () => {
166
+ const writer = new PayloadRefWriter({ root, generation: 1 });
167
+ const { reader, delivered } = link(writer);
168
+ const ref = await writer.create(bytesOf(8192));
169
+ expect(await reader.accept(ref)).toEqual({ ok: true });
170
+ await delivered();
171
+ expect(writer.pendingIds()).toEqual([]);
172
+ expect(reader.ownedIds()).toEqual([ref.refId]);
173
+ expect(await exists(refFilePath(root, ref))).toBe(true);
174
+ await teardownRefGeneration(root, 1);
175
+ expect(await exists(refFilePath(root, ref))).toBe(false);
176
+ });
177
+ it("refuses a ref past refsBudget with ref_budget_exceeded, on both sides", async () => {
178
+ expect(new PayloadRefWriter({ root, generation: 1 }).budget).toBe(TOOL_CHILD_LIMITS.refsBudget);
179
+ const writer = new PayloadRefWriter({ root, generation: 1, budget: 6 * MIB });
180
+ const first = await writer.create(bytesOf(5 * MIB));
181
+ await expect(writer.create(bytesOf(2 * MIB))).rejects.toMatchObject({ code: "ref_budget_exceeded" });
182
+ await expect(writer.create(bytesOf(2 * MIB))).rejects.toBeInstanceOf(PayloadRefError);
183
+ expect(await readdir(join(refFilePath(root, first), ".."))).toEqual([first.refId]);
184
+ const { reader, sent, delivered } = link(writer, { budget: 4 * MIB });
185
+ expect(await reader.accept(first)).toEqual({ ok: false, reason: "budget" });
186
+ await delivered();
187
+ expect(sent).toEqual([{ type: "ref_nack", refId: first.refId, reason: "budget" }]);
188
+ expect(writer.ownedBytes).toBe(0);
189
+ await writer.create(bytesOf(5 * MIB));
190
+ });
191
+ it("reports an unacknowledged ref overdue after refAckMs without deleting it", async () => {
192
+ let now = 1_000;
193
+ const writer = new PayloadRefWriter({ root, generation: 1, now: () => now });
194
+ const ref = await writer.create(bytesOf(16));
195
+ now += TOOL_CHILD_LIMITS.refAckMs - 1;
196
+ expect(writer.overdue()).toEqual([]);
197
+ now += 1;
198
+ expect(writer.overdue()).toEqual([ref.refId]);
199
+ expect(await exists(refFilePath(root, ref))).toBe(true);
200
+ });
201
+ });
202
+ describe("payloads above the reference threshold, in both directions", () => {
203
+ it("a 17 MiB host_call.args (child -> server) round-trips by acknowledged reference", async () => {
204
+ const childWriter = new PayloadRefWriter({ root, generation: 5 });
205
+ const { reader: serverReader, sent, delivered } = link(childWriter);
206
+ const args = { path: "big.txt", content: "a".repeat(17 * MIB) };
207
+ const inlineAttempt = () => encodeFrame({ type: "host_call", callId: "c1", jobId: "j1", tool: "write", args });
208
+ expect(inlineAttempt).toThrow(ToolChildCodecError);
209
+ const packed = await packPayload(args, childWriter);
210
+ if (packed.inline)
211
+ throw new Error("17 MiB args must move by reference");
212
+ const frame = received(encodeFrame({ type: "host_call", callId: "c1", jobId: "j1", tool: "write", argsRef: packed.ref }));
213
+ if (frame.type !== "host_call" || frame.argsRef === undefined)
214
+ throw new Error("expected host_call with argsRef");
215
+ expect(await unpackPayload(frame.argsRef, serverReader)).toEqual(args);
216
+ await delivered();
217
+ expect(sent).toEqual([{ type: "ref_ack", refId: packed.ref.refId }]);
218
+ expect(await exists(refFilePath(root, packed.ref))).toBe(false);
219
+ expect(childWriter.pendingIds()).toEqual([]);
220
+ });
221
+ it("a 20 MiB host_reply.value (server -> child) round-trips by acknowledged reference", async () => {
222
+ const serverWriter = new PayloadRefWriter({ root, generation: 5 });
223
+ const { reader: childReader, sent, delivered } = link(serverWriter);
224
+ const value = "v".repeat(20 * MIB);
225
+ const packed = await packPayload(value, serverWriter);
226
+ if (packed.inline)
227
+ throw new Error("20 MiB value must move by reference");
228
+ const frame = received(encodeFrame({ type: "host_reply", callId: "c1", valueRef: packed.ref }));
229
+ if (frame.type !== "host_reply" || frame.valueRef === undefined)
230
+ throw new Error("expected host_reply with valueRef");
231
+ expect(await unpackPayload(frame.valueRef, childReader)).toEqual(value);
232
+ await delivered();
233
+ expect(sent).toEqual([{ type: "ref_ack", refId: packed.ref.refId }]);
234
+ expect(await exists(refFilePath(root, packed.ref))).toBe(false);
235
+ });
236
+ it("keeps a payload at or below REF_THRESHOLD inline and writes no file", async () => {
237
+ const writer = new PayloadRefWriter({ root, generation: 1 });
238
+ expect(await packPayload({ small: true }, writer)).toEqual({ inline: true, value: { small: true } });
239
+ expect(await exists(join(root, "gen-1"))).toBe(false);
240
+ });
241
+ it("unpackPayload rejects a ref it had to nack", async () => {
242
+ const writer = new PayloadRefWriter({ root, generation: 1 });
243
+ const { reader } = link(writer);
244
+ const packed = await packPayload("x".repeat(TOOL_CHILD_LIMITS.REF_THRESHOLD + 1), writer);
245
+ if (packed.inline)
246
+ throw new Error("expected a ref");
247
+ await rm(refFilePath(root, packed.ref));
248
+ await expect(unpackPayload(packed.ref, reader)).rejects.toMatchObject({
249
+ code: "ref_rejected",
250
+ reason: "missing",
251
+ });
252
+ });
253
+ });
254
+ //# sourceMappingURL=payload-ref.test.js.map
@@ -0,0 +1,13 @@
1
+ import { type PayloadRefReader, type PayloadRefWriter } from "./payload-ref.ts";
2
+ import { type PayloadRef } from "./protocol.ts";
3
+ export type PackedPayload = {
4
+ inline: true;
5
+ value: unknown;
6
+ } | {
7
+ inline: false;
8
+ ref: PayloadRef;
9
+ };
10
+ /** Inline at or below `REF_THRESHOLD` JSON bytes; above it, by acknowledged reference. Same rule in both directions. */
11
+ export declare function packPayload(value: unknown, writer: PayloadRefWriter): Promise<PackedPayload>;
12
+ export declare function unpackPayload(ref: PayloadRef, reader: PayloadRefReader, signal?: AbortSignal): Promise<unknown>;
13
+ //# sourceMappingURL=payload-wire.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { PayloadRefError } from "./payload-ref.js";
2
+ import { TOOL_CHILD_LIMITS } from "./protocol.js";
3
+ /** Inline at or below `REF_THRESHOLD` JSON bytes; above it, by acknowledged reference. Same rule in both directions. */
4
+ export async function packPayload(value, writer) {
5
+ // `undefined` stringifies to nothing (JSON.stringify returns undefined), which no decoder accepts; the explicit
6
+ // null is the encoding the peer's guard takes (senpi#3569 review).
7
+ const normalized = value === undefined ? null : value;
8
+ const json = JSON.stringify(normalized);
9
+ if (json === undefined || Buffer.byteLength(json, "utf8") <= TOOL_CHILD_LIMITS.REF_THRESHOLD) {
10
+ return { inline: true, value: normalized };
11
+ }
12
+ return { inline: false, ref: await writer.create(Buffer.from(json, "utf8")) };
13
+ }
14
+ export async function unpackPayload(ref, reader, signal) {
15
+ const acceptance = await reader.accept(ref, signal);
16
+ if (!acceptance.ok) {
17
+ throw new PayloadRefError("ref_rejected", `ref ${ref.refId} was refused (${acceptance.reason})`, acceptance.reason);
18
+ }
19
+ const bytes = await reader.consume(ref.refId);
20
+ return JSON.parse(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8"));
21
+ }
22
+ //# sourceMappingURL=payload-wire.js.map
@@ -0,0 +1,9 @@
1
+ import { type ToolChildMessage } from "./protocol.ts";
2
+ export declare const TOOL_CHILD_MESSAGE_TYPES: readonly string[];
3
+ export declare const isRefId: (value: unknown) => value is string;
4
+ /**
5
+ * Boundary check for a decoded frame: a known `type`, every required field of the right kind, no unknown field.
6
+ * An unknown field is refused rather than ignored, so a `trace` frame can never smuggle a result, proof or tick.
7
+ */
8
+ export declare function isToolChildMessage(value: unknown): value is ToolChildMessage;
9
+ //# sourceMappingURL=protocol-guard.d.ts.map
@@ -0,0 +1,128 @@
1
+ import { LAUNCH_APIS, LAUNCH_CONTAINMENTS, LAUNCH_LIFETIMES, LAUNCH_ORIGINS, REF_NACK_REASONS, TRACE_RESERVED_KEYS, } from "./protocol.js";
2
+ const req = (kind) => ({ kind });
3
+ const opt = (kind) => ({ kind, optional: true });
4
+ const SHAPES = {
5
+ hello_ack: { generation: req("number") },
6
+ job: {
7
+ jobId: req("string"),
8
+ kind: req("string"),
9
+ parentJobId: opt("string"),
10
+ args: opt("json"),
11
+ argsRef: opt("ref"),
12
+ },
13
+ cancel: { jobId: req("string") },
14
+ host_reply: { callId: req("string"), value: opt("json"), valueRef: opt("ref"), error: opt("string") },
15
+ host_call_refused: { callId: req("string"), code: req("string") },
16
+ launch_ack: { launchId: req("string") },
17
+ wake_ack: { wakeId: req("string") },
18
+ slot_query: { launchId: req("string") },
19
+ shutdown: {},
20
+ hello: { generation: req("number"), pid: req("number"), protocol: req("number") },
21
+ heartbeat: { seq: req("number") },
22
+ result: { jobId: req("string"), value: req("json") },
23
+ result_ref: { jobId: req("string"), ref: req("ref") },
24
+ job_error: { jobId: req("string"), code: req("string"), message: req("string") },
25
+ job_refused: { jobId: req("string"), code: req("string") },
26
+ host_call: {
27
+ callId: req("string"),
28
+ jobId: req("string"),
29
+ tool: req("string"),
30
+ args: opt("json"),
31
+ argsRef: opt("ref"),
32
+ },
33
+ launch: {
34
+ launchId: req("string"),
35
+ jobId: req("string"),
36
+ origin: req(LAUNCH_ORIGINS),
37
+ api: req(LAUNCH_APIS),
38
+ containment: req(LAUNCH_CONTAINMENTS),
39
+ lifetime: req(LAUNCH_LIFETIMES),
40
+ pid: opt("number"),
41
+ pgid: opt("number"),
42
+ },
43
+ launch_proof: { launchId: req("string"), ackTick: req("number"), invokeTick: req("number") },
44
+ launch_done: { launchId: req("string"), exitCode: opt("number"), signal: opt("string") },
45
+ command_started: {
46
+ launchId: req("string"),
47
+ pid: req("number"),
48
+ startedAtMs: req("number"),
49
+ creatorInJob: req("boolean"),
50
+ breakaway: req("boolean"),
51
+ },
52
+ slot_state: {
53
+ launchId: req("string"),
54
+ ackTick: req("number|null"),
55
+ invokeTick: req("number|null"),
56
+ invocations: req("number"),
57
+ },
58
+ wake: { wakeId: req("string"), source: req("string"), bytes: req("number") },
59
+ batch: { jobId: req("string"), entries: req("entries"), done: req("boolean") },
60
+ trace: { atMs: req("number"), event: req("string"), detail: opt("detail") },
61
+ ref_ack: { refId: req("string") },
62
+ ref_nack: { refId: req("string"), reason: req(REF_NACK_REASONS) },
63
+ };
64
+ export const TOOL_CHILD_MESSAGE_TYPES = Object.keys(SHAPES);
65
+ const REF_ID = /^[A-Za-z0-9_-]{1,64}$/;
66
+ const SHA256 = /^[0-9a-f]{64}$/;
67
+ const RESERVED = new Set(TRACE_RESERVED_KEYS);
68
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
69
+ const isCount = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
70
+ export const isRefId = (value) => typeof value === "string" && REF_ID.test(value);
71
+ function isPayloadRef(value) {
72
+ return (isRecord(value) &&
73
+ isRefId(value.refId) &&
74
+ isCount(value.generation) &&
75
+ isCount(value.bytes) &&
76
+ typeof value.sha256 === "string" &&
77
+ SHA256.test(value.sha256));
78
+ }
79
+ function isEntryMeta(value) {
80
+ return (isRecord(value) &&
81
+ typeof value.id === "string" &&
82
+ (value.parentId === null || typeof value.parentId === "string") &&
83
+ isCount(value.lineOffset) &&
84
+ isCount(value.lineBytes) &&
85
+ Number.isSafeInteger(value.lineDelta));
86
+ }
87
+ function isTraceDetail(value) {
88
+ if (!isRecord(value))
89
+ return false;
90
+ return Object.entries(value).every(([key, item]) => !RESERVED.has(key) && (typeof item === "string" || typeof item === "number" || typeof item === "boolean"));
91
+ }
92
+ function matches(kind, value) {
93
+ if (typeof kind !== "string")
94
+ return typeof value === "string" && kind.includes(value);
95
+ switch (kind) {
96
+ case "string":
97
+ return typeof value === "string";
98
+ case "number":
99
+ return typeof value === "number" && Number.isFinite(value);
100
+ case "boolean":
101
+ return typeof value === "boolean";
102
+ case "json":
103
+ return value !== undefined;
104
+ case "ref":
105
+ return isPayloadRef(value);
106
+ case "entries":
107
+ return Array.isArray(value) && value.every(isEntryMeta);
108
+ case "detail":
109
+ return isTraceDetail(value);
110
+ case "number|null":
111
+ return value === null || (typeof value === "number" && Number.isFinite(value));
112
+ }
113
+ }
114
+ const isMessageType = (value) => typeof value === "string" && Object.hasOwn(SHAPES, value);
115
+ /**
116
+ * Boundary check for a decoded frame: a known `type`, every required field of the right kind, no unknown field.
117
+ * An unknown field is refused rather than ignored, so a `trace` frame can never smuggle a result, proof or tick.
118
+ */
119
+ export function isToolChildMessage(value) {
120
+ if (!isRecord(value) || !isMessageType(value.type))
121
+ return false;
122
+ const rules = SHAPES[value.type];
123
+ for (const key of Object.keys(value))
124
+ if (key !== "type" && !Object.hasOwn(rules, key))
125
+ return false;
126
+ return Object.entries(rules).every(([key, rule]) => value[key] === undefined ? rule.optional === true : matches(rule.kind, value[key]));
127
+ }
128
+ //# sourceMappingURL=protocol-guard.js.map