talon-agent 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Dylan Neve
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
5
5
  [![Claude](https://img.shields.io/badge/Claude_Agent_SDK-Anthropic-D97706)](https://github.com/anthropics/claude-agent-sdk-typescript)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![CI](https://github.com/dylanneve1/talon/actions/workflows/ci.yml/badge.svg)](https://github.com/dylanneve1/talon/actions/workflows/ci.yml)
7
8
 
8
9
  Multi-platform agentic AI harness powered by Claude. Runs on Telegram, Teams, and Terminal with full tool access through MCP.
9
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.0.0",
3
+ "version": "1.1.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,6 +36,7 @@
36
36
  "tsconfig.json"
37
37
  ],
38
38
  "scripts": {
39
+ "build:binary": "bun build --compile --minify src/index.ts --outfile talon-bun",
39
40
  "start": "tsx src/index.ts",
40
41
  "cli": "tsx src/cli.ts",
41
42
  "setup": "tsx src/cli.ts setup",
@@ -45,28 +46,30 @@
45
46
  "test:coverage": "vitest run --coverage",
46
47
  "test:mutation": "stryker run",
47
48
  "typecheck": "tsc --noEmit",
48
- "lint": "oxlint src/ --ignore-pattern '**/__tests__/**'",
49
+ "lint": "oxlint src/",
50
+ "knip": "knip",
49
51
  "format": "prettier --write src/ prompts/",
50
52
  "format:check": "prettier --check src/ prompts/"
51
53
  },
52
54
  "dependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "^0.2.89",
55
+ "@anthropic-ai/claude-agent-sdk": "^0.2.96",
54
56
  "@clack/prompts": "^1.2.0",
55
57
  "@grammyjs/auto-retry": "^2.0.2",
56
58
  "@grammyjs/transformer-throttler": "^1.2.1",
57
59
  "@modelcontextprotocol/sdk": "^1.29.0",
58
- "@opencode-ai/sdk": "^1.3.13",
60
+ "@opencode-ai/sdk": "^1.4.0",
59
61
  "big-integer": "^1.6.52",
60
62
  "cheerio": "^1.2.0",
61
63
  "croner": "^10.0.1",
62
- "grammy": "^1.41.1",
63
- "marked": "^17.0.5",
64
+ "grammy": "^1.42.0",
65
+ "marked": "^18.0.0",
66
+ "p-retry": "^8.0.0",
64
67
  "picocolors": "^1.1.1",
65
68
  "pino": "^10.3.1",
66
69
  "pino-pretty": "^13.1.3",
67
70
  "telegram": "^2.26.22",
68
71
  "tsx": "^4.21.0",
69
- "undici": "^7.24.7",
72
+ "undici": "^8.0.2",
70
73
  "write-file-atomic": "^7.0.1",
71
74
  "zod": "^4.3.6"
72
75
  },
@@ -74,13 +77,14 @@
74
77
  "@stryker-mutator/core": "^9.6.0",
75
78
  "@stryker-mutator/typescript-checker": "^9.6.0",
76
79
  "@stryker-mutator/vitest-runner": "^9.6.0",
77
- "@types/node": "^25.5.0",
80
+ "@types/node": "^25.5.2",
78
81
  "@types/write-file-atomic": "^4.0.3",
79
- "@vitest/coverage-v8": "^4.1.2",
82
+ "@vitest/coverage-v8": "^4.1.3",
80
83
  "fast-check": "^4.6.0",
81
- "oxlint": "^1.58.0",
84
+ "knip": "^6.3.1",
85
+ "oxlint": "^1.59.0",
82
86
  "prettier": "^3.8.1",
83
87
  "typescript": "^6.0.2",
84
- "vitest": "^4.1.2"
88
+ "vitest": "^4.1.3"
85
89
  }
86
90
  }
package/prompts/dream.md CHANGED
@@ -5,11 +5,12 @@ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep).
5
5
  ## Your 4-stage task
6
6
 
7
7
  ### Stage 1 — Orient
8
- - Read `{{dreamStateFile}}` to confirm `last_run` timestamp
8
+
9
9
  - List log files in `{{logsDir}}/` that are dated on or after `{{lastRunIso}}`
10
- - If there are no new log files, update dream_state.json status to "idle" and stop
10
+ - If there are no new log files, stop the system will handle state updates
11
11
 
12
12
  ### Stage 2 — Gather
13
+
13
14
  - Read each new log file
14
15
  - Each log file uses this format:
15
16
  - User messages appear as `## HH:MM -- [Username]` followed by the full message text
@@ -25,17 +26,20 @@ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep).
25
26
  - Be selective — only extract genuinely new or updated information
26
27
 
27
28
  ### Stage 3 — Consolidate
29
+
28
30
  - Read the current memory file at `{{memoryFile}}`
29
31
  - Merge new information into the appropriate sections
30
32
  - Update existing entries if new info contradicts or extends them
31
33
  - Add new entries where appropriate
32
34
  - Keep entries concise and factual — no padding, no narrative
33
35
  - Preserve all existing structure and sections
36
+ - Also write daily memory summaries to `{{dailyMemoryDir}}/YYYY-MM-DD.md` for each day of logs you processed. Include key learnings, conversation summaries, and follow-ups. Keep these concise — the bot reads them on demand for context.
34
37
 
35
38
  ### Stage 4 — Prune
39
+
36
40
  - Remove entries that have been explicitly contradicted
37
41
  - Remove entries that are clearly stale or irrelevant
38
42
  - Do NOT remove entries just because they're old — only remove if wrong or superseded
39
43
  - Write the updated memory.md back to `{{memoryFile}}`
40
44
 
41
- When done, your final action is to write `{ "last_run": <current_unix_ms>, "status": "idle" }` to `{{dreamStateFile}}`.
45
+ When done with memory consolidation, stop. The system handles all dream_state.json updates.
@@ -0,0 +1,30 @@
1
+ You are Talon's background heartbeat agent. You run periodically (every {{intervalMinutes}} minutes) to perform maintenance tasks defined by the user.
2
+
3
+ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep). Do NOT attempt to use any Telegram, MCP, or messaging tools.
4
+
5
+ ## Context
6
+
7
+ - Workspace: `{{workspace}}`
8
+ - Memory file: `{{memoryFile}}`
9
+ - Logs directory: `{{logsDir}}`
10
+ - Last heartbeat: `{{lastRunIso}}`
11
+ - Run number: #{{runCount}}
12
+ - Today's daily memory: `{{dailyMemoryFile}}`
13
+
14
+ ## Instructions
15
+
16
+ Read the user-defined instructions file at `{{instructionsFile}}`. Follow whatever tasks are defined there.
17
+
18
+ If the instructions file does not exist or is empty, perform these default tasks:
19
+
20
+ 1. **Review recent logs** — Check `{{logsDir}}/` for log files dated after `{{lastRunIso}}`. If `{{lastRunIso}}` is `never`, treat it as the beginning of time and review all available logs. Extract any new facts, preferences, or notable events.
21
+ 2. **Update memory** — Merge any new information into `{{memoryFile}}`, keeping entries concise and factual.
22
+ 3. **Update daily notes** — Write today's learnings, observations, corrections, and follow-ups to `{{dailyMemoryFile}}`. Keep entries concise — the bot reads this file on demand for context.
23
+ 4. **Workspace hygiene** — Note any issues but do not delete files unless the instructions explicitly say to.
24
+
25
+ ## Rules
26
+
27
+ - Be surgical and precise. Do not rewrite files unnecessarily.
28
+ - Do not modify files outside the workspace unless the instructions explicitly allow it.
29
+ - Keep your work focused and efficient — you have a 10-minute time limit.
30
+ - When done, stop. The system handles all state tracking.
@@ -17,6 +17,7 @@
17
17
  Your identity is defined in `~/.talon/workspace/identity.md`. Read it to know who you are.
18
18
 
19
19
  If the identity file is empty or only contains the template comments, you MUST ask the user during your first interaction:
20
+
20
21
  - What should I be called?
21
22
  - Who are you / who created me?
22
23
  - What will I be used for?
package/prompts/teams.md CHANGED
@@ -32,6 +32,7 @@ Webhook-based integration — no reactions, media uploads, message editing, typi
32
32
  Messages render as Adaptive Cards. The formatting engine is NOT standard Markdown.
33
33
 
34
34
  What WORKS:
35
+
35
36
  - **bold** and _italic_
36
37
  - [links](https://example.com)
37
38
  - Fenced code blocks (triple backticks) — render as monospace in a grey box
@@ -39,11 +40,13 @@ What WORKS:
39
40
  - Numbered and bulleted lists
40
41
 
41
42
  What does NOT work:
43
+
42
44
  - Inline code with backticks — do NOT use `code` style, just write the text plain
43
45
  - Headings with # — use **bold** text instead
44
46
  - Images/media — not supported via webhook
45
47
 
46
48
  Style:
49
+
47
50
  - Concise. No filler.
48
51
  - Use **bold** for emphasis, _italic_ for secondary emphasis.
49
52
  - Use markdown tables for structured/tabular data — they render as proper grid tables.
@@ -75,6 +75,7 @@ When a user presses a callback button, you'll receive "[Button pressed]" with th
75
75
  ### Stickers
76
76
 
77
77
  Use stickers like a human would — they're part of Telegram culture:
78
+
78
79
  - When users send stickers, their set_name is captured. Use `save_sticker_pack` to save packs you like.
79
80
  - Once saved, read `~/.talon/workspace/stickers/<set_name>.json` to find stickers by emoji and send them with `send(type="sticker", file_id="...")`.
80
81
  - Send stickers to express emotions, reactions, or just for fun. Don't overuse them.
@@ -16,6 +16,11 @@ vi.mock("node:fs", () => ({
16
16
  mkdirSync: vi.fn(),
17
17
  }));
18
18
 
19
+ // Mock write-file-atomic to prevent writes to the real production file
20
+ vi.mock("write-file-atomic", () => ({
21
+ default: { sync: vi.fn() },
22
+ }));
23
+
19
24
  const {
20
25
  getChatSettings,
21
26
  setChatModel,
@@ -252,7 +257,9 @@ describe("chat-settings", () => {
252
257
  const settings = getChatSettings("migrate-1");
253
258
  expect(settings.effort).toBe("off");
254
259
  // maxThinkingTokens should be removed
255
- expect((settings as Record<string, unknown>).maxThinkingTokens).toBeUndefined();
260
+ expect(
261
+ (settings as Record<string, unknown>).maxThinkingTokens,
262
+ ).toBeUndefined();
256
263
  });
257
264
 
258
265
  it("migrates maxThinkingTokens=1000 to effort=low", () => {
@@ -320,7 +327,9 @@ describe("chat-settings", () => {
320
327
  const settings = getChatSettings("migrate-6");
321
328
  // Should keep existing effort, just clean up old field
322
329
  expect(settings.effort).toBe("high");
323
- expect((settings as Record<string, unknown>).maxThinkingTokens).toBeUndefined();
330
+ expect(
331
+ (settings as Record<string, unknown>).maxThinkingTokens,
332
+ ).toBeUndefined();
324
333
  });
325
334
 
326
335
  it("handles missing store file gracefully", () => {
@@ -335,3 +344,100 @@ describe("chat-settings", () => {
335
344
  });
336
345
  });
337
346
  });
347
+
348
+ describe("chat-settings — setPulseLastCheckMsgId", () => {
349
+ it("sets pulseLastCheckMsgId when msgId is provided", async () => {
350
+ const { setPulseLastCheckMsgId, getChatSettings } =
351
+ await import("../storage/chat-settings.js");
352
+ setPulseLastCheckMsgId("pulse-check-1", 42);
353
+ expect(getChatSettings("pulse-check-1").pulseLastCheckMsgId).toBe(42);
354
+ });
355
+
356
+ it("clears pulseLastCheckMsgId when undefined is passed", async () => {
357
+ const { setPulseLastCheckMsgId, getChatSettings } =
358
+ await import("../storage/chat-settings.js");
359
+ setPulseLastCheckMsgId("pulse-check-2", 100);
360
+ setPulseLastCheckMsgId("pulse-check-2", undefined);
361
+ expect(
362
+ getChatSettings("pulse-check-2").pulseLastCheckMsgId,
363
+ ).toBeUndefined();
364
+ });
365
+
366
+ it("removes empty settings object after all fields cleared", async () => {
367
+ const { setPulseLastCheckMsgId, getChatSettings } =
368
+ await import("../storage/chat-settings.js");
369
+ // Set only pulseLastCheckMsgId (no model, effort, pulse, pulseIntervalMs)
370
+ setPulseLastCheckMsgId("pulse-cleanup-1", 99);
371
+ setPulseLastCheckMsgId("pulse-cleanup-1", undefined);
372
+ // cleanupEmpty should have removed the settings object
373
+ expect(getChatSettings("pulse-cleanup-1")).toEqual({});
374
+ });
375
+ });
376
+
377
+ describe("chat-settings — migration of has-effort + maxThinkingTokens", () => {
378
+ it("cleans up maxThinkingTokens when effort already set", async () => {
379
+ const { loadChatSettings, getChatSettings } =
380
+ await import("../storage/chat-settings.js");
381
+ const { existsSync, readFileSync } = await import("node:fs");
382
+ vi.mocked(existsSync).mockReturnValueOnce(true);
383
+ vi.mocked(readFileSync).mockReturnValueOnce(
384
+ JSON.stringify({
385
+ "migrate-has-effort": { effort: "high", maxThinkingTokens: 16000 },
386
+ }),
387
+ );
388
+ loadChatSettings();
389
+ const s = getChatSettings("migrate-has-effort");
390
+ expect(s.effort).toBe("high");
391
+ expect((s as Record<string, unknown>).maxThinkingTokens).toBeUndefined();
392
+ });
393
+ });
394
+
395
+ describe("chat-settings — flushChatSettings", () => {
396
+ it("does not throw when called", async () => {
397
+ const { flushChatSettings, setChatModel } =
398
+ await import("../storage/chat-settings.js");
399
+ // Make dirty first so save() runs
400
+ setChatModel("flush-test", "claude-opus-4-6");
401
+ expect(() => flushChatSettings()).not.toThrow();
402
+ });
403
+ });
404
+
405
+ describe("chat-settings — cleanupEmpty keeps entry when other fields remain (line 115 FALSE branch)", () => {
406
+ it("does not delete entry when effort is still set after clearing model", () => {
407
+ const chatId = `cleanup-keep-entry-${Date.now()}`;
408
+ // Set both model and effort
409
+ setChatModel(chatId, "claude-sonnet-4-6");
410
+ setChatEffort(chatId, "high");
411
+ expect(getChatSettings(chatId).model).toBe("claude-sonnet-4-6");
412
+ expect(getChatSettings(chatId).effort).toBe("high");
413
+
414
+ // Clear model only — effort still set → cleanupEmpty condition is FALSE → entry kept
415
+ setChatModel(chatId, undefined);
416
+ expect(getChatSettings(chatId).effort).toBe("high");
417
+ expect(getChatSettings(chatId).model).toBeUndefined();
418
+ });
419
+ });
420
+
421
+ describe("chat-settings — backup recovery on corrupt primary", () => {
422
+ it("loads from backup when primary JSON is corrupt", async () => {
423
+ const { loadChatSettings, getChatSettings } =
424
+ await import("../storage/chat-settings.js");
425
+ vi.mocked(existsSync)
426
+ .mockReturnValueOnce(true) // primary exists
427
+ .mockReturnValueOnce(true); // backup exists
428
+ vi.mocked(readFileSync)
429
+ .mockReturnValueOnce("{ INVALID JSON") // primary corrupt
430
+ .mockReturnValueOnce(
431
+ JSON.stringify({
432
+ "backup-settings-chat": {
433
+ model: "claude-sonnet-4-6",
434
+ effort: "medium",
435
+ },
436
+ }),
437
+ );
438
+ loadChatSettings();
439
+ const s = getChatSettings("backup-settings-chat");
440
+ expect(s.model).toBe("claude-sonnet-4-6");
441
+ expect(s.effort).toBe("medium");
442
+ });
443
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tests for src/util/cleanup-registry.ts
3
+ */
4
+ import { describe, it, expect } from "vitest";
5
+ import { registerCleanup } from "../util/cleanup-registry.js";
6
+
7
+ describe("cleanup-registry", () => {
8
+ it("registered handler is called when the exit event fires", () => {
9
+ let called = false;
10
+ registerCleanup(() => {
11
+ called = true;
12
+ });
13
+ process.emit("exit", 0);
14
+ expect(called).toBe(true);
15
+ });
16
+
17
+ it("multiple handlers are all called on exit", () => {
18
+ const results: number[] = [];
19
+ registerCleanup(() => results.push(1));
20
+ registerCleanup(() => results.push(2));
21
+ registerCleanup(() => results.push(3));
22
+ process.emit("exit", 0);
23
+ expect(results).toContain(1);
24
+ expect(results).toContain(2);
25
+ expect(results).toContain(3);
26
+ });
27
+
28
+ it("handler that throws does not prevent subsequent handlers from running", () => {
29
+ let afterCalled = false;
30
+ registerCleanup(() => {
31
+ throw new Error("boom");
32
+ });
33
+ registerCleanup(() => {
34
+ afterCalled = true;
35
+ });
36
+ expect(() => process.emit("exit", 0)).not.toThrow();
37
+ expect(afterCalled).toBe(true);
38
+ });
39
+
40
+ it("registering the same function object twice calls it twice", () => {
41
+ let count = 0;
42
+ const fn = () => count++;
43
+ registerCleanup(fn);
44
+ registerCleanup(fn);
45
+ process.emit("exit", 0);
46
+ expect(count).toBe(2);
47
+ });
48
+
49
+ it("does not add a new process exit listener on each registerCleanup call", () => {
50
+ const before = process.listenerCount("exit");
51
+ registerCleanup(() => {});
52
+ registerCleanup(() => {});
53
+ registerCleanup(() => {});
54
+ const after = process.listenerCount("exit");
55
+ // Listener count must not grow — one listener handles all handlers
56
+ expect(after).toBe(before);
57
+ });
58
+ });