@co0ontty/wand 1.18.12 → 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 +4 -8
- package/dist/process-manager.js +12 -161
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +73 -1
- package/dist/server.d.ts +19 -1
- package/dist/server.js +90 -5
- 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 -0
- package/dist/web-ui/content/scripts.js +749 -141
- package/dist/web-ui/content/styles.css +334 -3
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +3 -1
package/dist/process-manager.js
CHANGED
|
@@ -10,7 +10,7 @@ import { ClaudePtyBridge } from "./claude-pty-bridge.js";
|
|
|
10
10
|
import { truncateMessagesForTransport } from "./message-truncator.js";
|
|
11
11
|
import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
|
|
12
12
|
import { prepareSessionWorktree } from "./git-worktree.js";
|
|
13
|
-
import { getResumeCommandSessionId
|
|
13
|
+
import { getResumeCommandSessionId } from "./resume-policy.js";
|
|
14
14
|
function resolveProviderFromCommand(command) {
|
|
15
15
|
return /^codex\b/.test(command.trim()) ? "codex" : "claude";
|
|
16
16
|
}
|
|
@@ -127,52 +127,8 @@ function selectClaudeProjectSessionForRecord(record) {
|
|
|
127
127
|
}
|
|
128
128
|
return candidates[0] ?? null;
|
|
129
129
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Broader fallback: find a JSONL file by mtime proximity when strict
|
|
132
|
-
* mtime-correlation fails (e.g., file existed before session but Claude
|
|
133
|
-
* wrote conversation content during this session).
|
|
134
|
-
* Looks for the most recently modified file that was active near the
|
|
135
|
-
* session's start time and has real conversation content.
|
|
136
|
-
*/
|
|
137
|
-
function selectClaudeProjectSessionByProximity(record) {
|
|
138
|
-
const hasUserTurn = record.messages.some((turn) => turn.role === "user"
|
|
139
|
-
&& turn.content.some((block) => block.type === "text" && block.text.trim().length > 0));
|
|
140
|
-
if (!hasUserTurn) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
const startedAtMs = Date.parse(record.startedAt);
|
|
144
|
-
const now = Date.now();
|
|
145
|
-
// Look for files modified from ~60s before session start up to now
|
|
146
|
-
const proximityWindowMs = 60 * 1000;
|
|
147
|
-
const candidates = listClaudeProjectSessionCandidates(record.cwd)
|
|
148
|
-
.filter((candidate) => {
|
|
149
|
-
if (!Number.isFinite(startedAtMs))
|
|
150
|
-
return true;
|
|
151
|
-
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
152
|
-
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
153
|
-
})
|
|
154
|
-
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
155
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
156
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
157
|
-
return candidates[0] ?? null;
|
|
158
|
-
}
|
|
159
|
-
function getResumeEligibility(record) {
|
|
160
|
-
const hasClaudeSessionId = Boolean(record.claudeSessionId);
|
|
161
|
-
const hasRealConversation = hasRealConversationMessages(record.messages);
|
|
162
|
-
return {
|
|
163
|
-
hasClaudeSessionId,
|
|
164
|
-
hasRealConversation,
|
|
165
|
-
eligible: hasClaudeSessionId && hasRealConversation
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
function hasResumeEligibleConversation(record) {
|
|
169
|
-
return getResumeEligibility(record).eligible;
|
|
170
|
-
}
|
|
171
130
|
function getLatestClaudeProjectSessionId(record) {
|
|
172
|
-
|
|
173
|
-
return selectClaudeProjectSessionForRecord(record)?.id
|
|
174
|
-
?? selectClaudeProjectSessionByProximity(record)?.id
|
|
175
|
-
?? null;
|
|
131
|
+
return selectClaudeProjectSessionForRecord(record)?.id ?? null;
|
|
176
132
|
}
|
|
177
133
|
function listRecentClaudeProjectSessionIds(cwd, startedAt) {
|
|
178
134
|
return listClaudeProjectSessionCandidates(cwd)
|
|
@@ -180,33 +136,6 @@ function listRecentClaudeProjectSessionIds(cwd, startedAt) {
|
|
|
180
136
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
181
137
|
.map((candidate) => candidate.id);
|
|
182
138
|
}
|
|
183
|
-
function findRealClaudeProjectSessionId(cwd, startedAt) {
|
|
184
|
-
// Strict mtime-based discovery first
|
|
185
|
-
const candidates = listRecentClaudeProjectSessionIds(cwd, startedAt)
|
|
186
|
-
.map((id) => {
|
|
187
|
-
const filePath = path.join(getClaudeProjectDir(cwd), `${id}.jsonl`);
|
|
188
|
-
return readClaudeProjectSessionDetails(filePath, id);
|
|
189
|
-
})
|
|
190
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
191
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
192
|
-
if (candidates.length > 0)
|
|
193
|
-
return candidates[0].id;
|
|
194
|
-
// Fallback: broader proximity search for files with conversation content
|
|
195
|
-
const startedAtMs = Date.parse(startedAt);
|
|
196
|
-
const now = Date.now();
|
|
197
|
-
const proximityWindowMs = 60 * 1000;
|
|
198
|
-
const proximityCandidates = listClaudeProjectSessionCandidates(cwd)
|
|
199
|
-
.filter((candidate) => {
|
|
200
|
-
if (!Number.isFinite(startedAtMs))
|
|
201
|
-
return true;
|
|
202
|
-
return candidate.mtimeMs >= startedAtMs - proximityWindowMs
|
|
203
|
-
&& candidate.mtimeMs <= now + DISCOVERY_RECENT_WINDOW_MS;
|
|
204
|
-
})
|
|
205
|
-
.map((candidate) => readClaudeProjectSessionDetails(candidate.filePath, candidate.id))
|
|
206
|
-
.filter((candidate) => Boolean(candidate?.hasConversation))
|
|
207
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
208
|
-
return proximityCandidates[0]?.id ?? null;
|
|
209
|
-
}
|
|
210
139
|
function isClaudeSessionFileAvailable(cwd, claudeSessionId) {
|
|
211
140
|
const filePath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
|
|
212
141
|
return Boolean(readClaudeProjectSessionDetails(filePath, claudeSessionId));
|
|
@@ -348,18 +277,6 @@ function listAllClaudeHistorySessions() {
|
|
|
348
277
|
return [];
|
|
349
278
|
}
|
|
350
279
|
}
|
|
351
|
-
function shouldAutoResumeSession(record) {
|
|
352
|
-
return record.status === "exited"
|
|
353
|
-
&& !record.archived
|
|
354
|
-
&& record.ptyProcess === null
|
|
355
|
-
&& hasResumeEligibleConversation(record);
|
|
356
|
-
}
|
|
357
|
-
function shouldBackfillClaudeSessionId(record) {
|
|
358
|
-
return record.status === "exited"
|
|
359
|
-
&& !record.claudeSessionId
|
|
360
|
-
&& /^claude\b/.test(record.command.trim())
|
|
361
|
-
&& hasRealConversationMessages(record.messages);
|
|
362
|
-
}
|
|
363
280
|
function snapshotMessages(record) {
|
|
364
281
|
return record.ptyBridge?.getMessages() ?? record.messages;
|
|
365
282
|
}
|
|
@@ -455,6 +372,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
455
372
|
persistDebounceTimers = new Map();
|
|
456
373
|
/** Last persisted message state per session — used to skip redundant message writes */
|
|
457
374
|
lastPersistedMessageState = new Map();
|
|
375
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
|
|
376
|
+
orphanRecoveredCount = 0;
|
|
458
377
|
constructor(config, storage, configDir) {
|
|
459
378
|
super();
|
|
460
379
|
this.config = config;
|
|
@@ -509,7 +428,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
509
428
|
claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
|
|
510
429
|
approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
|
|
511
430
|
});
|
|
512
|
-
|
|
431
|
+
this.orphanRecoveredCount += 1;
|
|
513
432
|
}
|
|
514
433
|
else {
|
|
515
434
|
this.sessions.set(snapshot.id, {
|
|
@@ -544,12 +463,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
544
463
|
});
|
|
545
464
|
}
|
|
546
465
|
}
|
|
547
|
-
// Defer expensive file-system scanning and auto-recovery so the server
|
|
548
|
-
// can start responding to requests immediately.
|
|
549
|
-
setImmediate(() => {
|
|
550
|
-
this.backfillExitedClaudeSessionIds();
|
|
551
|
-
this.autoRecoverExitedSessions();
|
|
552
|
-
});
|
|
553
466
|
this.archiveExpiredSessions();
|
|
554
467
|
this.archiveTimer = setInterval(() => {
|
|
555
468
|
try {
|
|
@@ -564,6 +477,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
564
477
|
on(event, listener) {
|
|
565
478
|
return super.on("process", listener);
|
|
566
479
|
}
|
|
480
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数量(仅用于启动摘要展示)。 */
|
|
481
|
+
getOrphanRecoveredCount() {
|
|
482
|
+
return this.orphanRecoveredCount;
|
|
483
|
+
}
|
|
567
484
|
emitEvent(event) {
|
|
568
485
|
this.emit("process", event);
|
|
569
486
|
}
|
|
@@ -939,15 +856,13 @@ export class ProcessManager extends EventEmitter {
|
|
|
939
856
|
get(id) {
|
|
940
857
|
const record = this.sessions.get(id);
|
|
941
858
|
if (!record) {
|
|
942
|
-
// Fallback: check SQLite for sessions that were evicted from memory
|
|
943
859
|
return this.storage.getSession(id) ?? null;
|
|
944
860
|
}
|
|
945
|
-
|
|
946
|
-
// Prefer in-memory output (live PTY data), fall back to stored output.
|
|
861
|
+
const result = this.snapshot(record);
|
|
947
862
|
if (!record.output && record.storedOutput) {
|
|
948
|
-
|
|
863
|
+
result.output = record.storedOutput;
|
|
949
864
|
}
|
|
950
|
-
return
|
|
865
|
+
return result;
|
|
951
866
|
}
|
|
952
867
|
getPtyTranscript(id) {
|
|
953
868
|
return this.logger.readPtyOutput(id);
|
|
@@ -1374,70 +1289,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
1374
1289
|
}
|
|
1375
1290
|
this.persist(record);
|
|
1376
1291
|
}
|
|
1377
|
-
backfillExitedClaudeSessionIds() {
|
|
1378
|
-
for (const record of this.sessions.values()) {
|
|
1379
|
-
record.messages = snapshotMessages(record);
|
|
1380
|
-
if (!shouldBackfillClaudeSessionId(record)) {
|
|
1381
|
-
continue;
|
|
1382
|
-
}
|
|
1383
|
-
const discoveredSessionId = findRealClaudeProjectSessionId(record.cwd, record.startedAt);
|
|
1384
|
-
if (!discoveredSessionId) {
|
|
1385
|
-
continue;
|
|
1386
|
-
}
|
|
1387
|
-
record.claudeSessionId = discoveredSessionId;
|
|
1388
|
-
this.persist(record);
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
/**
|
|
1392
|
-
* Auto-recover the most recent exited session that has a Claude session ID.
|
|
1393
|
-
* Only resumes one session per server start, using the most recent eligible
|
|
1394
|
-
* session. Reuses the original session ID (in-place resume) and sets
|
|
1395
|
-
* `autoRecovered: true`.
|
|
1396
|
-
*/
|
|
1397
|
-
autoRecoverExitedSessions() {
|
|
1398
|
-
// Find eligible exited sessions
|
|
1399
|
-
const eligibleSessions = [];
|
|
1400
|
-
for (const record of this.sessions.values()) {
|
|
1401
|
-
record.messages = snapshotMessages(record);
|
|
1402
|
-
if (shouldAutoResumeSession(record)) {
|
|
1403
|
-
eligibleSessions.push(record);
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
if (eligibleSessions.length === 0)
|
|
1407
|
-
return;
|
|
1408
|
-
// Sort by startedAt descending (most recent first)
|
|
1409
|
-
eligibleSessions.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1410
|
-
// Only auto-recover the single most recent session
|
|
1411
|
-
const original = eligibleSessions[0];
|
|
1412
|
-
const isClaude = /^claude\b/.test(original.command.trim());
|
|
1413
|
-
if (!isClaude)
|
|
1414
|
-
return;
|
|
1415
|
-
// If no claudeSessionId is bound yet, try to discover it via proximity search
|
|
1416
|
-
if (!original.claudeSessionId) {
|
|
1417
|
-
const discovered = findRealClaudeProjectSessionId(original.cwd, original.startedAt);
|
|
1418
|
-
if (discovered) {
|
|
1419
|
-
original.claudeSessionId = discovered;
|
|
1420
|
-
process.stderr.write(`[wand] Backfilled Claude session ID for auto-recovery: ${discovered}\n`);
|
|
1421
|
-
this.persist(original);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
if (!original.claudeSessionId) {
|
|
1425
|
-
console.error(`[ProcessManager] Skipping auto-recovery: no Claude session ID for session ${original.id}`);
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
console.error(`[ProcessManager] Auto-recovering session ${original.id} with Claude session ID ${original.claudeSessionId}`);
|
|
1429
|
-
const resumeCommand = `${original.command.trim()} --resume ${original.claudeSessionId}`;
|
|
1430
|
-
try {
|
|
1431
|
-
const snapshot = this.start(resumeCommand, original.cwd, original.mode, undefined, {
|
|
1432
|
-
reuseId: original.id,
|
|
1433
|
-
autoRecovered: true
|
|
1434
|
-
});
|
|
1435
|
-
console.error(`[ProcessManager] Auto-recovered session ${snapshot.id} (in-place)`);
|
|
1436
|
-
}
|
|
1437
|
-
catch (err) {
|
|
1438
|
-
console.error(`[ProcessManager] Auto-recovery failed: ${String(err)}`);
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
1292
|
archiveExpiredSessions() {
|
|
1442
1293
|
const now = Date.now();
|
|
1443
1294
|
for (const record of this.sessions.values()) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const CLAUDE_TIMEOUT_MS = 60_000;
|
|
3
|
+
const MAX_INPUT_LENGTH = 8000;
|
|
4
|
+
export class PromptOptimizeError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(message, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "PromptOptimizeError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function callClaudeText(prompt, cwd) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = execFile("claude", ["-p", "--output-format", "text"], {
|
|
15
|
+
cwd: cwd && cwd.length > 0 ? cwd : undefined,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
18
|
+
timeout: CLAUDE_TIMEOUT_MS,
|
|
19
|
+
}, (error, stdout, stderr) => {
|
|
20
|
+
if (error) {
|
|
21
|
+
const e = error;
|
|
22
|
+
if (e.code === "ENOENT") {
|
|
23
|
+
reject(new PromptOptimizeError("未找到 claude CLI。", "CLAUDE_CLI_MISSING"));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (e.code === "ETIMEDOUT") {
|
|
27
|
+
reject(new PromptOptimizeError("Claude 优化超时,请稍后重试。", "CLAUDE_TIMEOUT"));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const msg = (stderr || "").trim() || e.message || "claude 调用失败";
|
|
31
|
+
reject(new PromptOptimizeError(`Claude CLI 失败:${msg}`, "CLAUDE_CLI_FAILED"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
resolve((stdout || "").trim());
|
|
35
|
+
});
|
|
36
|
+
child.stdin?.end(prompt);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function buildOptimizePrompt(userInput, language) {
|
|
40
|
+
const lang = (language || "").trim() || "中文";
|
|
41
|
+
return [
|
|
42
|
+
`你是一名提示词优化助手。请把用户写给编码 AI 的「原始提示词」改写得更清晰、结构化、可执行,便于 AI 理解并完成任务。`,
|
|
43
|
+
`要求:`,
|
|
44
|
+
`1. 保留用户原意和所有关键信息(文件路径、变量名、技术名词、数字、约束等),不要删减事实,也不要新增臆测的需求。`,
|
|
45
|
+
`2. 必要时拆分为「目标 / 上下文 / 约束 / 验收标准」几个部分;如果原文很短或很简单,则只做语句润色,不要硬塞结构。`,
|
|
46
|
+
`3. 用${lang}输出。语气克制专业,不寒暄、不解释你做了什么。`,
|
|
47
|
+
`4. 只输出优化后的提示词正文,不要包裹在代码块或引号里,不要加任何前后缀(比如「优化后:」之类)。`,
|
|
48
|
+
``,
|
|
49
|
+
`原始提示词:`,
|
|
50
|
+
userInput,
|
|
51
|
+
].join("\n");
|
|
52
|
+
}
|
|
53
|
+
export async function optimizePrompt(rawText, language, cwd) {
|
|
54
|
+
const text = (rawText || "").trim();
|
|
55
|
+
if (!text) {
|
|
56
|
+
throw new PromptOptimizeError("请先输入要优化的内容。", "EMPTY_INPUT");
|
|
57
|
+
}
|
|
58
|
+
if (text.length > MAX_INPUT_LENGTH) {
|
|
59
|
+
throw new PromptOptimizeError(`输入过长(${text.length} 字符),请缩短到 ${MAX_INPUT_LENGTH} 以内。`, "INPUT_TOO_LONG");
|
|
60
|
+
}
|
|
61
|
+
const prompt = buildOptimizePrompt(text, language);
|
|
62
|
+
const raw = await callClaudeText(prompt, cwd);
|
|
63
|
+
const cleaned = raw
|
|
64
|
+
.replace(/^```[a-zA-Z]*\n?/, "")
|
|
65
|
+
.replace(/\n?```$/, "")
|
|
66
|
+
.replace(/^["'`]+|["'`]+$/g, "")
|
|
67
|
+
.trim();
|
|
68
|
+
if (!cleaned) {
|
|
69
|
+
throw new PromptOptimizeError("Claude 返回了空结果。", "EMPTY_RESULT");
|
|
70
|
+
}
|
|
71
|
+
return cleaned;
|
|
72
|
+
}
|
|
@@ -2,7 +2,7 @@ import { Express } from "express";
|
|
|
2
2
|
import { ProcessManager } from "./process-manager.js";
|
|
3
3
|
import { StructuredSessionManager } from "./structured-session-manager.js";
|
|
4
4
|
import { WandStorage } from "./storage.js";
|
|
5
|
-
import { ExecutionMode } from "./types.js";
|
|
5
|
+
import { ExecutionMode, WandConfig } from "./types.js";
|
|
6
6
|
export declare function getErrorMessage(error: unknown, fallback: string): string;
|
|
7
|
-
export declare function registerSessionRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, defaultMode: ExecutionMode): void;
|
|
7
|
+
export declare function registerSessionRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, defaultMode: ExecutionMode, config: WandConfig): void;
|
|
8
8
|
export declare function registerClaudeHistoryRoutes(app: Express, processes: ProcessManager, storage: WandStorage): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { SessionInputError } from "./process-manager.js";
|
|
3
3
|
import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
|
|
4
|
+
import { getGitStatus, QuickCommitError, runQuickCommit, generateCommitMessageOnly } from "./git-quick-commit.js";
|
|
4
5
|
export function getErrorMessage(error, fallback) {
|
|
5
6
|
return error instanceof Error ? error.message : fallback;
|
|
6
7
|
}
|
|
@@ -135,7 +136,7 @@ function canMergeSession(snapshot) {
|
|
|
135
136
|
function isMergeActionAllowed(snapshot) {
|
|
136
137
|
return snapshot.status !== "running";
|
|
137
138
|
}
|
|
138
|
-
export function registerSessionRoutes(app, processes, structured, storage, defaultMode) {
|
|
139
|
+
export function registerSessionRoutes(app, processes, structured, storage, defaultMode, config) {
|
|
139
140
|
app.get("/api/sessions", (_req, res) => {
|
|
140
141
|
const all = listAllSessionsSlim(processes, structured);
|
|
141
142
|
console.log("[WAND] GET /api/sessions count:", all.length, "sessions:", all.map(s => ({ id: s.id.substring(0, 8), kind: s.sessionKind, runner: s.runner, status: s.status })));
|
|
@@ -302,6 +303,77 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
302
303
|
res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法合并 worktree。"));
|
|
303
304
|
}
|
|
304
305
|
});
|
|
306
|
+
app.get("/api/sessions/:id/git-status", (req, res) => {
|
|
307
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
308
|
+
if (!snapshot) {
|
|
309
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!snapshot.cwd) {
|
|
313
|
+
res.json({ isGit: false });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
res.json(getGitStatus(snapshot.cwd));
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
res.json({ isGit: false, error: getErrorMessage(error, "无法读取 git 状态。") });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
app.post("/api/sessions/:id/quick-commit", express.json(), async (req, res) => {
|
|
324
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
325
|
+
if (!snapshot) {
|
|
326
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!snapshot.cwd) {
|
|
330
|
+
res.status(400).json({ error: "会话没有工作目录。", errorCode: "NO_CWD" });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const body = (req.body ?? {});
|
|
334
|
+
try {
|
|
335
|
+
const result = await runQuickCommit({
|
|
336
|
+
cwd: snapshot.cwd,
|
|
337
|
+
language: config.language ?? "",
|
|
338
|
+
autoMessage: body.autoMessage !== false,
|
|
339
|
+
customMessage: typeof body.customMessage === "string" ? body.customMessage : undefined,
|
|
340
|
+
tag: typeof body.tag === "string" ? body.tag : undefined,
|
|
341
|
+
autoTag: !!body.autoTag,
|
|
342
|
+
push: !!body.push,
|
|
343
|
+
});
|
|
344
|
+
res.json(result);
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (error instanceof QuickCommitError) {
|
|
348
|
+
const status = error.code === "NOTHING_TO_COMMIT" || error.code === "TAG_EXISTS" ? 409 : 400;
|
|
349
|
+
res.status(status).json({ error: error.message, errorCode: error.code });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
res.status(400).json({ error: getErrorMessage(error, "快捷提交失败。") });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
app.post("/api/sessions/:id/generate-commit-message", express.json(), async (req, res) => {
|
|
356
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
357
|
+
if (!snapshot) {
|
|
358
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!snapshot.cwd) {
|
|
362
|
+
res.status(400).json({ error: "会话没有工作目录。" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const message = await generateCommitMessageOnly(snapshot.cwd, config.language ?? "");
|
|
367
|
+
res.json({ message });
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (error instanceof QuickCommitError) {
|
|
371
|
+
res.status(400).json({ error: error.message, errorCode: error.code });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
res.status(400).json({ error: getErrorMessage(error, "生成 commit message 失败。") });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
305
377
|
app.post("/api/sessions/:id/worktree/cleanup", (req, res) => {
|
|
306
378
|
try {
|
|
307
379
|
const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
|
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
|
}
|
|
@@ -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;
|