@co0ontty/wand 1.18.0 → 1.18.12

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.
@@ -29,7 +29,8 @@ export declare class ProcessManager extends EventEmitter {
29
29
  private readonly storage;
30
30
  private readonly sessions;
31
31
  private readonly logger;
32
- private readonly lifecycleManager;
32
+ /** 24h archive scan timer */
33
+ private archiveTimer;
33
34
  /** Per-session debounce timers for throttled persist calls */
34
35
  private readonly persistDebounceTimers;
35
36
  /** Last persisted message state per session — used to skip redundant message writes */
@@ -6,7 +6,6 @@ import process from "node:process";
6
6
  import os from "node:os";
7
7
  import pty from "node-pty";
8
8
  import { SessionLogger } from "./session-logger.js";
9
- import { SessionLifecycleManager } from "./session-lifecycle.js";
10
9
  import { ClaudePtyBridge } from "./claude-pty-bridge.js";
11
10
  import { truncateMessagesForTransport } from "./message-truncator.js";
12
11
  import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
@@ -352,7 +351,6 @@ function listAllClaudeHistorySessions() {
352
351
  function shouldAutoResumeSession(record) {
353
352
  return record.status === "exited"
354
353
  && !record.archived
355
- && !record.resumedToSessionId
356
354
  && record.ptyProcess === null
357
355
  && hasResumeEligibleConversation(record);
358
356
  }
@@ -451,7 +449,8 @@ export class ProcessManager extends EventEmitter {
451
449
  storage;
452
450
  sessions = new Map();
453
451
  logger;
454
- lifecycleManager;
452
+ /** 24h archive scan timer */
453
+ archiveTimer = null;
455
454
  /** Per-session debounce timers for throttled persist calls */
456
455
  persistDebounceTimers = new Map();
457
456
  /** Last persisted message state per session — used to skip redundant message writes */
@@ -461,16 +460,6 @@ export class ProcessManager extends EventEmitter {
461
460
  this.config = config;
462
461
  this.storage = storage;
463
462
  this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"), config.shortcutLogMaxBytes);
464
- // Initialize lifecycle manager
465
- this.lifecycleManager = new SessionLifecycleManager({
466
- onStateChange: (sessionId, oldState, newState) => {
467
- this.emitEvent({ type: "status", sessionId, data: { oldState, newState } });
468
- },
469
- onIdle: (_sessionId) => { },
470
- onArchived: (sessionId, reason) => {
471
- console.error(`[ProcessManager] Session ${sessionId} archived: ${reason}`);
472
- },
473
- });
474
463
  for (const snapshot of this.storage.loadSessions()) {
475
464
  if ((snapshot.sessionKind ?? "pty") !== "pty") {
476
465
  continue;
@@ -520,7 +509,6 @@ export class ProcessManager extends EventEmitter {
520
509
  claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
521
510
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
522
511
  });
523
- this.lifecycleManager.register(snapshot.id, "idle");
524
512
  console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
525
513
  }
526
514
  else {
@@ -554,7 +542,6 @@ export class ProcessManager extends EventEmitter {
554
542
  claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId,
555
543
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
556
544
  });
557
- this.lifecycleManager.register(snapshot.id, "archived");
558
545
  }
559
546
  }
560
547
  // Defer expensive file-system scanning and auto-recovery so the server
@@ -564,6 +551,15 @@ export class ProcessManager extends EventEmitter {
564
551
  this.autoRecoverExitedSessions();
565
552
  });
566
553
  this.archiveExpiredSessions();
554
+ this.archiveTimer = setInterval(() => {
555
+ try {
556
+ this.archiveExpiredSessions();
557
+ }
558
+ catch (err) {
559
+ console.error(`[ProcessManager] archive scan failed: ${String(err)}`);
560
+ }
561
+ }, 60 * 1000);
562
+ this.archiveTimer.unref?.();
567
563
  }
568
564
  on(event, listener) {
569
565
  return super.on("process", listener);
@@ -670,7 +666,7 @@ export class ProcessManager extends EventEmitter {
670
666
  ptyPermissionBlocked: false,
671
667
  lastAutoConfirmAt: 0,
672
668
  autoApprovePermissions: this.shouldAutoApprovePermissions(command, effectiveMode, provider),
673
- resumedFromSessionId: opts?.resumedFromSessionId ?? null,
669
+ resumedFromSessionId: opts?.resumedFromSessionId ?? (opts?.reuseId ? opts.reuseId : null),
674
670
  autoRecovered: opts?.autoRecovered ?? false,
675
671
  rememberedEscalationScopes: new Set(),
676
672
  rememberedEscalationTargets: new Set(),
@@ -704,7 +700,6 @@ export class ProcessManager extends EventEmitter {
704
700
  this.claudeHistoryCache = null;
705
701
  }
706
702
  this.cleanupOldSessions();
707
- this.lifecycleManager.register(id, "initializing");
708
703
  const shellArgs = this.buildShellArgs(processedCommand);
709
704
  let child;
710
705
  try {
@@ -727,14 +722,12 @@ export class ProcessManager extends EventEmitter {
727
722
  record.exitCode = -1;
728
723
  record.endedAt = new Date().toISOString();
729
724
  record.ptyProcess = null;
730
- this.lifecycleManager.archive(id, "Session spawn failed", "error");
731
725
  this.persist(record);
732
726
  return this.snapshot(record);
733
727
  }
734
728
  record.processId = child.pid;
735
729
  record.ptyProcess = child;
736
730
  record.status = "running";
737
- this.lifecycleManager.setState(id, "running");
738
731
  child.onExit(({ exitCode }) => {
739
732
  const current = this.sessions.get(id);
740
733
  if (!current)
@@ -757,7 +750,6 @@ export class ProcessManager extends EventEmitter {
757
750
  current.exitCode = current.stopRequested ? null : exitCode;
758
751
  current.endedAt = new Date().toISOString();
759
752
  current.ptyProcess = null;
760
- this.lifecycleManager.archive(id, `Session ${current.status}`, current.stopRequested ? "user" : "error");
761
753
  this.flushPersist(current);
762
754
  this.storage.saveSession(this.snapshot(current));
763
755
  this.emitEvent({ type: "ended", sessionId: id, data: this.snapshot(current) });
@@ -877,14 +869,12 @@ export class ProcessManager extends EventEmitter {
877
869
  return this.snapshot(record);
878
870
  }
879
871
  list() {
880
- this.archiveExpiredSessions();
881
872
  return Array.from(this.sessions.values())
882
873
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
883
874
  .map((session) => this.snapshot(session));
884
875
  }
885
876
  /** Return lightweight snapshots for the session list (no output/messages). */
886
877
  listSlim() {
887
- this.archiveExpiredSessions();
888
878
  return Array.from(this.sessions.values())
889
879
  .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
890
880
  .map((session) => this.snapshotSlim(session));
@@ -947,7 +937,6 @@ export class ProcessManager extends EventEmitter {
947
937
  return deleted;
948
938
  }
949
939
  get(id) {
950
- this.archiveExpiredSessions();
951
940
  const record = this.sessions.get(id);
952
941
  if (!record) {
953
942
  // Fallback: check SQLite for sessions that were evicted from memory
@@ -990,8 +979,6 @@ export class ProcessManager extends EventEmitter {
990
979
  throw new SessionInputError("Session is not running.", "SESSION_NOT_RUNNING", id, record.status);
991
980
  }
992
981
  // Update lifecycle
993
- this.lifecycleManager.touch(id);
994
- this.lifecycleManager.startThinking(id);
995
982
  if (!record.ptyProcess) {
996
983
  console.error(`[ProcessManager] Rejecting input: session ${id} has no PTY`);
997
984
  throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
@@ -1003,7 +990,7 @@ export class ProcessManager extends EventEmitter {
1003
990
  const ctx = {
1004
991
  mode: record.mode,
1005
992
  autoApprove: record.autoApprovePermissions,
1006
- permissionBlocked: record.ptyPermissionBlocked || !!record.pendingEscalation,
993
+ permissionBlocked: this.isPermissionBlocked(record),
1007
994
  input,
1008
995
  };
1009
996
  this.logger.appendShortcutLog(id, shortcutKey, tailLines, ctx);
@@ -1093,7 +1080,6 @@ export class ProcessManager extends EventEmitter {
1093
1080
  record.ptyBridge = null;
1094
1081
  }
1095
1082
  // Update lifecycle
1096
- this.lifecycleManager.archive(id, "Session stopped by user", "user");
1097
1083
  this.persist(record);
1098
1084
  return this.snapshot(record);
1099
1085
  }
@@ -1130,7 +1116,6 @@ export class ProcessManager extends EventEmitter {
1130
1116
  record.ptyBridge.removeAllListeners();
1131
1117
  record.ptyBridge = null;
1132
1118
  }
1133
- this.lifecycleManager.unregister(record.id);
1134
1119
  }
1135
1120
  delete(id) {
1136
1121
  const record = this.mustGet(id);
@@ -1183,7 +1168,6 @@ export class ProcessManager extends EventEmitter {
1183
1168
  this.deleteClaudeCache(record);
1184
1169
  this.sessions.delete(id);
1185
1170
  this.lastPersistedMessageState.delete(id);
1186
- this.lifecycleManager.unregister(id);
1187
1171
  if (record.claudeSessionId) {
1188
1172
  this.claudeHistoryCache = null;
1189
1173
  }
@@ -1246,7 +1230,6 @@ export class ProcessManager extends EventEmitter {
1246
1230
  claudeSessionId: record.claudeSessionId || null,
1247
1231
  messages: messages.length > 0 ? messages : undefined,
1248
1232
  resumedFromSessionId: record.resumedFromSessionId ?? undefined,
1249
- resumedToSessionId: record.resumedToSessionId ?? undefined,
1250
1233
  autoRecovered: record.autoRecovered ?? false,
1251
1234
  autoApprovePermissions: record.autoApprovePermissions || undefined,
1252
1235
  approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined,
@@ -1265,7 +1248,7 @@ export class ProcessManager extends EventEmitter {
1265
1248
  };
1266
1249
  }
1267
1250
  isPermissionBlocked(record) {
1268
- return record.ptyBridge?.isPermissionBlocked() ?? record.pendingEscalation !== null;
1251
+ return record.ptyPermissionBlocked || record.pendingEscalation !== null;
1269
1252
  }
1270
1253
  defaultAutonomyPolicy(mode) {
1271
1254
  if (mode === "agent" || mode === "agent-max" || mode === "managed" || mode === "native" || mode === "full-access") {
@@ -1357,7 +1340,6 @@ export class ProcessManager extends EventEmitter {
1357
1340
  endedAt: record.endedAt,
1358
1341
  claudeSessionId: record.claudeSessionId,
1359
1342
  resumedFromSessionId: record.resumedFromSessionId ?? null,
1360
- resumedToSessionId: record.resumedToSessionId ?? null,
1361
1343
  autoRecovered: record.autoRecovered ?? false,
1362
1344
  });
1363
1345
  if (shouldSaveMessages) {
@@ -1637,8 +1619,6 @@ export class ProcessManager extends EventEmitter {
1637
1619
  record.ptyBridge?.clearRememberedPermissions();
1638
1620
  record.rememberedEscalationScopes.clear();
1639
1621
  record.rememberedEscalationTargets.clear();
1640
- this.lifecycleManager.stopThinking(record.id);
1641
- this.lifecycleManager.waitingInput(record.id);
1642
1622
  this.persist(record);
1643
1623
  this.storage.saveSession(this.snapshot(record));
1644
1624
  break;
@@ -1,7 +1,5 @@
1
1
  /**
2
- * Shared PTY text processing utilities.
3
- * Used by both claude-pty-bridge.ts and message-parser.ts to ensure
4
- * consistent ANSI stripping and noise filtering.
2
+ * Shared PTY text processing utilities for consistent ANSI stripping and noise filtering.
5
3
  */
6
4
  /** Strip ANSI escape sequences and control characters from raw PTY output. */
7
5
  export declare function stripAnsi(text: string): string;
@@ -1,7 +1,5 @@
1
1
  /**
2
- * Shared PTY text processing utilities.
3
- * Used by both claude-pty-bridge.ts and message-parser.ts to ensure
4
- * consistent ANSI stripping and noise filtering.
2
+ * Shared PTY text processing utilities for consistent ANSI stripping and noise filtering.
5
3
  */
6
4
  /** Strip ANSI escape sequences and control characters from raw PTY output. */
7
5
  export function stripAnsi(text) {
@@ -1,5 +1,4 @@
1
1
  import express from "express";
2
- import { parseMessages } from "./message-parser.js";
3
2
  import { SessionInputError } from "./process-manager.js";
4
3
  import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
5
4
  export function getErrorMessage(error, fallback) {
@@ -198,9 +197,10 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
198
197
  });
199
198
  app.post("/api/structured-sessions/:id/messages", express.json(), async (req, res) => {
200
199
  const input = String(req.body?.input ?? "");
201
- console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50));
200
+ const interrupt = !!req.body?.interrupt;
201
+ console.log("[WAND] POST /api/structured-sessions/:id/messages id:", req.params.id, "input:", input.substring(0, 50), "interrupt:", interrupt);
202
202
  try {
203
- const snapshot = await structured.sendMessage(req.params.id, input);
203
+ const snapshot = await structured.sendMessage(req.params.id, input, { interrupt });
204
204
  res.json(snapshot);
205
205
  }
206
206
  catch (error) {
@@ -362,13 +362,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
362
362
  ? processes.getPtyTranscript(snapshot.id) ?? snapshot.output
363
363
  : snapshot.output;
364
364
  if (req.query.format === "chat") {
365
- const allowFallback = (snapshot.sessionKind ?? "pty") === "pty";
366
- const fallbackOutput = allowFallback ? transcriptOutput : "";
367
- const messages = snapshot.messages && snapshot.messages.length > 0
368
- ? snapshot.messages
369
- : allowFallback
370
- ? parseMessages(fallbackOutput, snapshot.command)
371
- : [];
365
+ const messages = snapshot.messages ?? [];
372
366
  res.json({ ...snapshot, output: transcriptOutput, messages });
373
367
  }
374
368
  else {
@@ -407,7 +401,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
407
401
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
408
402
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
409
403
  const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: sessionId });
410
- res.status(201).json({ resumedFromSessionId: sessionId, ...newSnapshot });
404
+ res.status(201).json(newSnapshot);
411
405
  }
412
406
  catch (error) {
413
407
  res.status(400).json({ error: getErrorMessage(error, "无法恢复会话。") });
@@ -444,7 +438,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
444
438
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
445
439
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
446
440
  const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: existingSession.id });
447
- res.status(201).json({ resumedFromSessionId: existingSession.id, resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
441
+ res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
448
442
  }
449
443
  else {
450
444
  const cwd = body.cwd?.trim();
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