@co0ontty/wand 1.15.1 → 1.17.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.
@@ -85,6 +85,8 @@ export declare class ClaudePtyBridge extends EventEmitter {
85
85
  private _exited;
86
86
  private rememberedScopes;
87
87
  private rememberedTargets;
88
+ private lastChatEmitAt;
89
+ private chatEmitTimer;
88
90
  private lastOutputAt;
89
91
  private lastUserInputAt;
90
92
  private idleProbeTimer;
@@ -170,6 +172,7 @@ export declare class ClaudePtyBridge extends EventEmitter {
170
172
  private inferScope;
171
173
  private parseChatResponse;
172
174
  private detectCompletion;
175
+ private static readonly CHAT_THROTTLE_MS;
173
176
  private updateAssistantContent;
174
177
  private finalizeResponse;
175
178
  /**
@@ -7,7 +7,7 @@
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 } from "./pty-text-utils.js";
10
+ import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu } from "./pty-text-utils.js";
11
11
  // ── Constants ──
12
12
  const OUTPUT_MAX_SIZE = 120000;
13
13
  const SESSION_ID_WINDOW_SIZE = 16384;
@@ -69,6 +69,9 @@ export class ClaudePtyBridge extends EventEmitter {
69
69
  // Permission memory for "approve_turn" policy
70
70
  rememberedScopes = new Set();
71
71
  rememberedTargets = new Set();
72
+ // Chat event throttle
73
+ lastChatEmitAt = 0;
74
+ chatEmitTimer = null;
72
75
  // Idle probe state (last-resort robustness)
73
76
  lastOutputAt;
74
77
  lastUserInputAt;
@@ -202,6 +205,11 @@ export class ClaudePtyBridge extends EventEmitter {
202
205
  clearTimeout(this.taskDebounceTimer);
203
206
  this.taskDebounceTimer = null;
204
207
  }
208
+ // Flush pending chat emit
209
+ if (this.chatEmitTimer) {
210
+ clearTimeout(this.chatEmitTimer);
211
+ this.chatEmitTimer = null;
212
+ }
205
213
  // Clear permission state — prevents stale blocked state after exit
206
214
  this.cancelPendingAutoApprove();
207
215
  this.clearIdleProbeTimer();
@@ -655,6 +663,10 @@ export class ClaudePtyBridge extends EventEmitter {
655
663
  this.ptyWrite("\r");
656
664
  }
657
665
  isPermissionPromptDetected(normalized) {
666
+ // Slash-command selection menus (/model, /effort, /output-style …) share
667
+ // "Enter to confirm" with permission prompts but must be left alone.
668
+ if (isSlashCommandMenu(normalized))
669
+ return false;
658
670
  const hasIntent = /\bdo you want to\b/i.test(normalized)
659
671
  || /\bwould you like to\b/i.test(normalized)
660
672
  || /\benter to confirm\b/i.test(normalized)
@@ -737,6 +749,7 @@ export class ClaudePtyBridge extends EventEmitter {
737
749
  }
738
750
  return false;
739
751
  }
752
+ static CHAT_THROTTLE_MS = 80;
740
753
  updateAssistantContent() {
741
754
  const idx = this.chatState.assistantIndex;
742
755
  if (idx === null)
@@ -745,6 +758,27 @@ export class ClaudePtyBridge extends EventEmitter {
745
758
  if (text) {
746
759
  this.messages[idx].content = [{ type: "text", text }];
747
760
  }
761
+ const now = Date.now();
762
+ if (now - this.lastChatEmitAt < ClaudePtyBridge.CHAT_THROTTLE_MS) {
763
+ if (!this.chatEmitTimer) {
764
+ this.chatEmitTimer = setTimeout(() => {
765
+ this.chatEmitTimer = null;
766
+ this.lastChatEmitAt = Date.now();
767
+ this.emitEvent({
768
+ type: "output.chat",
769
+ sessionId: this.sessionId,
770
+ timestamp: Date.now(),
771
+ data: {
772
+ messages: this.messages,
773
+ streamingIndex: this.chatState.assistantIndex,
774
+ isResponding: true,
775
+ },
776
+ });
777
+ }, ClaudePtyBridge.CHAT_THROTTLE_MS);
778
+ }
779
+ return;
780
+ }
781
+ this.lastChatEmitAt = now;
748
782
  this.emitEvent({
749
783
  type: "output.chat",
750
784
  sessionId: this.sessionId,
package/dist/config.js CHANGED
@@ -19,6 +19,7 @@ export const defaultConfig = () => ({
19
19
  language: "",
20
20
  android: defaultAndroidApkConfig(),
21
21
  cardDefaults: defaultCardExpandDefaults(),
22
+ defaultModel: "",
22
23
  commandPresets: [
23
24
  {
24
25
  label: "Claude",
@@ -183,6 +184,7 @@ function mergeWithDefaults(input) {
183
184
  : crypto.randomBytes(32).toString("hex"),
184
185
  android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
185
186
  cardDefaults: normalizeCardDefaults(input.cardDefaults),
187
+ defaultModel: typeof input.defaultModel === "string" ? input.defaultModel.trim() : defaults.defaultModel,
186
188
  };
187
189
  }
188
190
  export function isExecutionMode(value) {
@@ -0,0 +1,13 @@
1
+ import { ClaudeModelInfo } from "./types.js";
2
+ interface ModelCache {
3
+ models: ClaudeModelInfo[];
4
+ claudeVersion: string | null;
5
+ refreshedAt: string;
6
+ }
7
+ export declare function getCachedModels(): ModelCache;
8
+ export declare function refreshModels(): Promise<ModelCache>;
9
+ /** 返回可用于 claude CLI 的全部已知 model id(含别名) */
10
+ export declare function knownModelIds(): string[];
11
+ /** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
12
+ export declare function isKnownModel(_value: string): boolean;
13
+ export {};
package/dist/models.js ADDED
@@ -0,0 +1,54 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execAsync = promisify(exec);
4
+ const BUILT_IN_MODELS = [
5
+ { id: "default", label: "default(跟随 Claude Code 默认)", alias: true },
6
+ { id: "opus", label: "opus(最新 Opus)", alias: true },
7
+ { id: "sonnet", label: "sonnet(最新 Sonnet)", alias: true },
8
+ { id: "haiku", label: "haiku(最新 Haiku)", alias: true },
9
+ { id: "claude-opus-4-7", label: "Opus 4.7 · claude-opus-4-7" },
10
+ { id: "claude-opus-4-6", label: "Opus 4.6 · claude-opus-4-6" },
11
+ { id: "claude-sonnet-4-6", label: "Sonnet 4.6 · claude-sonnet-4-6" },
12
+ { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 · claude-haiku-4-5-20251001" },
13
+ ];
14
+ let cache = null;
15
+ function cloneDefaults() {
16
+ return BUILT_IN_MODELS.map((m) => ({ ...m }));
17
+ }
18
+ async function probeClaudeVersion() {
19
+ try {
20
+ const { stdout } = await execAsync("claude --version", { timeout: 5000 });
21
+ const match = stdout.match(/\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?/);
22
+ return match ? match[0] : stdout.trim().slice(0, 64) || null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ export function getCachedModels() {
29
+ if (!cache) {
30
+ cache = {
31
+ models: cloneDefaults(),
32
+ claudeVersion: null,
33
+ refreshedAt: new Date().toISOString(),
34
+ };
35
+ }
36
+ return cache;
37
+ }
38
+ export async function refreshModels() {
39
+ const version = await probeClaudeVersion();
40
+ cache = {
41
+ models: cloneDefaults(),
42
+ claudeVersion: version,
43
+ refreshedAt: new Date().toISOString(),
44
+ };
45
+ return cache;
46
+ }
47
+ /** 返回可用于 claude CLI 的全部已知 model id(含别名) */
48
+ export function knownModelIds() {
49
+ return BUILT_IN_MODELS.map((m) => m.id);
50
+ }
51
+ /** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
52
+ export function isKnownModel(_value) {
53
+ return true;
54
+ }
@@ -43,6 +43,7 @@ export declare class ProcessManager extends EventEmitter {
43
43
  autoRecovered?: boolean;
44
44
  worktreeEnabled?: boolean;
45
45
  provider?: SessionProvider;
46
+ model?: string;
46
47
  }): SessionSnapshot;
47
48
  list(): SessionSnapshot[];
48
49
  /** Return lightweight snapshots for the session list (no output/messages). */
@@ -57,6 +58,12 @@ export declare class ProcessManager extends EventEmitter {
57
58
  }[]): number;
58
59
  get(id: string): SessionSnapshot | null;
59
60
  getPtyTranscript(id: string): string | null;
61
+ /**
62
+ * Set the Claude model for an existing PTY session. Persists the selection
63
+ * and, when the session is live, pipes a `/model <id>` slash command into
64
+ * the PTY so Claude Code switches on the fly.
65
+ */
66
+ setSessionModel(id: string, model: string | null): SessionSnapshot;
60
67
  sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string): SessionSnapshot;
61
68
  /** Emit a task event for a session, debounced to avoid flooding */
62
69
  private emitTask;
@@ -314,12 +314,14 @@ function readClaudeSessionSummary(filePath, id, cwd) {
314
314
  return null;
315
315
  }
316
316
  }
317
+ const WORKTREE_DIR_PATTERN = /--?\.?(?:wand-worktrees|claude-worktrees)-/;
317
318
  /** Scan all ~/.claude/projects/ directories for session JSONL files. */
318
319
  function listAllClaudeHistorySessions() {
319
320
  const projectsDir = path.join(os.homedir(), ".claude", "projects");
320
321
  try {
321
322
  const projectDirs = readdirSync(projectsDir, { withFileTypes: true })
322
- .filter((entry) => entry.isDirectory());
323
+ .filter((entry) => entry.isDirectory())
324
+ .filter((entry) => !WORKTREE_DIR_PATTERN.test(entry.name));
323
325
  const results = [];
324
326
  for (const dir of projectDirs) {
325
327
  const dirPath = path.join(projectsDir, dir.name);
@@ -620,7 +622,8 @@ export class ProcessManager extends EventEmitter {
620
622
  const provider = opts?.provider ?? resolveProviderFromCommand(command);
621
623
  const effectiveMode = provider === "codex" ? "full-access" : mode;
622
624
  const isClaudeProvider = provider === "claude";
623
- const processedCommand = this.processCommandForMode(command, effectiveMode, provider);
625
+ const selectedModel = opts?.model?.trim() || undefined;
626
+ const processedCommand = this.processCommandForMode(command, effectiveMode, provider, selectedModel);
624
627
  const resumeCommandSessionId = isClaudeProvider
625
628
  ? getResumeCommandSessionId(processedCommand) ?? getResumeCommandSessionId(command)
626
629
  : null;
@@ -671,7 +674,8 @@ export class ProcessManager extends EventEmitter {
671
674
  knownClaudeTaskIds: knownClaudeTaskIds ?? undefined,
672
675
  claudeTaskDiscoveryTimer: null,
673
676
  knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
674
- approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
677
+ approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
678
+ selectedModel: selectedModel ?? null,
675
679
  };
676
680
  if (isClaudeProvider) {
677
681
  record.ptyBridge = new ClaudePtyBridge({
@@ -945,6 +949,26 @@ export class ProcessManager extends EventEmitter {
945
949
  getPtyTranscript(id) {
946
950
  return this.logger.readPtyOutput(id);
947
951
  }
952
+ /**
953
+ * Set the Claude model for an existing PTY session. Persists the selection
954
+ * and, when the session is live, pipes a `/model <id>` slash command into
955
+ * the PTY so Claude Code switches on the fly.
956
+ */
957
+ setSessionModel(id, model) {
958
+ const record = this.mustGet(id);
959
+ if (record.provider !== "claude") {
960
+ throw new Error("仅 Claude 会话支持切换模型。");
961
+ }
962
+ const normalized = model?.trim() || null;
963
+ record.selectedModel = normalized;
964
+ if (record.status === "running" && record.ptyProcess) {
965
+ const value = normalized && normalized !== "default" ? normalized : "default";
966
+ record.ptyProcess.write(`/model ${value}\r`);
967
+ }
968
+ this.persist(record);
969
+ this.emitEvent({ type: "status", sessionId: id, data: { selectedModel: normalized } });
970
+ return this.snapshot(record);
971
+ }
948
972
  sendInput(id, input, view, shortcutKey) {
949
973
  const record = this.mustGet(id);
950
974
  if (record.status !== "running") {
@@ -974,11 +998,7 @@ export class ProcessManager extends EventEmitter {
974
998
  if (record.ptyBridge) {
975
999
  record.ptyBridge.onUserInput(input);
976
1000
  }
977
- // Ensure input advances to a new line so subsequent PTY output doesn't overwrite it
978
1001
  record.ptyProcess.write(input);
979
- if (view !== "terminal" && !input.endsWith("\n")) {
980
- record.ptyProcess.write("\n");
981
- }
982
1002
  this.persist(record);
983
1003
  return this.snapshot(record);
984
1004
  }
@@ -1180,6 +1200,7 @@ export class ProcessManager extends EventEmitter {
1180
1200
  approvalStats: record.approvalStats.total > 0 ? record.approvalStats : undefined,
1181
1201
  summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
1182
1202
  currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
1203
+ selectedModel: record.selectedModel ?? null,
1183
1204
  };
1184
1205
  }
1185
1206
  /** Lightweight snapshot for list views — omits output and messages. */
@@ -1453,9 +1474,20 @@ export class ProcessManager extends EventEmitter {
1453
1474
  */
1454
1475
  handleBridgeEvent(record, event) {
1455
1476
  switch (event.type) {
1456
- case "output.raw":
1477
+ case "output.raw": {
1478
+ record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1479
+ this.emitEvent({
1480
+ type: "output",
1481
+ sessionId: event.sessionId,
1482
+ data: {
1483
+ incremental: true,
1484
+ chunk: event.data.chunk,
1485
+ permissionBlocked: this.isPermissionBlocked(record),
1486
+ },
1487
+ });
1488
+ break;
1489
+ }
1457
1490
  case "output.chat": {
1458
- // Sync record.output from bridge before emitting so the event carries fresh data
1459
1491
  record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1460
1492
  const rawMessages = record.ptyBridge?.getMessages() ?? [];
1461
1493
  const isStreaming = record.status === "running";
@@ -1463,7 +1495,6 @@ export class ProcessManager extends EventEmitter {
1463
1495
  permissionBlocked: this.isPermissionBlocked(record),
1464
1496
  };
1465
1497
  if (isStreaming && rawMessages.length > 0) {
1466
- // Incremental mode: send only chunk + last (streaming) turn
1467
1498
  data.incremental = true;
1468
1499
  const lastTurn = rawMessages[rawMessages.length - 1];
1469
1500
  const truncatedLast = truncateMessagesForTransport([lastTurn], this.config.cardDefaults ?? {}, 0);
@@ -1471,13 +1502,9 @@ export class ProcessManager extends EventEmitter {
1471
1502
  data.messageCount = rawMessages.length;
1472
1503
  }
1473
1504
  else {
1474
- // Full mode: non-streaming or empty messages
1475
1505
  data.output = record.output;
1476
1506
  data.messages = truncateMessagesForTransport(rawMessages, this.config.cardDefaults ?? {}, rawMessages.length - 1);
1477
1507
  }
1478
- if (event.type === "output.raw") {
1479
- data.chunk = event.data.chunk;
1480
- }
1481
1508
  this.emitEvent({
1482
1509
  type: "output",
1483
1510
  sessionId: event.sessionId,
@@ -1618,7 +1645,7 @@ export class ProcessManager extends EventEmitter {
1618
1645
  }
1619
1646
  return false;
1620
1647
  }
1621
- processCommandForMode(command, mode, provider) {
1648
+ processCommandForMode(command, mode, provider, model) {
1622
1649
  if (provider === "codex") {
1623
1650
  if (mode !== "full-access") {
1624
1651
  return command;
@@ -1632,6 +1659,11 @@ export class ProcessManager extends EventEmitter {
1632
1659
  if (!isClaudeCmd)
1633
1660
  return command;
1634
1661
  let result = command;
1662
+ const trimmedModel = model?.trim();
1663
+ if (trimmedModel && trimmedModel !== "default" && !/--model\s/.test(command)) {
1664
+ const escapedModel = trimmedModel.replace(/'/g, "'\\''");
1665
+ result += ` --model '${escapedModel}'`;
1666
+ }
1635
1667
  const hasPermFlag = /--permission-mode\s/.test(command);
1636
1668
  if (!hasPermFlag) {
1637
1669
  if (isRunningAsRoot()) {
@@ -11,6 +11,13 @@ export declare function isNoiseLine(line: string): boolean;
11
11
  export declare function appendWindow(buffer: string, chunk: string, maxSize: number): string;
12
12
  export declare function hasExplicitConfirmSyntax(normalized: string): boolean;
13
13
  export declare function hasPermissionActionContext(normalized: string): boolean;
14
+ /**
15
+ * Detect Claude CLI slash-command selection menus (/model, /effort, /output-style, etc.).
16
+ * These share "Enter to confirm" with permission prompts but are user-driven choices
17
+ * that must never be auto-approved. Distinguishing footer: "Esc to exit" (vs permission
18
+ * prompts' "Esc to cancel" / "Tab to amend").
19
+ */
20
+ export declare function isSlashCommandMenu(normalized: string): boolean;
14
21
  interface PermissionScore {
15
22
  score: number;
16
23
  matched: string[];
@@ -144,6 +144,15 @@ export function hasExplicitConfirmSyntax(normalized) {
144
144
  export function hasPermissionActionContext(normalized) {
145
145
  return PERMISSION_ACTION_PATTERNS.some((pattern) => pattern.test(normalized));
146
146
  }
147
+ /**
148
+ * Detect Claude CLI slash-command selection menus (/model, /effort, /output-style, etc.).
149
+ * These share "Enter to confirm" with permission prompts but are user-driven choices
150
+ * that must never be auto-approved. Distinguishing footer: "Esc to exit" (vs permission
151
+ * prompts' "Esc to cancel" / "Tab to amend").
152
+ */
153
+ export function isSlashCommandMenu(normalized) {
154
+ return /\besc\s+to\s+exit\b/i.test(normalized);
155
+ }
147
156
  const PERMISSION_KEYWORD_WEIGHTS = [
148
157
  { pattern: /\bdo you want to proceed\b/i, weight: 5, label: "do you want to proceed" },
149
158
  { pattern: /\bwould you like to proceed\b/i, weight: 5, label: "would you like to proceed" },
@@ -168,6 +177,11 @@ export function scorePermissionLikelihood(normalized) {
168
177
  // Take the last ~5 lines
169
178
  const lines = normalized.split("\n");
170
179
  const tail = lines.slice(-8).join("\n");
180
+ // Slash-command menus are never permission prompts — zero the score so
181
+ // fallback auto-approve and idle-probe both skip them.
182
+ if (isSlashCommandMenu(tail)) {
183
+ return { score: 0, matched: [] };
184
+ }
171
185
  let score = 0;
172
186
  const matched = [];
173
187
  for (const { pattern, weight, label } of PERMISSION_KEYWORD_WEIGHTS) {
package/dist/pwa.js CHANGED
@@ -53,10 +53,8 @@ const STATIC_ASSETS = [
53
53
  '/icon.svg',
54
54
  '/icon-192.png',
55
55
  '/icon-512.png',
56
- '/vendor/xterm/css/xterm.css',
57
- '/vendor/xterm/lib/xterm.js',
58
- '/vendor/xterm-addon-fit/lib/addon-fit.js',
59
- '/vendor/xterm-addon-serialize/lib/xterm-addon-serialize.js'
56
+ '/vendor/wterm/terminal.css',
57
+ '/vendor/wterm/wterm.bundle.js'
60
58
  ];
61
59
 
62
60
  self.addEventListener('install', (event) => {
@@ -144,7 +144,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
144
144
  });
145
145
  app.post("/api/structured-sessions", express.json(), async (req, res) => {
146
146
  const body = req.body;
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 }));
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 }));
148
148
  try {
149
149
  if (body.provider && body.provider !== "claude") {
150
150
  res.status(400).json({ error: "结构化会话当前仅支持 Claude provider。" });
@@ -156,6 +156,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
156
156
  prompt: body.prompt,
157
157
  runner: body.runner ?? "claude-cli-print",
158
158
  worktreeEnabled: body.worktreeEnabled === true,
159
+ model: typeof body.model === "string" ? body.model.trim() : undefined,
159
160
  });
160
161
  console.log("[WAND] structured session created:", JSON.stringify({ id: snapshot.id, sessionKind: snapshot.sessionKind, runner: snapshot.runner, status: snapshot.status }));
161
162
  res.status(201).json(snapshot);
@@ -164,6 +165,29 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
164
165
  res.status(400).json({ error: getErrorMessage(error, "无法启动结构化会话。") });
165
166
  }
166
167
  });
168
+ app.post("/api/sessions/:id/model", express.json(), (req, res) => {
169
+ const body = req.body;
170
+ const rawModel = typeof body?.model === "string" ? body.model.trim() : null;
171
+ const id = req.params.id;
172
+ try {
173
+ const structuredSnapshot = structured.get(id);
174
+ if (structuredSnapshot) {
175
+ const updated = structured.setSessionModel(id, rawModel);
176
+ res.json(updated);
177
+ return;
178
+ }
179
+ const ptySnapshot = processes.get(id);
180
+ if (!ptySnapshot) {
181
+ res.status(404).json({ error: "未找到该会话。" });
182
+ return;
183
+ }
184
+ const updated = processes.setSessionModel(id, rawModel);
185
+ res.json(updated);
186
+ }
187
+ catch (error) {
188
+ res.status(400).json({ error: getErrorMessage(error, "切换模型失败。") });
189
+ }
190
+ });
167
191
  app.get("/api/structured-sessions/:id/messages", (req, res) => {
168
192
  const snapshot = structured.get(req.params.id);
169
193
  if (!snapshot) {