@co0ontty/wand 1.18.1 → 1.20.4

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/dist/server.d.ts CHANGED
@@ -1,2 +1,20 @@
1
+ import { ProcessManager } from "./process-manager.js";
2
+ import { StructuredSessionManager } from "./structured-session-manager.js";
1
3
  import { WandConfig } from "./types.js";
2
- export declare function startServer(config: WandConfig, configPath: string): Promise<void>;
4
+ export interface ServerUrl {
5
+ url: string;
6
+ scheme: "HTTP" | "HTTPS";
7
+ }
8
+ export interface ServerHandle {
9
+ processManager: ProcessManager;
10
+ structuredSessions: StructuredSessionManager;
11
+ configPath: string;
12
+ dbPath: string;
13
+ urls: ServerUrl[];
14
+ bindAddr: string;
15
+ httpsEnabled: boolean;
16
+ version: string;
17
+ orphanRecoveredCount: number;
18
+ close(): Promise<void>;
19
+ }
20
+ export declare function startServer(config: WandConfig, configPath: string): Promise<ServerHandle>;
package/dist/server.js CHANGED
@@ -20,7 +20,9 @@ import { StructuredSessionManager } from "./structured-session-manager.js";
20
20
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
21
21
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
22
22
  import { registerUploadRoutes } from "./upload-routes.js";
23
+ import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
23
24
  import { resolveDatabasePath, WandStorage } from "./storage.js";
25
+ import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
24
26
  import { renderApp } from "./web-ui/index.js";
25
27
  import { WsBroadcastManager } from "./ws-broadcast.js";
26
28
  import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
@@ -445,12 +447,24 @@ process.on("unhandledRejection", (reason) => {
445
447
  wandError("未处理的异步错误", msg);
446
448
  });
447
449
  function wandError(label, message, suggestion) {
450
+ if (isLogBusActive()) {
451
+ wandTuiLog("error", `✗ [wand] ${label}:${message}`);
452
+ if (suggestion)
453
+ wandTuiLog("error", ` 解决方法:${suggestion}`);
454
+ return;
455
+ }
448
456
  process.stderr.write(`\n✗ [wand] ${label}:${message}\n`);
449
457
  if (suggestion)
450
458
  process.stderr.write(` 解决方法:${suggestion}\n`);
451
459
  process.stderr.write("\n");
452
460
  }
453
461
  function wandWarn(message, hint) {
462
+ if (isLogBusActive()) {
463
+ wandTuiLog("warn", `⚠️ [wand] 警告:${message}`);
464
+ if (hint)
465
+ wandTuiLog("warn", ` 提示:${hint}`);
466
+ return;
467
+ }
454
468
  process.stderr.write(`⚠️ [wand] 警告:${message}\n`);
455
469
  if (hint)
456
470
  process.stderr.write(` 提示:${hint}\n`);
@@ -493,7 +507,6 @@ function getLanguageFromExt(ext, filePath) {
493
507
  return "plaintext";
494
508
  return map[ext] || "plaintext";
495
509
  }
496
- // ── Main server ──
497
510
  export async function startServer(config, configPath) {
498
511
  const app = express();
499
512
  const storage = new WandStorage(resolveDatabasePath(configPath));
@@ -906,9 +919,31 @@ export async function startServer(config, configPath) {
906
919
  updateInFlight = false;
907
920
  }
908
921
  });
909
- registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode);
922
+ registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode, config);
910
923
  registerClaudeHistoryRoutes(app, processes, storage);
911
924
  registerUploadRoutes(app, processes);
925
+ app.post("/api/optimize-prompt", express.json({ limit: "256kb" }), async (req, res) => {
926
+ const body = (req.body ?? {});
927
+ const text = typeof body.text === "string" ? body.text : "";
928
+ let cwd;
929
+ if (typeof body.sessionId === "string" && body.sessionId.length > 0) {
930
+ const snap = storage.getSession(body.sessionId);
931
+ if (snap?.cwd)
932
+ cwd = snap.cwd;
933
+ }
934
+ try {
935
+ const optimized = await optimizePrompt(text, config.language ?? "", cwd);
936
+ res.json({ optimized });
937
+ }
938
+ catch (error) {
939
+ if (error instanceof PromptOptimizeError) {
940
+ const status = error.code === "EMPTY_INPUT" || error.code === "INPUT_TOO_LONG" ? 400 : 500;
941
+ res.status(status).json({ error: error.message, errorCode: error.code });
942
+ return;
943
+ }
944
+ res.status(500).json({ error: getErrorMessage(error, "提示词优化失败。") });
945
+ }
946
+ });
912
947
  // ── Path suggestion ──
913
948
  app.get("/api/path-suggestions", async (req, res) => {
914
949
  const query = typeof req.query.q === "string" ? req.query.q : "";
@@ -1240,11 +1275,20 @@ export async function startServer(config, configPath) {
1240
1275
  setTimeout(() => process.exit(0), 5000);
1241
1276
  }, 600);
1242
1277
  });
1278
+ let bindAddr = config.host === "0.0.0.0" ? "0.0.0.0" : config.host;
1279
+ const collectedUrls = [];
1243
1280
  await new Promise((resolve, reject) => {
1244
1281
  server.listen(config.port, config.host, () => {
1245
- const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
1246
- process.stdout.write(`[wand] Web console listening on ${listenAddr}:${config.port}\n` +
1247
- `[wand] 本地访问: ${protocol}://127.0.0.1:${config.port}\n`);
1282
+ bindAddr = `${config.host}:${config.port}`;
1283
+ const scheme = useHttps ? "HTTPS" : "HTTP";
1284
+ // URL:本机回环;若绑定 0.0.0.0 再补一个对外提示。
1285
+ collectedUrls.push({ url: `${protocol}://127.0.0.1:${config.port}`, scheme });
1286
+ if (config.host === "0.0.0.0") {
1287
+ collectedUrls.push({ url: `${protocol}://0.0.0.0:${config.port}`, scheme });
1288
+ }
1289
+ else if (config.host !== "127.0.0.1" && config.host !== "localhost") {
1290
+ collectedUrls.push({ url: `${protocol}://${config.host}:${config.port}`, scheme });
1291
+ }
1248
1292
  resolve();
1249
1293
  });
1250
1294
  server.on("error", (err) => {
@@ -1336,4 +1380,45 @@ export async function startServer(config, configPath) {
1336
1380
  setInterval(() => {
1337
1381
  performAutoUpdate().catch(() => { });
1338
1382
  }, 30 * 60 * 1000);
1383
+ const close = () => new Promise((resolve) => {
1384
+ let done = false;
1385
+ const finish = () => {
1386
+ if (done)
1387
+ return;
1388
+ done = true;
1389
+ try {
1390
+ storage.close();
1391
+ }
1392
+ catch { /* ignore */ }
1393
+ resolve();
1394
+ };
1395
+ try {
1396
+ wss.clients.forEach((c) => c.close());
1397
+ }
1398
+ catch { /* ignore */ }
1399
+ try {
1400
+ wss.close();
1401
+ }
1402
+ catch { /* ignore */ }
1403
+ try {
1404
+ server.close(() => finish());
1405
+ }
1406
+ catch {
1407
+ finish();
1408
+ return;
1409
+ }
1410
+ setTimeout(finish, 3000); // 兜底:3s 内未关完强制 resolve
1411
+ });
1412
+ return {
1413
+ processManager: processes,
1414
+ structuredSessions,
1415
+ configPath,
1416
+ dbPath: resolveDatabasePath(configPath),
1417
+ urls: collectedUrls,
1418
+ bindAddr,
1419
+ httpsEnabled: useHttps,
1420
+ version: PKG_VERSION,
1421
+ orphanRecoveredCount: processes.getOrphanRecoveredCount(),
1422
+ close,
1423
+ };
1339
1424
  }
package/dist/storage.js CHANGED
@@ -54,12 +54,12 @@ function mapWorktreeMergeFields(row) {
54
54
  }
55
55
  function sessionSelectFields() {
56
56
  return `id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, archived, archived_at, claude_session_id, messages, queued_messages, structured_state
57
- , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info`;
57
+ , resumed_from_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info`;
58
58
  }
59
59
  function sessionPersistFields() {
60
60
  return `id, command, cwd, mode, status, exit_code, started_at, ended_at, output
61
61
  , archived, archived_at, claude_session_id, provider, session_kind, runner, messages, queued_messages, structured_state
62
- , resumed_from_session_id, resumed_to_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info`;
62
+ , resumed_from_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info`;
63
63
  }
64
64
  function sessionPersistAssignments() {
65
65
  return `command = excluded.command,
@@ -80,7 +80,6 @@ function sessionPersistAssignments() {
80
80
  queued_messages = excluded.queued_messages,
81
81
  structured_state = excluded.structured_state,
82
82
  resumed_from_session_id = excluded.resumed_from_session_id,
83
- resumed_to_session_id = excluded.resumed_to_session_id,
84
83
  auto_recovered = excluded.auto_recovered,
85
84
  worktree_enabled = excluded.worktree_enabled,
86
85
  worktree_info = excluded.worktree_info,
@@ -92,7 +91,7 @@ function sessionMetadataAssignments() {
92
91
  started_at = ?, ended_at = ?, output = ?,
93
92
  archived = ?, archived_at = ?, claude_session_id = ?,
94
93
  provider = ?, session_kind = ?, runner = ?, structured_state = ?,
95
- resumed_from_session_id = ?, resumed_to_session_id = ?, auto_recovered = ?,
94
+ resumed_from_session_id = ?, auto_recovered = ?,
96
95
  worktree_enabled = ?, worktree_info = ?, worktree_merge_status = ?, worktree_merge_info = ?`;
97
96
  }
98
97
  function sessionPersistValues(snapshot) {
@@ -116,7 +115,6 @@ function sessionPersistValues(snapshot) {
116
115
  snapshot.queuedMessages ? JSON.stringify(snapshot.queuedMessages) : null,
117
116
  snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null,
118
117
  snapshot.resumedFromSessionId ?? null,
119
- snapshot.resumedToSessionId ?? null,
120
118
  snapshot.autoRecovered ? 1 : 0,
121
119
  snapshot.worktreeEnabled ? 1 : 0,
122
120
  serializeWorktreeInfo(snapshot.worktree),
@@ -142,7 +140,6 @@ function sessionMetadataValues(snapshot) {
142
140
  snapshot.runner ?? null,
143
141
  snapshot.structuredState ? JSON.stringify(snapshot.structuredState) : null,
144
142
  snapshot.resumedFromSessionId ?? null,
145
- snapshot.resumedToSessionId ?? null,
146
143
  snapshot.autoRecovered ? 1 : 0,
147
144
  snapshot.worktreeEnabled ? 1 : 0,
148
145
  serializeWorktreeInfo(snapshot.worktree),
@@ -173,7 +170,6 @@ function mapSessionCore(row) {
173
170
  queuedMessages: parseQueuedMessages(row.queued_messages),
174
171
  structuredState: safeJsonParse(row.structured_state),
175
172
  resumedFromSessionId: row.resumed_from_session_id ?? undefined,
176
- resumedToSessionId: row.resumed_to_session_id ?? undefined,
177
173
  autoRecovered: Boolean(row.auto_recovered),
178
174
  worktreeEnabled: Boolean(row.worktree_enabled),
179
175
  worktree: parseWorktreeInfo(row.worktree_info) ?? null,
@@ -309,7 +305,7 @@ export class WandStorage {
309
305
  this.db
310
306
  .prepare(`INSERT INTO command_sessions (
311
307
  ${sessionPersistFields()}
312
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
308
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
313
309
  ON CONFLICT(id) DO UPDATE SET
314
310
  ${sessionPersistAssignments()}`)
315
311
  .run(...sessionPersistValues(snapshot));
@@ -14,15 +14,20 @@ export declare class StructuredSessionManager {
14
14
  private readonly config;
15
15
  private readonly sessions;
16
16
  private readonly pendingChildren;
17
+ private readonly interruptedWith;
17
18
  private emitEvent;
19
+ private archiveTimer;
18
20
  constructor(storage: WandStorage, config: WandConfig);
21
+ private archiveExpiredSessions;
19
22
  setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
20
23
  list(): SessionSnapshot[];
21
24
  /** Return lightweight snapshots for the session list (no output/messages). */
22
25
  listSlim(): SessionSnapshot[];
23
26
  get(id: string): SessionSnapshot | null;
24
27
  createSession(options: CreateStructuredSessionOptions): SessionSnapshot;
25
- sendMessage(id: string, input: string): Promise<SessionSnapshot>;
28
+ sendMessage(id: string, input: string, opts?: {
29
+ interrupt?: boolean;
30
+ }): Promise<SessionSnapshot>;
26
31
  /** Approve a pending permission request. */
27
32
  approvePermission(sessionId: string): SessionSnapshot;
28
33
  /** Deny a pending permission request. */
@@ -2,9 +2,44 @@ import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { prepareSessionWorktree } from "./git-worktree.js";
4
4
  const STREAM_EMIT_DEBOUNCE_MS = 16;
5
+ const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
5
6
  function isRunningAsRoot() {
6
7
  return process.getuid?.() === 0 || process.geteuid?.() === 0;
7
8
  }
9
+ /**
10
+ * 找出最后一条 assistant turn 中尚未配对 tool_result 的 AskUserQuestion tool_use。
11
+ * 用来识别"刚被 SIGTERM 中断、正在等用户提交答案"的状态。
12
+ */
13
+ function findUnpairedAskUserQuestion(messages) {
14
+ for (let i = messages.length - 1; i >= 0; i--) {
15
+ const turn = messages[i];
16
+ if (turn.role !== "assistant")
17
+ continue;
18
+ for (const block of turn.content) {
19
+ if (block.type === "tool_use" && block.name === "AskUserQuestion") {
20
+ const toolUseId = block.id;
21
+ // 检查后续 turn 中是否已有对应 tool_result
22
+ let answered = false;
23
+ for (let j = i + 1; j < messages.length; j++) {
24
+ const nextTurn = messages[j];
25
+ for (const nb of nextTurn.content) {
26
+ if (nb.type === "tool_result" && nb.tool_use_id === toolUseId) {
27
+ answered = true;
28
+ break;
29
+ }
30
+ }
31
+ if (answered)
32
+ break;
33
+ }
34
+ if (!answered)
35
+ return { id: toolUseId };
36
+ }
37
+ }
38
+ // 只检查最后一条 assistant turn
39
+ return null;
40
+ }
41
+ return null;
42
+ }
8
43
  /** Enrich a snapshot with a derived summary from the first user message. */
9
44
  function withSummary(snapshot) {
10
45
  if (snapshot.summary)
@@ -51,7 +86,9 @@ export class StructuredSessionManager {
51
86
  config;
52
87
  sessions = new Map();
53
88
  pendingChildren = new Map();
89
+ interruptedWith = new Map();
54
90
  emitEvent = null;
91
+ archiveTimer = null;
55
92
  constructor(storage, config) {
56
93
  this.storage = storage;
57
94
  this.config = config;
@@ -83,6 +120,30 @@ export class StructuredSessionManager {
83
120
  this.sessions.set(restored.id, restored);
84
121
  this.storage.saveSession(restored);
85
122
  }
123
+ this.archiveExpiredSessions();
124
+ this.archiveTimer = setInterval(() => {
125
+ try {
126
+ this.archiveExpiredSessions();
127
+ }
128
+ catch (err) {
129
+ console.error(`[StructuredSessionManager] archive scan failed: ${String(err)}`);
130
+ }
131
+ }, 60 * 1000);
132
+ this.archiveTimer.unref?.();
133
+ }
134
+ archiveExpiredSessions() {
135
+ const now = Date.now();
136
+ for (const session of this.sessions.values()) {
137
+ if (session.archived || session.status === "running")
138
+ continue;
139
+ const referenceTime = session.endedAt ?? session.startedAt;
140
+ const endedAtMs = Date.parse(referenceTime);
141
+ if (!Number.isFinite(endedAtMs) || now - endedAtMs < ARCHIVE_AFTER_MS)
142
+ continue;
143
+ session.archived = true;
144
+ session.archivedAt = new Date(now).toISOString();
145
+ this.storage.saveSession(session);
146
+ }
86
147
  }
87
148
  setEventEmitter(emitEvent) {
88
149
  this.emitEvent = emitEvent;
@@ -155,7 +216,7 @@ export class StructuredSessionManager {
155
216
  }
156
217
  return snapshot;
157
218
  }
158
- async sendMessage(id, input) {
219
+ async sendMessage(id, input, opts) {
159
220
  let session = this.requireSession(id);
160
221
  const prompt = input.trim();
161
222
  if (!prompt)
@@ -181,6 +242,14 @@ export class StructuredSessionManager {
181
242
  this.storage.saveSession(recovered);
182
243
  session = recovered;
183
244
  }
245
+ else if (opts?.interrupt) {
246
+ this.interruptedWith.set(id, prompt);
247
+ try {
248
+ child.kill("SIGTERM");
249
+ }
250
+ catch (_err) { /* ignore */ }
251
+ return session;
252
+ }
184
253
  else {
185
254
  const queue = [...(session.queuedMessages ?? [])];
186
255
  if (queue.length >= 10) {
@@ -196,10 +265,26 @@ export class StructuredSessionManager {
196
265
  return queued;
197
266
  }
198
267
  }
199
- const userTurn = {
200
- role: "user",
201
- content: [{ type: "text", text: prompt }],
202
- };
268
+ // 检测上一轮 assistant 是否有未配对的 AskUserQuestion tool_use(说明前一次
269
+ // child 是被 SIGTERM 主动 kill 的,正在等用户回答)。如果有,把这次的输入打包
270
+ // tool_result 注入到 messages,让 UI 把卡片渲染为 answered。
271
+ const pendingAsk = findUnpairedAskUserQuestion(session.messages ?? []);
272
+ const userTurn = pendingAsk
273
+ ? {
274
+ role: "user",
275
+ content: [
276
+ {
277
+ type: "tool_result",
278
+ tool_use_id: pendingAsk.id,
279
+ content: prompt,
280
+ is_error: false,
281
+ },
282
+ ],
283
+ }
284
+ : {
285
+ role: "user",
286
+ content: [{ type: "text", text: prompt }],
287
+ };
203
288
  const requestId = randomUUID();
204
289
  const updated = {
205
290
  ...session,
@@ -222,8 +307,13 @@ export class StructuredSessionManager {
222
307
  sessionId: id,
223
308
  data: { status: "running", sessionKind: "structured", queuedMessages: updated.queuedMessages, structuredState: updated.structuredState },
224
309
  });
310
+ // 续接 AskUserQuestion 时给 Claude 加上下文,避免它把刚才悬挂的 tool_use 当作
311
+ // 异常重试。结构化模式 (claude -p) 没有 tool_result 回传通道,所以用文本告知。
312
+ const claudePrompt = pendingAsk
313
+ ? `[对刚才 AskUserQuestion 工具的回答 — 结构化模式不支持工具结果回传,下面是用户从选项中的选择]\n${prompt}`
314
+ : prompt;
225
315
  try {
226
- await this.runClaudeStreaming(id, updated, prompt);
316
+ await this.runClaudeStreaming(id, updated, claudePrompt);
227
317
  const finished = this.requireSession(id);
228
318
  return finished;
229
319
  }
@@ -325,6 +415,7 @@ export class StructuredSessionManager {
325
415
  }
326
416
  stop(id) {
327
417
  const session = this.requireSession(id);
418
+ this.interruptedWith.delete(id);
328
419
  const child = this.pendingChildren.get(id);
329
420
  if (child) {
330
421
  child.kill();
@@ -529,17 +620,27 @@ export class StructuredSessionManager {
529
620
  if (modelChoice && modelChoice !== "default") {
530
621
  args.push("--model", modelChoice);
531
622
  }
623
+ // 托管模式:禁用 AskUserQuestion,让 agent 自己拍板,不要等用户决策。
624
+ // 非托管模式:保留工具,靠 processLine 检测后主动 kill child 触发"中断+续接"流程。
625
+ const isManaged = session.mode === "managed";
626
+ if (isManaged) {
627
+ args.push("--disallowedTools", "AskUserQuestion");
628
+ }
532
629
  if (session.claudeSessionId) {
533
630
  args.push("--resume", session.claudeSessionId);
534
631
  }
535
- args.push(prompt);
632
+ // 通过 stdin 传 prompt,避免被 --allowedTools / --disallowedTools 这类
633
+ // variadic 参数贪婪吞掉(commander 的 <tools...> 会一直吃 positional 直到
634
+ // 下一个 flag)。表现为 claude 报 "Input must be provided either through
635
+ // stdin or as a prompt argument when using --print"。
536
636
  const child = spawn("claude", args, {
537
637
  cwd: session.cwd,
538
638
  env: process.env,
539
- stdio: ["ignore", "pipe", "pipe"],
639
+ stdio: ["pipe", "pipe", "pipe"],
540
640
  });
541
- console.log("[WAND] spawned claude -p pid:", child.pid, "args:", args.filter(a => a !== prompt).join(" "));
641
+ console.log("[WAND] spawned claude -p pid:", child.pid, "args:", args.join(" "));
542
642
  this.pendingChildren.set(sessionId, child);
643
+ child.stdin?.end(prompt);
543
644
  const turnState = {
544
645
  blocks: [],
545
646
  result: "",
@@ -551,6 +652,10 @@ export class StructuredSessionManager {
551
652
  let lineBuf = "";
552
653
  // Debounce output events to avoid flooding the WebSocket.
553
654
  let emitTimer = null;
655
+ // 当 Claude 在非托管模式调用 AskUserQuestion 时,stdin 关闭导致它会 hang 等
656
+ // tool_result。我们检测到后主动 kill child,让它顺利退出,UI 把 tool_use 卡片
657
+ // 渲染成可交互选项;用户提交后由 sendMessage() 通过 --resume 续接。
658
+ let killedForAskUserQuestion = false;
554
659
  const flushEmit = () => {
555
660
  if (emitTimer) {
556
661
  clearTimeout(emitTimer);
@@ -625,6 +730,20 @@ export class StructuredSessionManager {
625
730
  // We only use the authoritative usage from the final "result" event.
626
731
  syncSnapshot();
627
732
  scheduleEmit();
733
+ // 非托管模式下检测 AskUserQuestion:claude -p 的 stdin 被 ignore,无法回传
734
+ // tool_result,进程会 hang 住。主动 SIGTERM 让它退出;后续用户提交答案时由
735
+ // sendMessage() 注入伪造的 tool_result 并通过 --resume 续接。
736
+ if (!isManaged && !killedForAskUserQuestion) {
737
+ const askBlock = extracted.content.find((b) => b.type === "tool_use" && b.name === "AskUserQuestion");
738
+ if (askBlock) {
739
+ killedForAskUserQuestion = true;
740
+ flushEmit();
741
+ try {
742
+ child.kill("SIGTERM");
743
+ }
744
+ catch (_err) { /* ignore */ }
745
+ }
746
+ }
628
747
  return;
629
748
  }
630
749
  if (parsed && parsed.type === "user" && parsed.message && Array.isArray(parsed.message.content)) {
@@ -746,14 +865,19 @@ export class StructuredSessionManager {
746
865
  else {
747
866
  msgs.push(assistantTurn);
748
867
  }
868
+ // 被 AskUserQuestion 检测或用户中断主动 kill 时,保持 status="running"
869
+ // 让 UI 不跳到"已停止"。inFlight=false 才能触发后续 sendMessage。
870
+ const interruptPrompt = this.interruptedWith.get(sessionId);
871
+ const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
749
872
  const finished = {
750
873
  ...current,
751
- status: "stopped",
752
- exitCode: 0,
753
- endedAt: new Date().toISOString(),
874
+ status: keepRunning ? "running" : "stopped",
875
+ exitCode: keepRunning ? null : 0,
876
+ endedAt: keepRunning ? null : new Date().toISOString(),
754
877
  output: turnState.result,
755
878
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
756
879
  messages: msgs,
880
+ queuedMessages: interruptPrompt ? [] : current.queuedMessages,
757
881
  pendingEscalation: null,
758
882
  permissionBlocked: false,
759
883
  structuredState: {
@@ -767,7 +891,26 @@ export class StructuredSessionManager {
767
891
  this.sessions.set(sessionId, finished);
768
892
  this.storage.saveSession(finished);
769
893
  this.emitStructuredSnapshot(finished);
770
- this.emitStructuredSnapshot(finished, "ended");
894
+ if (!keepRunning) {
895
+ this.emitStructuredSnapshot(finished, "ended");
896
+ }
897
+ // 等待用户回答 AskUserQuestion 时,跳过后续自续接和队列推进。
898
+ if (killedForAskUserQuestion) {
899
+ resolve();
900
+ return;
901
+ }
902
+ // 用户中断当前回复:保存部分回复后立即发送新消息。
903
+ if (interruptPrompt) {
904
+ this.interruptedWith.delete(sessionId);
905
+ console.log("[WAND] interrupt-and-send for session:", sessionId, "prompt:", interruptPrompt.substring(0, 50));
906
+ resolve();
907
+ setImmediate(() => {
908
+ this.sendMessage(sessionId, interruptPrompt).catch((err) => {
909
+ console.error("[WAND] interrupt-and-send failed:", err);
910
+ });
911
+ });
912
+ return;
913
+ }
771
914
  // Auto-continue after plan mode exit: when Claude calls ExitPlanMode,
772
915
  // the `-p` process exits because stdin is "ignore" and it cannot get
773
916
  // user confirmation. Detect this and automatically resume execution
@@ -0,0 +1,24 @@
1
+ import { ProcessManager } from "../process-manager.js";
2
+ import { StructuredSessionManager } from "../structured-session-manager.js";
3
+ export interface TuiDeps {
4
+ processManager: ProcessManager;
5
+ structuredSessions: StructuredSessionManager;
6
+ version: string;
7
+ configPath: string;
8
+ dbPath: string;
9
+ bindAddr: string;
10
+ httpsEnabled: boolean;
11
+ urls: Array<{
12
+ url: string;
13
+ scheme: "HTTP" | "HTTPS";
14
+ }>;
15
+ orphanRecoveredCount: number;
16
+ /** 退出 TUI 时调用。返回的 Promise resolve 后 cli 才会 process.exit。 */
17
+ onExit: (reason: ExitReason) => void | Promise<void>;
18
+ }
19
+ export type ExitReason = "user" | "signal" | "error";
20
+ export interface TuiHandle {
21
+ isActive: boolean;
22
+ stop(reason: ExitReason): Promise<void>;
23
+ }
24
+ export declare function startTui(deps: TuiDeps): TuiHandle;