@co0ontty/wand 1.18.1 → 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.
- package/dist/process-manager.d.ts +2 -1
- package/dist/process-manager.js +14 -34
- package/dist/pty-text-utils.d.ts +1 -3
- package/dist/pty-text-utils.js +1 -3
- package/dist/server-session-routes.js +6 -12
- package/dist/storage.js +4 -8
- package/dist/structured-session-manager.d.ts +6 -1
- package/dist/structured-session-manager.js +156 -13
- package/dist/types.d.ts +0 -14
- package/dist/web-ui/content/scripts.js +512 -141
- package/dist/web-ui/content/styles.css +202 -16
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 */
|
package/dist/process-manager.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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;
|
package/dist/pty-text-utils.d.ts
CHANGED
|
@@ -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;
|
package/dist/pty-text-utils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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({
|
|
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,
|
|
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,
|
|
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 = ?,
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
|
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
|
-
|
|
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: ["
|
|
639
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
540
640
|
});
|
|
541
|
-
console.log("[WAND] spawned claude -p pid:", child.pid, "args:", args.
|
|
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
|
-
|
|
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
|