talon-agent 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -37
- package/package.json +1 -2
- package/prompts/mempalace.md +3 -0
- package/src/__tests__/chat-settings.test.ts +5 -1
- package/src/__tests__/claude-sdk-models.test.ts +17 -13
- package/src/__tests__/fuzz.test.ts +3 -3
- package/src/__tests__/handlers.test.ts +55 -0
- package/src/__tests__/mcp-launcher-functional.test.ts +334 -0
- package/src/__tests__/mcp-launcher.test.ts +139 -0
- package/src/__tests__/mempalace-plugin.test.ts +56 -1
- package/src/__tests__/plugin.test.ts +8 -0
- package/src/__tests__/teams-frontend.test.ts +28 -0
- package/src/__tests__/telegram-formatting.test.ts +4 -0
- package/src/__tests__/telegram-helpers.test.ts +23 -28
- package/src/__tests__/telegram.test.ts +2 -3
- package/src/__tests__/terminal-commands.test.ts +40 -122
- package/src/__tests__/workspace.test.ts +48 -9
- package/src/backend/claude-sdk/constants.ts +9 -30
- package/src/backend/claude-sdk/model-provider.ts +14 -39
- package/src/backend/claude-sdk/models.ts +49 -16
- package/src/backend/claude-sdk/options.ts +5 -4
- package/src/backend/opencode/model-provider.ts +12 -0
- package/src/bootstrap.ts +4 -0
- package/src/core/constants.ts +30 -0
- package/src/core/dream.ts +3 -3
- package/src/core/heartbeat.ts +2 -2
- package/src/core/models.ts +5 -7
- package/src/core/plugin.ts +13 -7
- package/src/core/tools/web.ts +2 -4
- package/src/core/types.ts +8 -1
- package/src/frontend/teams/formatting.ts +7 -1
- package/src/frontend/teams/index.ts +5 -4
- package/src/frontend/telegram/admin.ts +3 -4
- package/src/frontend/telegram/callbacks.ts +1 -1
- package/src/frontend/telegram/commands.ts +2 -2
- package/src/frontend/telegram/formatting.ts +4 -2
- package/src/frontend/telegram/handlers.ts +12 -21
- package/src/frontend/telegram/helpers.ts +8 -35
- package/src/frontend/terminal/commands.ts +127 -202
- package/src/frontend/terminal/index.ts +6 -7
- package/src/plugins/mempalace/index.ts +34 -10
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +2 -7
- package/src/util/mcp-launcher.mjs +72 -0
- package/src/util/mcp-launcher.ts +58 -0
package/README.md
CHANGED
|
@@ -12,15 +12,15 @@ Multi-platform agentic AI harness powered by Claude. Runs on **Telegram**, **Tea
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
|
|
|
16
|
-
|
|
17
|
-
| **Multi-frontend**
|
|
18
|
-
| **Claude Agent SDK**
|
|
19
|
-
| **MCP tools**
|
|
20
|
-
| **Plugins**
|
|
21
|
-
| **Background agents** | Heartbeat (periodic maintenance) and Dream (memory consolidation + diary)
|
|
22
|
-
| **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard
|
|
23
|
-
| **Model registry**
|
|
15
|
+
| | |
|
|
16
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| **Multi-frontend** | Telegram (Grammy + GramJS userbot), Microsoft Teams (Bot Framework), Terminal with live tool visibility |
|
|
18
|
+
| **Claude Agent SDK** | Streaming responses, extended thinking, adaptive effort, 1M token context, dynamic model discovery |
|
|
19
|
+
| **MCP tools** | Messaging, media, history, search, web fetch, cron jobs, stickers, file system, admin controls |
|
|
20
|
+
| **Plugins** | Hot-reloadable plugin system. Built-in: GitHub, MemPalace, Playwright, Brave Search |
|
|
21
|
+
| **Background agents** | Heartbeat (periodic maintenance) and Dream (memory consolidation + diary) |
|
|
22
|
+
| **Per-chat settings** | Model, effort level, and pulse toggle per conversation via inline keyboard |
|
|
23
|
+
| **Model registry** | Models discovered from the SDK at startup --- new models appear in all pickers automatically |
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -39,8 +39,10 @@ npx talon chat # terminal chat mode
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
**Prerequisites:**
|
|
42
|
+
|
|
42
43
|
- [Node.js 22+](https://nodejs.org/)
|
|
43
44
|
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated (`claude` CLI on PATH)
|
|
45
|
+
- Talon runs from a normal source or package install; standalone compiled binaries are not supported.
|
|
44
46
|
|
|
45
47
|
---
|
|
46
48
|
|
|
@@ -161,9 +163,7 @@ Plugins add MCP tools and gateway actions without modifying core code. SOLID int
|
|
|
161
163
|
|
|
162
164
|
```json
|
|
163
165
|
{
|
|
164
|
-
"plugins": [
|
|
165
|
-
{ "path": "/path/to/my-plugin", "config": { "apiKey": "..." } }
|
|
166
|
-
]
|
|
166
|
+
"plugins": [{ "path": "/path/to/my-plugin", "config": { "apiKey": "..." } }]
|
|
167
167
|
}
|
|
168
168
|
```
|
|
169
169
|
|
|
@@ -172,12 +172,24 @@ export default {
|
|
|
172
172
|
name: "my-plugin",
|
|
173
173
|
version: "1.0.0",
|
|
174
174
|
mcpServerPath: resolve(import.meta.dirname, "tools.ts"),
|
|
175
|
-
validateConfig(config) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
validateConfig(config) {
|
|
176
|
+
/* return errors or undefined */
|
|
177
|
+
},
|
|
178
|
+
getEnvVars(config) {
|
|
179
|
+
return { MY_KEY: config.apiKey };
|
|
180
|
+
},
|
|
181
|
+
handleAction(body, chatId) {
|
|
182
|
+
/* gateway action handler */
|
|
183
|
+
},
|
|
184
|
+
getSystemPromptAddition(config) {
|
|
185
|
+
return "## My Plugin\n...";
|
|
186
|
+
},
|
|
187
|
+
init(config) {
|
|
188
|
+
/* one-time setup */
|
|
189
|
+
},
|
|
190
|
+
destroy() {
|
|
191
|
+
/* cleanup */
|
|
192
|
+
},
|
|
181
193
|
};
|
|
182
194
|
```
|
|
183
195
|
|
|
@@ -204,25 +216,25 @@ talon doctor Validate environment and dependencies
|
|
|
204
216
|
|
|
205
217
|
Config file: `~/.talon/config.json`
|
|
206
218
|
|
|
207
|
-
| Field
|
|
208
|
-
|
|
209
|
-
| `frontend`
|
|
210
|
-
| `backend`
|
|
211
|
-
| `botToken`
|
|
212
|
-
| `model`
|
|
213
|
-
| `concurrency`
|
|
214
|
-
| `pulse`
|
|
215
|
-
| `heartbeat`
|
|
216
|
-
| `heartbeatIntervalMinutes` | `60`
|
|
217
|
-
| `braveApiKey`
|
|
218
|
-
| `timezone`
|
|
219
|
-
| `plugins`
|
|
220
|
-
| `adminUserId`
|
|
221
|
-
| `allowedUsers`
|
|
222
|
-
| `apiId` / `apiHash`
|
|
223
|
-
| `github`
|
|
224
|
-
| `mempalace`
|
|
225
|
-
| `playwright`
|
|
219
|
+
| Field | Default | Description |
|
|
220
|
+
| -------------------------- | ------------ | ------------------------------------------------------------------- |
|
|
221
|
+
| `frontend` | `"telegram"` | `"telegram"`, `"terminal"`, `"teams"`, or an array |
|
|
222
|
+
| `backend` | `"claude"` | `"claude"` or `"opencode"` |
|
|
223
|
+
| `botToken` | --- | Telegram bot token |
|
|
224
|
+
| `model` | `"default"` | Default Claude model. Legacy `claude-*` aliases are still accepted. |
|
|
225
|
+
| `concurrency` | `1` | Max concurrent AI queries (1--20) |
|
|
226
|
+
| `pulse` | `true` | Periodic group engagement |
|
|
227
|
+
| `heartbeat` | `false` | Background maintenance agent |
|
|
228
|
+
| `heartbeatIntervalMinutes` | `60` | Heartbeat interval |
|
|
229
|
+
| `braveApiKey` | --- | Brave Search API key |
|
|
230
|
+
| `timezone` | --- | IANA timezone (e.g. `"Europe/London"`) |
|
|
231
|
+
| `plugins` | `[]` | External plugin packages |
|
|
232
|
+
| `adminUserId` | --- | Telegram user ID for `/admin` commands |
|
|
233
|
+
| `allowedUsers` | --- | Whitelist of Telegram user IDs |
|
|
234
|
+
| `apiId` / `apiHash` | --- | Telegram API credentials for full message history |
|
|
235
|
+
| `github` | --- | GitHub plugin config (see above) |
|
|
236
|
+
| `mempalace` | --- | MemPalace plugin config (see above) |
|
|
237
|
+
| `playwright` | --- | Playwright plugin config (see above) |
|
|
226
238
|
|
|
227
239
|
---
|
|
228
240
|
|
|
@@ -241,6 +253,7 @@ Commands: `/model`, `/effort`, `/reset`, `/status`, `/help`
|
|
|
241
253
|
## Production
|
|
242
254
|
|
|
243
255
|
**Docker:**
|
|
256
|
+
|
|
244
257
|
```bash
|
|
245
258
|
docker compose up -d
|
|
246
259
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"tsconfig.json"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build:binary": "bun build --compile --minify src/index.ts --outfile talon-bun",
|
|
40
39
|
"start": "tsx src/index.ts",
|
|
41
40
|
"cli": "tsx src/cli.ts",
|
|
42
41
|
"setup": "tsx src/cli.ts setup",
|
package/prompts/mempalace.md
CHANGED
|
@@ -53,5 +53,8 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
|
|
|
53
53
|
- The knowledge graph stores typed relationships with **time windows**. It knows WHEN things were true.
|
|
54
54
|
- Use `mempalace_check_duplicate` before storing new content to avoid clutter.
|
|
55
55
|
- Diary entries accumulate across sessions. Write them to build continuity of self.
|
|
56
|
+
- Entity detection runs per-language; results include `created_at` timestamps you can surface when the user asks "when did I last…".
|
|
56
57
|
|
|
57
58
|
### Palace location: `{{palacePath}}`
|
|
59
|
+
|
|
60
|
+
### Entity-detection languages: `{{entityLanguages}}`
|
|
@@ -38,9 +38,13 @@ const { registerClaudeModelsStatic, CLAUDE_MODELS_STATIC } =
|
|
|
38
38
|
await import("../backend/claude-sdk/models.js");
|
|
39
39
|
registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
|
|
40
40
|
|
|
41
|
+
// convertSdkModels collapses base + 1M variants into a single canonical ID
|
|
42
|
+
// per family+version, preferring the 1M variant (and "default" when the SDK
|
|
43
|
+
// marks one canonical). So sonnet/sonnet[1m] → "default", opus/opus[1m] →
|
|
44
|
+
// "opus[1m]", and plain "haiku" stays.
|
|
41
45
|
const SDK_MODEL_IDS = {
|
|
42
46
|
sonnet: "default",
|
|
43
|
-
opus: "opus",
|
|
47
|
+
opus: "opus[1m]",
|
|
44
48
|
haiku: "haiku",
|
|
45
49
|
} as const;
|
|
46
50
|
|
|
@@ -58,36 +58,40 @@ describe("registerClaudeModels", () => {
|
|
|
58
58
|
clearModels();
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
it("
|
|
61
|
+
it("collapses family+version duplicates (base + 1M + claude-*) into a single canonical entry", async () => {
|
|
62
62
|
const { registerClaudeModels } =
|
|
63
63
|
await import("../backend/claude-sdk/models.js");
|
|
64
64
|
const { getModels, resolveModelId } = await import("../core/models.js");
|
|
65
65
|
|
|
66
66
|
await registerClaudeModels({ model: "default" });
|
|
67
67
|
|
|
68
|
+
// sonnet, sonnet[1m], claude-sonnet-4-6 all share family+version and
|
|
69
|
+
// collapse into "default" (the SDK's recommended canonical). opus/opus[1m]
|
|
70
|
+
// collapse into opus[1m] (1M-preferred since no "default" exists for that
|
|
71
|
+
// family). haiku stands alone.
|
|
68
72
|
const anthropicModels = getModels("anthropic");
|
|
69
73
|
expect(anthropicModels.map((model) => model.id)).toEqual([
|
|
70
74
|
"default",
|
|
71
|
-
"sonnet[1m]",
|
|
72
|
-
"opus",
|
|
73
75
|
"opus[1m]",
|
|
74
76
|
"haiku",
|
|
75
77
|
]);
|
|
76
78
|
|
|
77
79
|
expect(
|
|
78
80
|
anthropicModels.find((model) => model.id === "default")?.displayName,
|
|
79
|
-
).toBe("
|
|
81
|
+
).toBe("Sonnet 4.6");
|
|
80
82
|
expect(
|
|
81
|
-
anthropicModels.find((model) => model.id === "
|
|
82
|
-
).toBe("
|
|
83
|
-
// claude-sonnet-4-6 collapsed into "default" as alias
|
|
83
|
+
anthropicModels.find((model) => model.id === "opus[1m]")?.displayName,
|
|
84
|
+
).toBe("Opus 4.6");
|
|
84
85
|
expect(
|
|
85
|
-
anthropicModels.
|
|
86
|
-
).toBe(
|
|
86
|
+
anthropicModels.find((model) => model.id === "haiku")?.displayName,
|
|
87
|
+
).toBe("Haiku 4.5");
|
|
87
88
|
|
|
89
|
+
expect(resolveModelId("sonnet")).toBe("default");
|
|
90
|
+
expect(resolveModelId("sonnet[1m]")).toBe("default");
|
|
88
91
|
expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
|
|
89
|
-
expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("
|
|
90
|
-
expect(resolveModelId("
|
|
92
|
+
expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("default");
|
|
93
|
+
expect(resolveModelId("opus")).toBe("opus[1m]");
|
|
94
|
+
expect(resolveModelId("claude-opus-4-6")).toBe("opus[1m]");
|
|
91
95
|
});
|
|
92
96
|
|
|
93
97
|
it("derives compatibility aliases from SDK metadata instead of hardcoded versions", async () => {
|
|
@@ -134,8 +138,8 @@ describe("registerClaudeModels", () => {
|
|
|
134
138
|
|
|
135
139
|
expect(resolveModelId("claude-sonnet-5-0")).toBe("default");
|
|
136
140
|
expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
|
|
137
|
-
expect(resolveModelId("claude-opus-5-0")).toBe("opus");
|
|
138
|
-
expect(resolveModelId("claude-opus-4-6")).toBe("opus");
|
|
141
|
+
expect(resolveModelId("claude-opus-5-0")).toBe("opus[1m]");
|
|
142
|
+
expect(resolveModelId("claude-opus-4-6")).toBe("opus[1m]");
|
|
139
143
|
expect(resolveModelId("claude-haiku-5-0")).toBe("haiku");
|
|
140
144
|
expect(resolveModelId("claude-haiku-4-5")).toBe("haiku");
|
|
141
145
|
});
|
|
@@ -327,13 +327,13 @@ describe("fuzz: resolveModelName()", () => {
|
|
|
327
327
|
it("known aliases resolve to the expected SDK model IDs", () => {
|
|
328
328
|
const aliasMappings = [
|
|
329
329
|
["sonnet", "default"],
|
|
330
|
-
["opus", "opus"],
|
|
330
|
+
["opus", "opus[1m]"],
|
|
331
331
|
["haiku", "haiku"],
|
|
332
332
|
["sonnet-4.6", "default"],
|
|
333
|
-
["opus-4.6", "opus"],
|
|
333
|
+
["opus-4.6", "opus[1m]"],
|
|
334
334
|
["haiku-4.5", "haiku"],
|
|
335
335
|
["sonnet-4-6", "default"],
|
|
336
|
-
["opus-4-6", "opus"],
|
|
336
|
+
["opus-4-6", "opus[1m]"],
|
|
337
337
|
["haiku-4-5", "haiku"],
|
|
338
338
|
] as const;
|
|
339
339
|
fc.assert(
|
|
@@ -1917,6 +1917,61 @@ describe("sendHtml — falls back to plain text on HTML send failure", () => {
|
|
|
1917
1917
|
// Restore sendMessage mock for other tests
|
|
1918
1918
|
mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
|
|
1919
1919
|
}, 3000);
|
|
1920
|
+
|
|
1921
|
+
it("fallback iterates to strip nested tag sequences", async () => {
|
|
1922
|
+
executeMock.mockResolvedValue({
|
|
1923
|
+
text: "",
|
|
1924
|
+
durationMs: 10,
|
|
1925
|
+
inputTokens: 1,
|
|
1926
|
+
outputTokens: 1,
|
|
1927
|
+
cacheRead: 0,
|
|
1928
|
+
cacheWrite: 0,
|
|
1929
|
+
bridgeMessageCount: 0,
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
let callCount = 0;
|
|
1933
|
+
mockBot.api.sendMessage = vi.fn(async () => {
|
|
1934
|
+
callCount++;
|
|
1935
|
+
if (callCount === 1) throw new Error("Bad Request: can't parse entities");
|
|
1936
|
+
return { message_id: callCount };
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
const { classify, friendlyMessage } = await import("../core/errors.js");
|
|
1940
|
+
executeMock.mockRejectedValueOnce(new Error("some error"));
|
|
1941
|
+
(classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
|
1942
|
+
reason: "error",
|
|
1943
|
+
message: "some error",
|
|
1944
|
+
retryable: false,
|
|
1945
|
+
});
|
|
1946
|
+
// A single-pass regex leaves a `<script>` survivor after one removal
|
|
1947
|
+
// of the inner placeholder — the iterative loop must keep going.
|
|
1948
|
+
(friendlyMessage as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
|
1949
|
+
"<scr<script>ipt>alert(1)</script> tail",
|
|
1950
|
+
);
|
|
1951
|
+
|
|
1952
|
+
const ctx = {
|
|
1953
|
+
chat: { id: 97002, type: "private" },
|
|
1954
|
+
message: {
|
|
1955
|
+
text: "nested tag fallback",
|
|
1956
|
+
message_id: 961,
|
|
1957
|
+
reply_to_message: null,
|
|
1958
|
+
},
|
|
1959
|
+
me: { id: 999, username: "testbot" },
|
|
1960
|
+
from: { id: 95, first_name: "Zoe" },
|
|
1961
|
+
} as any;
|
|
1962
|
+
|
|
1963
|
+
await handleTextMessage(ctx, mockBot, mockConfig);
|
|
1964
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
1965
|
+
|
|
1966
|
+
expect(mockBot.api.sendMessage).toHaveBeenCalledTimes(2);
|
|
1967
|
+
const plain = (mockBot.api.sendMessage as ReturnType<typeof vi.fn>).mock
|
|
1968
|
+
.calls[1][1];
|
|
1969
|
+
expect(plain).not.toMatch(/<[^<>]*>/); // no complete tag remains
|
|
1970
|
+
expect(plain).not.toContain("<");
|
|
1971
|
+
expect(plain).toContain("alert(1)");
|
|
1972
|
+
|
|
1973
|
+
mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
|
|
1974
|
+
}, 3000);
|
|
1920
1975
|
});
|
|
1921
1976
|
|
|
1922
1977
|
describe("createStreamCallbacks — onStreamDelta streaming path", () => {
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functional tests for the MCP launcher supervisor.
|
|
3
|
+
*
|
|
4
|
+
* Each test targets a distinct failure mode that would produce an orphaned
|
|
5
|
+
* MCP subprocess in production. All spawn real Node subprocesses and tear
|
|
6
|
+
* them down in afterEach — no mocks, no shortcuts. If these pass on Linux
|
|
7
|
+
* and macOS, the "launcher-wrapped spawns never orphan" claim holds.
|
|
8
|
+
*
|
|
9
|
+
* Cases kept:
|
|
10
|
+
* 1. SIGKILL of parent at scale (headline bug from PR #67).
|
|
11
|
+
* 2. Graceful stdin-close shutdown (normal Talon exit).
|
|
12
|
+
* 3. Stubborn child that ignores SIGTERM (validates SIGKILL fallback).
|
|
13
|
+
* 4. Supervised child exits on its own (launcher doesn't hang).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
17
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join, resolve, dirname } from "node:path";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
24
|
+
const LAUNCHER_MODULE = pathToFileURL(
|
|
25
|
+
resolve(REPO_ROOT, "src/util/mcp-launcher.ts"),
|
|
26
|
+
).href;
|
|
27
|
+
const TSX_IMPORT = pathToFileURL(
|
|
28
|
+
resolve(REPO_ROOT, "node_modules/tsx/dist/esm/index.mjs"),
|
|
29
|
+
).href;
|
|
30
|
+
const FUNCTIONAL_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function pidAlive(pid: number): boolean {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
40
|
+
if (code === "ESRCH") return false;
|
|
41
|
+
// EPERM: exists but unreachable. Count as alive.
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitForPidGone(
|
|
47
|
+
pid: number,
|
|
48
|
+
timeoutMs: number,
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
const deadline = Date.now() + timeoutMs;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (!pidAlive(pid)) return true;
|
|
53
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
54
|
+
}
|
|
55
|
+
return !pidAlive(pid);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function assertAllGone(pids: number[], timeoutMs: number): Promise<void> {
|
|
59
|
+
const stuck: number[] = [];
|
|
60
|
+
for (const pid of pids) {
|
|
61
|
+
if (!(await waitForPidGone(pid, timeoutMs))) stuck.push(pid);
|
|
62
|
+
}
|
|
63
|
+
if (stuck.length > 0) {
|
|
64
|
+
// Clean up the leak so it doesn't poison sibling tests.
|
|
65
|
+
for (const pid of stuck) {
|
|
66
|
+
try {
|
|
67
|
+
process.kill(pid, "SIGKILL");
|
|
68
|
+
} catch {
|
|
69
|
+
/* ok */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`orphaned pids after teardown: ${stuck.join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Read the first stdout line matching `match`, with a timeout. */
|
|
77
|
+
async function readMarker(
|
|
78
|
+
child: ChildProcess,
|
|
79
|
+
match: RegExp,
|
|
80
|
+
timeoutMs: number,
|
|
81
|
+
label: string,
|
|
82
|
+
): Promise<RegExpMatchArray> {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const timer = setTimeout(
|
|
85
|
+
() => reject(new Error(`timeout waiting for ${label}`)),
|
|
86
|
+
timeoutMs,
|
|
87
|
+
);
|
|
88
|
+
let buf = "";
|
|
89
|
+
const onData = (d: Buffer) => {
|
|
90
|
+
buf += d.toString();
|
|
91
|
+
const m = buf.match(match);
|
|
92
|
+
if (m) {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
child.stdout!.off("data", onData);
|
|
95
|
+
resolve(m);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
child.stdout!.on("data", onData);
|
|
99
|
+
child.once("exit", (code) => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(new Error(`process exited before ${label} (code=${code})`));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type IdlerOpts = {
|
|
107
|
+
name?: string;
|
|
108
|
+
/** If false, the idler keeps running even after its stdin closes. */
|
|
109
|
+
exitOnStdinClose?: boolean;
|
|
110
|
+
/** If true, the idler ignores SIGTERM (forces SIGKILL cleanup path). */
|
|
111
|
+
ignoreSigterm?: boolean;
|
|
112
|
+
/** If set, the idler exits on its own this many ms after starting. */
|
|
113
|
+
selfExitAfterMs?: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function writeIdler(dir: string, opts: IdlerOpts = {}): string {
|
|
117
|
+
const path = join(dir, opts.name ?? "idler.mjs");
|
|
118
|
+
const exitOnStdin = opts.exitOnStdinClose !== false;
|
|
119
|
+
const ignoreTerm = opts.ignoreSigterm === true;
|
|
120
|
+
const selfExit = opts.selfExitAfterMs;
|
|
121
|
+
writeFileSync(
|
|
122
|
+
path,
|
|
123
|
+
`
|
|
124
|
+
process.stderr.write("IDLER_PID=" + process.pid + "\\n");
|
|
125
|
+
${exitOnStdin ? 'process.stdin.on("end", () => process.exit(0));' : ""}
|
|
126
|
+
process.on("SIGTERM", () => { ${
|
|
127
|
+
ignoreTerm ? "/* stubborn: ignore */" : "process.exit(0);"
|
|
128
|
+
} });
|
|
129
|
+
process.on("SIGINT", () => process.exit(0));
|
|
130
|
+
process.stdin.resume();
|
|
131
|
+
${selfExit !== undefined ? `setTimeout(() => process.exit(0), ${selfExit});` : ""}
|
|
132
|
+
setInterval(() => {}, 1 << 30);
|
|
133
|
+
`,
|
|
134
|
+
);
|
|
135
|
+
return path;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type HarnessResult = { harness: ChildProcess; pids: number[] };
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Spawn a harness that uses the real `wrapMcpServer()` to supervise
|
|
142
|
+
* `count` idlers. Resolves once every idler has reported its PID, returning
|
|
143
|
+
* [launcher PIDs..., idler PIDs...] (length = count * 2).
|
|
144
|
+
*/
|
|
145
|
+
async function spawnHarness(opts: {
|
|
146
|
+
workDir: string;
|
|
147
|
+
count: number;
|
|
148
|
+
idlerPath: string;
|
|
149
|
+
}): Promise<HarnessResult> {
|
|
150
|
+
const { workDir, count, idlerPath } = opts;
|
|
151
|
+
const harnessPath = join(workDir, "harness.mjs");
|
|
152
|
+
writeFileSync(
|
|
153
|
+
harnessPath,
|
|
154
|
+
`
|
|
155
|
+
import { spawn } from "node:child_process";
|
|
156
|
+
import { wrapMcpServer } from ${JSON.stringify(LAUNCHER_MODULE)};
|
|
157
|
+
|
|
158
|
+
const launchers = [];
|
|
159
|
+
const idlers = [];
|
|
160
|
+
const TARGET = ${count};
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < TARGET; i++) {
|
|
163
|
+
const cfg = wrapMcpServer({
|
|
164
|
+
command: "node",
|
|
165
|
+
args: [${JSON.stringify(idlerPath)}],
|
|
166
|
+
env: {},
|
|
167
|
+
});
|
|
168
|
+
const c = spawn(cfg.command, cfg.args, {
|
|
169
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
170
|
+
env: { ...process.env, ...cfg.env },
|
|
171
|
+
});
|
|
172
|
+
launchers.push(c);
|
|
173
|
+
|
|
174
|
+
let buf = "";
|
|
175
|
+
c.stderr.on("data", (d) => {
|
|
176
|
+
buf += d.toString();
|
|
177
|
+
const m = buf.match(/IDLER_PID=(\\d+)/);
|
|
178
|
+
if (m) {
|
|
179
|
+
idlers.push(parseInt(m[1], 10));
|
|
180
|
+
buf = buf.replace(/IDLER_PID=\\d+\\n?/, "");
|
|
181
|
+
if (idlers.length === TARGET) {
|
|
182
|
+
process.stdout.write(
|
|
183
|
+
"PIDS=" + launchers.map((c) => c.pid).concat(idlers).join(",") + "\\n",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
process.stdin.on("end", () => process.exit(0));
|
|
191
|
+
process.stdin.resume();
|
|
192
|
+
`,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const harness = spawn("node", ["--import", TSX_IMPORT, harnessPath], {
|
|
196
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
197
|
+
env: { ...process.env, HOME: workDir },
|
|
198
|
+
});
|
|
199
|
+
const marker = await readMarker(
|
|
200
|
+
harness,
|
|
201
|
+
/PIDS=([\d,]+)/,
|
|
202
|
+
15_000,
|
|
203
|
+
"harness PID marker",
|
|
204
|
+
);
|
|
205
|
+
const pids = marker[1].split(",").map((s) => parseInt(s, 10));
|
|
206
|
+
if (pids.length !== count * 2) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`harness reported ${pids.length} pids, expected ${count * 2}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return { harness, pids };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
describe("launcher functional: no orphaned MCP processes", () => {
|
|
217
|
+
let workDir: string;
|
|
218
|
+
const cleanup: Array<() => void> = [];
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
workDir = mkdtempSync(join(tmpdir(), "talon-launcher-fn-"));
|
|
222
|
+
process.env.HOME = workDir; // paths.ts reads homedir() → this
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
for (const fn of cleanup.splice(0)) {
|
|
227
|
+
try {
|
|
228
|
+
fn();
|
|
229
|
+
} catch {
|
|
230
|
+
/* ok */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
235
|
+
} catch {
|
|
236
|
+
/* ok */
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
function track(child: ChildProcess): void {
|
|
241
|
+
cleanup.push(() => {
|
|
242
|
+
if (!child.killed) {
|
|
243
|
+
try {
|
|
244
|
+
child.kill("SIGKILL");
|
|
245
|
+
} catch {
|
|
246
|
+
/* ok */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it(
|
|
253
|
+
"SIGKILL of parent cleans up every descendant (10 wrapped children)",
|
|
254
|
+
async () => {
|
|
255
|
+
const idler = writeIdler(workDir);
|
|
256
|
+
const { harness, pids } = await spawnHarness({
|
|
257
|
+
workDir,
|
|
258
|
+
count: 10,
|
|
259
|
+
idlerPath: idler,
|
|
260
|
+
});
|
|
261
|
+
track(harness);
|
|
262
|
+
expect(pids).toHaveLength(20); // 10 launchers + 10 idlers
|
|
263
|
+
|
|
264
|
+
harness.kill("SIGKILL");
|
|
265
|
+
await assertAllGone(pids, 5_000);
|
|
266
|
+
},
|
|
267
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
it(
|
|
271
|
+
"graceful shutdown (stdin close) cleans up every descendant",
|
|
272
|
+
async () => {
|
|
273
|
+
const idler = writeIdler(workDir);
|
|
274
|
+
const { harness, pids } = await spawnHarness({
|
|
275
|
+
workDir,
|
|
276
|
+
count: 3,
|
|
277
|
+
idlerPath: idler,
|
|
278
|
+
});
|
|
279
|
+
track(harness);
|
|
280
|
+
|
|
281
|
+
harness.stdin!.end();
|
|
282
|
+
const exitCode = await new Promise<number | null>((r) =>
|
|
283
|
+
harness.on("exit", (c) => r(c)),
|
|
284
|
+
);
|
|
285
|
+
expect(exitCode).toBe(0);
|
|
286
|
+
await assertAllGone(pids, 5_000);
|
|
287
|
+
},
|
|
288
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
it(
|
|
292
|
+
"SIGKILLs stubborn children that ignore SIGTERM",
|
|
293
|
+
async () => {
|
|
294
|
+
const stubborn = writeIdler(workDir, {
|
|
295
|
+
name: "stubborn.mjs",
|
|
296
|
+
ignoreSigterm: true,
|
|
297
|
+
exitOnStdinClose: false,
|
|
298
|
+
});
|
|
299
|
+
const { harness, pids } = await spawnHarness({
|
|
300
|
+
workDir,
|
|
301
|
+
count: 2,
|
|
302
|
+
idlerPath: stubborn,
|
|
303
|
+
});
|
|
304
|
+
track(harness);
|
|
305
|
+
|
|
306
|
+
harness.kill("SIGKILL");
|
|
307
|
+
// Launcher: SIGTERM → 1s grace → SIGKILL. Give 4s headroom.
|
|
308
|
+
await assertAllGone(pids, 4_000);
|
|
309
|
+
},
|
|
310
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
it(
|
|
314
|
+
"launcher exits when its supervised child exits on its own",
|
|
315
|
+
async () => {
|
|
316
|
+
const oneShot = writeIdler(workDir, {
|
|
317
|
+
name: "one-shot.mjs",
|
|
318
|
+
selfExitAfterMs: 200,
|
|
319
|
+
});
|
|
320
|
+
const { harness, pids } = await spawnHarness({
|
|
321
|
+
workDir,
|
|
322
|
+
count: 1,
|
|
323
|
+
idlerPath: oneShot,
|
|
324
|
+
});
|
|
325
|
+
track(harness);
|
|
326
|
+
|
|
327
|
+
// Both launcher (pids[0]) and idler (pids[1]) must be gone within seconds
|
|
328
|
+
// even though the harness itself is still running.
|
|
329
|
+
await assertAllGone(pids, 5_000);
|
|
330
|
+
expect(pidAlive(harness.pid!)).toBe(true);
|
|
331
|
+
},
|
|
332
|
+
FUNCTIONAL_TIMEOUT_MS,
|
|
333
|
+
);
|
|
334
|
+
});
|