miki-moni 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/README.zh-CN.md +275 -0
  4. package/README.zh-TW.md +275 -0
  5. package/bin/miki.mjs +49 -0
  6. package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
  7. package/dist/web/assets/index--89DkyV1.css +1 -0
  8. package/dist/web/assets/index-CyPlxvOn.js +64 -0
  9. package/dist/web/index.html +20 -0
  10. package/dist/web/pair-info.html +138 -0
  11. package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
  12. package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
  13. package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
  14. package/dist/web-phone/index.html +20 -0
  15. package/hooks/miki-emit.ps1 +56 -0
  16. package/package.json +89 -0
  17. package/shared/i18n.ts +915 -0
  18. package/src/cli/i18n-cli.ts +149 -0
  19. package/src/cli/miki.ts +168 -0
  20. package/src/cli/pair.ts +534 -0
  21. package/src/cli/prompt.ts +6 -0
  22. package/src/cli/pushable-iter.ts +45 -0
  23. package/src/cli/setup-self-host.ts +292 -0
  24. package/src/cli/setup-wizard.ts +130 -0
  25. package/src/cli/wrap.ts +742 -0
  26. package/src/config.ts +121 -0
  27. package/src/crypto.ts +66 -0
  28. package/src/data-dir.ts +31 -0
  29. package/src/ext-registry.ts +47 -0
  30. package/src/hook-handler.ts +86 -0
  31. package/src/index.ts +279 -0
  32. package/src/install-hooks.ts +107 -0
  33. package/src/notifier.ts +21 -0
  34. package/src/pairing.ts +100 -0
  35. package/src/protocol-ext.ts +46 -0
  36. package/src/relay-client.ts +468 -0
  37. package/src/relay-protocol.ts +57 -0
  38. package/src/server.ts +1134 -0
  39. package/src/session-resolver.ts +437 -0
  40. package/src/session-store.ts +131 -0
  41. package/src/types.ts +33 -0
  42. package/src/vscode-bridge.ts +407 -0
  43. package/src/wrap-process.ts +183 -0
  44. package/tools/tray.ps1 +286 -0
  45. package/worker/package.json +24 -0
  46. package/worker/src/daemon-relay.ts +348 -0
  47. package/worker/src/env.ts +11 -0
  48. package/worker/src/handshake.ts +63 -0
  49. package/worker/src/index.ts +81 -0
  50. package/worker/src/pairing-code.ts +39 -0
  51. package/worker/src/pairing-coordinator.ts +145 -0
  52. package/worker/wrangler-selfhost.toml +36 -0
  53. package/worker/wrangler.toml +29 -0
@@ -0,0 +1,407 @@
1
+ import { spawn } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import type { WebSocket } from "ws";
7
+ import type { ExtRegistry } from "./ext-registry.js";
8
+ import type { MsgSubmit, MsgSubmitAck, ExtMessage } from "./protocol-ext.js";
9
+
10
+ /**
11
+ * Hook-reported cwd ≠ where the session's transcript actually lives.
12
+ * Find the directory under ~/.claude/projects/ that contains `<sessionUuid>.jsonl`,
13
+ * then read the actual cwd from the JSONL itself (entries carry a `cwd` field
14
+ * with the real workspace path).
15
+ *
16
+ * Why not decode the folder name (e.g. "D--code-miki-moni-tests"): Claude Code
17
+ * encodes both `\` and `-` as `-`, so the encoding is lossy — "miki-moni" inside
18
+ * the path becomes ambiguous. The JSONL `cwd` field is the source of truth.
19
+ *
20
+ * Falls back to the caller-provided cwd if nothing is found.
21
+ */
22
+ export async function resolveSessionCwd(sessionUuid: string, fallbackCwd: string): Promise<string> {
23
+ const projectsRoot = path.join(os.homedir(), ".claude", "projects");
24
+ let dirs: string[];
25
+ try {
26
+ dirs = await fs.readdir(projectsRoot);
27
+ } catch {
28
+ return fallbackCwd;
29
+ }
30
+ for (const d of dirs) {
31
+ const candidate = path.join(projectsRoot, d, `${sessionUuid}.jsonl`);
32
+ try {
33
+ await fs.access(candidate);
34
+ // Found the transcript. Read it and pull cwd from the first entry that has one.
35
+ try {
36
+ const raw = await fs.readFile(candidate, "utf8");
37
+ const lines = raw.split(/\r?\n/);
38
+ for (const line of lines) {
39
+ if (!line || !line.includes('"cwd"')) continue;
40
+ try {
41
+ const e = JSON.parse(line);
42
+ if (typeof e?.cwd === "string" && e.cwd) return e.cwd;
43
+ } catch { /* keep scanning */ }
44
+ }
45
+ } catch { /* file read failed — fall through */ }
46
+ // No cwd in JSONL; use fallback rather than guess from folder name.
47
+ return fallbackCwd;
48
+ } catch {
49
+ // not in this dir, keep looking
50
+ }
51
+ }
52
+ return fallbackCwd;
53
+ }
54
+
55
+ export type LaunchFn = (url: string) => Promise<void>;
56
+
57
+ export const defaultLaunch: LaunchFn = (url) =>
58
+ new Promise((resolve, reject) => {
59
+ const child = spawn(
60
+ "powershell",
61
+ ["-NoProfile", "-Command", `Start-Process -FilePath '${url.replace(/'/g, "''")}'`],
62
+ { stdio: "ignore", windowsHide: true }
63
+ );
64
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
65
+ child.on("error", reject);
66
+ });
67
+
68
+ // Headless: run `claude -r <session_uuid> -p "<prompt>"` in the session's cwd.
69
+ // Returns Claude's plaintext reply. Throws on non-zero exit or timeout.
70
+ export type HeadlessFn = (opts: {
71
+ sessionUuid: string;
72
+ cwd: string;
73
+ prompt: string;
74
+ maxBudgetUsd?: number;
75
+ timeoutMs?: number;
76
+ }) => Promise<{ reply: string; exitCode: number; durationMs: number }>;
77
+
78
+ // Resolve the `claude` executable to an absolute path so spawn doesn't depend
79
+ // on daemon PATH inheritance (which can be empty depending on how the daemon
80
+ // was launched). Tries common Windows + POSIX install locations.
81
+ let resolvedClaudePathCache: string | null = null;
82
+ async function resolveClaudePath(): Promise<string> {
83
+ if (resolvedClaudePathCache) return resolvedClaudePathCache;
84
+ const home = os.homedir();
85
+ const candidates = process.platform === "win32"
86
+ ? [
87
+ path.join(home, ".local", "bin", "claude.exe"),
88
+ path.join(home, ".local", "bin", "claude.cmd"),
89
+ path.join(home, "AppData", "Local", "Programs", "claude", "claude.exe"),
90
+ path.join(home, "AppData", "Roaming", "npm", "claude.cmd"),
91
+ ]
92
+ : [
93
+ "/usr/local/bin/claude",
94
+ path.join(home, ".local", "bin", "claude"),
95
+ path.join(home, ".npm-global", "bin", "claude"),
96
+ ];
97
+ for (const p of candidates) {
98
+ try { await fs.access(p); resolvedClaudePathCache = p; return p; } catch { /* try next */ }
99
+ }
100
+ // Last resort: trust PATH and hope for the best. spawn will ENOENT clearly.
101
+ return "claude";
102
+ }
103
+
104
+ export const defaultHeadless: HeadlessFn = async ({ sessionUuid, cwd, prompt, maxBudgetUsd = 5, timeoutMs = 120_000 }) => {
105
+ // The session's transcript may live in a different workspace than the cwd
106
+ // the daemon last saw (hooks fire with whatever sub-dir was active). Find the
107
+ // real one or `claude -r <id>` will fail with "No conversation found".
108
+ const actualCwd = await resolveSessionCwd(sessionUuid, cwd);
109
+ const claudeBin = await resolveClaudePath();
110
+ return new Promise<{ reply: string; exitCode: number; durationMs: number }>((resolve, reject) => {
111
+ const start = Date.now();
112
+ // Spawn claude.exe via absolute path. Pass prompt via STDIN.
113
+ // KNOWN LIMITATION on Windows: non-ASCII characters in the prompt get
114
+ // mangled in claude.exe's stdin parsing (system codepage issue).
115
+ // English prompts work correctly. See README "Known Limitations".
116
+ const child = spawn(
117
+ claudeBin,
118
+ ["-r", sessionUuid, "-p", "--max-budget-usd", String(maxBudgetUsd)],
119
+ { cwd: actualCwd, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, shell: false },
120
+ );
121
+ child.stdin?.write(Buffer.from(prompt, "utf8"));
122
+ child.stdin?.end();
123
+ let stdout = "";
124
+ let stderr = "";
125
+ const timer = setTimeout(() => {
126
+ try { child.kill("SIGTERM"); } catch { /* ignore */ }
127
+ reject(new Error(`headless claude timeout after ${timeoutMs}ms`));
128
+ }, timeoutMs);
129
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
130
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
131
+ child.on("exit", (code) => {
132
+ clearTimeout(timer);
133
+ const durationMs = Date.now() - start;
134
+ const reply = stdout.trim();
135
+ // Tolerate non-zero exit when the response was actually generated. Claude
136
+ // Code's community lifecycle hook (session-lifecycle-hook.mjs SessionEnd)
137
+ // sometimes fails AFTER the model already produced a response, causing
138
+ // claude.exe to exit 1 with empty stderr — but the reply IS on stdout
139
+ // and IS in the transcript. Treat that as success.
140
+ if (reply) {
141
+ resolve({ reply, exitCode: code ?? 0, durationMs });
142
+ return;
143
+ }
144
+ reject(new Error(
145
+ `headless claude exited ${code} (cwd=${actualCwd}, empty stdout): ${stderr.trim().slice(0, 500) || "(empty stderr too — likely SessionEnd hook failure with no response generated)"}`,
146
+ ));
147
+ });
148
+ child.on("error", (err) => { clearTimeout(timer); reject(err); });
149
+ });
150
+ };
151
+
152
+ /**
153
+ * Build a PowerShell script that fires a vscode:// URI, then explicitly finds
154
+ * a VSCode top-level window via Win32 EnumWindows, force-focuses it (defeating
155
+ * the foreground-stealing block with AttachThreadInput), and SendKeys ENTER.
156
+ *
157
+ * The `'@` here-string terminator MUST be at column 0 in the produced script,
158
+ * so this function builds the script with no indentation on that line.
159
+ */
160
+ function buildFocusAndEnterPS(url: string, folderHint: string, delayMs: number): string {
161
+ const u = url.replace(/'/g, "''");
162
+ const h = folderHint.replace(/'/g, "''");
163
+ // Note: the C# signature here-string MUST start with `@'` and end with `'@`
164
+ // both at column 0 of their line. JS template literal preserves indentation,
165
+ // so we avoid leading whitespace on those marker lines.
166
+ return `
167
+ $ErrorActionPreference = 'Stop'
168
+
169
+ Start-Process -FilePath '${u}'
170
+ Start-Sleep -Milliseconds ${delayMs}
171
+
172
+ $sig = @'
173
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
174
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd);
175
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
176
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
177
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
178
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
179
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr ProcessId);
180
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
181
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
182
+ [DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder s, int n);
183
+ public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
184
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
185
+ [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, IntPtr dwExtraInfo);
186
+ [DllImport("user32.dll")] public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);
187
+ [DllImport("user32.dll")] public static extern bool LockSetForegroundWindow(uint uLockCode);
188
+ [DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(uint dwProcessId);
189
+ '@
190
+ Add-Type -MemberDefinition $sig -Name 'U' -Namespace 'MikiMoni'
191
+
192
+ $candidates = New-Object System.Collections.ArrayList
193
+ $proc = [MikiMoni.U+EnumWindowsProc]{
194
+ param([IntPtr]$h, [IntPtr]$l)
195
+ if (-not [MikiMoni.U]::IsWindowVisible($h)) { return $true }
196
+ $sb = New-Object System.Text.StringBuilder 512
197
+ [MikiMoni.U]::GetWindowText($h, $sb, 512) | Out-Null
198
+ $title = $sb.ToString()
199
+ if ($title -match 'Visual Studio Code') {
200
+ [void]$candidates.Add(@{ Hwnd = $h; Title = $title })
201
+ }
202
+ return $true
203
+ }
204
+ [MikiMoni.U]::EnumWindows($proc, [IntPtr]::Zero) | Out-Null
205
+
206
+ # Pick: (1) workspace-folder-hint match preferred, (2) current foreground if VSCode,
207
+ # (3) first VSCode window.
208
+ $best = $null
209
+ $hint = '${h}'
210
+ if ($hint -ne '') {
211
+ $best = $candidates | Where-Object { $_.Title -match [regex]::Escape($hint) } | Select-Object -First 1
212
+ }
213
+ if (-not $best) {
214
+ $fg = [MikiMoni.U]::GetForegroundWindow()
215
+ $best = $candidates | Where-Object { $_.Hwnd -eq $fg } | Select-Object -First 1
216
+ }
217
+ if (-not $best -and $candidates.Count -gt 0) { $best = $candidates[0] }
218
+
219
+ Write-Output ("candidates=" + $candidates.Count)
220
+ foreach ($c in $candidates) { Write-Output (" cand hwnd=" + $c.Hwnd + " title=" + $c.Title) }
221
+
222
+ if (-not $best) {
223
+ Write-Error ("No VSCode window found (candidates: " + $candidates.Count + ")")
224
+ exit 2
225
+ }
226
+
227
+ $fgBefore = [MikiMoni.U]::GetForegroundWindow()
228
+ $hwnd = $best.Hwnd
229
+ Write-Output ("picked hwnd=" + $hwnd + " title=" + $best.Title)
230
+ Write-Output ("fg-before hwnd=" + $fgBefore)
231
+
232
+ if ([MikiMoni.U]::IsIconic($hwnd)) { [MikiMoni.U]::ShowWindow($hwnd, 9) | Out-Null } # SW_RESTORE
233
+
234
+ # Defeat Win10/11 foreground lock — proven AutoHotkey recipe:
235
+ # 1. LockSetForegroundWindow(LSFW_UNLOCK=2) — explicit unlock
236
+ # 2. keybd_event ALT down/up — simulated user input grants this thread
237
+ # foreground rights for one SetForegroundWindow call (the classic trick)
238
+ # 3. AttachThreadInput to target VSCode thread (defeats ownership check)
239
+ # 4. SetForegroundWindow + BringWindowToTop
240
+ # 5. SwitchToThisWindow as belt-and-suspenders (undocumented but bypasses lock)
241
+ [MikiMoni.U]::LockSetForegroundWindow(2) | Out-Null # LSFW_UNLOCK
242
+ [MikiMoni.U]::keybd_event(0x12, 0, 0, [IntPtr]::Zero) # VK_MENU down
243
+ [MikiMoni.U]::keybd_event(0x12, 0, 2, [IntPtr]::Zero) # VK_MENU up (KEYEVENTF_KEYUP=2)
244
+ Start-Sleep -Milliseconds 30
245
+
246
+ $targetTid = [MikiMoni.U]::GetWindowThreadProcessId($hwnd, [IntPtr]::Zero)
247
+ $myTid = [MikiMoni.U]::GetCurrentThreadId()
248
+ $attachOk = [MikiMoni.U]::AttachThreadInput($myTid, $targetTid, $true)
249
+ $setFgOk = [MikiMoni.U]::SetForegroundWindow($hwnd)
250
+ [MikiMoni.U]::BringWindowToTop($hwnd) | Out-Null
251
+ [MikiMoni.U]::SwitchToThisWindow($hwnd, $true)
252
+ [MikiMoni.U]::AttachThreadInput($myTid, $targetTid, $false) | Out-Null
253
+ Start-Sleep -Milliseconds 200
254
+
255
+ $fgAfter = [MikiMoni.U]::GetForegroundWindow()
256
+ Write-Output ("attach=" + $attachOk + " setFg=" + $setFgOk + " fg-after=" + $fgAfter + " match=" + ($fgAfter -eq $hwnd))
257
+
258
+ Add-Type -AssemblyName System.Windows.Forms
259
+ [System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
260
+ Write-Output "enter-sent"
261
+ `.trim();
262
+ }
263
+
264
+ export class VscodeBridge {
265
+ constructor(
266
+ private launch: LaunchFn = defaultLaunch,
267
+ private headless: HeadlessFn = defaultHeadless,
268
+ ) {}
269
+
270
+ async focus(sessionUuid: string | null): Promise<void> {
271
+ const base = "vscode://anthropic.claude-code/open";
272
+ const url = sessionUuid ? `${base}?session=${sessionUuid}` : base;
273
+ await this.launch(url);
274
+ }
275
+
276
+ /**
277
+ * Pre-fill the prompt in the Claude panel input box (does NOT submit).
278
+ * Uses vscode://anthropic.claude-code/open?session=...&prompt=...
279
+ */
280
+ async send(sessionUuid: string | null, prompt: string): Promise<void> {
281
+ const parts: string[] = [];
282
+ if (sessionUuid) parts.push(`session=${encodeURIComponent(sessionUuid)}`);
283
+ parts.push(`prompt=${encodeURIComponent(prompt)}`);
284
+ const url = `vscode://anthropic.claude-code/open?${parts.join("&")}`;
285
+ await this.launch(url);
286
+ }
287
+
288
+ /**
289
+ * Prefill + auto-press Enter — explicitly find a VSCode window and force-focus
290
+ * it via Win32 P/Invoke before SendKeys, so the Enter cannot land on the
291
+ * browser the user clicked "送出" from.
292
+ *
293
+ * Flow:
294
+ * 1. Start-Process URI → Windows dispatches to anthropic.claude-code
295
+ * extension, which opens/focuses the right tab
296
+ * 2. Sleep delayMs → let VSCode prefill the input box
297
+ * 3. EnumWindows → find top-level windows with title matching
298
+ * "Visual Studio Code"; prefer one whose
299
+ * title contains the workspace folder name
300
+ * 4. AttachThreadInput trick → defeat foreground-stealing prevention so
301
+ * SetForegroundWindow actually works even
302
+ * though this PowerShell process is not fg
303
+ * 5. SendKeys {ENTER} → submitted to the now-focused VSCode panel
304
+ */
305
+ async prefillAndSubmitLegacy(
306
+ sessionUuid: string | null,
307
+ prompt: string,
308
+ opts: { cwd?: string; delayMs?: number } = {},
309
+ ): Promise<{ diag: string }> {
310
+ if (process.platform !== "win32") {
311
+ await this.send(sessionUuid, prompt);
312
+ return { diag: "non-win32: prefill only, no SendKeys" };
313
+ }
314
+ const delayMs = opts.delayMs ?? 1000;
315
+ // Workspace folder name hint for title matching.
316
+ // "d:\code\miki-moni" → "miki-moni" ; "d:\code" → "code"
317
+ const folderHint = (opts.cwd ?? "").split(/[\\/]/).filter(Boolean).pop() ?? "";
318
+ const parts: string[] = [];
319
+ if (sessionUuid) parts.push(`session=${encodeURIComponent(sessionUuid)}`);
320
+ parts.push(`prompt=${encodeURIComponent(prompt)}`);
321
+ const url = `vscode://anthropic.claude-code/open?${parts.join("&")}`;
322
+ const ps = buildFocusAndEnterPS(url, folderHint, delayMs);
323
+ return new Promise<{ diag: string }>((resolve, reject) => {
324
+ const child = spawn(
325
+ "powershell",
326
+ ["-NoProfile", "-Command", ps],
327
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
328
+ );
329
+ let stdout = "";
330
+ let stderr = "";
331
+ child.stdout?.on("data", (c) => { stdout += c.toString(); });
332
+ child.stderr?.on("data", (c) => { stderr += c.toString(); });
333
+ child.on("exit", (code) => {
334
+ const diag = `exit=${code} stdout=[${stdout.trim()}] stderr=[${stderr.trim()}]`;
335
+ if (code === 0) resolve({ diag });
336
+ else reject(new Error(`prefillAndSubmit ${diag}`));
337
+ });
338
+ child.on("error", reject);
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Headless submit: actually run Claude with the prompt against the given session.
344
+ * Transcript is appended on disk → VSCode picks up the new user msg + Claude reply.
345
+ * Costs real API money. Caller chooses this vs prefill.
346
+ */
347
+ async submit(args: { sessionUuid: string; cwd: string; prompt: string; maxBudgetUsd?: number; timeoutMs?: number }): Promise<{ reply: string; exitCode: number; durationMs: number }> {
348
+ return this.headless(args);
349
+ }
350
+
351
+ /**
352
+ * Send a submit request to the helper extension that owns the given cwd's
353
+ * workspace, and await its ack. Returns {ok, error?, diag?}.
354
+ */
355
+ async submitViaHelper(args: {
356
+ sessionUuid: string;
357
+ prompt: string;
358
+ cwd: string;
359
+ registry: ExtRegistry;
360
+ timeoutMs?: number;
361
+ }): Promise<{ ok: boolean; error?: string; diag?: string }> {
362
+ const ws = args.registry.findForCwd(args.cwd) as WebSocket | null;
363
+ if (!ws) {
364
+ return {
365
+ ok: false,
366
+ error: `no miki-helper extension registered for workspace covering ${args.cwd}; install the VSIX into that VSCode window: npm run install-helper`,
367
+ };
368
+ }
369
+ const requestId = randomUUID();
370
+ const submitMsg: MsgSubmit = {
371
+ type: "submit",
372
+ request_id: requestId,
373
+ session_uuid: args.sessionUuid,
374
+ prompt: args.prompt,
375
+ };
376
+ const timeoutMs = args.timeoutMs ?? 10_000;
377
+
378
+ return new Promise((resolve) => {
379
+ const timer = setTimeout(() => {
380
+ ws.off("message", onMessage);
381
+ resolve({ ok: false, error: `submit_ack timeout after ${timeoutMs}ms` });
382
+ }, timeoutMs);
383
+
384
+ const onMessage = (raw: any) => {
385
+ let msg: ExtMessage;
386
+ try { msg = JSON.parse(String(raw)); } catch { return; }
387
+ if (msg.type !== "submit_ack") return;
388
+ if ((msg as MsgSubmitAck).request_id !== requestId) return;
389
+ clearTimeout(timer);
390
+ ws.off("message", onMessage);
391
+ const ack = msg as MsgSubmitAck;
392
+ resolve({
393
+ ok: ack.ok,
394
+ ...(ack.error !== undefined ? { error: ack.error } : {}),
395
+ ...(ack.diag !== undefined ? { diag: ack.diag } : {}),
396
+ });
397
+ };
398
+ ws.on("message", onMessage);
399
+ try { ws.send(JSON.stringify(submitMsg)); }
400
+ catch (err) {
401
+ clearTimeout(timer);
402
+ ws.off("message", onMessage);
403
+ resolve({ ok: false, error: `ws send failed: ${String(err)}` });
404
+ }
405
+ });
406
+ }
407
+ }
@@ -0,0 +1,183 @@
1
+ // wrap-process — owns the lifecycle of `miki claude` CLI processes that the
2
+ // daemon itself spawned (via POST /wrap/start). Decoupled from WS bookkeeping
3
+ // so the kill / orphan-detection logic stays testable.
4
+ //
5
+ // Responsibilities:
6
+ // 1. Record each spawn: cwd, intended session_uuid (if -r mode), spawn ts.
7
+ // 2. When a wrap WS register lands, link the connection's reported PID to
8
+ // that record (so we know which OS process backs which session).
9
+ // 3. On WS close, taskkill /T /F the PID tree → no Windows orphan.
10
+ // 4. On daemon startup, scan for stray `miki claude` processes that aren't
11
+ // backed by an active WS connection and kill them.
12
+ //
13
+ // Why a module: the daemon will grow more "things daemon owns" (helper exts,
14
+ // pty-mode tabs, future agent-pool workers). Each gets its own lifecycle file
15
+ // rather than scattering PID maps across server.ts.
16
+
17
+ import { spawn, execFile } from "node:child_process";
18
+ import { promisify } from "node:util";
19
+ import os from "node:os";
20
+
21
+ const execFileP = promisify(execFile);
22
+
23
+ export interface SpawnRecord {
24
+ /** Session this spawn is intended to attach to. null = `--fresh` style. */
25
+ sessionUuid: string | null;
26
+ cwd: string;
27
+ spawnedAt: number;
28
+ /** PID reported by wrap.ts at register time. null until we hear back. */
29
+ pid: number | null;
30
+ }
31
+
32
+ type Log = {
33
+ info: (obj: Record<string, unknown>, msg?: string) => void;
34
+ warn: (obj: Record<string, unknown>, msg?: string) => void;
35
+ error: (obj: Record<string, unknown>, msg?: string) => void;
36
+ };
37
+
38
+ export class WrapProcessRegistry {
39
+ // Keyed by sessionUuid for -r spawns. For fresh spawns the uuid is unknown
40
+ // at spawn time, so they get a temporary key until register lands.
41
+ private bySession = new Map<string, SpawnRecord>();
42
+ // Fresh-spawn records waiting for their first register. FIFO-ish: oldest
43
+ // unbound record gets linked to next incoming register without a uuid.
44
+ private pendingFresh: SpawnRecord[] = [];
45
+
46
+ constructor(private log?: Log) {}
47
+
48
+ /** Called by /wrap/start. `sessionUuid` is null for `--fresh`. */
49
+ recordSpawn(opts: { sessionUuid: string | null; cwd: string }): void {
50
+ const rec: SpawnRecord = {
51
+ sessionUuid: opts.sessionUuid,
52
+ cwd: opts.cwd,
53
+ spawnedAt: Date.now(),
54
+ pid: null,
55
+ };
56
+ if (opts.sessionUuid) {
57
+ this.bySession.set(opts.sessionUuid, rec);
58
+ } else {
59
+ this.pendingFresh.push(rec);
60
+ }
61
+ this.log?.info(
62
+ { sessionUuid: opts.sessionUuid, cwd: opts.cwd },
63
+ "wrap-process: recorded spawn",
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Called when a wrap WS register / late-bind tells us its PID + final UUID.
69
+ * If we previously had a `--fresh` record awaiting binding, hand it the uuid.
70
+ */
71
+ bindPid(sessionUuid: string, pid: number | null): void {
72
+ if (!pid) return;
73
+ let rec = this.bySession.get(sessionUuid);
74
+ if (!rec) {
75
+ // Promote oldest pending fresh record (if any) to this uuid.
76
+ rec = this.pendingFresh.shift() ?? null as any;
77
+ if (rec) {
78
+ rec.sessionUuid = sessionUuid;
79
+ this.bySession.set(sessionUuid, rec);
80
+ }
81
+ }
82
+ if (!rec) {
83
+ // External wrap (user typed `pnpm miki claude` themselves, not via dashboard).
84
+ // We don't manage its lifecycle; ignore.
85
+ return;
86
+ }
87
+ rec.pid = pid;
88
+ this.log?.info({ sessionUuid, pid }, "wrap-process: PID bound");
89
+ }
90
+
91
+ /**
92
+ * Called on wrap WS close. Returns the PID (if any) the caller should
93
+ * tree-kill, and the SpawnRecord so the caller can decide cleanup policy
94
+ * (e.g. delete empty session row). Removes the record from the registry.
95
+ */
96
+ takeOnClose(sessionUuid: string): SpawnRecord | null {
97
+ const rec = this.bySession.get(sessionUuid);
98
+ if (!rec) return null;
99
+ this.bySession.delete(sessionUuid);
100
+ return rec;
101
+ }
102
+
103
+ /** Visible for tests / debugging. */
104
+ list(): SpawnRecord[] {
105
+ return [...this.bySession.values(), ...this.pendingFresh];
106
+ }
107
+
108
+ /** Visible for tests. */
109
+ size(): number {
110
+ return this.bySession.size + this.pendingFresh.length;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Best-effort tree-kill on Windows. Calls `taskkill /T /F /PID <pid>` which
116
+ * terminates the process and ALL descendants. Resolves silently on failure
117
+ * (process already dead, etc.) — caller doesn't care about the distinction
118
+ * between "killed it" and "it was already gone".
119
+ */
120
+ export async function killProcessTree(pid: number, log?: Log): Promise<void> {
121
+ if (!Number.isFinite(pid) || pid <= 0) return;
122
+ try {
123
+ if (process.platform === "win32") {
124
+ await execFileP("taskkill.exe", ["/T", "/F", "/PID", String(pid)]);
125
+ } else {
126
+ // POSIX fallback (not used in production for miki, but keeps tests portable)
127
+ process.kill(pid, "SIGKILL");
128
+ }
129
+ log?.info({ pid }, "wrap-process: tree-killed");
130
+ } catch (err) {
131
+ // Most common cause: process already exited between our decision to kill
132
+ // and the syscall actually running. Genuinely fine.
133
+ log?.info({ pid, error: String(err) }, "wrap-process: kill skipped (likely already dead)");
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Scan the OS for `miki claude` node processes and return their PIDs. Used on
139
+ * daemon startup to clear out stragglers from a previous daemon session that
140
+ * left orphans behind. Windows-only implementation; returns [] elsewhere.
141
+ *
142
+ * Identifies miki processes by command-line substring match: looks for any
143
+ * node.exe whose CommandLine contains "miki.ts claude" or "src/cli/miki.ts".
144
+ * That's specific enough to not accidentally kill unrelated Node tools, and
145
+ * generic enough to catch both tsx and compiled invocations.
146
+ */
147
+ export async function findOrphanMikiCli(log?: Log): Promise<number[]> {
148
+ if (process.platform !== "win32") return [];
149
+ try {
150
+ // PowerShell one-liner — return PIDs whose command line matches our pattern.
151
+ // Output: one PID per line, no headers.
152
+ const { stdout } = await execFileP("powershell.exe", [
153
+ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
154
+ "-Command",
155
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match 'miki\\.ts.*claude|cli[/\\\\]miki' } | Select-Object -ExpandProperty ProcessId",
156
+ ]);
157
+ const pids = stdout.split(/\r?\n/).map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isFinite(n) && n > 0);
158
+ // Filter out ourselves and any parent of ourselves (defensive — daemon
159
+ // shouldn't match the pattern, but just in case during dev).
160
+ const self = process.pid;
161
+ return pids.filter((p) => p !== self);
162
+ } catch (err) {
163
+ log?.warn({ error: String(err) }, "wrap-process: orphan scan failed");
164
+ return [];
165
+ }
166
+ }
167
+
168
+ /** Convenience: scan + kill all orphans. Returns number killed. */
169
+ export async function killOrphans(log?: Log): Promise<number> {
170
+ const pids = await findOrphanMikiCli(log);
171
+ if (pids.length === 0) return 0;
172
+ await Promise.all(pids.map((pid) => killProcessTree(pid, log)));
173
+ log?.info({ pids, count: pids.length }, "wrap-process: orphans killed at startup");
174
+ return pids.length;
175
+ }
176
+
177
+ // Re-export `spawn` for callers that want to launch wt.exe through the same
178
+ // module (kept lightweight on purpose — we don't wrap spawn() because wt.exe
179
+ // is detached and we don't track its launcher PID anyway).
180
+ export { spawn };
181
+
182
+ // Expose os.platform for tests that want to skip on non-Windows.
183
+ export { os };