@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/cli.js +72 -5
- package/dist/ensure-node-pty-helper.d.ts +1 -0
- package/dist/ensure-node-pty-helper.js +51 -0
- package/dist/git-quick-commit.d.ts +18 -0
- package/dist/git-quick-commit.js +373 -0
- package/dist/process-manager.d.ts +6 -9
- package/dist/process-manager.js +26 -195
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/pty-text-utils.d.ts +1 -3
- package/dist/pty-text-utils.js +1 -3
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +79 -13
- package/dist/server.d.ts +19 -1
- package/dist/server.js +90 -5
- 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/tui/index.d.ts +24 -0
- package/dist/tui/index.js +138 -0
- package/dist/tui/layout.d.ts +25 -0
- package/dist/tui/layout.js +198 -0
- package/dist/tui/log-bus.d.ts +23 -0
- package/dist/tui/log-bus.js +111 -0
- package/dist/tui/relative-time.d.ts +4 -0
- package/dist/tui/relative-time.js +27 -0
- package/dist/tui/session-formatter.d.ts +17 -0
- package/dist/tui/session-formatter.js +111 -0
- package/dist/types.d.ts +42 -14
- package/dist/web-ui/content/scripts.js +1188 -209
- package/dist/web-ui/content/styles.css +536 -19
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +3 -1
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
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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,
|
|
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
|
|
@@ -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;
|