@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.
- package/README.md +69 -0
- package/bin/misakanet-setup.mjs +665 -89
- package/hook/checkpoint_reminder.mjs +16 -2
- package/package.json +1 -1
- package/voice/voice-hook.mjs +315 -38
|
@@ -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
|
-
|
|
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
|
-
'
|
|
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
|
+
"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"
|
package/voice/voice-hook.mjs
CHANGED
|
@@ -1,36 +1,103 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* MisakaNet
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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**:
|
|
17
|
-
* immediately, with a hard timeout as a backstop;
|
|
18
|
-
* - **never fail loudly**: a missing player, a missing
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
24
|
-
* test suite uses, so CI never
|
|
25
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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=${
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
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
|
+
});
|