@co0ontty/wand 1.20.4 → 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.
@@ -179,6 +179,14 @@ export declare class ClaudePtyBridge extends EventEmitter {
179
179
  * Find the end index of the echoed user input in the PTY buffer.
180
180
  * The echo may contain ANSI codes between characters.
181
181
  * Returns the index after the last character of the echo.
182
+ *
183
+ * Matching strategy:
184
+ * - Keep every printable codepoint of `userInput` (anything that is not a
185
+ * control char or whitespace) for comparison. The previous version dropped
186
+ * common symbols like `/`, `(`, `:`, space — which made commands such as
187
+ * `ls /tmp` mismatch and start parsing the chat response from a wrong offset.
188
+ * - In the buffer, skip ANSI escape sequences entirely, and skip whitespace
189
+ * so wrapped echoes (line continuation, padded columns) still align.
182
190
  */
183
191
  private findEchoEndIndex;
184
192
  private cleanForChat;
@@ -7,9 +7,14 @@
7
7
  * 2. Structured messages for chat view (parsed)
8
8
  */
9
9
  import { EventEmitter } from "node:events";
10
- import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu } from "./pty-text-utils.js";
10
+ import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu, stripForEchoMatch, skipAnsiSequence } from "./pty-text-utils.js";
11
11
  // ── Constants ──
12
- const OUTPUT_MAX_SIZE = 120000;
12
+ /**
13
+ * Hard cap on the in-memory PTY replay buffer. Aligned with the non-bridge
14
+ * branch of `ProcessManager.start()` so a session keeps the same amount of
15
+ * history regardless of which capture path is active.
16
+ */
17
+ const OUTPUT_MAX_SIZE = 200000;
13
18
  const SESSION_ID_WINDOW_SIZE = 16384;
14
19
  const PERMISSION_WINDOW_SIZE = 2000;
15
20
  const AUTO_APPROVE_DELAY_MS = 350;
@@ -832,27 +837,45 @@ export class ClaudePtyBridge extends EventEmitter {
832
837
  * Find the end index of the echoed user input in the PTY buffer.
833
838
  * The echo may contain ANSI codes between characters.
834
839
  * Returns the index after the last character of the echo.
840
+ *
841
+ * Matching strategy:
842
+ * - Keep every printable codepoint of `userInput` (anything that is not a
843
+ * control char or whitespace) for comparison. The previous version dropped
844
+ * common symbols like `/`, `(`, `:`, space — which made commands such as
845
+ * `ls /tmp` mismatch and start parsing the chat response from a wrong offset.
846
+ * - In the buffer, skip ANSI escape sequences entirely, and skip whitespace
847
+ * so wrapped echoes (line continuation, padded columns) still align.
835
848
  */
836
849
  findEchoEndIndex(buffer, userInput) {
837
- // Keep alphanumeric and common symbols for matching
838
- const inputChars = userInput.replace(/[^a-zA-Z0-9+=?!\-]/g, "");
850
+ const inputChars = stripForEchoMatch(userInput);
839
851
  if (inputChars.length === 0)
840
852
  return 0;
841
853
  let matchedChars = 0;
842
854
  let endIndex = 0;
843
- for (let i = 0; i < buffer.length && matchedChars < inputChars.length; i++) {
855
+ let i = 0;
856
+ while (i < buffer.length && matchedChars < inputChars.length) {
844
857
  const ch = buffer[i];
845
- // Check if this printable char matches the next expected char
846
- if (/[a-zA-Z0-9+=?!\-]/.test(ch) && ch.toLowerCase() === inputChars[matchedChars].toLowerCase()) {
858
+ const code = ch.charCodeAt(0);
859
+ // Skip a complete ANSI escape sequence (CSI/OSC/etc.).
860
+ if (code === 0x1b) {
861
+ i = skipAnsiSequence(buffer, i);
862
+ continue;
863
+ }
864
+ // Skip control chars and whitespace — they do not appear in `inputChars`.
865
+ if (code < 0x20 || code === 0x7f || code === 0x20) {
866
+ i++;
867
+ continue;
868
+ }
869
+ if (ch.toLowerCase() === inputChars[matchedChars].toLowerCase()) {
847
870
  matchedChars++;
848
871
  endIndex = i + 1;
849
872
  }
850
- // Skip ANSI codes and other non-matching characters
873
+ i++;
851
874
  }
852
875
  // Look for a newline or prompt marker after the echo
853
- for (let i = endIndex; i < buffer.length && i < endIndex + 50; i++) {
854
- if (buffer[i] === "\n" || buffer[i] === "\r") {
855
- endIndex = i + 1;
876
+ for (let j = endIndex; j < buffer.length && j < endIndex + 50; j++) {
877
+ if (buffer[j] === "\n" || buffer[j] === "\r") {
878
+ endIndex = j + 1;
856
879
  break;
857
880
  }
858
881
  }
@@ -341,9 +341,19 @@ export async function runQuickCommit(opts) {
341
341
  if (push) {
342
342
  try {
343
343
  let hasUpstream = false;
344
+ let pushRemote = "origin";
344
345
  try {
345
346
  runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
346
347
  hasUpstream = true;
348
+ try {
349
+ const currentBranch = runGit(["branch", "--show-current"], cwd);
350
+ if (currentBranch) {
351
+ pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
352
+ }
353
+ }
354
+ catch {
355
+ pushRemote = "origin";
356
+ }
347
357
  }
348
358
  catch {
349
359
  hasUpstream = false;
@@ -352,11 +362,9 @@ export async function runQuickCommit(opts) {
352
362
  runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
353
363
  }
354
364
  else {
355
- runGit(["push", "-u", "--recurse-submodules=on-demand", "origin", "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
356
- }
357
- if (tagName) {
358
- runGit(["push", "origin", `refs/tags/${tagName}`], cwd, GIT_PUSH_TIMEOUT_MS);
365
+ runGit(["push", "-u", "--recurse-submodules=on-demand", pushRemote, "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
359
366
  }
367
+ runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
360
368
  pushed = true;
361
369
  }
362
370
  catch (error) {
package/dist/models.d.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { ClaudeModelInfo } from "./types.js";
1
+ import { ClaudeModelInfo, SessionProvider } from "./types.js";
2
2
  interface ModelCache {
3
3
  models: ClaudeModelInfo[];
4
+ codexModels: ClaudeModelInfo[];
4
5
  claudeVersion: string | null;
5
6
  refreshedAt: string;
6
7
  }
7
8
  export declare function getCachedModels(): ModelCache;
8
9
  export declare function refreshModels(): Promise<ModelCache>;
10
+ export declare function getModelsForProvider(provider: SessionProvider): ClaudeModelInfo[];
9
11
  /** 返回可用于 claude CLI 的全部已知 model id(含别名) */
10
12
  export declare function knownModelIds(): string[];
11
13
  /** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
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) {
@@ -50,6 +50,8 @@ export declare class ProcessManager extends EventEmitter {
50
50
  provider?: SessionProvider;
51
51
  model?: string;
52
52
  reuseId?: string;
53
+ cols?: number;
54
+ rows?: number;
53
55
  }): SessionSnapshot;
54
56
  list(): SessionSnapshot[];
55
57
  /** Return lightweight snapshots for the session list (no output/messages). */
@@ -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,7 +129,19 @@ function selectClaudeProjectSessionForRecord(record) {
125
129
  if (!hasUserTurn) {
126
130
  return null;
127
131
  }
128
- return candidates[0] ?? null;
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
+ return null;
142
+ }
143
+ // Fallback: existing file that grew. Only bind if a single grown candidate.
144
+ return candidates.length === 1 ? candidates[0] : null;
129
145
  }
130
146
  function getLatestClaudeProjectSessionId(record) {
131
147
  return selectClaudeProjectSessionForRecord(record)?.id ?? null;
@@ -407,7 +423,10 @@ export class ProcessManager extends EventEmitter {
407
423
  confirmWindow: "",
408
424
  ptyPermissionBlocked: false,
409
425
  lastAutoConfirmAt: 0,
410
- 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),
411
430
  pendingEscalation: snapshot.pendingEscalation ?? null,
412
431
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
413
432
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -426,7 +445,9 @@ export class ProcessManager extends EventEmitter {
426
445
  claudeTaskDiscoveryTimer: null,
427
446
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
428
447
  claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
429
- 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,
430
451
  });
431
452
  this.orphanRecoveredCount += 1;
432
453
  }
@@ -440,7 +461,8 @@ export class ProcessManager extends EventEmitter {
440
461
  confirmWindow: "",
441
462
  ptyPermissionBlocked: false,
442
463
  lastAutoConfirmAt: 0,
443
- autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
464
+ autoApprovePermissions: snapshot.autoApprovePermissions
465
+ ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
444
466
  pendingEscalation: snapshot.pendingEscalation ?? null,
445
467
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
446
468
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -459,7 +481,9 @@ export class ProcessManager extends EventEmitter {
459
481
  claudeTaskDiscoveryTimer: null,
460
482
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
461
483
  claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId,
462
- 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,
463
487
  });
464
488
  }
465
489
  }
@@ -531,12 +555,22 @@ export class ProcessManager extends EventEmitter {
531
555
  ? path.resolve(process.cwd(), cwd)
532
556
  : path.resolve(process.cwd(), this.config.defaultCwd);
533
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 = [];
534
564
  if (opts?.reuseId) {
535
565
  const oldRecord = this.sessions.get(id);
536
566
  if (oldRecord) {
567
+ priorMessages = oldRecord.ptyBridge?.getMessages() ?? oldRecord.messages ?? [];
537
568
  this.cleanupRecord(oldRecord);
538
569
  this.sessions.delete(id);
539
570
  }
571
+ else {
572
+ priorMessages = this.storage.getSession(id)?.messages ?? [];
573
+ }
540
574
  }
541
575
  const worktreeSetup = opts?.worktreeEnabled
542
576
  ? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
@@ -588,7 +622,7 @@ export class ProcessManager extends EventEmitter {
588
622
  rememberedEscalationScopes: new Set(),
589
623
  rememberedEscalationTargets: new Set(),
590
624
  storedOutput: "",
591
- messages: [],
625
+ messages: priorMessages,
592
626
  childProcess: null,
593
627
  ptyBridge: null,
594
628
  currentTask: null,
@@ -599,6 +633,8 @@ export class ProcessManager extends EventEmitter {
599
633
  knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
600
634
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
601
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,
602
638
  };
603
639
  if (isClaudeProvider) {
604
640
  record.ptyBridge = new ClaudePtyBridge({
@@ -606,6 +642,7 @@ export class ProcessManager extends EventEmitter {
606
642
  isClaudeCommand: true,
607
643
  autoApprove: record.autoApprovePermissions,
608
644
  approvalPolicy: record.approvalPolicy,
645
+ initialMessages: priorMessages,
609
646
  });
610
647
  record.ptyBridge.on("event", (event) => {
611
648
  this.handleBridgeEvent(record, event);
@@ -629,8 +666,10 @@ export class ProcessManager extends EventEmitter {
629
666
  WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0"
630
667
  },
631
668
  name: "xterm-color",
632
- cols: 120,
633
- rows: 36
669
+ // 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
670
+ // 期间 Claude/Codex 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
671
+ cols: record.ptyCols,
672
+ rows: record.ptyRows
634
673
  });
635
674
  }
636
675
  catch (err) {
@@ -874,12 +913,9 @@ export class ProcessManager extends EventEmitter {
874
913
  */
875
914
  setSessionModel(id, model) {
876
915
  const record = this.mustGet(id);
877
- if (record.provider !== "claude") {
878
- throw new Error("仅 Claude 会话支持切换模型。");
879
- }
880
916
  const normalized = model?.trim() || null;
881
917
  record.selectedModel = normalized;
882
- if (record.status === "running" && record.ptyProcess) {
918
+ if (record.provider === "claude" && record.status === "running" && record.ptyProcess) {
883
919
  const value = normalized && normalized !== "default" ? normalized : "default";
884
920
  record.ptyProcess.write(`/model ${value}\r`);
885
921
  }
@@ -952,7 +988,20 @@ export class ProcessManager extends EventEmitter {
952
988
  }
953
989
  const safeCols = clampDimension(cols, 20, 400);
954
990
  const safeRows = clampDimension(rows, 10, 160);
991
+ const changed = safeCols !== record.ptyCols || safeRows !== record.ptyRows;
955
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
+ }
956
1005
  return this.snapshot(record);
957
1006
  }
958
1007
  stop(id) {
@@ -1151,6 +1200,8 @@ export class ProcessManager extends EventEmitter {
1151
1200
  summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
1152
1201
  currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
1153
1202
  selectedModel: record.selectedModel ?? null,
1203
+ ptyCols: record.ptyCols,
1204
+ ptyRows: record.ptyRows,
1154
1205
  };
1155
1206
  }
1156
1207
  /** Lightweight snapshot for list views — omits output and messages. */
@@ -1369,9 +1420,16 @@ export class ProcessManager extends EventEmitter {
1369
1420
  record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1370
1421
  const rawMessages = record.ptyBridge?.getMessages() ?? [];
1371
1422
  const isStreaming = record.status === "running";
1423
+ const bridgeData = event.data;
1372
1424
  const data = {
1373
1425
  permissionBlocked: this.isPermissionBlocked(record),
1374
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
+ }
1375
1433
  if (isStreaming && rawMessages.length > 0) {
1376
1434
  data.incremental = true;
1377
1435
  const lastTurn = rawMessages[rawMessages.length - 1];
@@ -1523,13 +1581,18 @@ export class ProcessManager extends EventEmitter {
1523
1581
  }
1524
1582
  processCommandForMode(command, mode, provider, model) {
1525
1583
  if (provider === "codex") {
1526
- if (mode !== "full-access") {
1527
- 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}'`;
1528
1589
  }
1529
- if (/--dangerously-bypass-approvals-and-sandbox(?:\s|$)/.test(command)) {
1530
- 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
+ }
1531
1594
  }
1532
- return `${command} --dangerously-bypass-approvals-and-sandbox`;
1595
+ return result;
1533
1596
  }
1534
1597
  const isClaudeCmd = /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command);
1535
1598
  if (!isClaudeCmd)
@@ -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,