@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
package/dist/models.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execAsync = promisify(exec);
4
- const BUILT_IN_MODELS = [
4
+ const CLAUDE_MODELS = [
5
5
  { id: "default", label: "default(跟随 Claude Code 默认)", alias: true },
6
6
  { id: "opus", label: "opus(最新 Opus)", alias: true },
7
7
  { id: "sonnet", label: "sonnet(最新 Sonnet)", alias: true },
@@ -11,9 +11,12 @@ const BUILT_IN_MODELS = [
11
11
  { id: "claude-sonnet-4-6", label: "Sonnet 4.6 · claude-sonnet-4-6" },
12
12
  { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 · claude-haiku-4-5-20251001" },
13
13
  ];
14
+ const CODEX_FALLBACK_MODELS = [
15
+ { id: "default", label: "default(跟随 Codex 默认)", alias: true },
16
+ ];
14
17
  let cache = null;
15
- function cloneDefaults() {
16
- return BUILT_IN_MODELS.map((m) => ({ ...m }));
18
+ function cloneClaudeModels() {
19
+ return CLAUDE_MODELS.map((m) => ({ ...m }));
17
20
  }
18
21
  async function probeClaudeVersion() {
19
22
  try {
@@ -25,10 +28,37 @@ async function probeClaudeVersion() {
25
28
  return null;
26
29
  }
27
30
  }
31
+ async function probeCodexModels() {
32
+ try {
33
+ const { stdout } = await execAsync("codex debug models", { timeout: 8000 });
34
+ const data = JSON.parse(stdout);
35
+ const visible = data.models
36
+ .filter((m) => m.visibility === "list")
37
+ .sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
38
+ if (!visible.length)
39
+ return CODEX_FALLBACK_MODELS.map((m) => ({ ...m }));
40
+ const result = [
41
+ { id: "default", label: "default(跟随 Codex 默认)", alias: true },
42
+ ];
43
+ for (const m of visible) {
44
+ result.push({
45
+ id: m.slug,
46
+ label: m.display_name && m.display_name !== m.slug
47
+ ? `${m.display_name} · ${m.slug}`
48
+ : m.slug,
49
+ });
50
+ }
51
+ return result;
52
+ }
53
+ catch {
54
+ return CODEX_FALLBACK_MODELS.map((m) => ({ ...m }));
55
+ }
56
+ }
28
57
  export function getCachedModels() {
29
58
  if (!cache) {
30
59
  cache = {
31
- models: cloneDefaults(),
60
+ models: cloneClaudeModels(),
61
+ codexModels: CODEX_FALLBACK_MODELS.map((m) => ({ ...m })),
32
62
  claudeVersion: null,
33
63
  refreshedAt: new Date().toISOString(),
34
64
  };
@@ -36,17 +66,25 @@ export function getCachedModels() {
36
66
  return cache;
37
67
  }
38
68
  export async function refreshModels() {
39
- const version = await probeClaudeVersion();
69
+ const [version, codexModels] = await Promise.all([
70
+ probeClaudeVersion(),
71
+ probeCodexModels(),
72
+ ]);
40
73
  cache = {
41
- models: cloneDefaults(),
74
+ models: cloneClaudeModels(),
75
+ codexModels,
42
76
  claudeVersion: version,
43
77
  refreshedAt: new Date().toISOString(),
44
78
  };
45
79
  return cache;
46
80
  }
81
+ export function getModelsForProvider(provider) {
82
+ const cached = getCachedModels();
83
+ return provider === "codex" ? cached.codexModels : cached.models;
84
+ }
47
85
  /** 返回可用于 claude CLI 的全部已知 model id(含别名) */
48
86
  export function knownModelIds() {
49
- return BUILT_IN_MODELS.map((m) => m.id);
87
+ return CLAUDE_MODELS.map((m) => m.id);
50
88
  }
51
89
  /** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
52
90
  export function isKnownModel(_value) {
@@ -35,8 +35,12 @@ export declare class ProcessManager extends EventEmitter {
35
35
  private readonly persistDebounceTimers;
36
36
  /** Last persisted message state per session — used to skip redundant message writes */
37
37
  private readonly lastPersistedMessageState;
38
+ /** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
39
+ private orphanRecoveredCount;
38
40
  constructor(config: WandConfig, storage: WandStorage, configDir?: string);
39
41
  on(event: "process", listener: ProcessEventHandler): this;
42
+ /** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数量(仅用于启动摘要展示)。 */
43
+ getOrphanRecoveredCount(): number;
40
44
  private emitEvent;
41
45
  private cleanupOldSessions;
42
46
  start(command: string, cwd: string | undefined, mode: ExecutionMode, initialInput?: string, opts?: {
@@ -46,6 +50,8 @@ export declare class ProcessManager extends EventEmitter {
46
50
  provider?: SessionProvider;
47
51
  model?: string;
48
52
  reuseId?: string;
53
+ cols?: number;
54
+ rows?: number;
49
55
  }): SessionSnapshot;
50
56
  list(): SessionSnapshot[];
51
57
  /** Return lightweight snapshots for the session list (no output/messages). */
@@ -103,14 +109,6 @@ export declare class ProcessManager extends EventEmitter {
103
109
  * Use this at critical points (exit, stop, delete) to ensure no data loss.
104
110
  */
105
111
  private flushPersist;
106
- private backfillExitedClaudeSessionIds;
107
- /**
108
- * Auto-recover the most recent exited session that has a Claude session ID.
109
- * Only resumes one session per server start, using the most recent eligible
110
- * session. Reuses the original session ID (in-place resume) and sets
111
- * `autoRecovered: true`.
112
- */
113
- private autoRecoverExitedSessions;
114
112
  private archiveExpiredSessions;
115
113
  private assertCommandAllowed;
116
114
  /**
@@ -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
  }
@@ -108,6 +108,10 @@ function hasRecentProjectActivity(candidate, startedAt) {
108
108
  }
109
109
  function selectClaudeProjectSessionForRecord(record) {
110
110
  const knownMtimes = record.knownClaudeProjectMtimes ?? new Map();
111
+ // Only consider files created/touched AFTER this wand session started — those
112
+ // are the ones a fresh `claude` invocation could have produced. Files that
113
+ // existed before (knownMtimes entry present) are tolerated only if they
114
+ // grew since we observed them, but we de-prioritize them below.
111
115
  const candidates = listClaudeProjectSessionCandidates(record.cwd)
112
116
  .filter((candidate) => {
113
117
  const previousMtime = knownMtimes.get(candidate.id);
@@ -125,54 +129,22 @@ function selectClaudeProjectSessionForRecord(record) {
125
129
  if (!hasUserTurn) {
126
130
  return null;
127
131
  }
128
- return candidates[0] ?? null;
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) {
132
+ // Prefer brand-new files (id not in knownMtimes at session start). When more
133
+ // than one fresh candidate exists in parallel sessions, refuse to bind —
134
+ // mis-binding another session's history is worse than waiting for the bridge
135
+ // to capture the canonical session id from PTY output.
136
+ const fresh = candidates.filter((candidate) => !knownMtimes.has(candidate.id));
137
+ if (fresh.length === 1) {
138
+ return fresh[0];
139
+ }
140
+ if (fresh.length > 1) {
141
141
  return null;
142
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;
143
+ // Fallback: existing file that grew. Only bind if a single grown candidate.
144
+ return candidates.length === 1 ? candidates[0] : null;
170
145
  }
171
146
  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;
147
+ return selectClaudeProjectSessionForRecord(record)?.id ?? null;
176
148
  }
177
149
  function listRecentClaudeProjectSessionIds(cwd, startedAt) {
178
150
  return listClaudeProjectSessionCandidates(cwd)
@@ -180,33 +152,6 @@ function listRecentClaudeProjectSessionIds(cwd, startedAt) {
180
152
  .sort((a, b) => b.mtimeMs - a.mtimeMs)
181
153
  .map((candidate) => candidate.id);
182
154
  }
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
155
  function isClaudeSessionFileAvailable(cwd, claudeSessionId) {
211
156
  const filePath = path.join(getClaudeProjectDir(cwd), `${claudeSessionId}.jsonl`);
212
157
  return Boolean(readClaudeProjectSessionDetails(filePath, claudeSessionId));
@@ -348,18 +293,6 @@ function listAllClaudeHistorySessions() {
348
293
  return [];
349
294
  }
350
295
  }
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
296
  function snapshotMessages(record) {
364
297
  return record.ptyBridge?.getMessages() ?? record.messages;
365
298
  }
@@ -455,6 +388,8 @@ export class ProcessManager extends EventEmitter {
455
388
  persistDebounceTimers = new Map();
456
389
  /** Last persisted message state per session — used to skip redundant message writes */
457
390
  lastPersistedMessageState = new Map();
391
+ /** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
392
+ orphanRecoveredCount = 0;
458
393
  constructor(config, storage, configDir) {
459
394
  super();
460
395
  this.config = config;
@@ -488,7 +423,10 @@ export class ProcessManager extends EventEmitter {
488
423
  confirmWindow: "",
489
424
  ptyPermissionBlocked: false,
490
425
  lastAutoConfirmAt: 0,
491
- autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
426
+ // Preserve a user-toggled auto-approve setting across server restarts
427
+ // instead of recomputing it from the command/mode pair.
428
+ autoApprovePermissions: snapshot.autoApprovePermissions
429
+ ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
492
430
  pendingEscalation: snapshot.pendingEscalation ?? null,
493
431
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
494
432
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -507,9 +445,11 @@ export class ProcessManager extends EventEmitter {
507
445
  claudeTaskDiscoveryTimer: null,
508
446
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
509
447
  claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
510
- approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
448
+ approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
449
+ ptyCols: snapshot.ptyCols ?? 120,
450
+ ptyRows: snapshot.ptyRows ?? 36,
511
451
  });
512
- console.error(`[ProcessManager] Restored session ${snapshot.id} marked as exited (PTY orphaned)`);
452
+ this.orphanRecoveredCount += 1;
513
453
  }
514
454
  else {
515
455
  this.sessions.set(snapshot.id, {
@@ -521,7 +461,8 @@ export class ProcessManager extends EventEmitter {
521
461
  confirmWindow: "",
522
462
  ptyPermissionBlocked: false,
523
463
  lastAutoConfirmAt: 0,
524
- autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
464
+ autoApprovePermissions: snapshot.autoApprovePermissions
465
+ ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
525
466
  pendingEscalation: snapshot.pendingEscalation ?? null,
526
467
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
527
468
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -540,16 +481,12 @@ export class ProcessManager extends EventEmitter {
540
481
  claudeTaskDiscoveryTimer: null,
541
482
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
542
483
  claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId,
543
- approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
484
+ approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
485
+ ptyCols: snapshot.ptyCols ?? 120,
486
+ ptyRows: snapshot.ptyRows ?? 36,
544
487
  });
545
488
  }
546
489
  }
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
490
  this.archiveExpiredSessions();
554
491
  this.archiveTimer = setInterval(() => {
555
492
  try {
@@ -564,6 +501,10 @@ export class ProcessManager extends EventEmitter {
564
501
  on(event, listener) {
565
502
  return super.on("process", listener);
566
503
  }
504
+ /** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数量(仅用于启动摘要展示)。 */
505
+ getOrphanRecoveredCount() {
506
+ return this.orphanRecoveredCount;
507
+ }
567
508
  emitEvent(event) {
568
509
  this.emit("process", event);
569
510
  }
@@ -614,12 +555,22 @@ export class ProcessManager extends EventEmitter {
614
555
  ? path.resolve(process.cwd(), cwd)
615
556
  : path.resolve(process.cwd(), this.config.defaultCwd);
616
557
  const id = opts?.reuseId || randomUUID();
558
+ // When a session is being resumed under the same id, capture its prior
559
+ // structured messages so the new bridge can present them as the chat
560
+ // history. We deliberately do NOT carry over rawOutput — `claude --resume`
561
+ // re-prints its own banner and replayed history into the new PTY, and
562
+ // mixing the two would surface every line twice in the terminal view.
563
+ let priorMessages = [];
617
564
  if (opts?.reuseId) {
618
565
  const oldRecord = this.sessions.get(id);
619
566
  if (oldRecord) {
567
+ priorMessages = oldRecord.ptyBridge?.getMessages() ?? oldRecord.messages ?? [];
620
568
  this.cleanupRecord(oldRecord);
621
569
  this.sessions.delete(id);
622
570
  }
571
+ else {
572
+ priorMessages = this.storage.getSession(id)?.messages ?? [];
573
+ }
623
574
  }
624
575
  const worktreeSetup = opts?.worktreeEnabled
625
576
  ? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
@@ -671,7 +622,7 @@ export class ProcessManager extends EventEmitter {
671
622
  rememberedEscalationScopes: new Set(),
672
623
  rememberedEscalationTargets: new Set(),
673
624
  storedOutput: "",
674
- messages: [],
625
+ messages: priorMessages,
675
626
  childProcess: null,
676
627
  ptyBridge: null,
677
628
  currentTask: null,
@@ -682,6 +633,8 @@ export class ProcessManager extends EventEmitter {
682
633
  knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
683
634
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
684
635
  selectedModel: selectedModel ?? null,
636
+ ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 400) : 120,
637
+ ptyRows: opts?.rows !== undefined ? clampDimension(opts.rows, 10, 160) : 36,
685
638
  };
686
639
  if (isClaudeProvider) {
687
640
  record.ptyBridge = new ClaudePtyBridge({
@@ -689,6 +642,7 @@ export class ProcessManager extends EventEmitter {
689
642
  isClaudeCommand: true,
690
643
  autoApprove: record.autoApprovePermissions,
691
644
  approvalPolicy: record.approvalPolicy,
645
+ initialMessages: priorMessages,
692
646
  });
693
647
  record.ptyBridge.on("event", (event) => {
694
648
  this.handleBridgeEvent(record, event);
@@ -712,8 +666,10 @@ export class ProcessManager extends EventEmitter {
712
666
  WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0"
713
667
  },
714
668
  name: "xterm-color",
715
- cols: 120,
716
- rows: 36
669
+ // 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
670
+ // 期间 Claude/Codex 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
671
+ cols: record.ptyCols,
672
+ rows: record.ptyRows
717
673
  });
718
674
  }
719
675
  catch (err) {
@@ -939,15 +895,13 @@ export class ProcessManager extends EventEmitter {
939
895
  get(id) {
940
896
  const record = this.sessions.get(id);
941
897
  if (!record) {
942
- // Fallback: check SQLite for sessions that were evicted from memory
943
898
  return this.storage.getSession(id) ?? null;
944
899
  }
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.
900
+ const result = this.snapshot(record);
947
901
  if (!record.output && record.storedOutput) {
948
- record.output = record.storedOutput;
902
+ result.output = record.storedOutput;
949
903
  }
950
- return this.snapshot(record);
904
+ return result;
951
905
  }
952
906
  getPtyTranscript(id) {
953
907
  return this.logger.readPtyOutput(id);
@@ -959,12 +913,9 @@ export class ProcessManager extends EventEmitter {
959
913
  */
960
914
  setSessionModel(id, model) {
961
915
  const record = this.mustGet(id);
962
- if (record.provider !== "claude") {
963
- throw new Error("仅 Claude 会话支持切换模型。");
964
- }
965
916
  const normalized = model?.trim() || null;
966
917
  record.selectedModel = normalized;
967
- if (record.status === "running" && record.ptyProcess) {
918
+ if (record.provider === "claude" && record.status === "running" && record.ptyProcess) {
968
919
  const value = normalized && normalized !== "default" ? normalized : "default";
969
920
  record.ptyProcess.write(`/model ${value}\r`);
970
921
  }
@@ -1037,7 +988,20 @@ export class ProcessManager extends EventEmitter {
1037
988
  }
1038
989
  const safeCols = clampDimension(cols, 20, 400);
1039
990
  const safeRows = clampDimension(rows, 10, 160);
991
+ const changed = safeCols !== record.ptyCols || safeRows !== record.ptyRows;
1040
992
  record.ptyProcess.resize(safeCols, safeRows);
993
+ record.ptyCols = safeCols;
994
+ record.ptyRows = safeRows;
995
+ if (changed) {
996
+ // Notify every subscribed client of the new authoritative dimensions so
997
+ // any other tab/device can re-fit its terminal instead of rendering
998
+ // wrap-broken output sized for someone else's viewport.
999
+ this.emitEvent({
1000
+ type: "status",
1001
+ sessionId: id,
1002
+ data: { ptyCols: safeCols, ptyRows: safeRows },
1003
+ });
1004
+ }
1041
1005
  return this.snapshot(record);
1042
1006
  }
1043
1007
  stop(id) {
@@ -1236,6 +1200,8 @@ export class ProcessManager extends EventEmitter {
1236
1200
  summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
1237
1201
  currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
1238
1202
  selectedModel: record.selectedModel ?? null,
1203
+ ptyCols: record.ptyCols,
1204
+ ptyRows: record.ptyRows,
1239
1205
  };
1240
1206
  }
1241
1207
  /** Lightweight snapshot for list views — omits output and messages. */
@@ -1374,70 +1340,6 @@ export class ProcessManager extends EventEmitter {
1374
1340
  }
1375
1341
  this.persist(record);
1376
1342
  }
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
1343
  archiveExpiredSessions() {
1442
1344
  const now = Date.now();
1443
1345
  for (const record of this.sessions.values()) {
@@ -1518,9 +1420,16 @@ export class ProcessManager extends EventEmitter {
1518
1420
  record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1519
1421
  const rawMessages = record.ptyBridge?.getMessages() ?? [];
1520
1422
  const isStreaming = record.status === "running";
1423
+ const bridgeData = event.data;
1521
1424
  const data = {
1522
1425
  permissionBlocked: this.isPermissionBlocked(record),
1523
1426
  };
1427
+ // 透传 bridge 给出的 isResponding(true=流式中, false=本轮已完成)。
1428
+ // 前端用它检测 thinking→idle 边界并主动做一次终端 resync,把 Claude/Codex
1429
+ // 在流式渲染过程中残留的错位光标定位序列洗掉(等价于按一次右上角缩放)。
1430
+ if (bridgeData && typeof bridgeData.isResponding === "boolean") {
1431
+ data.isResponding = bridgeData.isResponding;
1432
+ }
1524
1433
  if (isStreaming && rawMessages.length > 0) {
1525
1434
  data.incremental = true;
1526
1435
  const lastTurn = rawMessages[rawMessages.length - 1];
@@ -1672,13 +1581,18 @@ export class ProcessManager extends EventEmitter {
1672
1581
  }
1673
1582
  processCommandForMode(command, mode, provider, model) {
1674
1583
  if (provider === "codex") {
1675
- if (mode !== "full-access") {
1676
- return command;
1584
+ let result = command;
1585
+ const trimmedModel = model?.trim();
1586
+ if (trimmedModel && trimmedModel !== "default" && !/--model\s/.test(command) && !/-m\s/.test(command)) {
1587
+ const escapedModel = trimmedModel.replace(/'/g, "'\\''");
1588
+ result += ` --model '${escapedModel}'`;
1677
1589
  }
1678
- if (/--dangerously-bypass-approvals-and-sandbox(?:\s|$)/.test(command)) {
1679
- return command;
1590
+ if (mode === "full-access") {
1591
+ if (!/--dangerously-bypass-approvals-and-sandbox(?:\s|$)/.test(result)) {
1592
+ result += " --dangerously-bypass-approvals-and-sandbox";
1593
+ }
1680
1594
  }
1681
- return `${command} --dangerously-bypass-approvals-and-sandbox`;
1595
+ return result;
1682
1596
  }
1683
1597
  const isClaudeCmd = /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command);
1684
1598
  if (!isClaudeCmd)
@@ -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
+ }