@co0ontty/wand 1.18.12 → 1.21.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.
Files changed (39) hide show
  1. package/dist/claude-pty-bridge.d.ts +8 -0
  2. package/dist/claude-pty-bridge.js +34 -11
  3. package/dist/cli.js +72 -5
  4. package/dist/ensure-node-pty-helper.d.ts +1 -0
  5. package/dist/ensure-node-pty-helper.js +51 -0
  6. package/dist/git-quick-commit.d.ts +18 -0
  7. package/dist/git-quick-commit.js +381 -0
  8. package/dist/models.d.ts +3 -1
  9. package/dist/models.js +45 -7
  10. package/dist/process-manager.d.ts +6 -8
  11. package/dist/process-manager.js +90 -176
  12. package/dist/prompt-optimizer.d.ts +5 -0
  13. package/dist/prompt-optimizer.js +72 -0
  14. package/dist/pty-text-utils.d.ts +25 -1
  15. package/dist/pty-text-utils.js +158 -2
  16. package/dist/server-session-routes.d.ts +2 -2
  17. package/dist/server-session-routes.js +94 -8
  18. package/dist/server.d.ts +22 -1
  19. package/dist/server.js +138 -16
  20. package/dist/session-logger.d.ts +15 -4
  21. package/dist/session-logger.js +52 -4
  22. package/dist/structured-session-manager.d.ts +12 -2
  23. package/dist/structured-session-manager.js +465 -22
  24. package/dist/tui/index.d.ts +24 -0
  25. package/dist/tui/index.js +138 -0
  26. package/dist/tui/layout.d.ts +25 -0
  27. package/dist/tui/layout.js +198 -0
  28. package/dist/tui/log-bus.d.ts +23 -0
  29. package/dist/tui/log-bus.js +111 -0
  30. package/dist/tui/relative-time.d.ts +4 -0
  31. package/dist/tui/relative-time.js +27 -0
  32. package/dist/tui/session-formatter.d.ts +17 -0
  33. package/dist/tui/session-formatter.js +111 -0
  34. package/dist/types.d.ts +55 -2
  35. package/dist/web-ui/content/scripts.js +1371 -261
  36. package/dist/web-ui/content/styles.css +436 -9
  37. package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
  38. package/dist/ws-broadcast.js +74 -12
  39. package/package.json +3 -1
@@ -5,8 +5,32 @@
5
5
  export declare function stripAnsi(text: string): string;
6
6
  /** Lines considered as UI noise that should be excluded from chat view. */
7
7
  export declare function isNoiseLine(line: string): boolean;
8
- /** Append text to a windowed buffer, trimming from start if over max size. */
8
+ /**
9
+ * Append text to a windowed buffer, trimming from start if over max size.
10
+ *
11
+ * The cut point is chosen so it never lands inside:
12
+ * - a UTF-16 surrogate pair (would corrupt the leading codepoint)
13
+ * - an unterminated ANSI escape sequence (would feed orphan "[31m..."
14
+ * text to a downstream terminal renderer)
15
+ *
16
+ * The returned buffer may be slightly shorter than maxSize.
17
+ */
9
18
  export declare function appendWindow(buffer: string, chunk: string, maxSize: number): string;
19
+ /** Slice keeping the last ~maxSize chars on a safe boundary. Exported for tests. */
20
+ export declare function safeSliceTail(text: string, maxSize: number): string;
21
+ /**
22
+ * Strip a string down to the printable codepoints used for echo matching.
23
+ * Removes control characters, whitespace and ANSI escapes; keeps all other
24
+ * visible characters (including `/`, `()`, `:`, CJK, emoji, etc.) so that
25
+ * echo alignment works for any user input.
26
+ */
27
+ export declare function stripForEchoMatch(input: string): string;
28
+ /**
29
+ * Given an index pointing at ESC (0x1b), return the index of the first
30
+ * character AFTER the escape sequence. Handles CSI, OSC and simple ESC-
31
+ * letter forms. Returns idx+1 if nothing matches (best-effort skip).
32
+ */
33
+ export declare function skipAnsiSequence(text: string, idx: number): number;
10
34
  export declare function hasExplicitConfirmSyntax(normalized: string): boolean;
11
35
  export declare function hasPermissionActionContext(normalized: string): boolean;
12
36
  /**
@@ -115,10 +115,166 @@ export function isNoiseLine(line) {
115
115
  return true;
116
116
  return false;
117
117
  }
118
- /** Append text to a windowed buffer, trimming from start if over max size. */
118
+ /**
119
+ * Append text to a windowed buffer, trimming from start if over max size.
120
+ *
121
+ * The cut point is chosen so it never lands inside:
122
+ * - a UTF-16 surrogate pair (would corrupt the leading codepoint)
123
+ * - an unterminated ANSI escape sequence (would feed orphan "[31m..."
124
+ * text to a downstream terminal renderer)
125
+ *
126
+ * The returned buffer may be slightly shorter than maxSize.
127
+ */
119
128
  export function appendWindow(buffer, chunk, maxSize) {
120
129
  const next = buffer + chunk;
121
- return next.length > maxSize ? next.slice(-maxSize) : next;
130
+ if (next.length <= maxSize)
131
+ return next;
132
+ return safeSliceTail(next, maxSize);
133
+ }
134
+ /** Slice keeping the last ~maxSize chars on a safe boundary. Exported for tests. */
135
+ export function safeSliceTail(text, maxSize) {
136
+ if (text.length <= maxSize)
137
+ return text;
138
+ let start = text.length - maxSize;
139
+ // 1. Skip UTF-16 low surrogate half so we don't strand a high surrogate.
140
+ if (start > 0 && start < text.length) {
141
+ const code = text.charCodeAt(start);
142
+ if (code >= 0xdc00 && code <= 0xdfff)
143
+ start++;
144
+ }
145
+ // 2. Prefer cutting at the next newline within a small lookahead window.
146
+ // Newlines are always safe boundaries (no ANSI sequence spans a newline
147
+ // in well-formed terminal output) and keep lines aligned for replay.
148
+ const LOOKAHEAD = 4096;
149
+ const upper = Math.min(start + LOOKAHEAD, text.length);
150
+ for (let i = start; i < upper; i++) {
151
+ if (text.charCodeAt(i) === 0x0a)
152
+ return text.slice(i + 1);
153
+ }
154
+ // 3. No nearby newline. Detect whether `start` lands inside an open ANSI
155
+ // escape sequence by scanning backward for an ESC (0x1b). If we find one
156
+ // that is not yet terminated, advance past the sequence's final byte.
157
+ const lookback = Math.max(0, start - 256);
158
+ let escAt = -1;
159
+ for (let i = start - 1; i >= lookback; i--) {
160
+ const code = text.charCodeAt(i);
161
+ if (code === 0x1b) {
162
+ escAt = i;
163
+ break;
164
+ }
165
+ // If we hit a terminator before an ESC, the previous sequence is closed.
166
+ if (code === 0x07)
167
+ break;
168
+ if (code >= 0x40 && code <= 0x7e && i > 0 && isLikelyAnsiBody(text, i - 1))
169
+ break;
170
+ }
171
+ if (escAt !== -1) {
172
+ // OSC 序列以 `ESC ]` (0x1b 0x5d) 开头,必须用 BEL (0x07) 或
173
+ // ST (`ESC \\` = 0x1b 0x5c) 终止。其它范围内字节(包括裸 `\`)
174
+ // 都属于 payload,不能当终止符。CSI 等序列才用 0x40-0x7e final byte。
175
+ const isOsc = escAt + 1 < text.length && text.charCodeAt(escAt + 1) === 0x5d;
176
+ let terminated = false;
177
+ for (let i = escAt + 1; i < start; i++) {
178
+ const code = text.charCodeAt(i);
179
+ if (code === 0x07) {
180
+ terminated = true;
181
+ break;
182
+ }
183
+ if (isOsc) {
184
+ if (code === 0x1b && i + 1 < start && text.charCodeAt(i + 1) === 0x5c) {
185
+ terminated = true;
186
+ break;
187
+ }
188
+ continue;
189
+ }
190
+ if (code >= 0x40 && code <= 0x7e) {
191
+ terminated = true;
192
+ break;
193
+ }
194
+ }
195
+ if (!terminated) {
196
+ const ansiUpper = Math.min(start + 256, text.length);
197
+ for (let i = start; i < ansiUpper; i++) {
198
+ const code = text.charCodeAt(i);
199
+ if (code === 0x07)
200
+ return text.slice(i + 1);
201
+ if (isOsc) {
202
+ if (code === 0x1b && i + 1 < ansiUpper && text.charCodeAt(i + 1) === 0x5c) {
203
+ return text.slice(i + 2);
204
+ }
205
+ continue;
206
+ }
207
+ if (code >= 0x40 && code <= 0x7e)
208
+ return text.slice(i + 1);
209
+ }
210
+ }
211
+ }
212
+ return text.slice(start);
213
+ }
214
+ function isLikelyAnsiBody(text, idx) {
215
+ // CSI parameter/intermediate range covers most common ANSI bodies.
216
+ const code = text.charCodeAt(idx);
217
+ return code === 0x5b /* [ */ || code === 0x3f /* ? */ || (code >= 0x30 && code <= 0x3f);
218
+ }
219
+ /**
220
+ * Strip a string down to the printable codepoints used for echo matching.
221
+ * Removes control characters, whitespace and ANSI escapes; keeps all other
222
+ * visible characters (including `/`, `()`, `:`, CJK, emoji, etc.) so that
223
+ * echo alignment works for any user input.
224
+ */
225
+ export function stripForEchoMatch(input) {
226
+ let out = "";
227
+ for (let i = 0; i < input.length; i++) {
228
+ const code = input.charCodeAt(i);
229
+ if (code === 0x1b) {
230
+ i = skipAnsiSequence(input, i) - 1;
231
+ continue;
232
+ }
233
+ if (code < 0x20 || code === 0x7f)
234
+ continue;
235
+ if (code === 0x20)
236
+ continue;
237
+ out += input[i];
238
+ }
239
+ return out;
240
+ }
241
+ /**
242
+ * Given an index pointing at ESC (0x1b), return the index of the first
243
+ * character AFTER the escape sequence. Handles CSI, OSC and simple ESC-
244
+ * letter forms. Returns idx+1 if nothing matches (best-effort skip).
245
+ */
246
+ export function skipAnsiSequence(text, idx) {
247
+ if (text.charCodeAt(idx) !== 0x1b)
248
+ return idx;
249
+ const next = text.charCodeAt(idx + 1);
250
+ if (Number.isNaN(next))
251
+ return idx + 1;
252
+ // CSI: ESC [ ... final-byte (0x40-0x7E)
253
+ if (next === 0x5b /* [ */) {
254
+ let i = idx + 2;
255
+ while (i < text.length) {
256
+ const code = text.charCodeAt(i);
257
+ if (code >= 0x40 && code <= 0x7e)
258
+ return i + 1;
259
+ i++;
260
+ }
261
+ return text.length;
262
+ }
263
+ // OSC: ESC ] ... terminator (BEL or ESC \)
264
+ if (next === 0x5d /* ] */) {
265
+ let i = idx + 2;
266
+ while (i < text.length) {
267
+ const code = text.charCodeAt(i);
268
+ if (code === 0x07)
269
+ return i + 1;
270
+ if (code === 0x1b && text.charCodeAt(i + 1) === 0x5c)
271
+ return i + 2;
272
+ i++;
273
+ }
274
+ return text.length;
275
+ }
276
+ // Two-character ESC sequences (ESC = / ESC > / ESC M / etc.)
277
+ return idx + 2;
122
278
  }
123
279
  const EXPLICIT_CONFIRM_PATTERNS = [
124
280
  /(?:^|\b)(?:press\s+)?(?:y|yes)\s*(?:\/|\bor\b)\s*(?:n|no)(?:\b|$)/i,
@@ -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, onSessionCreated?: (cwd: string | undefined | null) => void): 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, onSessionCreated) {
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 })));
@@ -145,19 +146,27 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
145
146
  const body = req.body;
146
147
  console.log("[WAND] POST /api/structured-sessions body:", JSON.stringify({ cwd: body.cwd, mode: body.mode, runner: body.runner, provider: body.provider, worktreeEnabled: body.worktreeEnabled === true, hasPrompt: !!body.prompt, model: body.model }));
147
148
  try {
148
- if (body.provider && body.provider !== "claude") {
149
- res.status(400).json({ error: "结构化会话当前仅支持 Claude provider。" });
149
+ if (body.provider && body.provider !== "claude" && body.provider !== "codex") {
150
+ res.status(400).json({ error: "结构化会话当前仅支持 Claude 或 Codex provider。" });
150
151
  return;
151
152
  }
153
+ const provider = body.provider === "codex" ? "codex" : "claude";
152
154
  const snapshot = structured.createSession({
153
155
  cwd: body.cwd?.trim() || process.cwd(),
154
156
  mode: normalizeMode(body.mode, defaultMode),
155
- prompt: body.prompt,
156
- runner: body.runner ?? "claude-cli-print",
157
+ provider,
158
+ runner: body.runner ?? (provider === "codex" ? "codex-cli-exec" : "claude-cli-print"),
157
159
  worktreeEnabled: body.worktreeEnabled === true,
158
160
  model: typeof body.model === "string" ? body.model.trim() : undefined,
159
161
  });
160
162
  console.log("[WAND] structured session created:", JSON.stringify({ id: snapshot.id, sessionKind: snapshot.sessionKind, runner: snapshot.runner, status: snapshot.status }));
163
+ onSessionCreated?.(body.cwd ?? snapshot.cwd);
164
+ const prompt = body.prompt?.trim();
165
+ if (prompt) {
166
+ const finished = await structured.sendMessage(snapshot.id, prompt);
167
+ res.status(201).json(finished);
168
+ return;
169
+ }
161
170
  res.status(201).json(snapshot);
162
171
  }
163
172
  catch (error) {
@@ -302,6 +311,77 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
302
311
  res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法合并 worktree。"));
303
312
  }
304
313
  });
314
+ app.get("/api/sessions/:id/git-status", (req, res) => {
315
+ const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
316
+ if (!snapshot) {
317
+ res.status(404).json({ error: "未找到该会话。" });
318
+ return;
319
+ }
320
+ if (!snapshot.cwd) {
321
+ res.json({ isGit: false });
322
+ return;
323
+ }
324
+ try {
325
+ res.json(getGitStatus(snapshot.cwd));
326
+ }
327
+ catch (error) {
328
+ res.json({ isGit: false, error: getErrorMessage(error, "无法读取 git 状态。") });
329
+ }
330
+ });
331
+ app.post("/api/sessions/:id/quick-commit", express.json(), async (req, res) => {
332
+ const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
333
+ if (!snapshot) {
334
+ res.status(404).json({ error: "未找到该会话。" });
335
+ return;
336
+ }
337
+ if (!snapshot.cwd) {
338
+ res.status(400).json({ error: "会话没有工作目录。", errorCode: "NO_CWD" });
339
+ return;
340
+ }
341
+ const body = (req.body ?? {});
342
+ try {
343
+ const result = await runQuickCommit({
344
+ cwd: snapshot.cwd,
345
+ language: config.language ?? "",
346
+ autoMessage: body.autoMessage !== false,
347
+ customMessage: typeof body.customMessage === "string" ? body.customMessage : undefined,
348
+ tag: typeof body.tag === "string" ? body.tag : undefined,
349
+ autoTag: !!body.autoTag,
350
+ push: !!body.push,
351
+ });
352
+ res.json(result);
353
+ }
354
+ catch (error) {
355
+ if (error instanceof QuickCommitError) {
356
+ const status = error.code === "NOTHING_TO_COMMIT" || error.code === "TAG_EXISTS" ? 409 : 400;
357
+ res.status(status).json({ error: error.message, errorCode: error.code });
358
+ return;
359
+ }
360
+ res.status(400).json({ error: getErrorMessage(error, "快捷提交失败。") });
361
+ }
362
+ });
363
+ app.post("/api/sessions/:id/generate-commit-message", express.json(), async (req, res) => {
364
+ const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
365
+ if (!snapshot) {
366
+ res.status(404).json({ error: "未找到该会话。" });
367
+ return;
368
+ }
369
+ if (!snapshot.cwd) {
370
+ res.status(400).json({ error: "会话没有工作目录。" });
371
+ return;
372
+ }
373
+ try {
374
+ const message = await generateCommitMessageOnly(snapshot.cwd, config.language ?? "");
375
+ res.json({ message });
376
+ }
377
+ catch (error) {
378
+ if (error instanceof QuickCommitError) {
379
+ res.status(400).json({ error: error.message, errorCode: error.code });
380
+ return;
381
+ }
382
+ res.status(400).json({ error: getErrorMessage(error, "生成 commit message 失败。") });
383
+ }
384
+ });
305
385
  app.post("/api/sessions/:id/worktree/cleanup", (req, res) => {
306
386
  try {
307
387
  const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
@@ -400,7 +480,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
400
480
  }
401
481
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
402
482
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
403
- const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: sessionId });
483
+ const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
484
+ const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
485
+ const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: sessionId, cols: reqCols, rows: reqRows });
404
486
  res.status(201).json(newSnapshot);
405
487
  }
406
488
  catch (error) {
@@ -437,7 +519,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
437
519
  }
438
520
  const newMode = body.mode ? normalizeMode(body.mode, defaultMode) : normalizeMode(existingSession.mode, defaultMode);
439
521
  const resumeCommand = `${command} --resume ${claudeSessionId}`;
440
- const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: existingSession.id });
522
+ const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
523
+ const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
524
+ const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { reuseId: existingSession.id, cols: reqCols, rows: reqRows });
441
525
  res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
442
526
  }
443
527
  else {
@@ -448,7 +532,9 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
448
532
  }
449
533
  const newMode = normalizeMode(body.mode, defaultMode);
450
534
  const resumeCommand = `claude --resume ${claudeSessionId}`;
451
- const newSnapshot = processes.start(resumeCommand, cwd, newMode);
535
+ const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
536
+ const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
537
+ const newSnapshot = processes.start(resumeCommand, cwd, newMode, undefined, { cols: reqCols, rows: reqRows });
452
538
  res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
453
539
  }
454
540
  }
package/dist/server.d.ts CHANGED
@@ -1,2 +1,23 @@
1
+ import { ProcessManager } from "./process-manager.js";
2
+ import { StructuredSessionManager } from "./structured-session-manager.js";
3
+ import { WandStorage } from "./storage.js";
1
4
  import { WandConfig } from "./types.js";
2
- export declare function startServer(config: WandConfig, configPath: string): Promise<void>;
5
+ /** Persist a cwd to recent paths. Used by both REST and session creation hooks. */
6
+ export declare function recordRecentPath(storage: WandStorage, cwd: string | undefined | null): void;
7
+ export interface ServerUrl {
8
+ url: string;
9
+ scheme: "HTTP" | "HTTPS";
10
+ }
11
+ export interface ServerHandle {
12
+ processManager: ProcessManager;
13
+ structuredSessions: StructuredSessionManager;
14
+ configPath: string;
15
+ dbPath: string;
16
+ urls: ServerUrl[];
17
+ bindAddr: string;
18
+ httpsEnabled: boolean;
19
+ version: string;
20
+ orphanRecoveredCount: number;
21
+ close(): Promise<void>;
22
+ }
23
+ export declare function startServer(config: WandConfig, configPath: string): Promise<ServerHandle>;
package/dist/server.js CHANGED
@@ -16,11 +16,14 @@ import { ensureCertificates } from "./cert.js";
16
16
  import { isExecutionMode, normalizeCardDefaults, resolveConfigDir, saveConfig } from "./config.js";
17
17
  import { getCachedModels, refreshModels } from "./models.js";
18
18
  import { ProcessManager } from "./process-manager.js";
19
+ import { SessionLogger } from "./session-logger.js";
19
20
  import { StructuredSessionManager } from "./structured-session-manager.js";
20
21
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
21
22
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
22
23
  import { registerUploadRoutes } from "./upload-routes.js";
24
+ import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
23
25
  import { resolveDatabasePath, WandStorage } from "./storage.js";
26
+ import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
24
27
  import { renderApp } from "./web-ui/index.js";
25
28
  import { WsBroadcastManager } from "./ws-broadcast.js";
26
29
  import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
@@ -445,12 +448,24 @@ process.on("unhandledRejection", (reason) => {
445
448
  wandError("未处理的异步错误", msg);
446
449
  });
447
450
  function wandError(label, message, suggestion) {
451
+ if (isLogBusActive()) {
452
+ wandTuiLog("error", `✗ [wand] ${label}:${message}`);
453
+ if (suggestion)
454
+ wandTuiLog("error", ` 解决方法:${suggestion}`);
455
+ return;
456
+ }
448
457
  process.stderr.write(`\n✗ [wand] ${label}:${message}\n`);
449
458
  if (suggestion)
450
459
  process.stderr.write(` 解决方法:${suggestion}\n`);
451
460
  process.stderr.write("\n");
452
461
  }
453
462
  function wandWarn(message, hint) {
463
+ if (isLogBusActive()) {
464
+ wandTuiLog("warn", `⚠️ [wand] 警告:${message}`);
465
+ if (hint)
466
+ wandTuiLog("warn", ` 提示:${hint}`);
467
+ return;
468
+ }
454
469
  process.stderr.write(`⚠️ [wand] 警告:${message}\n`);
455
470
  if (hint)
456
471
  process.stderr.write(` 提示:${hint}\n`);
@@ -467,6 +482,33 @@ function parseStoredPathList(raw) {
467
482
  }
468
483
  }
469
484
  const MAX_RECENT_PATHS = 10;
485
+ /** Persist a cwd to recent paths. Used by both REST and session creation hooks. */
486
+ export function recordRecentPath(storage, cwd) {
487
+ if (!cwd)
488
+ return;
489
+ const trimmed = cwd.trim();
490
+ if (!trimmed)
491
+ return;
492
+ let resolved;
493
+ try {
494
+ resolved = normalizeFolderPath(trimmed);
495
+ }
496
+ catch {
497
+ return;
498
+ }
499
+ if (isBlockedFolderPath(resolved))
500
+ return;
501
+ const stored = storage.getConfigValue("recent_paths");
502
+ let recent = parseStoredPathList(stored);
503
+ recent = recent.filter((r) => normalizeFolderPath(r.path) !== resolved);
504
+ recent.unshift({
505
+ path: resolved,
506
+ name: path.basename(resolved),
507
+ lastUsedAt: new Date().toISOString(),
508
+ });
509
+ recent = recent.slice(0, MAX_RECENT_PATHS);
510
+ storage.setConfigValue("recent_paths", JSON.stringify(recent));
511
+ }
470
512
  // ── File language detection ──
471
513
  function getLanguageFromExt(ext, filePath) {
472
514
  const map = {
@@ -493,7 +535,6 @@ function getLanguageFromExt(ext, filePath) {
493
535
  return "plaintext";
494
536
  return map[ext] || "plaintext";
495
537
  }
496
- // ── Main server ──
497
538
  export async function startServer(config, configPath) {
498
539
  const app = express();
499
540
  const storage = new WandStorage(resolveDatabasePath(configPath));
@@ -501,7 +542,8 @@ export async function startServer(config, configPath) {
501
542
  const configDir = resolveConfigDir(configPath);
502
543
  const avatarSeed = await ensureAvatarSeed(configDir);
503
544
  const processes = new ProcessManager(config, storage, configDir);
504
- const structuredSessions = new StructuredSessionManager(storage, config);
545
+ const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
546
+ const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger);
505
547
  const useHttps = config.https === true;
506
548
  const protocol = useHttps ? "https" : "http";
507
549
  const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
@@ -682,7 +724,10 @@ export async function startServer(config, configPath) {
682
724
  defaultMode: config.defaultMode,
683
725
  defaultCwd: config.defaultCwd,
684
726
  commandPresets: config.commandPresets,
685
- structuredRunners: [{ label: "Claude Structured", runner: "claude-cli-print" }],
727
+ structuredRunners: [
728
+ { label: "Claude Structured", runner: "claude-cli-print" },
729
+ { label: "Codex Structured", runner: "codex-cli-exec" },
730
+ ],
686
731
  structuredChatPersona,
687
732
  cardDefaults: config.cardDefaults,
688
733
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
@@ -834,6 +879,7 @@ export async function startServer(config, configPath) {
834
879
  const cached = getCachedModels();
835
880
  res.json({
836
881
  models: cached.models,
882
+ codexModels: cached.codexModels,
837
883
  claudeVersion: cached.claudeVersion,
838
884
  refreshedAt: cached.refreshedAt,
839
885
  defaultModel: config.defaultModel ?? "",
@@ -844,6 +890,7 @@ export async function startServer(config, configPath) {
844
890
  const refreshed = await refreshModels();
845
891
  res.json({
846
892
  models: refreshed.models,
893
+ codexModels: refreshed.codexModels,
847
894
  claudeVersion: refreshed.claudeVersion,
848
895
  refreshedAt: refreshed.refreshedAt,
849
896
  defaultModel: config.defaultModel ?? "",
@@ -906,9 +953,33 @@ export async function startServer(config, configPath) {
906
953
  updateInFlight = false;
907
954
  }
908
955
  });
909
- registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode);
956
+ registerSessionRoutes(app, processes, structuredSessions, storage, config.defaultMode, config, (cwd) => {
957
+ recordRecentPath(storage, cwd);
958
+ });
910
959
  registerClaudeHistoryRoutes(app, processes, storage);
911
960
  registerUploadRoutes(app, processes);
961
+ app.post("/api/optimize-prompt", express.json({ limit: "256kb" }), async (req, res) => {
962
+ const body = (req.body ?? {});
963
+ const text = typeof body.text === "string" ? body.text : "";
964
+ let cwd;
965
+ if (typeof body.sessionId === "string" && body.sessionId.length > 0) {
966
+ const snap = storage.getSession(body.sessionId);
967
+ if (snap?.cwd)
968
+ cwd = snap.cwd;
969
+ }
970
+ try {
971
+ const optimized = await optimizePrompt(text, config.language ?? "", cwd);
972
+ res.json({ optimized });
973
+ }
974
+ catch (error) {
975
+ if (error instanceof PromptOptimizeError) {
976
+ const status = error.code === "EMPTY_INPUT" || error.code === "INPUT_TOO_LONG" ? 400 : 500;
977
+ res.status(status).json({ error: error.message, errorCode: error.code });
978
+ return;
979
+ }
980
+ res.status(500).json({ error: getErrorMessage(error, "提示词优化失败。") });
981
+ }
982
+ });
912
983
  // ── Path suggestion ──
913
984
  app.get("/api/path-suggestions", async (req, res) => {
914
985
  const query = typeof req.query.q === "string" ? req.query.q : "";
@@ -1063,18 +1134,12 @@ export async function startServer(config, configPath) {
1063
1134
  res.status(403).json({ error: "访问被拒绝:无法保存系统敏感目录。" });
1064
1135
  return;
1065
1136
  }
1066
- const stored = storage.getConfigValue("recent_paths");
1067
- let recent = parseStoredPathList(stored);
1068
- recent = recent.filter((r) => normalizeFolderPath(r.path) !== resolvedRecentPath);
1069
- const newRecent = {
1137
+ recordRecentPath(storage, resolvedRecentPath);
1138
+ res.json({
1070
1139
  path: resolvedRecentPath,
1071
1140
  name: path.basename(resolvedRecentPath),
1072
1141
  lastUsedAt: new Date().toISOString(),
1073
- };
1074
- recent.unshift(newRecent);
1075
- recent = recent.slice(0, MAX_RECENT_PATHS);
1076
- storage.setConfigValue("recent_paths", JSON.stringify(recent));
1077
- res.json(newRecent);
1142
+ });
1078
1143
  });
1079
1144
  app.get("/api/validate-path", async (req, res) => {
1080
1145
  const inputPath = typeof req.query.path === "string" ? req.query.path : "";
@@ -1180,11 +1245,16 @@ export async function startServer(config, configPath) {
1180
1245
  try {
1181
1246
  const rawModel = typeof body.model === "string" ? body.model.trim() : "";
1182
1247
  const effectiveModel = rawModel || (config.defaultModel ?? "").trim() || undefined;
1248
+ const reqCols = typeof body.cols === "number" && Number.isFinite(body.cols) ? body.cols : undefined;
1249
+ const reqRows = typeof body.rows === "number" && Number.isFinite(body.rows) ? body.rows : undefined;
1183
1250
  const snapshot = processes.start(body.command, body.cwd, normalizeMode(body.mode, config.defaultMode), initialInput || undefined, {
1184
1251
  worktreeEnabled: body.worktreeEnabled === true,
1185
1252
  provider: body.provider,
1186
1253
  model: effectiveModel,
1254
+ cols: reqCols,
1255
+ rows: reqRows,
1187
1256
  });
1257
+ recordRecentPath(storage, body.cwd ?? snapshot.cwd);
1188
1258
  res.status(201).json(snapshot);
1189
1259
  }
1190
1260
  catch (error) {
@@ -1240,11 +1310,20 @@ export async function startServer(config, configPath) {
1240
1310
  setTimeout(() => process.exit(0), 5000);
1241
1311
  }, 600);
1242
1312
  });
1313
+ let bindAddr = config.host === "0.0.0.0" ? "0.0.0.0" : config.host;
1314
+ const collectedUrls = [];
1243
1315
  await new Promise((resolve, reject) => {
1244
1316
  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`);
1317
+ bindAddr = `${config.host}:${config.port}`;
1318
+ const scheme = useHttps ? "HTTPS" : "HTTP";
1319
+ // URL:本机回环;若绑定 0.0.0.0 再补一个对外提示。
1320
+ collectedUrls.push({ url: `${protocol}://127.0.0.1:${config.port}`, scheme });
1321
+ if (config.host === "0.0.0.0") {
1322
+ collectedUrls.push({ url: `${protocol}://0.0.0.0:${config.port}`, scheme });
1323
+ }
1324
+ else if (config.host !== "127.0.0.1" && config.host !== "localhost") {
1325
+ collectedUrls.push({ url: `${protocol}://${config.host}:${config.port}`, scheme });
1326
+ }
1248
1327
  resolve();
1249
1328
  });
1250
1329
  server.on("error", (err) => {
@@ -1260,6 +1339,8 @@ export async function startServer(config, configPath) {
1260
1339
  }
1261
1340
  // Start configured background sessions after the server is already reachable.
1262
1341
  processes.runStartupCommands();
1342
+ // Pre-warm model cache (probes claude --version + codex debug models).
1343
+ refreshModels().catch(() => { });
1263
1344
  // ── Auto-update endpoints ──
1264
1345
  app.get("/api/auto-update", (_req, res) => {
1265
1346
  const web = storage.getConfigValue("autoUpdateWeb") === "true";
@@ -1336,4 +1417,45 @@ export async function startServer(config, configPath) {
1336
1417
  setInterval(() => {
1337
1418
  performAutoUpdate().catch(() => { });
1338
1419
  }, 30 * 60 * 1000);
1420
+ const close = () => new Promise((resolve) => {
1421
+ let done = false;
1422
+ const finish = () => {
1423
+ if (done)
1424
+ return;
1425
+ done = true;
1426
+ try {
1427
+ storage.close();
1428
+ }
1429
+ catch { /* ignore */ }
1430
+ resolve();
1431
+ };
1432
+ try {
1433
+ wss.clients.forEach((c) => c.close());
1434
+ }
1435
+ catch { /* ignore */ }
1436
+ try {
1437
+ wss.close();
1438
+ }
1439
+ catch { /* ignore */ }
1440
+ try {
1441
+ server.close(() => finish());
1442
+ }
1443
+ catch {
1444
+ finish();
1445
+ return;
1446
+ }
1447
+ setTimeout(finish, 3000); // 兜底:3s 内未关完强制 resolve
1448
+ });
1449
+ return {
1450
+ processManager: processes,
1451
+ structuredSessions,
1452
+ configPath,
1453
+ dbPath: resolveDatabasePath(configPath),
1454
+ urls: collectedUrls,
1455
+ bindAddr,
1456
+ httpsEnabled: useHttps,
1457
+ version: PKG_VERSION,
1458
+ orphanRecoveredCount: processes.getOrphanRecoveredCount(),
1459
+ close,
1460
+ };
1339
1461
  }