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
package/src/cli/wrap.ts
ADDED
|
@@ -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
|
+
});
|