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.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/README.zh-CN.md +275 -0
- package/README.zh-TW.md +275 -0
- package/bin/miki.mjs +49 -0
- package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
- package/dist/web/assets/index--89DkyV1.css +1 -0
- package/dist/web/assets/index-CyPlxvOn.js +64 -0
- package/dist/web/index.html +20 -0
- package/dist/web/pair-info.html +138 -0
- package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
- package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
- package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
- package/dist/web-phone/index.html +20 -0
- package/hooks/miki-emit.ps1 +56 -0
- package/package.json +89 -0
- package/shared/i18n.ts +915 -0
- package/src/cli/i18n-cli.ts +149 -0
- package/src/cli/miki.ts +168 -0
- package/src/cli/pair.ts +534 -0
- package/src/cli/prompt.ts +6 -0
- package/src/cli/pushable-iter.ts +45 -0
- package/src/cli/setup-self-host.ts +292 -0
- package/src/cli/setup-wizard.ts +130 -0
- package/src/cli/wrap.ts +742 -0
- package/src/config.ts +121 -0
- package/src/crypto.ts +66 -0
- package/src/data-dir.ts +31 -0
- package/src/ext-registry.ts +47 -0
- package/src/hook-handler.ts +86 -0
- package/src/index.ts +279 -0
- package/src/install-hooks.ts +107 -0
- package/src/notifier.ts +21 -0
- package/src/pairing.ts +100 -0
- package/src/protocol-ext.ts +46 -0
- package/src/relay-client.ts +468 -0
- package/src/relay-protocol.ts +57 -0
- package/src/server.ts +1134 -0
- package/src/session-resolver.ts +437 -0
- package/src/session-store.ts +131 -0
- package/src/types.ts +33 -0
- package/src/vscode-bridge.ts +407 -0
- package/src/wrap-process.ts +183 -0
- package/tools/tray.ps1 +286 -0
- package/worker/package.json +24 -0
- package/worker/src/daemon-relay.ts +348 -0
- package/worker/src/env.ts +11 -0
- package/worker/src/handshake.ts +63 -0
- package/worker/src/index.ts +81 -0
- package/worker/src/pairing-code.ts +39 -0
- package/worker/src/pairing-coordinator.ts +145 -0
- package/worker/wrangler-selfhost.toml +36 -0
- 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 };
|