@misaka-net/misakanet-setup 0.5.3 → 0.5.5

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.
@@ -21,6 +21,7 @@
21
21
  * echo '{"session_id":"demo"}' | node checkpoint_reminder.mjs prompt
22
22
  */
23
23
  import { writeFileSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
24
+ import { createHash } from 'node:crypto';
24
25
  import { homedir } from 'node:os';
25
26
  import { join, dirname } from 'node:path';
26
27
 
@@ -69,11 +70,20 @@ function parsePayload(raw) {
69
70
  }
70
71
 
71
72
  function sessionKey(payload) {
73
+ // An explicit key wins: the same value the strategy below would derive, but chosen by the user or
74
+ // by an agent that knows its own conversation boundaries.
75
+ const pinned = (process.env.MISAKANET_SESSION_KEY || '').trim();
76
+ if (pinned) return pinned.slice(0, 64);
72
77
  for (const key of ['session_id', 'sessionId', 'session', 'thread_id', 'conversation_id']) {
73
78
  const value = payload[key];
74
79
  if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 64);
75
80
  }
76
- return 'default';
81
+ // No session id at all used to fall back to the single name `default`, so every agent on the
82
+ // machine that does not report one shared *one* turn counter: agent A's twentieth turn fired the
83
+ // checkpoint in agent B's session and B never saw its own reminder (2026-09-18 review, 意见 9).
84
+ // Deriving the key from the working directory keeps one counter per project, which is the closest
85
+ // thing to "this conversation" that a hook can see without an id — and it is stable across turns.
86
+ return `default-${createHash('sha1').update(process.cwd()).digest('hex').slice(0, 8)}`;
77
87
  }
78
88
 
79
89
  function statePath(session) {
@@ -277,7 +287,11 @@ function promptMode(payload) {
277
287
  '1) 挑出本会话真正值得复用的「失败→根因→修复→验证」;门槛:可泛化、有可跑判据、且先 ' +
278
288
  'misakanet_search 确认语料里没有重复(有就引用其 id,不要另写一篇)。\n' +
279
289
  '2) 脱敏:密钥/token/凭据 → <REDACTED>;人名/邮箱/真实域名/绝对家目录 → 泛化成 ~/project、example.com;' +
280
- '不要粘会话转录或整段工具输出。\n' +
290
+ // The "内部业务细节抽象成结构性描述" clause is the Python hook's wording, which is the one that
291
+ // carries it (added 2026-09-14, `2af7a41df`). The two hooks had drifted here and nothing
292
+ // compared them; `tests/test_agent_autostart_parity.py` now does, and this line was its first
293
+ // find. The stronger clause wins: it is a redaction rule, and the weaker copy was the Node one.
294
+ '不要粘会话转录或整段工具输出;内部业务细节抽象成结构性描述。\n' +
281
295
  '3) 提交(无需 token):misakanet_submit_intake(kind="missing_lesson", problem="## Problem\\n…\\n\\n' +
282
296
  '## Root Cause\\n…\\n\\n## Solution\\n…\\n\\n## Verification\\n…");若这条其实是「问题」而非经验,' +
283
297
  '用 kind="question";若确实不够泛化/价值不高 → 不提交。\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misaka-net/misakanet-setup",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw / codewhale to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
5
5
  "bin": {
6
6
  "misakanet-setup": "bin/misakanet-setup.mjs"
@@ -1,36 +1,103 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * MisakaNet voice hook — play the cue a tool result asked for.
3
+ * MisakaNet attention hook — route the cue a tool result asked for to a sound and/or a
4
+ * desktop notification.
4
5
  *
5
6
  * The MCP server already answers with a `voice` field (`lesson-found` when a search hit,
6
7
  * `failure-warning` when it did not, `connect-success` for a lesson, `pair-success` for a
7
- * usage receipt). This hook turns that field into a sound. It is registered as a Claude Code
8
- * `PostToolUse` hook by `npx @misaka-net/misakanet-setup --voice`, and it is *opt-in*: nobody
9
- * wants an assistant that starts talking on its own.
8
+ * usage receipt). This hook turns that field into attention: a sound, and — because the
9
+ * *model* cannot see that a tool ran and neither can the human watching — a short desktop
10
+ * notification. It is registered as a Claude Code `PostToolUse` hook by
11
+ * `npx @misaka-net/misakanet-setup --voice`, and it is *opt-in*: nobody wants an assistant
12
+ * that starts talking on its own.
10
13
  *
11
14
  * Why Node and not the older shell script: that one parses stdin with `python`, and `python`
12
15
  * is not on PATH on many Linux/WSL boxes (`python3` is) — this repo has that exact bug on
13
16
  * record. Node is guaranteed here: the installer itself runs on it.
14
17
  *
15
18
  * Rules it must never break:
16
- * - **never block the agent**: the player is spawned detached and the process exits
17
- * immediately, with a hard timeout as a backstop;
18
- * - **never fail loudly**: a missing player, a missing MP3 or unparsable stdin all end in
19
- * exit 0 with no output, because a hook that prints to stderr can show up as a tool error;
20
- * - **be silent when asked**: `MISAKANET_VOICE=0` mutes it even after it is installed.
19
+ * - **never block the agent**: players and notifiers are spawned detached and the process
20
+ * exits immediately, with a hard timeout as a backstop;
21
+ * - **never fail loudly**: a missing player, a missing notification binary, a missing MP3,
22
+ * an unwritable state file or unparsable stdin all end in exit 0 with no output, because a
23
+ * hook that prints to stderr can show up as a tool error;
24
+ * - **be silent when asked**: `MISAKANET_VOICE=0` mutes sound *and* notifications even after
25
+ * it is installed, and `MISAKANET_NOTIFY=0` turns off only the notifications;
26
+ * - **never execute anything derived from server input**: see the table below.
21
27
  *
22
28
  * Test/debug affordances:
23
- * - `MISAKANET_VOICE_DRY_RUN=1` prints the cue name instead of playing it (that is what the
24
- * test suite uses, so CI never needs an audio device);
25
- * - `MISAKANET_VOICE_DIR` overrides where the MP3s live.
29
+ * - `MISAKANET_VOICE_DRY_RUN=1` prints the cue name and what the hook *would* do — sound and
30
+ * notification — while executing nothing (that is what the test suite uses, so CI never
31
+ * needs an audio device or a desktop session);
32
+ * - `MISAKANET_VOICE_DEBUG=1` prints one line per real run: cue, player, file, notification;
33
+ * - `MISAKANET_VOICE_DIR` overrides where the MP3s live;
34
+ * - `MISAKANET_HOOK_STATE` (shared with `checkpoint_reminder.mjs`) overrides the state
35
+ * directory that holds the "already notified once" ledger.
26
36
  */
27
37
  import { spawn } from 'node:child_process';
28
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
29
- import { dirname, join } from 'node:path';
38
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
39
+ import { homedir } from 'node:os';
40
+ import { delimiter, dirname, join } from 'node:path';
30
41
  import { fileURLToPath } from 'node:url';
31
42
 
32
43
  const HERE = dirname(fileURLToPath(import.meta.url));
33
- const CUES = ['connect-success', 'pair-success', 'lesson-found', 'failure-warning'];
44
+
45
+ /**
46
+ * THE TABLE — the single place a cue name turns into something this process does.
47
+ *
48
+ * ─── HARD SECURITY RULE (issue #1785) ──────────────────────────────────────────────────
49
+ * The cue is **server-supplied** and is used for exactly one thing: as a *key* into this
50
+ * table. It is never concatenated into a command line, never concatenated into a path,
51
+ * never handed to a shell, and never passed as an argument to any process. Every executable
52
+ * name, every argument, every MP3 file name and every notification text executed by this
53
+ * hook is a literal written in this file.
54
+ *
55
+ * An unknown cue — including `rm -rf /`, a cue full of `;`/`$()`/backticks, or a 5000
56
+ * character cue — matches no key, so there is no action: the hook exits 0 having done
57
+ * nothing at all, and reaches no binary. `workers/agent-autostart-hook.test.mjs` asserts
58
+ * this by running the hook with a stubbed PATH (every player and notifier replaced by a
59
+ * recording stub) and requiring the recording to stay empty.
60
+ * ──────────────────────────────────────────────────────────────────────────────────────
61
+ *
62
+ * Three more decisions worth stating:
63
+ * - `sound` is the MP3 **file name, spelled out**, rather than `${cue}.mp3`: the file name is
64
+ * then not built out of server input either, so there is no interpolation left to argue
65
+ * about.
66
+ * - `once: true` means "notify only the first time this cue is seen on this machine". Both
67
+ * success cues repeat on every single call (a lesson fetch, a receipt), and a toast per
68
+ * call is exactly the noise that makes people mute the whole feature; the ledger that
69
+ * remembers this lives in the state directory (see `ledgerMark`).
70
+ * - `notify: ''` for `failure-warning` is deliberate: "no lesson found" is a *negative*
71
+ * result the agent is already telling the user about, and it happens on every empty
72
+ * search — a notification there buys nothing and costs attention. Sound only, as the
73
+ * issue's acceptance criteria require. The issue drafted 「MisakaNet:未找到相关经验,已记录」
74
+ * for it; that string is deliberately not wired to anything, and is recorded here only so
75
+ * a future translation pass knows it was considered and dropped.
76
+ */
77
+ const CUE_ACTIONS = Object.freeze({
78
+ 'lesson-found': Object.freeze({
79
+ sound: 'lesson-found.mp3',
80
+ notify: 'MisakaNet:找到一条相关经验',
81
+ once: false,
82
+ }),
83
+ 'failure-warning': Object.freeze({
84
+ sound: 'failure-warning.mp3',
85
+ notify: '', // sound only — see above
86
+ once: false,
87
+ }),
88
+ 'connect-success': Object.freeze({
89
+ sound: 'connect-success.mp3',
90
+ notify: 'MisakaNet:已连接',
91
+ once: true,
92
+ }),
93
+ 'pair-success': Object.freeze({
94
+ sound: 'pair-success.mp3',
95
+ notify: 'MisakaNet:已连接',
96
+ once: true,
97
+ }),
98
+ });
99
+
100
+ const CUES = Object.keys(CUE_ACTIONS);
34
101
 
35
102
  /**
36
103
  * Find the first cue name anywhere in the payload.
@@ -81,9 +148,18 @@ function parseCue(raw) {
81
148
  /* try the next shape */
82
149
  }
83
150
  }
84
- // Last resort: a bare cue name on the line, so a hand-rolled hook still works.
85
- const bare = trimmed.match(new RegExp(`\\b(${CUES.join('|')})\\b`));
86
- return bare ? bare[1] : '';
151
+ // Last resort: a bare cue name on its own line, so a hand-rolled hook still works.
152
+ //
153
+ // The whole line must *be* the cue name. A fuzzy "contains a cue name" match would mean a
154
+ // hostile string like `lesson-found; notify-send pwned` routes to the legitimately-tabled
155
+ // `lesson-found` action: no injection happens either way (the action comes from the table),
156
+ // but "a cue we did not send triggers an action" is not a contract worth keeping. An exact
157
+ // match is also what lets the security test below demand *no* action for hostile input.
158
+ for (const line of trimmed.split('\n')) {
159
+ const bare = line.trim();
160
+ if (CUES.includes(bare)) return bare;
161
+ }
162
+ return '';
87
163
  }
88
164
 
89
165
  /** The path Windows needs, for the WSL case. */
@@ -152,32 +228,215 @@ function play(file) {
152
228
  return '';
153
229
  }
154
230
 
155
- function main() {
156
- if (process.env.MISAKANET_VOICE === '0') return;
157
- const cue = parseCue(readStdin());
158
- if (!cue) return;
231
+ /**
232
+ * Where the "already notified once" ledger lives.
233
+ *
234
+ * The installer already owns `~/.misakanet-agent/` (it writes the version stamp and
235
+ * checkpoints state there), so the voice hook writes its one small file in the same place
236
+ * instead of inventing a second home-directory convention. `MISAKANET_HOOK_STATE` is the
237
+ * same override `checkpoint_reminder.mjs` honours, which is also how the test suite keeps
238
+ * these files out of a developer's real home.
239
+ */
240
+ const AGENT_DIR = process.env.MISAKANET_AGENT_DIR || join(homedir(), '.misakanet-agent');
241
+ const STATE_DIR = process.env.MISAKANET_HOOK_STATE || join(AGENT_DIR, 'state');
242
+ const NOTIFY_LEDGER = join(STATE_DIR, 'voice-notified.json');
159
243
 
160
- if (process.env.MISAKANET_VOICE_DRY_RUN === '1') {
161
- process.stdout.write(`${cue}\n`);
162
- return;
244
+ function ledgerRead() {
245
+ try {
246
+ const raw = JSON.parse(readFileSync(NOTIFY_LEDGER, 'utf8'));
247
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
248
+ } catch {
249
+ return {}; // missing or unreadable: nothing has been announced
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Remember that `cue` has been announced, atomically (temp file + rename, so a hook killed
255
+ * mid-write cannot leave half a JSON file behind).
256
+ *
257
+ * Every failure here is swallowed on purpose: this is bookkeeping for a courtesy, and a
258
+ * read-only home directory must not be able to turn a desktop notification into a tool
259
+ * error. The worst case of a lost write is one extra toast after a reboot.
260
+ *
261
+ * The key is always a key of `CUE_ACTIONS` (see the security rule there) — never raw input.
262
+ */
263
+ function ledgerMark(cue) {
264
+ try {
265
+ mkdirSync(dirname(NOTIFY_LEDGER), { recursive: true });
266
+ const next = { ...ledgerRead(), [cue]: new Date().toISOString() };
267
+ const tmp = `${NOTIFY_LEDGER}.tmp`;
268
+ writeFileSync(tmp, JSON.stringify(next));
269
+ renameSync(tmp, NOTIFY_LEDGER);
270
+ } catch {
271
+ /* never break the agent over bookkeeping */
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Is `name` an executable we can actually run? (A plain PATH walk — never `which`, because
277
+ * spawning a shell to answer "does X exist" is the execution surface this hook must not
278
+ * have.)
279
+ *
280
+ * Deliberate, rather than spawn-and-swallow-the-ENOENT: a machine with no notifier at all
281
+ * then forks nothing on every tool call, and the debug/dry-run line can name the notifier it
282
+ * really would use instead of guessing. `name` always comes from the code table below.
283
+ */
284
+ function findExecutable(name) {
285
+ const path = process.env.PATH || '/usr/local/bin:/usr/bin:/bin';
286
+ for (const entry of path.split(delimiter)) {
287
+ const dir = entry.trim().replace(/^"(.*)"$/, '$1');
288
+ if (!dir) continue;
289
+ try {
290
+ accessSync(join(dir, name), constants.X_OK);
291
+ return join(dir, name);
292
+ } catch {
293
+ /* keep looking */
294
+ }
163
295
  }
296
+ return '';
297
+ }
164
298
 
165
- // The cues sit next to the player when it is installed (…/voice/), next to the canonical
166
- // copy in a repo checkout, or under docs/assets/voice/ there. Getting this wrong is silent
167
- // by design — which is exactly how the first version of this file shipped mute.
299
+ /**
300
+ * The notifier candidates for *this* platform, in preference order.
301
+ *
302
+ * Every binary name and every argument below is a literal; the only thing that varies is the
303
+ * message text, which is a literal in `CUE_ACTIONS`. Nothing in this function looks at the
304
+ * cue — that is the whole point of the table.
305
+ *
306
+ * - **macOS**: `osascript -e 'display notification "…" with title "MisakaNet"'`.
307
+ * - **Linux**: `notify-send -a MisakaNet <text>` (the freedesktop notifier every desktop
308
+ * environment ships).
309
+ * - **Windows / WSL**: PowerShell. The real WinRT toast API
310
+ * (`Windows.UI.Notifications.ToastNotificationManager`) needs a registered AppID — a
311
+ * Start-menu shortcut with the right AUMID — and on a clean machine it fails with
312
+ * "Element not found", which a hook can only swallow. The dependency-free call that does
313
+ * work from a bare `powershell.exe` is a `NotifyIcon` balloon, which Windows 10/11 renders
314
+ * through the same Action Center; that is the documented fallback. On WSL the Windows side
315
+ * goes **first**, exactly like the player, because the Linux side of WSL usually has no
316
+ * notification daemon to talk to (and, as `players` records, no usable audio card either).
317
+ *
318
+ * `powershell.exe` over argv carries the message text; PowerShell single quotes are escaped
319
+ * by doubling, and the string itself is a constant from the table.
320
+ */
321
+ function notifiers(text) {
322
+ const winText = `'${text.replace(/'/g, "''")}'`;
323
+ const powershell = [
324
+ 'powershell.exe',
325
+ [
326
+ '-NoProfile',
327
+ '-NonInteractive',
328
+ '-Command',
329
+ 'Add-Type -AssemblyName System.Windows.Forms;'
330
+ + 'Add-Type -AssemblyName System.Drawing;'
331
+ + '$n = New-Object System.Windows.Forms.NotifyIcon;'
332
+ + '$n.Icon = [System.Drawing.SystemIcons]::Information;'
333
+ + "$n.BalloonTipTitle = 'MisakaNet';"
334
+ + `$n.BalloonTipText = ${winText};`
335
+ + '$n.Visible = $true; $n.ShowBalloonTip(5000);'
336
+ + 'Start-Sleep -Seconds 6; $n.Dispose()',
337
+ ],
338
+ ];
339
+ const candidates = [];
340
+ if (isWsl() || process.platform === 'win32') candidates.push(powershell);
341
+ if (process.platform === 'darwin') {
342
+ const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
343
+ candidates.push(['osascript', ['-e', `display notification "${escaped}" with title "MisakaNet"`]]);
344
+ }
345
+ if (process.platform === 'linux') candidates.push(['notify-send', ['-a', 'MisakaNet', text]]);
346
+ return candidates;
347
+ }
348
+
349
+ /**
350
+ * Best-effort desktop notification. Returns the binary that was used, or ''.
351
+ *
352
+ * Missing binary, missing platform, denied dbus, a `spawn` throw — all of them mean "no
353
+ * notification happened", which is a silent outcome by design: this hook must never print an
354
+ * error that surfaces as a tool failure in the middle of someone's session.
355
+ */
356
+ function notify(text) {
357
+ for (const [cmd, args] of notifiers(text)) {
358
+ if (!findExecutable(cmd)) continue;
359
+ try {
360
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
361
+ child.on('error', () => {});
362
+ child.unref();
363
+ return cmd;
364
+ } catch {
365
+ /* try the next notifier */
366
+ }
367
+ }
368
+ return '';
369
+ }
370
+
371
+ /**
372
+ * The MP3 for a table entry, or ''.
373
+ *
374
+ * The cues sit next to the player when it is installed (…/voice/), next to the canonical
375
+ * copy in a repo checkout, or under docs/assets/voice/ there. Getting this wrong is silent
376
+ * by design — which is exactly how the first version of this file shipped mute. `name` is a
377
+ * literal from `CUE_ACTIONS`, never the cue.
378
+ */
379
+ function cueFile(name) {
168
380
  const dirs = [
169
381
  process.env.MISAKANET_VOICE_DIR,
170
382
  HERE,
171
383
  join(HERE, 'voice'),
172
384
  join(HERE, '..', '..', 'docs', 'assets', 'voice'),
173
385
  ].filter(Boolean);
174
- const file = dirs.map((dir) => join(dir, `${cue}.mp3`)).find((candidate) => existsSync(candidate));
175
- if (!file) return;
176
- const used = play(file);
177
- if (process.env.MISAKANET_VOICE_DEBUG === '1') {
386
+ return dirs.map((dir) => join(dir, name)).find((candidate) => existsSync(candidate)) || '';
387
+ }
388
+
389
+ function main() {
390
+ // One switch silences both halves: sound *and* notifications.
391
+ if (process.env.MISAKANET_VOICE === '0') return;
392
+ const cue = parseCue(readStdin());
393
+ if (!cue) return;
394
+
395
+ // ── the cue's only use, right here: a table lookup ────────────────────────────────
396
+ const action = CUE_ACTIONS[cue];
397
+ if (!action) return; // unreachable (parseCue only returns table keys), and
398
+ // keeping it explicit is what makes the rule total:
399
+ // no table entry ⇒ no action, no process, no path.
400
+ const dryRun = process.env.MISAKANET_VOICE_DRY_RUN === '1';
401
+ const notifyAllowed = process.env.MISAKANET_NOTIFY !== '0';
402
+ const debug = process.env.MISAKANET_VOICE_DEBUG === '1';
403
+
404
+ // ── sound ────────────────────────────────────────────────────────────────────────
405
+ const file = cueFile(action.sound);
406
+ const would = file ? `sound=${action.sound} (would play)` : `sound=${action.sound} (missing — would be silent)`;
407
+ const player = dryRun || !file ? '' : play(file);
408
+
409
+ // ── notification ─────────────────────────────────────────────────────────────────
410
+ let note;
411
+ if (!action.notify) {
412
+ note = 'none (this cue does not notify)';
413
+ } else if (!notifyAllowed) {
414
+ note = 'disabled (MISAKANET_NOTIFY=0)';
415
+ } else {
416
+ const seen = action.once ? ledgerRead()[cue] : '';
417
+ if (seen) {
418
+ note = `already sent at ${seen} (first time only)`;
419
+ } else if (dryRun) {
420
+ note = `would send "${action.notify}"${action.once ? ' (first time only)' : ''}`;
421
+ } else {
422
+ const used = notify(action.notify);
423
+ // Marked even when no notifier was available: otherwise a machine without one would
424
+ // retry the lookup on every single tool call forever, for a toast nobody can see.
425
+ if (action.once) ledgerMark(cue);
426
+ note = used ? `${used} "${action.notify}"` : 'none (no notifier available on this platform)';
427
+ }
428
+ }
429
+
430
+ if (dryRun) {
431
+ // Reports both halves and executes neither. Read-only on purpose: a preview must not
432
+ // consume the one-time notification or write a ledger a real run would then trust.
433
+ process.stdout.write(`${cue}\n${would}\nnotify=${note}\n`);
434
+ return;
435
+ }
436
+ if (debug) {
178
437
  // Synchronous write: process.exit(0) below would truncate an async stdout write, and a
179
438
  // hook that prints on the normal path can look like a tool failure.
180
- writeFileSync(1, `cue=${cue} player=${used || 'none'} file=${file}\n`);
439
+ writeFileSync(1, `cue=${cue} player=${player || 'none'} file=${file || 'none'} notify=${note}\n`);
181
440
  }
182
441
  }
183
442
 
@@ -185,10 +444,28 @@ try {
185
444
  main();
186
445
  } catch {
187
446
  /* a hook must never surface as a tool failure */
188
- } finally {
189
- process.exit(0);
190
447
  }
191
448
 
192
- // Backstop: if a detached player leaves this process alive (some players keep a handle),
193
- // leave anyway. The sound is best-effort by design.
194
- setTimeout(() => process.exit(0), 3000).unref();
449
+ // Exit on the next tick rather than through `process.exit(0)` *in* the finally above.
450
+ //
451
+ // The old shape was:
452
+ //
453
+ // } finally { process.exit(0); }
454
+ // setTimeout(() => process.exit(0), 3000).unref(); // ← unreachable, every run
455
+ //
456
+ // `process.exit` ends the process before the backstop line is ever evaluated, so the safety net
457
+ // for "a detached player kept a handle open" had never run — dead code that read like protection.
458
+ // (Found by the 2026-09-18 setup review; verified by reading the tail of this file, which is also
459
+ // why the package's `voice/voice-hook.mjs` copy is regenerated by `prepack`.)
460
+ //
461
+ // Why not just `process.exitCode = 0` with no exit call at all: the debug line above is written with
462
+ // `writeFileSync(1, …)` precisely so it cannot be truncated, and this process must not stay alive
463
+ // waiting on a spawned player — the agent is waiting for the hook to return. `setImmediate` keeps
464
+ // stdout draining for one tick while still ending the process deterministically, and the timer is
465
+ // the real backstop for a handle the spawn left behind.
466
+ setImmediate(() => {
467
+ setTimeout(() => process.exit(0), 3000).unref();
468
+ // If the loop is otherwise empty, `unref`ed timers do not hold it open, so Node exits by itself
469
+ // within this tick — the timer only matters when something else is still keeping us alive.
470
+ process.exitCode = 0;
471
+ });