talon-agent 1.9.2 → 1.10.1
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/package.json +10 -5
- package/prompts/telegram.md +24 -6
- package/src/__tests__/claude-sdk-options.test.ts +95 -0
- package/src/__tests__/end-turn.test.ts +307 -0
- package/src/__tests__/handlers.test.ts +107 -43
- package/src/__tests__/integration/sdk-stub.test.ts +208 -0
- package/src/__tests__/integration/stub-claude/build-sea.mjs +114 -0
- package/src/__tests__/integration/stub-claude/fake-claude.mjs +352 -0
- package/src/__tests__/integration/stub-claude/helpers.ts +263 -0
- package/src/__tests__/integration/stub-claude/protocol.ts +108 -0
- package/src/__tests__/integration/stub-claude/sea-config.json +7 -0
- package/src/__tests__/integration/talon-bootstrap.ts +206 -0
- package/src/__tests__/integration/talon-functional.test.ts +190 -0
- package/src/__tests__/package.functional.test.ts +178 -0
- package/src/backend/claude-sdk/handler.ts +110 -1
- package/src/backend/claude-sdk/options.ts +59 -1
- package/src/backend/claude-sdk/stream.ts +67 -0
- package/src/core/tools/index.ts +41 -0
- package/src/core/tools/messaging.ts +79 -1
- package/src/core/tools/types.ts +14 -0
- package/src/frontend/teams/index.ts +20 -10
- package/src/frontend/telegram/handlers.ts +16 -12
|
@@ -1731,45 +1731,103 @@ describe("handleAnimationMessage — downloads and enqueues", () => {
|
|
|
1731
1731
|
});
|
|
1732
1732
|
|
|
1733
1733
|
describe("processAndReply — onToolUse callback triggers appendDailyLogResponse", () => {
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1734
|
+
// The SDK emits MCP-prefixed tool names in production
|
|
1735
|
+
// (`mcp__telegram-tools__send` etc.). Bare names like `send` are how the
|
|
1736
|
+
// tool is registered but NOT what the handler actually receives. This
|
|
1737
|
+
// test matrix exercises both shapes — the prefixed forms are what would
|
|
1738
|
+
// catch a regression where the strip-prefix logic gets dropped.
|
|
1739
|
+
const cases = [
|
|
1740
|
+
{
|
|
1741
|
+
label: "MCP-prefixed send (production shape)",
|
|
1742
|
+
toolName: "mcp__telegram-tools__send",
|
|
1743
|
+
input: { type: "text", text: "Production send text" },
|
|
1744
|
+
expectedText: "Production send text",
|
|
1745
|
+
shouldCapture: true,
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
label: "MCP-prefixed end_turn (production shape)",
|
|
1749
|
+
toolName: "mcp__telegram-tools__end_turn",
|
|
1750
|
+
input: { text: "Production end_turn text" },
|
|
1751
|
+
expectedText: "Production end_turn text",
|
|
1752
|
+
shouldCapture: true,
|
|
1753
|
+
},
|
|
1754
|
+
{
|
|
1755
|
+
label: "bare send (defensive — registry-shape compat)",
|
|
1756
|
+
toolName: "send",
|
|
1757
|
+
input: { type: "text", text: "Bare send text" },
|
|
1758
|
+
expectedText: "Bare send text",
|
|
1759
|
+
shouldCapture: true,
|
|
1760
|
+
},
|
|
1761
|
+
{
|
|
1762
|
+
label: "bare end_turn (defensive — registry-shape compat)",
|
|
1763
|
+
toolName: "end_turn",
|
|
1764
|
+
input: { text: "Bare end_turn text" },
|
|
1765
|
+
expectedText: "Bare end_turn text",
|
|
1766
|
+
shouldCapture: true,
|
|
1767
|
+
},
|
|
1768
|
+
{
|
|
1769
|
+
label: "non-text send (photo) — should NOT capture",
|
|
1770
|
+
toolName: "mcp__telegram-tools__send",
|
|
1771
|
+
input: { type: "photo", file_path: "/x.jpg", caption: "ignored" },
|
|
1772
|
+
expectedText: "",
|
|
1773
|
+
shouldCapture: false,
|
|
1774
|
+
},
|
|
1775
|
+
{
|
|
1776
|
+
label: "react tool — should NOT capture",
|
|
1777
|
+
toolName: "mcp__telegram-tools__react",
|
|
1778
|
+
input: { message_id: 1, emoji: "👍" },
|
|
1779
|
+
expectedText: "",
|
|
1780
|
+
shouldCapture: false,
|
|
1781
|
+
},
|
|
1782
|
+
];
|
|
1783
|
+
|
|
1784
|
+
for (const c of cases) {
|
|
1785
|
+
it(`${c.shouldCapture ? "captures" : "ignores"} ${c.label}`, async () => {
|
|
1786
|
+
const { appendDailyLogResponse } =
|
|
1787
|
+
await import("../storage/daily-log.js");
|
|
1788
|
+
const mockedFn = vi.mocked(appendDailyLogResponse);
|
|
1789
|
+
const before = mockedFn.mock.calls.length;
|
|
1790
|
+
|
|
1791
|
+
executeMock.mockImplementationOnce(
|
|
1792
|
+
async (params: Record<string, unknown>) => {
|
|
1793
|
+
const onToolUse = params.onToolUse as (
|
|
1794
|
+
toolName: string,
|
|
1795
|
+
input: Record<string, unknown>,
|
|
1796
|
+
) => void;
|
|
1797
|
+
onToolUse?.(c.toolName, c.input);
|
|
1798
|
+
return {
|
|
1799
|
+
text: "",
|
|
1800
|
+
durationMs: 5,
|
|
1801
|
+
inputTokens: 1,
|
|
1802
|
+
outputTokens: 2,
|
|
1803
|
+
cacheRead: 0,
|
|
1804
|
+
cacheWrite: 0,
|
|
1805
|
+
bridgeMessageCount: 1,
|
|
1806
|
+
};
|
|
1807
|
+
},
|
|
1808
|
+
);
|
|
1766
1809
|
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1810
|
+
const ctx = {
|
|
1811
|
+
chat: { id: 96001, type: "private" },
|
|
1812
|
+
message: { text: "hi", message_id: 950, reply_to_message: null },
|
|
1813
|
+
me: { id: 999, username: "testbot" },
|
|
1814
|
+
from: { id: 93, first_name: "Yuki" },
|
|
1815
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1816
|
+
} as any;
|
|
1817
|
+
|
|
1818
|
+
await handleTextMessage(ctx, mockBot, mockConfig);
|
|
1819
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
1820
|
+
|
|
1821
|
+
const newCalls = mockedFn.mock.calls.slice(before);
|
|
1822
|
+
if (c.shouldCapture) {
|
|
1823
|
+
expect(newCalls).toHaveLength(1);
|
|
1824
|
+
expect(newCalls[0][0]).toBe("Talon");
|
|
1825
|
+
expect(newCalls[0][1]).toBe(c.expectedText);
|
|
1826
|
+
} else {
|
|
1827
|
+
expect(newCalls).toHaveLength(0);
|
|
1828
|
+
}
|
|
1829
|
+
}, 3000);
|
|
1830
|
+
}
|
|
1773
1831
|
});
|
|
1774
1832
|
|
|
1775
1833
|
describe("createStreamCallbacks — onTextBlock delivers message via sendHtml", () => {
|
|
@@ -2676,13 +2734,19 @@ describe("processAndReply — group message without senderId", () => {
|
|
|
2676
2734
|
}, 3000);
|
|
2677
2735
|
});
|
|
2678
2736
|
|
|
2679
|
-
describe("processAndReply —
|
|
2680
|
-
|
|
2737
|
+
describe("processAndReply — fallback text no longer suppressed", () => {
|
|
2738
|
+
// Pre-end_turn behavior: when bridgeMessageCount=0 and result.text was
|
|
2739
|
+
// non-empty, the frontend logged "Suppressed fallback text" and dropped
|
|
2740
|
+
// the content (the scratchpad bug). The handler now fires onTextBlock
|
|
2741
|
+
// with trailing text instead, so:
|
|
2742
|
+
// - the "Suppressed fallback" log is gone entirely
|
|
2743
|
+
// - the delivery path is exercised by createStreamCallbacks tests above
|
|
2744
|
+
it("does NOT log a 'Suppressed fallback' warning anymore", async () => {
|
|
2681
2745
|
const { log } = await import("../util/log.js");
|
|
2682
2746
|
(log as ReturnType<typeof vi.fn>).mockClear();
|
|
2683
2747
|
|
|
2684
2748
|
executeMock.mockResolvedValueOnce({
|
|
2685
|
-
text: "
|
|
2749
|
+
text: "trailing prose that used to be suppressed",
|
|
2686
2750
|
durationMs: 10,
|
|
2687
2751
|
inputTokens: 1,
|
|
2688
2752
|
outputTokens: 1,
|
|
@@ -2694,7 +2758,7 @@ describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)
|
|
|
2694
2758
|
const ctx = {
|
|
2695
2759
|
chat: { id: 99600, type: "private" },
|
|
2696
2760
|
message: {
|
|
2697
|
-
text: "test
|
|
2761
|
+
text: "test fallback delivery",
|
|
2698
2762
|
message_id: 1600,
|
|
2699
2763
|
reply_to_message: null,
|
|
2700
2764
|
},
|
|
@@ -2709,7 +2773,7 @@ describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)
|
|
|
2709
2773
|
const suppressedLog = logCalls.find((c: unknown[]) =>
|
|
2710
2774
|
String(c[1]).includes("Suppressed fallback"),
|
|
2711
2775
|
);
|
|
2712
|
-
expect(suppressedLog).
|
|
2776
|
+
expect(suppressedLog).toBeUndefined();
|
|
2713
2777
|
}, 3000);
|
|
2714
2778
|
});
|
|
2715
2779
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration tests against the real Claude Agent SDK driving a
|
|
3
|
+
* stub `claude` binary (see `stub-claude/`).
|
|
4
|
+
*
|
|
5
|
+
* Unlike the unit tests in `src/__tests__/`, these don't `vi.mock` the SDK —
|
|
6
|
+
* the real `query()` runs, real subprocess spawn, real hook dispatcher, real
|
|
7
|
+
* stream-json protocol. The binary is replaced with a scripted stub so we
|
|
8
|
+
* can assert on what the SDK produces given known inputs.
|
|
9
|
+
*
|
|
10
|
+
* What this catches that unit tests can't:
|
|
11
|
+
* - Bugs in our integration with the SDK's protocol shapes (e.g. the
|
|
12
|
+
* MCP-prefix bug in `isTurnTerminator` would have surfaced here).
|
|
13
|
+
* - Hook wiring — confirms options.hooks actually gets invoked by the SDK.
|
|
14
|
+
* - Stream sequence handling against authentic message ordering.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from "vitest";
|
|
18
|
+
import {
|
|
19
|
+
runWithStub,
|
|
20
|
+
cleanup,
|
|
21
|
+
assistantText,
|
|
22
|
+
assistantToolUse,
|
|
23
|
+
successResult,
|
|
24
|
+
fireHook,
|
|
25
|
+
} from "./stub-claude/helpers.js";
|
|
26
|
+
|
|
27
|
+
// On Windows the stub binary is a SEA-compiled `.exe` (built via
|
|
28
|
+
// `npm run build:stub-sea`). On POSIX it's the `.mjs` source with shebang.
|
|
29
|
+
// If the .exe isn't present (build step skipped), we skip the suite cleanly
|
|
30
|
+
// rather than fail with `ENOENT`.
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { resolve as resolvePath, dirname as dirnamePath } from "node:path";
|
|
33
|
+
import { fileURLToPath as fileUrl } from "node:url";
|
|
34
|
+
const __testDir = dirnamePath(fileUrl(import.meta.url));
|
|
35
|
+
const stubBinaryPath = resolvePath(
|
|
36
|
+
__testDir,
|
|
37
|
+
process.platform === "win32"
|
|
38
|
+
? "stub-claude/fake-claude.exe"
|
|
39
|
+
: "stub-claude/fake-claude.mjs",
|
|
40
|
+
);
|
|
41
|
+
const stubReady = existsSync(stubBinaryPath);
|
|
42
|
+
|
|
43
|
+
describe.skipIf(!stubReady)("SDK integration (stub binary)", () => {
|
|
44
|
+
it("completes a simple text-only turn", async () => {
|
|
45
|
+
const result = await runWithStub({
|
|
46
|
+
prompt: "say hi",
|
|
47
|
+
script: {
|
|
48
|
+
turns: [
|
|
49
|
+
{
|
|
50
|
+
emit: [assistantText("hello from stub"), successResult("hello")],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Should have system init, assistant message, and result
|
|
57
|
+
const types = result.messages.map((m) => (m as { type: string }).type);
|
|
58
|
+
expect(types).toContain("system");
|
|
59
|
+
expect(types).toContain("assistant");
|
|
60
|
+
expect(types).toContain("result");
|
|
61
|
+
|
|
62
|
+
cleanup(result);
|
|
63
|
+
}, 15000);
|
|
64
|
+
|
|
65
|
+
it("yields assistant content blocks intact", async () => {
|
|
66
|
+
const result = await runWithStub({
|
|
67
|
+
prompt: "test",
|
|
68
|
+
script: {
|
|
69
|
+
turns: [
|
|
70
|
+
{
|
|
71
|
+
emit: [assistantText("specific text payload"), successResult()],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const assistant = result.messages.find(
|
|
78
|
+
(m) => (m as { type: string }).type === "assistant",
|
|
79
|
+
) as
|
|
80
|
+
| { message: { content: { type: string; text?: string }[] } }
|
|
81
|
+
| undefined;
|
|
82
|
+
|
|
83
|
+
expect(assistant).toBeDefined();
|
|
84
|
+
const text = assistant?.message.content.find((b) => b.type === "text");
|
|
85
|
+
expect(text?.text).toBe("specific text payload");
|
|
86
|
+
|
|
87
|
+
cleanup(result);
|
|
88
|
+
}, 15000);
|
|
89
|
+
|
|
90
|
+
it("invokes registered PostToolBatch hooks", async () => {
|
|
91
|
+
// The stub emits an assistant message with a tool_use, then synthesizes a
|
|
92
|
+
// PostToolBatch hook fire (via fireHook). The SDK should look up the
|
|
93
|
+
// callback id we registered during init and invoke our hook function.
|
|
94
|
+
const result = await runWithStub({
|
|
95
|
+
prompt: "trigger end_turn",
|
|
96
|
+
script: {
|
|
97
|
+
turns: [
|
|
98
|
+
{
|
|
99
|
+
emit: [
|
|
100
|
+
assistantToolUse("mcp__telegram-tools__end_turn", {
|
|
101
|
+
text: "delivered",
|
|
102
|
+
}),
|
|
103
|
+
fireHook("PostToolBatch", {
|
|
104
|
+
tool_calls: [
|
|
105
|
+
{
|
|
106
|
+
tool_name: "mcp__telegram-tools__end_turn",
|
|
107
|
+
tool_input: { text: "delivered" },
|
|
108
|
+
tool_use_id: "tu_1",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
}),
|
|
112
|
+
successResult("delivered"),
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
sdkOptions: {
|
|
118
|
+
hooks: {
|
|
119
|
+
PostToolBatch: [
|
|
120
|
+
{
|
|
121
|
+
hooks: [
|
|
122
|
+
async () => ({
|
|
123
|
+
continue: false,
|
|
124
|
+
stopReason: "test: end_turn fired",
|
|
125
|
+
}),
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(result.hookFires.PostToolBatch ?? 0).toBeGreaterThanOrEqual(1);
|
|
134
|
+
|
|
135
|
+
const calls = result.hookInputs.PostToolBatch ?? [];
|
|
136
|
+
const firstInput = calls[0] as
|
|
137
|
+
| { tool_calls: { tool_name: string }[] }
|
|
138
|
+
| undefined;
|
|
139
|
+
expect(firstInput?.tool_calls.map((tc) => tc.tool_name)).toContain(
|
|
140
|
+
"mcp__telegram-tools__end_turn",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
cleanup(result);
|
|
144
|
+
}, 15000);
|
|
145
|
+
|
|
146
|
+
it("runs the production turn-terminator hook (PostToolBatch + isTurnTerminator)", async () => {
|
|
147
|
+
// This test composes the actual hook from `src/backend/claude-sdk/options.ts`
|
|
148
|
+
// — we reach into the module to grab it. If the hook implementation drifts
|
|
149
|
+
// from what the SDK calls, this test fails.
|
|
150
|
+
const { isTurnTerminator } = await import("../../core/tools/index.js");
|
|
151
|
+
|
|
152
|
+
const result = await runWithStub({
|
|
153
|
+
prompt: "drive end_turn through the real isTurnTerminator helper",
|
|
154
|
+
script: {
|
|
155
|
+
turns: [
|
|
156
|
+
{
|
|
157
|
+
emit: [
|
|
158
|
+
assistantToolUse("mcp__telegram-tools__end_turn", {
|
|
159
|
+
text: "ok",
|
|
160
|
+
}),
|
|
161
|
+
fireHook("PostToolBatch", {
|
|
162
|
+
tool_calls: [
|
|
163
|
+
{
|
|
164
|
+
tool_name: "mcp__telegram-tools__end_turn",
|
|
165
|
+
tool_input: { text: "ok" },
|
|
166
|
+
tool_use_id: "tu_1",
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
successResult(),
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
sdkOptions: {
|
|
176
|
+
hooks: {
|
|
177
|
+
PostToolBatch: [
|
|
178
|
+
{
|
|
179
|
+
hooks: [
|
|
180
|
+
async (input) => {
|
|
181
|
+
type Batch = {
|
|
182
|
+
hook_event_name: string;
|
|
183
|
+
tool_calls: { tool_name: string }[];
|
|
184
|
+
};
|
|
185
|
+
if ((input as Batch).hook_event_name !== "PostToolBatch") {
|
|
186
|
+
return { continue: true };
|
|
187
|
+
}
|
|
188
|
+
const batch = input as Batch;
|
|
189
|
+
const ended = batch.tool_calls.some((tc) =>
|
|
190
|
+
isTurnTerminator(tc.tool_name),
|
|
191
|
+
);
|
|
192
|
+
return ended
|
|
193
|
+
? { continue: false, stopReason: "turn terminated" }
|
|
194
|
+
: { continue: true };
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Hook should have fired exactly once and matched the MCP-prefixed name.
|
|
204
|
+
expect(result.hookFires.PostToolBatch).toBe(1);
|
|
205
|
+
|
|
206
|
+
cleanup(result);
|
|
207
|
+
}, 15000);
|
|
208
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build a Single Executable Application (SEA) wrapping `fake-claude.mjs`.
|
|
4
|
+
*
|
|
5
|
+
* Why we need this: on Windows, Node 20.19+ refuses to spawn `.cmd` / `.bat`
|
|
6
|
+
* shims via `child_process.spawn` without `shell: true` (CVE-2024-27980).
|
|
7
|
+
* The Claude Agent SDK calls `spawn(claudeBinary, args)` directly, so a
|
|
8
|
+
* `.cmd` wrapper around our `.mjs` stub is unspawnable. SEA produces a
|
|
9
|
+
* native `.exe` (or platform-equivalent binary) that the SDK can spawn.
|
|
10
|
+
*
|
|
11
|
+
* Steps:
|
|
12
|
+
* 1. Bundle `fake-claude.mjs` (ESM) → `fake-claude.cjs` (CJS) with esbuild,
|
|
13
|
+
* because Node SEA loads main as CommonJS by default.
|
|
14
|
+
* 2. Generate the SEA preparation blob:
|
|
15
|
+
* node --experimental-sea-config sea-config.json
|
|
16
|
+
* 3. Copy the running `node` binary to `fake-claude(.exe)`.
|
|
17
|
+
* 4. Inject the blob with postject (Sentinel: `NODE_SEA_FUSE_…`).
|
|
18
|
+
*
|
|
19
|
+
* The build is idempotent — safe to re-run. Output binary lives next to
|
|
20
|
+
* the source files in `stub-claude/`.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* node build-sea.mjs # builds for the current platform
|
|
24
|
+
* npm run build:stub-sea # same, via the npm script
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { copyFileSync, chmodSync, existsSync, statSync } from "node:fs";
|
|
29
|
+
import { dirname, resolve } from "node:path";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const BUNDLE_SRC = resolve(__dirname, "fake-claude.mjs");
|
|
34
|
+
const BUNDLE_DEST = resolve(__dirname, "fake-claude.cjs");
|
|
35
|
+
const BLOB_PATH = resolve(__dirname, "sea-prep.blob");
|
|
36
|
+
const EXE_NAME =
|
|
37
|
+
process.platform === "win32" ? "fake-claude.exe" : "fake-claude";
|
|
38
|
+
const EXE_PATH = resolve(__dirname, EXE_NAME);
|
|
39
|
+
const SEA_CONFIG = resolve(__dirname, "sea-config.json");
|
|
40
|
+
const SENTINEL_FUSE = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2";
|
|
41
|
+
|
|
42
|
+
function step(label) {
|
|
43
|
+
process.stdout.write(`\n[build-sea] ${label}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function run(label, cmd, args, opts = {}) {
|
|
47
|
+
step(label);
|
|
48
|
+
const result = spawnSync(cmd, args, {
|
|
49
|
+
stdio: "inherit",
|
|
50
|
+
cwd: __dirname,
|
|
51
|
+
shell: process.platform === "win32",
|
|
52
|
+
...opts,
|
|
53
|
+
});
|
|
54
|
+
if (result.status !== 0) {
|
|
55
|
+
throw new Error(`${label} failed (exit ${result.status})`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 1. Bundle ESM → CJS ────────────────────────────────────────────────────
|
|
60
|
+
const esbuildBin = resolve(
|
|
61
|
+
__dirname,
|
|
62
|
+
"../../../../node_modules/.bin/esbuild" +
|
|
63
|
+
(process.platform === "win32" ? ".cmd" : ""),
|
|
64
|
+
);
|
|
65
|
+
if (!existsSync(esbuildBin)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`esbuild not found at ${esbuildBin}. Run \`npm install\` first.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
run("Bundling fake-claude.mjs → fake-claude.cjs", esbuildBin, [
|
|
71
|
+
BUNDLE_SRC,
|
|
72
|
+
"--bundle",
|
|
73
|
+
"--platform=node",
|
|
74
|
+
"--format=cjs",
|
|
75
|
+
`--outfile=${BUNDLE_DEST}`,
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// ── 2. Generate SEA blob ───────────────────────────────────────────────────
|
|
79
|
+
run("Generating SEA preparation blob", process.execPath, [
|
|
80
|
+
"--experimental-sea-config",
|
|
81
|
+
SEA_CONFIG,
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
if (!existsSync(BLOB_PATH)) {
|
|
85
|
+
throw new Error(`Expected blob at ${BLOB_PATH} but it doesn't exist`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── 3. Copy node binary ────────────────────────────────────────────────────
|
|
89
|
+
step(`Copying ${process.execPath} → ${EXE_PATH}`);
|
|
90
|
+
copyFileSync(process.execPath, EXE_PATH);
|
|
91
|
+
if (process.platform !== "win32") {
|
|
92
|
+
chmodSync(EXE_PATH, 0o755);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── 4. Inject blob via postject ────────────────────────────────────────────
|
|
96
|
+
const postjectArgs = [
|
|
97
|
+
"postject",
|
|
98
|
+
EXE_PATH,
|
|
99
|
+
"NODE_SEA_BLOB",
|
|
100
|
+
BLOB_PATH,
|
|
101
|
+
"--sentinel-fuse",
|
|
102
|
+
SENTINEL_FUSE,
|
|
103
|
+
];
|
|
104
|
+
if (process.platform === "darwin") {
|
|
105
|
+
// macOS requires the mach-o segment name to be specified.
|
|
106
|
+
postjectArgs.push("--macho-segment-name", "NODE_SEA");
|
|
107
|
+
}
|
|
108
|
+
run("Injecting SEA blob with postject", "npx", ["--yes", ...postjectArgs]);
|
|
109
|
+
|
|
110
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
111
|
+
const finalSize = statSync(EXE_PATH).size;
|
|
112
|
+
step(
|
|
113
|
+
`Done. ${EXE_NAME} (${(finalSize / 1024 / 1024).toFixed(1)} MB) ready at ${EXE_PATH}`,
|
|
114
|
+
);
|