@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.
@@ -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, hasRealConversationMessages, } from "./resume-policy.js";
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
- // Try strict mtime-correlation first, then fall back to mtime proximity
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
- console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
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
- // For sessions loaded from storage on startup, in-memory output starts empty.
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
- record.output = record.storedOutput;
863
+ result.output = record.storedOutput;
949
864
  }
950
- return this.snapshot(record);
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,5 @@
1
+ export declare class PromptOptimizeError extends Error {
2
+ readonly code: string;
3
+ constructor(message: string, code: string);
4
+ }
5
+ export declare function optimizePrompt(rawText: string, language: string, cwd?: string): Promise<string>;
@@ -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 declare function startServer(config: WandConfig, configPath: string): Promise<void>;
4
+ export interface ServerUrl {
5
+ url: string;
6
+ scheme: "HTTP" | "HTTPS";
7
+ }
8
+ export interface ServerHandle {
9
+ processManager: ProcessManager;
10
+ structuredSessions: StructuredSessionManager;
11
+ configPath: string;
12
+ dbPath: string;
13
+ urls: ServerUrl[];
14
+ bindAddr: string;
15
+ httpsEnabled: boolean;
16
+ version: string;
17
+ orphanRecoveredCount: number;
18
+ close(): Promise<void>;
19
+ }
20
+ export declare function startServer(config: WandConfig, configPath: string): Promise<ServerHandle>;
package/dist/server.js CHANGED
@@ -20,7 +20,9 @@ import { StructuredSessionManager } from "./structured-session-manager.js";
20
20
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
21
21
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
22
22
  import { registerUploadRoutes } from "./upload-routes.js";
23
+ import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
23
24
  import { resolveDatabasePath, WandStorage } from "./storage.js";
25
+ import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
24
26
  import { renderApp } from "./web-ui/index.js";
25
27
  import { WsBroadcastManager } from "./ws-broadcast.js";
26
28
  import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
@@ -445,12 +447,24 @@ process.on("unhandledRejection", (reason) => {
445
447
  wandError("未处理的异步错误", msg);
446
448
  });
447
449
  function wandError(label, message, suggestion) {
450
+ if (isLogBusActive()) {
451
+ wandTuiLog("error", `✗ [wand] ${label}:${message}`);
452
+ if (suggestion)
453
+ wandTuiLog("error", ` 解决方法:${suggestion}`);
454
+ return;
455
+ }
448
456
  process.stderr.write(`\n✗ [wand] ${label}:${message}\n`);
449
457
  if (suggestion)
450
458
  process.stderr.write(` 解决方法:${suggestion}\n`);
451
459
  process.stderr.write("\n");
452
460
  }
453
461
  function wandWarn(message, hint) {
462
+ if (isLogBusActive()) {
463
+ wandTuiLog("warn", `⚠️ [wand] 警告:${message}`);
464
+ if (hint)
465
+ wandTuiLog("warn", ` 提示:${hint}`);
466
+ return;
467
+ }
454
468
  process.stderr.write(`⚠️ [wand] 警告:${message}\n`);
455
469
  if (hint)
456
470
  process.stderr.write(` 提示:${hint}\n`);
@@ -493,7 +507,6 @@ function getLanguageFromExt(ext, filePath) {
493
507
  return "plaintext";
494
508
  return map[ext] || "plaintext";
495
509
  }
496
- // ── Main server ──
497
510
  export async function startServer(config, configPath) {
498
511
  const app = express();
499
512
  const storage = new WandStorage(resolveDatabasePath(configPath));
@@ -906,9 +919,31 @@ export async function startServer(config, configPath) {
906
919
  updateInFlight = false;
907
920
  }
908
921
  });
909
- registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode);
922
+ registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode, config);
910
923
  registerClaudeHistoryRoutes(app, processes, storage);
911
924
  registerUploadRoutes(app, processes);
925
+ app.post("/api/optimize-prompt", express.json({ limit: "256kb" }), async (req, res) => {
926
+ const body = (req.body ?? {});
927
+ const text = typeof body.text === "string" ? body.text : "";
928
+ let cwd;
929
+ if (typeof body.sessionId === "string" && body.sessionId.length > 0) {
930
+ const snap = storage.getSession(body.sessionId);
931
+ if (snap?.cwd)
932
+ cwd = snap.cwd;
933
+ }
934
+ try {
935
+ const optimized = await optimizePrompt(text, config.language ?? "", cwd);
936
+ res.json({ optimized });
937
+ }
938
+ catch (error) {
939
+ if (error instanceof PromptOptimizeError) {
940
+ const status = error.code === "EMPTY_INPUT" || error.code === "INPUT_TOO_LONG" ? 400 : 500;
941
+ res.status(status).json({ error: error.message, errorCode: error.code });
942
+ return;
943
+ }
944
+ res.status(500).json({ error: getErrorMessage(error, "提示词优化失败。") });
945
+ }
946
+ });
912
947
  // ── Path suggestion ──
913
948
  app.get("/api/path-suggestions", async (req, res) => {
914
949
  const query = typeof req.query.q === "string" ? req.query.q : "";
@@ -1240,11 +1275,20 @@ export async function startServer(config, configPath) {
1240
1275
  setTimeout(() => process.exit(0), 5000);
1241
1276
  }, 600);
1242
1277
  });
1278
+ let bindAddr = config.host === "0.0.0.0" ? "0.0.0.0" : config.host;
1279
+ const collectedUrls = [];
1243
1280
  await new Promise((resolve, reject) => {
1244
1281
  server.listen(config.port, config.host, () => {
1245
- const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
1246
- process.stdout.write(`[wand] Web console listening on ${listenAddr}:${config.port}\n` +
1247
- `[wand] 本地访问: ${protocol}://127.0.0.1:${config.port}\n`);
1282
+ bindAddr = `${config.host}:${config.port}`;
1283
+ const scheme = useHttps ? "HTTPS" : "HTTP";
1284
+ // URL:本机回环;若绑定 0.0.0.0 再补一个对外提示。
1285
+ collectedUrls.push({ url: `${protocol}://127.0.0.1:${config.port}`, scheme });
1286
+ if (config.host === "0.0.0.0") {
1287
+ collectedUrls.push({ url: `${protocol}://0.0.0.0:${config.port}`, scheme });
1288
+ }
1289
+ else if (config.host !== "127.0.0.1" && config.host !== "localhost") {
1290
+ collectedUrls.push({ url: `${protocol}://${config.host}:${config.port}`, scheme });
1291
+ }
1248
1292
  resolve();
1249
1293
  });
1250
1294
  server.on("error", (err) => {
@@ -1336,4 +1380,45 @@ export async function startServer(config, configPath) {
1336
1380
  setInterval(() => {
1337
1381
  performAutoUpdate().catch(() => { });
1338
1382
  }, 30 * 60 * 1000);
1383
+ const close = () => new Promise((resolve) => {
1384
+ let done = false;
1385
+ const finish = () => {
1386
+ if (done)
1387
+ return;
1388
+ done = true;
1389
+ try {
1390
+ storage.close();
1391
+ }
1392
+ catch { /* ignore */ }
1393
+ resolve();
1394
+ };
1395
+ try {
1396
+ wss.clients.forEach((c) => c.close());
1397
+ }
1398
+ catch { /* ignore */ }
1399
+ try {
1400
+ wss.close();
1401
+ }
1402
+ catch { /* ignore */ }
1403
+ try {
1404
+ server.close(() => finish());
1405
+ }
1406
+ catch {
1407
+ finish();
1408
+ return;
1409
+ }
1410
+ setTimeout(finish, 3000); // 兜底:3s 内未关完强制 resolve
1411
+ });
1412
+ return {
1413
+ processManager: processes,
1414
+ structuredSessions,
1415
+ configPath,
1416
+ dbPath: resolveDatabasePath(configPath),
1417
+ urls: collectedUrls,
1418
+ bindAddr,
1419
+ httpsEnabled: useHttps,
1420
+ version: PKG_VERSION,
1421
+ orphanRecoveredCount: processes.getOrphanRecoveredCount(),
1422
+ close,
1423
+ };
1339
1424
  }
@@ -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;