@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.
- package/CHANGELOG.md +29 -0
- package/dist/bundle/chunks/{anthropic-messages-3CFTFCKV.js → anthropic-messages-EMTBHJMM.js} +2 -2
- package/dist/bundle/chunks/{args-SJWMIH2H.js → args-LWR7VFKS.js} +1 -1
- package/dist/bundle/chunks/{chunk-IRKZ6FVC.js → chunk-35M5MVUZ.js} +149 -145
- package/dist/bundle/chunks/chunk-3DNYAO3P.js +2 -0
- package/dist/bundle/chunks/{chunk-T2BEB5SO.js → chunk-7QPFFAJT.js} +1 -1
- package/dist/bundle/chunks/{chunk-4RUVN7K3.js → chunk-BOCN744P.js} +1 -1
- package/dist/bundle/chunks/{chunk-DDHBG2HU.js → chunk-FM7NDZR5.js} +1 -1
- package/dist/bundle/chunks/{chunk-OMY4EC25.js → chunk-FSENE7JK.js} +2 -2
- package/dist/bundle/chunks/{chunk-Z2LCTXS2.js → chunk-G7LLAQXI.js} +3 -3
- package/dist/bundle/chunks/{chunk-NRHOGI4Z.js → chunk-IM6ZWYTW.js} +2 -2
- package/dist/bundle/chunks/{chunk-MLKYJIUK.js → chunk-KM4FMQII.js} +1 -1
- package/dist/bundle/chunks/{chunk-OOWML2KE.js → chunk-PMGEQYEP.js} +2 -2
- package/dist/bundle/chunks/chunk-QGJLZ6BI.js +2 -0
- package/dist/bundle/chunks/{chunk-6EXDMO4S.js → chunk-QJ6MUFXY.js} +1 -1
- package/dist/bundle/chunks/{chunk-FZTO2ICI.js → chunk-QRGAMVEE.js} +1 -1
- package/dist/bundle/chunks/{chunk-3QVP3ZVI.js → chunk-QXEWAKXA.js} +1 -1
- package/dist/bundle/chunks/{chunk-UUMKAA7S.js → chunk-RL5QMGQD.js} +9 -9
- package/dist/bundle/chunks/chunk-T36LJ6MW.js +3 -0
- package/dist/bundle/chunks/{chunk-GAHIHXVE.js → chunk-VEKNDMUA.js} +1 -1
- package/dist/bundle/chunks/{chunk-TFXUF2IL.js → chunk-VPV7ADF3.js} +1 -1
- package/dist/bundle/chunks/{chunk-RELAITQ7.js → chunk-X6TWPBI6.js} +1 -1
- package/dist/bundle/chunks/{chunk-PKDWFVE4.js → chunk-YUMRZIKJ.js} +6 -6
- package/dist/bundle/chunks/chunk-ZPSJQHDK.js +395 -0
- package/dist/bundle/chunks/{cli-main-JCSB54VT.js → cli-main-EABMHWFV.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-FVPGRSYA.js → help-fast-path-BYWJN6YX.js} +1 -1
- package/dist/bundle/chunks/{host-command-X2ZZFTME.js → host-command-5T34JLBP.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-OKWTQFUJ.js → host-lifecycle-GSRIQP6Z.js} +1 -1
- package/dist/bundle/chunks/host-lifecycle.js +1 -1
- package/dist/bundle/chunks/{host-session-command-HKJKOAVS.js → host-session-command-LF6JODPX.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-GJERAFGV.js → interactive-mode-GMWHBIUC.js} +1 -1
- package/dist/bundle/chunks/{list-tips-AWDAUHF2.js → list-tips-VSWED3PT.js} +1 -1
- package/dist/bundle/chunks/{multi-session-host-JWMGLKAG.js → multi-session-host-3J7STKGD.js} +4 -4
- package/dist/bundle/chunks/{openai-codex-responses-RLYFSLK6.js → openai-codex-responses-7YOBPZ2N.js} +1 -1
- package/dist/bundle/chunks/{openai-completions-OWNSXMTD.js → openai-completions-HSZPYXEA.js} +1 -1
- package/dist/bundle/chunks/{package-manager-cli-FSVS2ND2.js → package-manager-cli-LWQZSWKL.js} +1 -1
- package/dist/bundle/chunks/{rotation-stream-M6QV2MMB.js → rotation-stream-QRRWL73X.js} +1 -1
- package/dist/bundle/chunks/{rpc-mode-TPG3J3ZJ.js → rpc-mode-6LSWRKXR.js} +1 -1
- package/dist/bundle/chunks/{schedule-command-PAVLVMWL.js → schedule-command-7CXMW4WK.js} +1 -1
- package/dist/bundle/chunks/{session-control-endpoint-NSMRMKKK.js → session-control-endpoint-E265WAIQ.js} +1 -1
- package/dist/bundle/chunks/{session-picker-UV6IVWJY.js → session-picker-XRB3LP5W.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +298 -293
- package/dist/bundle/cli.js +2 -2
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/bundle/runtime-manifest.json +1 -1
- package/dist/core/agent-run-continuation.d.ts +24 -0
- package/dist/core/agent-run-continuation.js +57 -0
- package/dist/core/agent-session.d.ts +38 -1
- package/dist/core/agent-session.js +122 -13
- package/dist/core/extension-handler-stall-report.d.ts +11 -0
- package/dist/core/extension-handler-stall-report.js +26 -0
- package/dist/core/extensions/builtin/anthropic-subscription/account-command.js +1 -0
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +13 -1
- package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
- package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/failover.js +3 -2
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -0
- package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +29 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +2 -0
- package/dist/core/extensions/builtin/anthropic-subscription/settings.js +4 -0
- package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +4 -2
- package/dist/core/extensions/handler-budget.d.ts +84 -0
- package/dist/core/extensions/handler-budget.js +136 -0
- package/dist/core/extensions/runner.d.ts +23 -0
- package/dist/core/extensions/runner.js +184 -99
- package/dist/core/footer-data-provider.js +2 -1
- package/dist/core/keybindings.d.ts +1 -1
- package/dist/core/keybindings.js +3 -2
- package/dist/core/pending-input-journal.d.ts +6 -1
- package/dist/core/pending-input-journal.js +90 -34
- package/dist/core/pending-input-public.d.ts +11 -0
- package/dist/core/pending-input-public.js +10 -0
- package/dist/core/pending-input-session-port.d.ts +15 -0
- package/dist/core/pending-input-session-port.js +93 -0
- package/dist/core/pending-input-session.d.ts +108 -0
- package/dist/core/pending-input-session.js +220 -0
- package/dist/core/pending-input-store.d.ts +32 -1
- package/dist/core/pending-input-store.js +74 -26
- package/dist/core/pending-input-types.d.ts +25 -4
- package/dist/core/pending-input.js +36 -18
- package/dist/core/session-entry-file.js +1 -0
- package/dist/core/session-log.js +1 -1
- package/dist/core/session-manager.d.ts +1 -0
- package/dist/core/session-manager.js +5 -1
- package/dist/core/session-move/bundle.d.ts +39 -0
- package/dist/core/session-move/bundle.js +108 -0
- package/dist/core/session-move/export.d.ts +18 -0
- package/dist/core/session-move/export.js +83 -0
- package/dist/core/session-move/finish.d.ts +24 -0
- package/dist/core/session-move/finish.js +81 -0
- package/dist/core/session-move/import.d.ts +32 -0
- package/dist/core/session-move/import.js +124 -0
- package/dist/core/session-move/move-ledger.d.ts +47 -0
- package/dist/core/session-move/move-ledger.js +151 -0
- package/dist/core/session-move/ownership.d.ts +61 -0
- package/dist/core/session-move/ownership.js +89 -0
- package/dist/core/session-move/requeue.d.ts +8 -0
- package/dist/core/session-move/requeue.js +18 -0
- package/dist/core/session-move/write-guard.d.ts +22 -0
- package/dist/core/session-move/write-guard.js +44 -0
- package/dist/core/settings-manager.d.ts +13 -0
- package/dist/core/settings-manager.js +17 -0
- package/dist/core/turn-deadline.d.ts +2 -0
- package/dist/core/turn-deadline.js +10 -3
- package/dist/modes/interactive/components/ask-user-async-widget.d.ts +7 -0
- package/dist/modes/interactive/components/ask-user-async-widget.js +20 -6
- package/dist/modes/rpc/connection-handler.js +3 -0
- package/dist/modes/rpc/effect-commands.js +3 -4
- package/dist/modes/rpc/host-observe-request.js +1 -0
- package/dist/modes/rpc/rpc-client.d.ts +10 -0
- package/dist/modes/rpc/rpc-client.js +11 -0
- package/dist/modes/rpc/rpc-input-validation.js +8 -0
- package/dist/modes/rpc/rpc-types.d.ts +25 -1
- package/dist/modes/rpc/session-command-activity.js +1 -0
- package/dist/modes/rpc/session-control-actions-data.js +17 -8
- package/dist/modes/rpc/session-held.js +1 -0
- package/dist/modes/rpc/session-release.js +16 -1
- package/dist/modes/rpc/tool-child/admission.d.ts +42 -0
- package/dist/modes/rpc/tool-child/admission.js +81 -0
- package/dist/modes/rpc/tool-child/admission.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/admission.test.js +166 -0
- package/dist/modes/rpc/tool-child/codec.d.ts +21 -0
- package/dist/modes/rpc/tool-child/codec.js +137 -0
- package/dist/modes/rpc/tool-child/codec.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/codec.test.js +234 -0
- package/dist/modes/rpc/tool-child/context.d.ts +42 -0
- package/dist/modes/rpc/tool-child/context.js +23 -0
- package/dist/modes/rpc/tool-child/flow-control.d.ts +24 -0
- package/dist/modes/rpc/tool-child/flow-control.js +80 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.d.ts +9 -0
- package/dist/modes/rpc/tool-child/payload-ref-paths.js +45 -0
- package/dist/modes/rpc/tool-child/payload-ref.d.ts +58 -0
- package/dist/modes/rpc/tool-child/payload-ref.js +166 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.d.ts +2 -0
- package/dist/modes/rpc/tool-child/payload-ref.test.js +254 -0
- package/dist/modes/rpc/tool-child/payload-wire.d.ts +13 -0
- package/dist/modes/rpc/tool-child/payload-wire.js +22 -0
- package/dist/modes/rpc/tool-child/protocol-guard.d.ts +9 -0
- package/dist/modes/rpc/tool-child/protocol-guard.js +128 -0
- package/dist/modes/rpc/tool-child/protocol.d.ts +192 -0
- package/dist/modes/rpc/tool-child/protocol.js +54 -0
- package/dist/utils/wsl.d.ts +2 -2
- package/dist/utils/wsl.js +4 -4
- package/docs/providers.md +2 -0
- package/docs/rpc.md +16 -2
- package/docs/settings.md +1 -0
- package/docs/terminal-setup.md +2 -0
- package/package.json +10 -6
- package/dist/bundle/chunks/chunk-COJK5FDH.js +0 -2
- package/dist/bundle/chunks/chunk-IDXW4FAO.js +0 -2
- package/dist/bundle/chunks/chunk-PFNBY43W.js +0 -2
- package/dist/bundle/chunks/chunk-QBD4LY7D.js +0 -395
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { encodeFrame, FRAME_HEADER_BYTES, FrameDecoder, ToolChildCodecError } from "./codec.js";
|
|
3
|
+
import { narrowContext } from "./context.js";
|
|
4
|
+
import { LAUNCH_CONTAINMENTS, TOOL_CHILD_LIMITS } from "./protocol.js";
|
|
5
|
+
import { TOOL_CHILD_MESSAGE_TYPES } from "./protocol-guard.js";
|
|
6
|
+
const ref = { refId: "r-1", generation: 3, bytes: 10, sha256: "a".repeat(64) };
|
|
7
|
+
const SAMPLES = [
|
|
8
|
+
...LAUNCH_CONTAINMENTS.map((containment) => ({
|
|
9
|
+
type: "launch",
|
|
10
|
+
launchId: `l-${containment}`,
|
|
11
|
+
jobId: "j1",
|
|
12
|
+
origin: "tool",
|
|
13
|
+
api: "bun.spawn",
|
|
14
|
+
containment,
|
|
15
|
+
lifetime: "owned",
|
|
16
|
+
pid: 10,
|
|
17
|
+
pgid: 10,
|
|
18
|
+
})),
|
|
19
|
+
{
|
|
20
|
+
type: "launch",
|
|
21
|
+
launchId: "l-det",
|
|
22
|
+
jobId: "j1",
|
|
23
|
+
origin: "kernel",
|
|
24
|
+
api: "cp.spawn",
|
|
25
|
+
containment: "breakaway",
|
|
26
|
+
lifetime: "detached",
|
|
27
|
+
},
|
|
28
|
+
{ type: "launch_ack", launchId: "l-1" },
|
|
29
|
+
{ type: "launch_proof", launchId: "l-1", ackTick: 4, invokeTick: 5 },
|
|
30
|
+
{ type: "launch_done", launchId: "l-1", exitCode: 0 },
|
|
31
|
+
{ type: "launch_done", launchId: "l-2", signal: "SIGTERM" },
|
|
32
|
+
{ type: "command_started", launchId: "l-1", pid: 77, startedAtMs: 1234, creatorInJob: true, breakaway: false },
|
|
33
|
+
{ type: "slot_query", launchId: "l-1" },
|
|
34
|
+
{ type: "slot_state", launchId: "l-1", ackTick: null, invokeTick: null, invocations: 0 },
|
|
35
|
+
{ type: "slot_state", launchId: "l-1", ackTick: 4, invokeTick: 5, invocations: 1 },
|
|
36
|
+
{ type: "trace", atMs: 1, event: "job_start", detail: { kind: "grep", ok: true, n: 3 } },
|
|
37
|
+
{ type: "trace", atMs: 2, event: "idle" },
|
|
38
|
+
{
|
|
39
|
+
type: "batch",
|
|
40
|
+
jobId: "j-parse",
|
|
41
|
+
done: false,
|
|
42
|
+
entries: [
|
|
43
|
+
{ id: "e1", parentId: null, lineOffset: 0, lineBytes: 120, lineDelta: 0, entry: { type: "message" } },
|
|
44
|
+
{ id: "e2", parentId: "e1", lineOffset: 121, lineBytes: 4096, lineDelta: -3500 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{ type: "hello", generation: 3, pid: 99, protocol: 1 },
|
|
48
|
+
{ type: "hello_ack", generation: 3 },
|
|
49
|
+
{ type: "heartbeat", seq: 7 },
|
|
50
|
+
{ type: "job", jobId: "j1", kind: "read", args: { path: "a\nb.txt" } },
|
|
51
|
+
{ type: "job", jobId: "j2", kind: "write", parentJobId: "j1", argsRef: ref },
|
|
52
|
+
{ type: "cancel", jobId: "j1" },
|
|
53
|
+
{ type: "result", jobId: "j1", value: { content: [{ type: "text", text: "line\n" }] } },
|
|
54
|
+
{ type: "result", jobId: "j3", value: null },
|
|
55
|
+
{ type: "result_ref", jobId: "j1", ref },
|
|
56
|
+
{ type: "job_error", jobId: "j1", code: "tool_failed", message: "boom" },
|
|
57
|
+
{ type: "job_refused", jobId: "j9", code: "tool_child_jobs_full" },
|
|
58
|
+
{ type: "host_call", callId: "c1", jobId: "j1", tool: "read", args: { path: "x" } },
|
|
59
|
+
{ type: "host_call", callId: "c2", jobId: "j1", tool: "write", argsRef: ref },
|
|
60
|
+
{ type: "host_reply", callId: "c1", value: [1, 2] },
|
|
61
|
+
{ type: "host_reply", callId: "c2", valueRef: ref },
|
|
62
|
+
{ type: "host_reply", callId: "c3", error: "denied" },
|
|
63
|
+
{ type: "host_call_refused", callId: "c265", code: "host_call_capacity" },
|
|
64
|
+
{ type: "wake", wakeId: "w1", source: "monitor", bytes: 12 },
|
|
65
|
+
{ type: "wake_ack", wakeId: "w1" },
|
|
66
|
+
{ type: "ref_ack", refId: "r-1" },
|
|
67
|
+
{ type: "ref_nack", refId: "r-1", reason: "cancelled" },
|
|
68
|
+
{ type: "shutdown" },
|
|
69
|
+
];
|
|
70
|
+
function concat(parts) {
|
|
71
|
+
return Buffer.concat(parts.map((part) => Buffer.from(part)));
|
|
72
|
+
}
|
|
73
|
+
function codecError(run) {
|
|
74
|
+
try {
|
|
75
|
+
run();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof ToolChildCodecError)
|
|
79
|
+
return error.code;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function frameWithBody(body) {
|
|
85
|
+
const bytes = Buffer.from(body, "utf8");
|
|
86
|
+
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
87
|
+
header.writeUInt32BE(bytes.length, 0);
|
|
88
|
+
return concat([header, bytes]);
|
|
89
|
+
}
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
vi.restoreAllMocks();
|
|
92
|
+
});
|
|
93
|
+
describe("tool-child codec", () => {
|
|
94
|
+
it("round-trips every message type through one chunked stream", () => {
|
|
95
|
+
expect(new Set(SAMPLES.map((message) => message.type))).toEqual(new Set(TOOL_CHILD_MESSAGE_TYPES));
|
|
96
|
+
const stream = concat(SAMPLES.map(encodeFrame));
|
|
97
|
+
const decoder = new FrameDecoder();
|
|
98
|
+
const decoded = [];
|
|
99
|
+
for (let offset = 0; offset < stream.length; offset += 7) {
|
|
100
|
+
decoded.push(...decoder.push(stream.subarray(offset, offset + 7)));
|
|
101
|
+
}
|
|
102
|
+
decoder.end();
|
|
103
|
+
expect(decoded).toEqual(SAMPLES);
|
|
104
|
+
});
|
|
105
|
+
it("frames with a big-endian length prefix, not NDJSON", () => {
|
|
106
|
+
const frame = Buffer.from(encodeFrame({ type: "job", jobId: "j1", kind: "read", args: { text: "a\nb" } }));
|
|
107
|
+
const bodyBytes = frame.readUInt32BE(0);
|
|
108
|
+
expect(frame.length).toBe(FRAME_HEADER_BYTES + bodyBytes);
|
|
109
|
+
expect(frame.at(-1)).not.toBe(0x0a);
|
|
110
|
+
});
|
|
111
|
+
it("accepts a body of exactly the cap and refuses one byte more on encode", () => {
|
|
112
|
+
const overhead = JSON.stringify({ type: "result", jobId: "j", value: "" }).length;
|
|
113
|
+
const fits = "x".repeat(TOOL_CHILD_LIMITS.frameBytes - overhead);
|
|
114
|
+
const frame = encodeFrame({ type: "result", jobId: "j", value: fits });
|
|
115
|
+
expect(new FrameDecoder().push(frame)).toEqual([{ type: "result", jobId: "j", value: fits }]);
|
|
116
|
+
expect(codecError(() => encodeFrame({ type: "result", jobId: "j", value: `${fits}x` }))).toBe("tool_child_frame_too_large");
|
|
117
|
+
});
|
|
118
|
+
it("refuses a 16 MiB+1 length header before allocating its body, and stays failed", () => {
|
|
119
|
+
const alloc = vi.spyOn(Buffer, "alloc");
|
|
120
|
+
const allocUnsafe = vi.spyOn(Buffer, "allocUnsafe");
|
|
121
|
+
const header = Buffer.alloc(FRAME_HEADER_BYTES);
|
|
122
|
+
header.writeUInt32BE(TOOL_CHILD_LIMITS.frameBytes + 1, 0);
|
|
123
|
+
alloc.mockClear();
|
|
124
|
+
const decoder = new FrameDecoder();
|
|
125
|
+
expect(codecError(() => decoder.push(header))).toBe("tool_child_frame_too_large");
|
|
126
|
+
const large = (spy) => spy.mock.calls.filter(([size]) => Number(size) > 1024);
|
|
127
|
+
expect(large(alloc)).toEqual([]);
|
|
128
|
+
expect(large(allocUnsafe)).toEqual([]);
|
|
129
|
+
expect(decoder.bufferedBytes).toBe(0);
|
|
130
|
+
expect(codecError(() => decoder.push(encodeFrame({ type: "shutdown" })))).toBe("tool_child_frame_too_large");
|
|
131
|
+
});
|
|
132
|
+
it("reports a truncated stream as tool_child_truncated", () => {
|
|
133
|
+
const frame = encodeFrame({ type: "heartbeat", seq: 1 });
|
|
134
|
+
const partialBody = new FrameDecoder();
|
|
135
|
+
expect(partialBody.push(frame.subarray(0, frame.length - 1))).toEqual([]);
|
|
136
|
+
expect(codecError(() => partialBody.end())).toBe("tool_child_truncated");
|
|
137
|
+
const partialHeader = new FrameDecoder();
|
|
138
|
+
partialHeader.push(frame.subarray(0, 2));
|
|
139
|
+
expect(codecError(() => partialHeader.end())).toBe("tool_child_truncated");
|
|
140
|
+
const clean = new FrameDecoder();
|
|
141
|
+
clean.push(frame);
|
|
142
|
+
expect(codecError(() => clean.end())).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
it("refuses malformed frames instead of dropping them", () => {
|
|
145
|
+
const cases = [
|
|
146
|
+
"not json",
|
|
147
|
+
JSON.stringify({ type: "unknown_type" }),
|
|
148
|
+
JSON.stringify({ type: "launch_proof", launchId: "l", ackTick: 1 }),
|
|
149
|
+
JSON.stringify({ type: "trace", atMs: 1, event: "e", value: { result: 1 } }),
|
|
150
|
+
JSON.stringify({ type: "trace", atMs: 1, event: "e", detail: { ackTick: 4 } }),
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
type: "launch",
|
|
153
|
+
launchId: "l",
|
|
154
|
+
jobId: "j",
|
|
155
|
+
origin: "tool",
|
|
156
|
+
api: "bun.spawn",
|
|
157
|
+
containment: "pid",
|
|
158
|
+
lifetime: "owned",
|
|
159
|
+
}),
|
|
160
|
+
];
|
|
161
|
+
for (const body of cases) {
|
|
162
|
+
expect(codecError(() => new FrameDecoder().push(frameWithBody(body)))).toBe("tool_child_malformed");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("ChildToolContext", () => {
|
|
167
|
+
it("narrowContext keeps plain data only: no functions, no ui, no sessionManager", () => {
|
|
168
|
+
const extensionContext = {
|
|
169
|
+
ui: { notify: () => undefined },
|
|
170
|
+
sessionManager: { getEntries: () => [] },
|
|
171
|
+
cwd: "/work",
|
|
172
|
+
agentDir: "/agent",
|
|
173
|
+
mode: "rpc",
|
|
174
|
+
model: { provider: "anthropic", id: "claude", api: "messages", compat: () => true },
|
|
175
|
+
thinkingLevel: "medium",
|
|
176
|
+
isProjectTrusted: () => true,
|
|
177
|
+
isIdle: () => false,
|
|
178
|
+
goalStoreFile: "/agent/goal.json",
|
|
179
|
+
};
|
|
180
|
+
const narrowed = narrowContext(extensionContext, { sessionId: "s1", toolCallId: "t1" });
|
|
181
|
+
const keys = [];
|
|
182
|
+
const functions = [];
|
|
183
|
+
const walk = (value, path) => {
|
|
184
|
+
if (typeof value === "function")
|
|
185
|
+
functions.push(path);
|
|
186
|
+
if (typeof value !== "object" || value === null)
|
|
187
|
+
return;
|
|
188
|
+
for (const [key, item] of Object.entries(value)) {
|
|
189
|
+
keys.push(key);
|
|
190
|
+
walk(item, `${path}.${key}`);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
walk(narrowed, "ctx");
|
|
194
|
+
expect(functions).toEqual([]);
|
|
195
|
+
expect(keys).not.toContain("ui");
|
|
196
|
+
expect(keys).not.toContain("sessionManager");
|
|
197
|
+
expect(narrowed).toEqual({
|
|
198
|
+
cwd: "/work",
|
|
199
|
+
agentDir: "/agent",
|
|
200
|
+
mode: "rpc",
|
|
201
|
+
sessionId: "s1",
|
|
202
|
+
toolCallId: "t1",
|
|
203
|
+
projectTrusted: true,
|
|
204
|
+
model: { provider: "anthropic", id: "claude" },
|
|
205
|
+
thinkingLevel: "medium",
|
|
206
|
+
goalStoreFile: "/agent/goal.json",
|
|
207
|
+
});
|
|
208
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "job", jobId: "j", kind: "read", args: narrowed }));
|
|
209
|
+
expect(decoded).toEqual({ type: "job", jobId: "j", kind: "read", args: narrowed });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe("encode-time normalization (senpi#3569 review)", () => {
|
|
213
|
+
it("a job result of undefined encodes as an explicit null the decoder accepts", () => {
|
|
214
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "result", jobId: "j", value: undefined }));
|
|
215
|
+
expect(decoded).toMatchObject({ type: "result", jobId: "j", value: null });
|
|
216
|
+
});
|
|
217
|
+
it("a NaN job result encodes as null instead of killing the channel", () => {
|
|
218
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "result", jobId: "j", value: Number.NaN }));
|
|
219
|
+
expect(decoded).toMatchObject({ type: "result", jobId: "j", value: null });
|
|
220
|
+
});
|
|
221
|
+
it("optional fields set to undefined keep stringify's default drop and still encode (delta review HIGH 1)", () => {
|
|
222
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "launch_done", launchId: "l1", signal: "SIGTERM" }));
|
|
223
|
+
expect(decoded).toEqual({ type: "launch_done", launchId: "l1", signal: "SIGTERM" });
|
|
224
|
+
expect("exitCode" in decoded).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
it("nested undefined payload keys stay dropped, matching the ref path (delta review MEDIUM 2)", () => {
|
|
227
|
+
const [decoded] = new FrameDecoder().push(encodeFrame({ type: "result", jobId: "j", value: { content: "x", details: undefined } }));
|
|
228
|
+
expect(decoded.value).toEqual({ content: "x" });
|
|
229
|
+
});
|
|
230
|
+
it("a non-finite required number is refused at encode with a typed error, never as a frame", () => {
|
|
231
|
+
expect(() => encodeFrame({ type: "heartbeat", seq: Number.POSITIVE_INFINITY })).toThrow(ToolChildCodecError);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
//# sourceMappingURL=codec.test.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BrowserEngine } from "../../../core/browser-engine.ts";
|
|
2
|
+
import type { ExtensionMode } from "../../../core/extensions/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* What a tool body running in the tool child may see of its extension context: plain, serializable data only.
|
|
5
|
+
* No functions, no `ui`, no `sessionManager` - those stay on the server and are reached through host calls.
|
|
6
|
+
*/
|
|
7
|
+
export interface ChildToolContext {
|
|
8
|
+
cwd: string;
|
|
9
|
+
agentDir: string;
|
|
10
|
+
mode: ExtensionMode;
|
|
11
|
+
toolCallId: string;
|
|
12
|
+
projectTrusted: boolean;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
model?: {
|
|
15
|
+
provider: string;
|
|
16
|
+
id: string;
|
|
17
|
+
};
|
|
18
|
+
thinkingLevel?: string;
|
|
19
|
+
serviceTier?: string;
|
|
20
|
+
browserEngine?: BrowserEngine;
|
|
21
|
+
goalStoreFile?: string;
|
|
22
|
+
}
|
|
23
|
+
/** The fields `narrowContext` reads; `ExtensionContext` satisfies it structurally. */
|
|
24
|
+
export interface NarrowableContext {
|
|
25
|
+
readonly cwd: string;
|
|
26
|
+
readonly agentDir: string;
|
|
27
|
+
readonly mode: ExtensionMode;
|
|
28
|
+
readonly model: {
|
|
29
|
+
readonly provider: string;
|
|
30
|
+
readonly id: string;
|
|
31
|
+
} | undefined;
|
|
32
|
+
readonly thinkingLevel?: string | undefined;
|
|
33
|
+
readonly serviceTier?: string | undefined;
|
|
34
|
+
readonly browserEngine?: BrowserEngine | undefined;
|
|
35
|
+
readonly goalStoreFile?: string | undefined;
|
|
36
|
+
isProjectTrusted(): boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function narrowContext(ctx: NarrowableContext, ids: {
|
|
39
|
+
sessionId?: string | undefined;
|
|
40
|
+
toolCallId: string;
|
|
41
|
+
}): ChildToolContext;
|
|
42
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function narrowContext(ctx, ids) {
|
|
2
|
+
const narrowed = {
|
|
3
|
+
cwd: ctx.cwd,
|
|
4
|
+
agentDir: ctx.agentDir,
|
|
5
|
+
mode: ctx.mode,
|
|
6
|
+
toolCallId: ids.toolCallId,
|
|
7
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
8
|
+
};
|
|
9
|
+
if (ids.sessionId !== undefined)
|
|
10
|
+
narrowed.sessionId = ids.sessionId;
|
|
11
|
+
if (ctx.model !== undefined)
|
|
12
|
+
narrowed.model = { provider: ctx.model.provider, id: ctx.model.id };
|
|
13
|
+
if (ctx.thinkingLevel !== undefined)
|
|
14
|
+
narrowed.thinkingLevel = ctx.thinkingLevel;
|
|
15
|
+
if (ctx.serviceTier !== undefined)
|
|
16
|
+
narrowed.serviceTier = ctx.serviceTier;
|
|
17
|
+
if (ctx.browserEngine !== undefined)
|
|
18
|
+
narrowed.browserEngine = ctx.browserEngine;
|
|
19
|
+
if (ctx.goalStoreFile !== undefined)
|
|
20
|
+
narrowed.goalStoreFile = ctx.goalStoreFile;
|
|
21
|
+
return narrowed;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=context.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ToolChildMessage, type ToolChildMessageOf } from "./protocol.ts";
|
|
2
|
+
export declare class WakeAdmissionError extends Error {
|
|
3
|
+
readonly code = "wake_too_large";
|
|
4
|
+
constructor(bytes: number);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Wake notifications awaiting `wake_ack`, bounded by count and summed bytes. A wake past either bound waits, FIFO,
|
|
8
|
+
* until an ack frees room: wakes are durable wake-source demand and are never dropped.
|
|
9
|
+
*/
|
|
10
|
+
export declare class WakeAdmission {
|
|
11
|
+
#private;
|
|
12
|
+
get pending(): number;
|
|
13
|
+
get blocked(): number;
|
|
14
|
+
send(wakeId: string, bytes: number): Promise<void>;
|
|
15
|
+
ack(wakeId: string): void;
|
|
16
|
+
}
|
|
17
|
+
/** Per-second budget for `trace` frames, the only lossy class: an over-budget frame is dropped and counted. */
|
|
18
|
+
export declare class TraceBudget {
|
|
19
|
+
#private;
|
|
20
|
+
get dropped(): number;
|
|
21
|
+
admit(_frame: ToolChildMessageOf<"trace">, nowMs: number): boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function isLossy(message: ToolChildMessage): boolean;
|
|
24
|
+
//# sourceMappingURL=flow-control.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { LOSSY_FRAME_TYPES, TOOL_CHILD_LIMITS } from "./protocol.js";
|
|
2
|
+
export class WakeAdmissionError extends Error {
|
|
3
|
+
constructor(bytes) {
|
|
4
|
+
super(`wake_too_large: ${bytes} bytes exceeds the ${TOOL_CHILD_LIMITS.wakePendingBytes}-byte pending budget`);
|
|
5
|
+
this.code = "wake_too_large";
|
|
6
|
+
this.name = "WakeAdmissionError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Wake notifications awaiting `wake_ack`, bounded by count and summed bytes. A wake past either bound waits, FIFO,
|
|
11
|
+
* until an ack frees room: wakes are durable wake-source demand and are never dropped.
|
|
12
|
+
*/
|
|
13
|
+
export class WakeAdmission {
|
|
14
|
+
#pending = new Map();
|
|
15
|
+
#blocked = [];
|
|
16
|
+
#pendingBytes = 0;
|
|
17
|
+
get pending() {
|
|
18
|
+
return this.#pending.size;
|
|
19
|
+
}
|
|
20
|
+
get blocked() {
|
|
21
|
+
return this.#blocked.length;
|
|
22
|
+
}
|
|
23
|
+
send(wakeId, bytes) {
|
|
24
|
+
if (bytes > TOOL_CHILD_LIMITS.wakePendingBytes)
|
|
25
|
+
return Promise.reject(new WakeAdmissionError(bytes));
|
|
26
|
+
if (this.#blocked.length === 0 && this.#fits(bytes)) {
|
|
27
|
+
this.#admit(wakeId, bytes);
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
return new Promise((release) => {
|
|
31
|
+
this.#blocked.push({ wakeId, bytes, release });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
ack(wakeId) {
|
|
35
|
+
const bytes = this.#pending.get(wakeId);
|
|
36
|
+
if (bytes === undefined)
|
|
37
|
+
return;
|
|
38
|
+
this.#pending.delete(wakeId);
|
|
39
|
+
this.#pendingBytes -= bytes;
|
|
40
|
+
for (let next = this.#blocked[0]; next !== undefined && this.#fits(next.bytes); next = this.#blocked[0]) {
|
|
41
|
+
this.#blocked.shift();
|
|
42
|
+
this.#admit(next.wakeId, next.bytes);
|
|
43
|
+
next.release();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
#fits(bytes) {
|
|
47
|
+
return (this.#pending.size < TOOL_CHILD_LIMITS.wakePending &&
|
|
48
|
+
this.#pendingBytes + bytes <= TOOL_CHILD_LIMITS.wakePendingBytes);
|
|
49
|
+
}
|
|
50
|
+
#admit(wakeId, bytes) {
|
|
51
|
+
this.#pending.set(wakeId, bytes);
|
|
52
|
+
this.#pendingBytes += bytes;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Per-second budget for `trace` frames, the only lossy class: an over-budget frame is dropped and counted. */
|
|
56
|
+
export class TraceBudget {
|
|
57
|
+
#second = Number.NaN;
|
|
58
|
+
#admitted = 0;
|
|
59
|
+
#dropped = 0;
|
|
60
|
+
get dropped() {
|
|
61
|
+
return this.#dropped;
|
|
62
|
+
}
|
|
63
|
+
admit(_frame, nowMs) {
|
|
64
|
+
const second = Math.floor(nowMs / 1000);
|
|
65
|
+
if (second !== this.#second) {
|
|
66
|
+
this.#second = second;
|
|
67
|
+
this.#admitted = 0;
|
|
68
|
+
}
|
|
69
|
+
if (this.#admitted >= TOOL_CHILD_LIMITS.tracePerSecond) {
|
|
70
|
+
this.#dropped += 1;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
this.#admitted += 1;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function isLossy(message) {
|
|
78
|
+
return LOSSY_FRAME_TYPES.has(message.type);
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=flow-control.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PayloadRef } from "./protocol.ts";
|
|
2
|
+
export declare function refGenerationDir(root: string, generation: number): string;
|
|
3
|
+
export declare function refFilePath(root: string, ref: Pick<PayloadRef, "refId" | "generation">): string;
|
|
4
|
+
export declare function spoolDir(root: string, generation: number): string;
|
|
5
|
+
/** Generation teardown: every ref of that generation, acknowledged or not, and its `spool/`. */
|
|
6
|
+
export declare function teardownRefGeneration(root: string, generation: number): Promise<void>;
|
|
7
|
+
/** Removes the directory of every generation not in `live` (left by a dead child or server); returns them, sorted. */
|
|
8
|
+
export declare function sweepRefGenerations(root: string, live: ReadonlySet<number>): Promise<number[]>;
|
|
9
|
+
//# sourceMappingURL=payload-ref-paths.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isRefId } from "./protocol-guard.js";
|
|
4
|
+
/**
|
|
5
|
+
* Layout under the channel's ref root: `gen-<generation>/refs/<refId>` and `gen-<generation>/spool/`. Refs of both
|
|
6
|
+
* directions live under the child generation that owns the channel, so one teardown removes all of them.
|
|
7
|
+
*/
|
|
8
|
+
const GENERATION_DIR = /^gen-(\d+)$/;
|
|
9
|
+
export function refGenerationDir(root, generation) {
|
|
10
|
+
return join(root, `gen-${generation}`);
|
|
11
|
+
}
|
|
12
|
+
export function refFilePath(root, ref) {
|
|
13
|
+
if (!isRefId(ref.refId))
|
|
14
|
+
throw new Error(`invalid ref id: ${JSON.stringify(ref.refId)}`);
|
|
15
|
+
return join(refGenerationDir(root, ref.generation), "refs", ref.refId);
|
|
16
|
+
}
|
|
17
|
+
export function spoolDir(root, generation) {
|
|
18
|
+
return join(refGenerationDir(root, generation), "spool");
|
|
19
|
+
}
|
|
20
|
+
/** Generation teardown: every ref of that generation, acknowledged or not, and its `spool/`. */
|
|
21
|
+
export async function teardownRefGeneration(root, generation) {
|
|
22
|
+
await rm(refGenerationDir(root, generation), { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
/** Removes the directory of every generation not in `live` (left by a dead child or server); returns them, sorted. */
|
|
25
|
+
export async function sweepRefGenerations(root, live) {
|
|
26
|
+
let names;
|
|
27
|
+
try {
|
|
28
|
+
names = await readdir(root);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
32
|
+
return [];
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
const stale = names
|
|
36
|
+
.map((name) => GENERATION_DIR.exec(name)?.[1])
|
|
37
|
+
.filter((digits) => digits !== undefined)
|
|
38
|
+
.map(Number)
|
|
39
|
+
.filter((generation) => !live.has(generation))
|
|
40
|
+
.sort((left, right) => left - right);
|
|
41
|
+
for (const generation of stale)
|
|
42
|
+
await teardownRefGeneration(root, generation);
|
|
43
|
+
return stale;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=payload-ref-paths.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type PayloadRef, type RefControl, type RefNackReason } from "./protocol.ts";
|
|
2
|
+
export type PayloadRefErrorCode = "ref_budget_exceeded" | "ref_rejected" | "ref_not_owned";
|
|
3
|
+
export declare class PayloadRefError extends Error {
|
|
4
|
+
readonly code: PayloadRefErrorCode;
|
|
5
|
+
readonly reason: RefNackReason | undefined;
|
|
6
|
+
constructor(code: PayloadRefErrorCode, message: string, reason?: RefNackReason);
|
|
7
|
+
}
|
|
8
|
+
export interface PayloadRefWriterOptions {
|
|
9
|
+
root: string;
|
|
10
|
+
generation: number;
|
|
11
|
+
budget?: number | undefined;
|
|
12
|
+
now?: (() => number) | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Writer side of an acknowledged payload reference. The writer owns a ref only until the receiver answers:
|
|
16
|
+
* `ref_ack` transfers ownership (the writer forgets the file and never deletes it), `ref_nack` makes the writer
|
|
17
|
+
* delete it. Settling the sending operation changes nothing; only generation teardown removes an unanswered ref.
|
|
18
|
+
*/
|
|
19
|
+
export declare class PayloadRefWriter {
|
|
20
|
+
#private;
|
|
21
|
+
readonly generation: number;
|
|
22
|
+
readonly budget: number;
|
|
23
|
+
constructor(options: PayloadRefWriterOptions);
|
|
24
|
+
get ownedBytes(): number;
|
|
25
|
+
create(payload: Uint8Array): Promise<PayloadRef>;
|
|
26
|
+
/** A `ref_ack` or `ref_nack` from the receiver; an answer for a ref this writer does not hold is ignored. */
|
|
27
|
+
handle(control: RefControl): Promise<void>;
|
|
28
|
+
pendingIds(): string[];
|
|
29
|
+
/** Refs still unanswered after `refAckMs`; reported for liveness handling, never deleted here. */
|
|
30
|
+
overdue(): string[];
|
|
31
|
+
teardown(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export interface PayloadRefReaderOptions {
|
|
34
|
+
root: string;
|
|
35
|
+
generation: number;
|
|
36
|
+
budget?: number | undefined;
|
|
37
|
+
send: (control: RefControl) => void;
|
|
38
|
+
}
|
|
39
|
+
export type RefAcceptance = {
|
|
40
|
+
ok: true;
|
|
41
|
+
} | {
|
|
42
|
+
ok: false;
|
|
43
|
+
reason: RefNackReason;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Receiver side. `accept` verifies size and sha256 and answers `ref_ack` (taking ownership) or `ref_nack`.
|
|
47
|
+
* The owner deletes the file when it consumes it; a dead owner's files go with its generation's teardown.
|
|
48
|
+
*/
|
|
49
|
+
export declare class PayloadRefReader {
|
|
50
|
+
#private;
|
|
51
|
+
constructor(options: PayloadRefReaderOptions);
|
|
52
|
+
accept(ref: PayloadRef, signal?: AbortSignal): Promise<RefAcceptance>;
|
|
53
|
+
/** Answers `ref_nack` for a ref this side will not take, e.g. one whose job was cancelled before it was opened. */
|
|
54
|
+
refuse(ref: PayloadRef, reason: RefNackReason): void;
|
|
55
|
+
consume(refId: string): Promise<Uint8Array>;
|
|
56
|
+
ownedIds(): string[];
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=payload-ref.d.ts.map
|