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,742 @@
1
+ // `miki claude` — wrap Claude Agent SDK in a long-lived process so miki-moni
2
+ // daemon can push prompts into the SAME query() stream as the user's terminal,
3
+ // without spawning a new `claude -p` per message.
4
+ //
5
+ // Architecture (one process per session):
6
+ //
7
+ // ┌─ stdin (readline) ────┐
8
+ // │ │
9
+ // │ ▼
10
+ // │ ┌─ PushableAsyncIterable ─┐
11
+ // │ │ .push(SDKUserMessage) │
12
+ // │ └─────────────┬───────────┘
13
+ // │ │
14
+ // │ ▼
15
+ // │ ┌── query({ prompt, options: { resume, cwd } })
16
+ // │ │
17
+ // │ ▼ for await (msg of query)
18
+ // │ ┌─ render to terminal ───┐
19
+ // │ │ + send to daemon over │
20
+ // │ │ WebSocket │
21
+ // │ └────────────────────────┘
22
+ // │
23
+ // └─ daemon ws.onmessage ──── parses { type:"push", prompt } → push to iter
24
+ //
25
+ // Usage:
26
+ // miki claude # new session, cwd=$PWD
27
+ // miki claude -c # resume last session in cwd
28
+ // miki claude -r <uuid> # resume specific session
29
+
30
+ import path from "node:path";
31
+ import os from "node:os";
32
+ import http from "node:http";
33
+ import { spawn } from "node:child_process";
34
+ import { createRequire } from "node:module";
35
+ import { promises as fs } from "node:fs";
36
+ import { fileURLToPath } from "node:url";
37
+ import readline from "node:readline";
38
+ import { WebSocket } from "ws";
39
+ import { query, type SDKMessage, type SDKUserMessage, type Query } from "@anthropic-ai/claude-agent-sdk";
40
+ import { PushableAsyncIterable } from "./pushable-iter.js";
41
+ import { PORT_FILE } from "../data-dir.js";
42
+
43
+ interface WrapArgs {
44
+ resume?: string;
45
+ continue: boolean;
46
+ fresh: boolean; // explicit opt-in to a brand-new session; otherwise we auto-continue
47
+ model?: string;
48
+ permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan" | "auto";
49
+ cwd: string;
50
+ }
51
+
52
+ function parseArgs(argv: string[]): WrapArgs {
53
+ const args: WrapArgs = { continue: false, fresh: false, cwd: process.cwd() };
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ if (a === "-r" || a === "--resume") {
57
+ const next = argv[i + 1];
58
+ if (next && !next.startsWith("-")) { args.resume = next; i++; }
59
+ else { /* picker not supported here — treat as no-op */ }
60
+ } else if (a === "-c" || a === "--continue") {
61
+ args.continue = true;
62
+ } else if (a === "--new" || a === "--fresh") {
63
+ args.fresh = true;
64
+ } else if (a === "--model") {
65
+ args.model = argv[++i];
66
+ } else if (a === "--permission-mode") {
67
+ const next = argv[++i] as WrapArgs["permissionMode"];
68
+ args.permissionMode = next;
69
+ } else if (a === "--bypass-permissions") {
70
+ args.permissionMode = "bypassPermissions";
71
+ }
72
+ }
73
+ return args;
74
+ }
75
+
76
+ async function readDaemonPort(): Promise<number | null> {
77
+ try {
78
+ const raw = await fs.readFile(PORT_FILE, "utf8");
79
+ const n = parseInt(raw.trim(), 10);
80
+ return Number.isFinite(n) ? n : null;
81
+ } catch { return null; }
82
+ }
83
+
84
+ // Quick liveness check — port file alone is unreliable because a crashed
85
+ // daemon leaves it behind. HTTP GET /sessions is cheap and unambiguous.
86
+ function pingDaemon(port: number): Promise<boolean> {
87
+ return new Promise((resolve) => {
88
+ const req = http.get(`http://127.0.0.1:${port}/sessions`, { timeout: 800 }, (res) => {
89
+ res.resume();
90
+ resolve((res.statusCode ?? 0) > 0);
91
+ });
92
+ req.on("error", () => resolve(false));
93
+ req.on("timeout", () => { req.destroy(); resolve(false); });
94
+ });
95
+ }
96
+
97
+ // Canonical default port (mirrors src/index.ts DEFAULT_PORT). When PORT_FILE
98
+ // points at a dead port (stale entry from a daemon that crashed before
99
+ // cleanup), we probe this BEFORE assuming nothing's home. Otherwise wrap's
100
+ // auto-spawn forks a duplicate daemon on a different port and the dashboard
101
+ // + CLI split-brain across two daemons (root cause of the 8766 race).
102
+ const MIKI_DEFAULT_PORT = 8765;
103
+
104
+ // If no daemon is reachable, spawn one as a detached background process so
105
+ // `miki claude` works as a single-command UX. The child keeps running after
106
+ // the wrap exits (other wraps + dashboard browser can attach to it).
107
+ async function ensureDaemonRunning(): Promise<number | null> {
108
+ const existing = await readDaemonPort();
109
+ if (existing && await pingDaemon(existing)) return existing;
110
+ // PORT_FILE stale (or missing). Before spawning a duplicate, see if a live
111
+ // daemon is actually sitting on the default port — common when the previous
112
+ // daemon got hard-killed and a new one is already up but PORT_FILE wasn't
113
+ // refreshed. Saves us from racing over PORT_FILE with a needless spawn.
114
+ if (existing !== MIKI_DEFAULT_PORT && await pingDaemon(MIKI_DEFAULT_PORT)) {
115
+ return MIKI_DEFAULT_PORT;
116
+ }
117
+
118
+ // Locate this script's directory → walk up to project root → find tsx + index.
119
+ // Works for both dev (src/cli/wrap.ts under tsx) and a published npm package
120
+ // shipped with the same layout (we don't pre-compile TS).
121
+ const here = path.dirname(fileURLToPath(import.meta.url));
122
+ const projectRoot = path.join(here, "..", "..");
123
+ const indexEntry = path.join(here, "..", "index.ts");
124
+
125
+ const req = createRequire(import.meta.url);
126
+ let tsxBin: string;
127
+ try {
128
+ const tsxPkgPath = req.resolve("tsx/package.json", { paths: [projectRoot] });
129
+ const tsxPkg = req(tsxPkgPath);
130
+ const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
131
+ if (!tsxBinRel) throw new Error("tsx bin not found");
132
+ tsxBin = path.join(path.dirname(tsxPkgPath), tsxBinRel);
133
+ } catch (err) {
134
+ process.stdout.write(`${yellow(`[miki] could not locate tsx — install miki-moni cleanly: ${(err as Error).message}`)}\n`);
135
+ return null;
136
+ }
137
+
138
+ process.stdout.write(`${dim(`[miki] daemon not running — spawning in background…`)}\n`);
139
+ // Log file under HUB_HOME so the daemon's stdout/stderr isn't lost.
140
+ const logPath = path.join(os.homedir(), ".miki-moni", "daemon.log");
141
+ try { await fs.mkdir(path.dirname(logPath), { recursive: true }); } catch { /* ignore */ }
142
+ const out = await fs.open(logPath, "a").catch(() => null);
143
+ const stdio: any = out
144
+ ? ["ignore", out.fd, out.fd]
145
+ : ["ignore", "ignore", "ignore"];
146
+ const child = spawn(process.execPath, [tsxBin, indexEntry], {
147
+ detached: true,
148
+ stdio,
149
+ windowsHide: true,
150
+ });
151
+ child.unref();
152
+ // open() handle is now owned by the child via fd inheritance — close ours.
153
+ if (out) out.close().catch(() => { /* ignore */ });
154
+
155
+ // Poll for port file + liveness up to 10s.
156
+ for (let i = 0; i < 50; i++) {
157
+ await new Promise((r) => setTimeout(r, 200));
158
+ const p = await readDaemonPort();
159
+ if (p && await pingDaemon(p)) {
160
+ process.stdout.write(`${dim(`[miki] daemon up (port ${p}, log → ${logPath})`)}\n`);
161
+ return p;
162
+ }
163
+ }
164
+ process.stdout.write(`${yellow(`[miki] daemon failed to come up in 10s — see ${logPath}`)}\n`);
165
+ return null;
166
+ }
167
+
168
+ // Find most recent session uuid for resuming -c (we mirror Claude's "last
169
+ // session in this cwd" lookup by reading ~/.claude/projects/<encoded>/*.jsonl
170
+ // and picking the newest by mtime). Claude itself also accepts --continue
171
+ // but it expects to control session IDs; for our wrap we want to forward an
172
+ // explicit UUID into query({ resume }) so we know which session we own.
173
+ async function findLatestSessionInCwd(cwd: string): Promise<string | null> {
174
+ const projectsRoot = path.join(os.homedir(), ".claude", "projects");
175
+ let dirs: string[]; try { dirs = await fs.readdir(projectsRoot); } catch { return null; }
176
+ let best: { uuid: string; mtime: number } | null = null;
177
+ for (const d of dirs) {
178
+ const dirPath = path.join(projectsRoot, d);
179
+ let files: string[]; try { files = await fs.readdir(dirPath); } catch { continue; }
180
+ for (const f of files) {
181
+ if (!f.endsWith(".jsonl")) continue;
182
+ const fp = path.join(dirPath, f);
183
+ try {
184
+ const raw = await fs.readFile(fp, "utf8");
185
+ // Confirm this transcript matches the wrapper's cwd
186
+ const firstLineWithCwd = raw.split(/\r?\n/).find((l) => l.includes('"cwd"'));
187
+ if (!firstLineWithCwd) continue;
188
+ const parsed = JSON.parse(firstLineWithCwd);
189
+ if (typeof parsed?.cwd !== "string") continue;
190
+ if (path.resolve(parsed.cwd).toLowerCase() !== path.resolve(cwd).toLowerCase()) continue;
191
+ const stat = await fs.stat(fp);
192
+ if (!best || stat.mtimeMs > best.mtime) {
193
+ best = { uuid: f.replace(/\.jsonl$/, ""), mtime: stat.mtimeMs };
194
+ }
195
+ } catch { /* skip */ }
196
+ }
197
+ }
198
+ return best?.uuid ?? null;
199
+ }
200
+
201
+ // Pretty terminal rendering for SDK messages. Intentionally simple — full
202
+ // fidelity (markdown, syntax highlight, spinners) is for later. Goal here is
203
+ // "you can clearly see what Claude is doing in the terminal".
204
+ const ESC = (s: string, code: number) => `\x1b[${code}m${s}\x1b[0m`;
205
+ const cyan = (s: string) => ESC(s, 36);
206
+ const green = (s: string) => ESC(s, 32);
207
+ const yellow = (s: string) => ESC(s, 33);
208
+ const dim = (s: string) => ESC(s, 2);
209
+ const bold = (s: string) => ESC(s, 1);
210
+
211
+ // Set when we've streamed at least one text block in the current turn via
212
+ // stream_event deltas. While true, the assistant-complete handler skips
213
+ // reprinting text (which would duplicate everything on screen). Reset on
214
+ // each result.
215
+ let didStreamThisTurn = false;
216
+ function renderMessage(msg: SDKMessage): void {
217
+ if (msg.type === "system" && msg.subtype === "init") {
218
+ const session = (msg as any).session_id ?? "(no id yet)";
219
+ process.stdout.write(`${dim("─".repeat(60))}\n`);
220
+ process.stdout.write(`${dim(`session: ${session}`)}\n`);
221
+ process.stdout.write(`${dim("─".repeat(60))}\n`);
222
+ return;
223
+ }
224
+ if (msg.type === "stream_event") {
225
+ const ev = (msg as any).event;
226
+ if (ev?.type === "content_block_start" && ev.content_block?.type === "text") {
227
+ process.stdout.write(`\n${green(bold("● "))} `);
228
+ didStreamThisTurn = true;
229
+ } else if (ev?.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") {
230
+ process.stdout.write(ev.delta.text);
231
+ } else if (ev?.type === "content_block_stop" && didStreamThisTurn) {
232
+ process.stdout.write(`\n`);
233
+ }
234
+ return;
235
+ }
236
+ if (msg.type === "assistant") {
237
+ const m = (msg as any).message;
238
+ const content = m?.content;
239
+ if (Array.isArray(content)) {
240
+ for (const block of content) {
241
+ if (block.type === "text" && typeof block.text === "string") {
242
+ // Already streamed via stream_event — skip duplicate print.
243
+ if (didStreamThisTurn) continue;
244
+ process.stdout.write(`\n${green(bold("● "))} ${block.text}\n`);
245
+ } else if (block.type === "tool_use") {
246
+ const desc = (block.input && typeof block.input === "object" && "description" in block.input)
247
+ ? (block.input as any).description
248
+ : null;
249
+ process.stdout.write(`\n${cyan("⚒ " + block.name)}${desc ? dim(" · " + desc) : ""}\n`);
250
+ }
251
+ }
252
+ }
253
+ return;
254
+ }
255
+ if (msg.type === "user") {
256
+ const m = (msg as any).message;
257
+ const content = m?.content;
258
+ if (Array.isArray(content)) {
259
+ for (const block of content) {
260
+ if (block.type === "tool_result") {
261
+ const text = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
262
+ const preview = text.replace(/\s+/g, " ").slice(0, 200);
263
+ process.stdout.write(`${dim(" ↳ " + preview + (text.length > 200 ? "…" : ""))}\n`);
264
+ }
265
+ }
266
+ }
267
+ return;
268
+ }
269
+ if (msg.type === "result") {
270
+ const subtype = (msg as any).subtype;
271
+ const cost = (msg as any).total_cost_usd;
272
+ process.stdout.write(`\n${dim(`└─ ${subtype}${cost ? ` · $${cost.toFixed(4)}` : ""}`)}\n`);
273
+ didStreamThisTurn = false; // ready for next turn
274
+ return;
275
+ }
276
+ }
277
+
278
+ function printPrompt(): void {
279
+ process.stdout.write(`\n${yellow("> ")}`);
280
+ }
281
+
282
+ async function main(): Promise<void> {
283
+ const args = parseArgs(process.argv.slice(3)); // skip "node miki claude"
284
+
285
+ // Resolve session uuid up front when --continue.
286
+ let resumeUuid: string | undefined = args.resume;
287
+ if (!resumeUuid && args.continue) {
288
+ const found = await findLatestSessionInCwd(args.cwd);
289
+ if (found) resumeUuid = found;
290
+ }
291
+
292
+ // Open WS to daemon with auto-reconnect. Daemon hot-reload during dev or any
293
+ // network blip kills the connection — we re-dial every 3s indefinitely.
294
+ // `currentWs` is mutated on each reconnect; `getWs()` always returns the
295
+ // freshest one so message handlers always read latest. If no daemon is
296
+ // running yet, we spawn one as a detached background process so the user's
297
+ // single `miki claude` invocation Just Works.
298
+ const port = await ensureDaemonRunning();
299
+ let currentWs: WebSocket | null = null;
300
+ let reconnectTimer: NodeJS.Timeout | null = null;
301
+ const getWs = (): WebSocket | null => currentWs;
302
+
303
+ // Mutable handle to the SDK Query. WS message handler is registered inside
304
+ // connect() (called BEFORE `q` is created), so we need a mutable ref so the
305
+ // handler reads the latest value when daemon pushes set_permission_mode.
306
+ // Also tracked: current mode so we can echo it back after each setPermissionMode.
307
+ let currentQ: Query | null = null;
308
+ let currentMode: NonNullable<WrapArgs["permissionMode"]> = args.permissionMode ?? "default";
309
+ // Track the active SDK model (set on init from --model flag, mutated via
310
+ // q.setModel from the dashboard model-chip). undefined = SDK default
311
+ // (whatever CLAUDE_DEFAULT_MODEL / Anthropic default resolves to). We
312
+ // echo this back to the daemon so it can populate /sessions for the
313
+ // dashboard chip.
314
+ let currentModel: string | undefined = args.model;
315
+
316
+ function connect(): void {
317
+ if (!port) return;
318
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/wrap`);
319
+ currentWs = ws;
320
+ ws.on("open", () => {
321
+ process.stdout.write(`${dim(`[wrap] WS connected to daemon (port ${port})`)}\n`);
322
+ ws.send(JSON.stringify({
323
+ type: "register",
324
+ session_uuid: resumeUuid ?? null,
325
+ cwd: args.cwd,
326
+ pid: process.pid,
327
+ permission_mode: currentMode,
328
+ // model is optional; daemon treats null as "SDK default" for chip
329
+ // labelling. Sent at register so reconnects re-seed the daemon map.
330
+ model: currentModel ?? null,
331
+ }));
332
+ // If we already had a session_uuid from SDK init (i.e., this is a
333
+ // RECONNECT after init already happened), tell daemon right away.
334
+ if (resumeUuid) {
335
+ ws.send(JSON.stringify({ type: "session_uuid", session_uuid: resumeUuid }));
336
+ }
337
+ });
338
+ ws.on("message", (raw) => {
339
+ try {
340
+ const m = JSON.parse(raw.toString());
341
+ if (m?.type === "push" && typeof m.prompt === "string") {
342
+ const imgs = Array.isArray(m.images) ? m.images.filter((i: any) => i?.media_type && i?.data) : undefined;
343
+ sendUser(m.prompt, "hub", imgs);
344
+ } else if (m?.type === "ask_question_answer" && typeof m.question_id === "string") {
345
+ // Dashboard answered an open AskUserQuestion. Format indices to a
346
+ // readable answer string and push as a user message.
347
+ if (!pendingAsk || pendingAsk.id !== m.question_id) return; // stale
348
+ const indices: string[][] = Array.isArray(m.answers) ? m.answers : [];
349
+ const answer = formatAnswerFromIndices(indices);
350
+ if (answer.trim()) answerAsk(answer);
351
+ } else if (m?.type === "set_permission_mode" && typeof m.mode === "string") {
352
+ // Dashboard requested a mode switch. SDK only supports this in
353
+ // streaming-input mode (we are), and `q` must exist (created after
354
+ // first connect). On success echo `permission_mode_changed` back so
355
+ // daemon updates the map + rebroadcasts to all browser clients.
356
+ const newMode = m.mode as NonNullable<WrapArgs["permissionMode"]>;
357
+ const q = currentQ;
358
+ if (!q) {
359
+ process.stdout.write(`${yellow(`[wrap] set_permission_mode received but SDK query not ready yet`)}\n`);
360
+ return;
361
+ }
362
+ q.setPermissionMode(newMode).then(() => {
363
+ currentMode = newMode;
364
+ process.stdout.write(`${cyan(`[hub] permission mode → ${newMode}`)}\n`);
365
+ const live = getWs();
366
+ if (live && live.readyState === live.OPEN && resumeUuid) {
367
+ try { live.send(JSON.stringify({ type: "permission_mode_changed", session_uuid: resumeUuid, mode: newMode })); }
368
+ catch { /* ignore */ }
369
+ }
370
+ }).catch((err: unknown) => {
371
+ process.stdout.write(`${yellow(`[wrap] setPermissionMode failed: ${(err as Error).message}`)}\n`);
372
+ });
373
+ } else if (m?.type === "set_model") {
374
+ // Dashboard requested a model switch. SDK's q.setModel() works in
375
+ // streaming-input mode (same as setPermissionMode). Empty string
376
+ // or null means "fall back to SDK default" — we forward as
377
+ // undefined since that's setModel's contract.
378
+ const newModelRaw = m.model;
379
+ const newModel: string | undefined = (typeof newModelRaw === "string" && newModelRaw.length > 0)
380
+ ? newModelRaw
381
+ : undefined;
382
+ const q = currentQ;
383
+ if (!q) {
384
+ process.stdout.write(`${yellow(`[wrap] set_model received but SDK query not ready yet`)}\n`);
385
+ return;
386
+ }
387
+ q.setModel(newModel).then(() => {
388
+ currentModel = newModel;
389
+ process.stdout.write(`${cyan(`[hub] model → ${newModel ?? "(default)"}`)}\n`);
390
+ const live = getWs();
391
+ if (live && live.readyState === live.OPEN && resumeUuid) {
392
+ try { live.send(JSON.stringify({ type: "model_changed", session_uuid: resumeUuid, model: newModel ?? null })); }
393
+ catch { /* ignore */ }
394
+ }
395
+ }).catch((err: unknown) => {
396
+ process.stdout.write(`${yellow(`[wrap] setModel failed: ${(err as Error).message}`)}\n`);
397
+ });
398
+ } else if (m?.type === "interrupt") {
399
+ // Dashboard pressed the ⏹ button. Stop whatever the model is doing.
400
+ const q = currentQ;
401
+ if (!q) {
402
+ process.stdout.write(`${yellow(`[wrap] interrupt received but SDK query not ready yet`)}\n`);
403
+ return;
404
+ }
405
+ q.interrupt().then(() => {
406
+ process.stdout.write(`${cyan(`[hub] ⏹ interrupted`)}\n`);
407
+ // Activity is meaningless once interrupted; clear so dashboard stops showing "Ideating"
408
+ setActivity(null);
409
+ }).catch((err: unknown) => {
410
+ process.stdout.write(`${yellow(`[wrap] interrupt failed: ${(err as Error).message}`)}\n`);
411
+ });
412
+ }
413
+ } catch { /* ignore non-JSON */ }
414
+ });
415
+ ws.on("error", (err) => {
416
+ process.stdout.write(`${yellow(`[wrap] WS error: ${(err as Error).message}`)}\n`);
417
+ });
418
+ ws.on("close", (code) => {
419
+ process.stdout.write(`${yellow(`[wrap] WS closed (code=${code}) — reconnecting in 3s…`)}\n`);
420
+ currentWs = null;
421
+ if (reconnectTimer) clearTimeout(reconnectTimer);
422
+ reconnectTimer = setTimeout(connect, 3000);
423
+ });
424
+ }
425
+ connect();
426
+
427
+ // The push-able iterable that feeds query() — both stdin reader and WS push
428
+ // messages into it.
429
+ const messages = new PushableAsyncIterable<SDKUserMessage>();
430
+
431
+ // Activity broadcaster — pushes "Ideating" / "Using <tool>" / "Replying" to
432
+ // daemon whenever the SDK stream signals a state transition. Daemon relays
433
+ // to dashboard. Cleared on result.
434
+ let currentActivity: string | null = null;
435
+ function setActivity(label: string | null): void {
436
+ if (label === currentActivity) return;
437
+ currentActivity = label;
438
+ const liveWs = getWs();
439
+ if (liveWs && liveWs.readyState === liveWs.OPEN && resumeUuid) {
440
+ try { liveWs.send(JSON.stringify({ type: "activity", session_uuid: resumeUuid, label })); }
441
+ catch { /* ignore */ }
442
+ }
443
+ }
444
+
445
+ // ── AskUserQuestion tracking ─────────────────────────────────────────────
446
+ // When Claude uses the AskUserQuestion tool, we surface it to dashboard +
447
+ // terminal and wait for the user's answer. Answer can come from either:
448
+ // - dashboard WS (user clicked the picker in browser)
449
+ // - terminal stdin (user typed a number 1-N or free text)
450
+ // First one wins; we then push the answer as a regular user message into
451
+ // the query iterable so Claude can see the response.
452
+ interface QQuestion { question: string; header: string; multiSelect?: boolean; options: Array<{ label: string; description: string }> }
453
+ interface PendingAsk { id: string; questions: QQuestion[] }
454
+ let pendingAsk: PendingAsk | null = null;
455
+
456
+ function emitAskQuestion(id: string, questions: QQuestion[]): void {
457
+ pendingAsk = { id, questions };
458
+ // 1. Daemon broadcast
459
+ const liveWs = getWs();
460
+ if (liveWs && liveWs.readyState === liveWs.OPEN && resumeUuid) {
461
+ try { liveWs.send(JSON.stringify({ type: "ask_question", session_uuid: resumeUuid, question_id: id, questions })); }
462
+ catch { /* ignore */ }
463
+ }
464
+ // 2. Terminal fallback render
465
+ process.stdout.write(`\n${yellow(bold("❓ Claude 在問你問題:"))}\n`);
466
+ for (let qi = 0; qi < questions.length; qi++) {
467
+ const q = questions[qi]!;
468
+ process.stdout.write(`\n${bold(`Q${qi + 1}. ${q.question}`)}\n`);
469
+ q.options.forEach((opt, i) => {
470
+ process.stdout.write(` ${cyan(String(i + 1))}. ${opt.label}${opt.description ? dim(` — ${opt.description}`) : ""}\n`);
471
+ });
472
+ }
473
+ process.stdout.write(`\n${dim("→ 在 dashboard 點選 OR terminal 直接輸入答案文字 / 編號 (如 1 或 1,3)")}\n`);
474
+ printPrompt();
475
+ }
476
+
477
+ // Convert raw user input (from stdin or dashboard) into a tidy answer string
478
+ // formatted as Claude expects. For dashboard, we get structured selections;
479
+ // for terminal, we get a string the user typed.
480
+ function formatAnswerFromIndices(indicesPerQuestion: string[][]): string {
481
+ if (!pendingAsk) return "";
482
+ const lines: string[] = [];
483
+ pendingAsk.questions.forEach((q, qi) => {
484
+ const idxs = indicesPerQuestion[qi] ?? [];
485
+ const picks = idxs.map((idx) => {
486
+ const n = parseInt(idx, 10);
487
+ if (Number.isFinite(n) && n >= 1 && n <= q.options.length) return q.options[n - 1]!.label;
488
+ return idx; // free-text fallback
489
+ });
490
+ lines.push(`${q.question} → ${picks.join(" / ")}`);
491
+ });
492
+ return lines.join("\n");
493
+ }
494
+
495
+ function answerAsk(answer: string): void {
496
+ if (!pendingAsk) return;
497
+ const id = pendingAsk.id;
498
+ pendingAsk = null;
499
+ // Tell daemon to dismiss any open picker for this question
500
+ const liveWs = getWs();
501
+ if (liveWs && liveWs.readyState === liveWs.OPEN && resumeUuid) {
502
+ try { liveWs.send(JSON.stringify({ type: "ask_question_done", session_uuid: resumeUuid, question_id: id })); }
503
+ catch { /* ignore */ }
504
+ }
505
+ process.stdout.write(`${cyan("→ 回應:")}${answer}\n`);
506
+ sendUser(answer, "stdin");
507
+ }
508
+
509
+ interface HubImage { media_type: string; data: string } // data = base64
510
+ function sendUser(text: string, source: "stdin" | "hub", images?: HubImage[]): void {
511
+ const hasText = !!text.trim();
512
+ const hasImages = images && images.length > 0;
513
+ if (!hasText && !hasImages) return;
514
+
515
+ if (source === "hub") {
516
+ const imgNote = hasImages ? cyan(` [${images!.length} image${images!.length > 1 ? "s" : ""}]`) : "";
517
+ process.stdout.write(`\n${cyan("[hub] ")}${text}${imgNote}\n`);
518
+ }
519
+
520
+ // Build SDK content: string for text-only, array of blocks when images present.
521
+ // Image blocks come first per Anthropic recommendation (Claude pays attention earlier).
522
+ let content: any = text;
523
+ if (hasImages) {
524
+ const blocks: any[] = images!.map((img) => ({
525
+ type: "image",
526
+ source: { type: "base64", media_type: img.media_type, data: img.data },
527
+ }));
528
+ if (hasText) blocks.push({ type: "text", text });
529
+ content = blocks;
530
+ }
531
+
532
+ messages.push({
533
+ type: "user",
534
+ parent_tool_use_id: null,
535
+ session_id: resumeUuid ?? "",
536
+ message: { role: "user", content },
537
+ } as SDKUserMessage);
538
+ // New user input → Claude is about to think. Flip immediately.
539
+ setActivity("Ideating");
540
+ // Tell daemon a new turn started — wrapped sessions don't fire Claude
541
+ // Code's UserPromptSubmit hook, so the cell status would otherwise stay
542
+ // wherever the last turn left it. Daemon synthesizes a user_prompt event
543
+ // → status flips to "active" + the dashboard's STOP button appears.
544
+ const liveWs = getWs();
545
+ if (liveWs && liveWs.readyState === liveWs.OPEN && resumeUuid) {
546
+ try { liveWs.send(JSON.stringify({ type: "turn_start", session_uuid: resumeUuid })); }
547
+ catch { /* ignore */ }
548
+ // Push the user text optimistically so the dashboard cell's "user" line
549
+ // updates the moment Enter is pressed — without waiting 1-2s for the SDK
550
+ // to flush to JSONL and /sessions/previews to repoll. Text-only; images
551
+ // aren't surfaced in the small-card preview anyway.
552
+ if (hasText) {
553
+ try {
554
+ liveWs.send(JSON.stringify({
555
+ type: "user_message",
556
+ session_uuid: resumeUuid,
557
+ text,
558
+ ts: Date.now(),
559
+ }));
560
+ } catch { /* ignore */ }
561
+ }
562
+ }
563
+ }
564
+
565
+ // (message handler is now wired up inside connect() above so it re-binds on each reconnect)
566
+
567
+ // Cold-start banner so the user knows we're alive and waiting for input.
568
+ process.stdout.write(`${dim("─".repeat(60))}\n`);
569
+ process.stdout.write(`${bold("miki claude")} ${dim("· cwd=" + args.cwd)}\n`);
570
+ if (resumeUuid) process.stdout.write(`${dim("resuming session: " + resumeUuid)}\n`);
571
+ else if (args.fresh) process.stdout.write(`${dim("new session (--fresh — auto-sending 'hi' to wake SDK)")}\n`);
572
+ else process.stdout.write(`${dim("new session (uuid will appear once SDK init fires)")}\n`);
573
+ if (port) process.stdout.write(`${dim("daemon: ws://127.0.0.1:" + port + "/wrap")}\n`);
574
+ else process.stdout.write(`${yellow("daemon: NOT connected (no port file — start miki-moni daemon)")}\n`);
575
+ process.stdout.write(`${dim("─".repeat(60))}\n`);
576
+ printPrompt();
577
+
578
+ // Start the query — long-lived, single shot for the whole session.
579
+ // `allowDangerouslySkipPermissions: true` is required for runtime switching
580
+ // to `bypassPermissions` mode. Without it, SDK rejects the setPermissionMode
581
+ // call with "Cannot set permission mode to bypassPermissions because the
582
+ // session was not launched with --dangerously-skip-permissions". Default
583
+ // behavior is unchanged — user still has to explicitly pick bypass from the
584
+ // dashboard chip; we're only unlocking the option.
585
+ const q = query({
586
+ prompt: messages,
587
+ options: {
588
+ cwd: args.cwd,
589
+ resume: resumeUuid,
590
+ model: args.model,
591
+ permissionMode: args.permissionMode,
592
+ allowDangerouslySkipPermissions: true,
593
+ // Stream Anthropic raw stream_event chunks back via the SDK so we can
594
+ // forward text-deltas to the dashboard in real time. Without this, the
595
+ // SDK only emits whole assistant messages on completion — the cell
596
+ // preview only updates once Claude's done a full turn.
597
+ includePartialMessages: true,
598
+ // AskUserQuestion needs user interaction; let it through and we'll
599
+ // surface the question ourselves once the tool_use appears in the
600
+ // message stream (Happy's pattern). For everything else, allow.
601
+ canUseTool: async (toolName: string, input: any) => {
602
+ return { behavior: "allow", updatedInput: (input ?? {}) as Record<string, unknown> };
603
+ },
604
+ } as any,
605
+ });
606
+ currentQ = q;
607
+
608
+ // --fresh: auto-push a tiny "hi" to wake the SDK so init fires and a
609
+ // session_uuid surfaces immediately. Without this, a brand-new session
610
+ // sits dormant (no UUID, dashboard can't bind) until the user types in
611
+ // the terminal. Costs one tiny API turn.
612
+ if (args.fresh && !resumeUuid) {
613
+ sendUser("hi", "stdin");
614
+ }
615
+
616
+ // Read user input from stdin without blocking the message loop. readline
617
+ // gives us line-by-line entry; for multi-line input the user can paste.
618
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
619
+ rl.on("line", (line) => {
620
+ const trimmed = line.trim();
621
+ if (!trimmed) return;
622
+ // If a question is pending, parse line as answer(s).
623
+ // Single question: "1" / "2,3" / "free text"
624
+ // Multiple questions: "1; 2,3; 1" (semicolon-separated per question)
625
+ if (pendingAsk) {
626
+ const segs = trimmed.includes(";") ? trimmed.split(";").map((s) => s.trim()) : [trimmed];
627
+ const idxPerQ: string[][] = pendingAsk.questions.map((_, qi) => {
628
+ const seg = segs[qi] ?? "";
629
+ return seg.split(",").map((s) => s.trim()).filter(Boolean);
630
+ });
631
+ const answer = formatAnswerFromIndices(idxPerQ);
632
+ if (answer.trim()) { answerAsk(answer); return; }
633
+ }
634
+ sendUser(trimmed, "stdin");
635
+ });
636
+ rl.on("close", () => messages.end());
637
+
638
+ // Consume the SDK stream — render + ship to daemon
639
+ try {
640
+ for await (const m of q) {
641
+ renderMessage(m);
642
+ const liveWs = getWs();
643
+ // Tell daemon the session uuid as soon as we see it (first init message)
644
+ if (liveWs && liveWs.readyState === liveWs.OPEN && m.type === "system" && (m as any).subtype === "init") {
645
+ const sid = (m as any).session_id as string | undefined;
646
+ if (sid && sid !== resumeUuid) {
647
+ resumeUuid = sid;
648
+ liveWs.send(JSON.stringify({ type: "session_uuid", session_uuid: sid }));
649
+ }
650
+ }
651
+ // Mirror message to daemon (optional — daemon already reads JSONL)
652
+ if (liveWs && liveWs.readyState === liveWs.OPEN) {
653
+ try { liveWs.send(JSON.stringify({ type: "message", message: m })); } catch { /* ignore */ }
654
+ }
655
+ // Streaming text deltas: when SDK emits a partial stream_event with a
656
+ // text_delta inside a content_block_delta, forward the chunk to daemon
657
+ // for real-time UI rendering. JSON-input deltas (tool_use argument
658
+ // streams) we skip for now — UI doesn't have a place to surface them.
659
+ if (liveWs && liveWs.readyState === liveWs.OPEN && resumeUuid && m.type === "stream_event") {
660
+ const ev = (m as any).event;
661
+ if (ev?.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") {
662
+ try {
663
+ liveWs.send(JSON.stringify({
664
+ type: "assistant_delta",
665
+ session_uuid: resumeUuid,
666
+ index: typeof ev.index === "number" ? ev.index : 0,
667
+ text: ev.delta.text,
668
+ }));
669
+ } catch { /* ignore */ }
670
+ } else if (ev?.type === "content_block_start" && ev.content_block?.type === "text") {
671
+ // Start of a new text block — tell client to start a new buffer slot.
672
+ try {
673
+ liveWs.send(JSON.stringify({
674
+ type: "assistant_delta_start",
675
+ session_uuid: resumeUuid,
676
+ index: typeof ev.index === "number" ? ev.index : 0,
677
+ }));
678
+ } catch { /* ignore */ }
679
+ } else if (ev?.type === "message_stop") {
680
+ // Whole assistant message complete — client should flush its
681
+ // streaming buffer (next /sessions/previews refresh will replace it
682
+ // with the canonical text from JSONL).
683
+ try {
684
+ liveWs.send(JSON.stringify({
685
+ type: "assistant_delta_end",
686
+ session_uuid: resumeUuid,
687
+ }));
688
+ } catch { /* ignore */ }
689
+ }
690
+ }
691
+ // Activity tracking — derive a coarse state per message for the
692
+ // dashboard cell header to show live progress.
693
+ if (m.type === "assistant") {
694
+ const content = (m as any).message?.content;
695
+ if (Array.isArray(content)) {
696
+ for (const block of content) {
697
+ if (block?.type === "tool_use" && typeof block.name === "string") {
698
+ setActivity(`Using ${block.name}`);
699
+ // AskUserQuestion: surface to dashboard + terminal so user can pick.
700
+ if (block.name === "AskUserQuestion" && block.input && typeof block.input === "object") {
701
+ const qs = (block.input as any).questions;
702
+ if (Array.isArray(qs) && qs.length > 0) {
703
+ emitAskQuestion(block.id || `q-${Date.now()}`, qs as QQuestion[]);
704
+ }
705
+ }
706
+ } else if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
707
+ setActivity("Replying");
708
+ }
709
+ }
710
+ }
711
+ } else if (m.type === "user") {
712
+ const content = (m as any).message?.content;
713
+ if (Array.isArray(content) && content.some((b: any) => b?.type === "tool_result")) {
714
+ // Tool just returned — model will ideate again before next move.
715
+ setActivity("Ideating");
716
+ }
717
+ } else if (m.type === "result") {
718
+ setActivity(null);
719
+ // Tell daemon the turn ended. Without this signal the dashboard cell
720
+ // stays in "進行中" forever (no Stop hook fires for SDK-driven wrap
721
+ // sessions) — and worse, the cell's STOP button overlays SEND, so
722
+ // clicking what looks like "send" actually invokes interrupt.
723
+ const liveResultWs = getWs();
724
+ if (liveResultWs && liveResultWs.readyState === liveResultWs.OPEN && resumeUuid) {
725
+ try { liveResultWs.send(JSON.stringify({ type: "turn_end", session_uuid: resumeUuid })); }
726
+ catch { /* ignore */ }
727
+ }
728
+ printPrompt();
729
+ }
730
+ }
731
+ } finally {
732
+ rl.close();
733
+ if (reconnectTimer) clearTimeout(reconnectTimer);
734
+ const liveWs = getWs();
735
+ if (liveWs) try { liveWs.close(); } catch { /* ignore */ }
736
+ }
737
+ }
738
+
739
+ main().catch((err) => {
740
+ console.error("\nwrap failed:", err);
741
+ process.exit(1);
742
+ });