@co0ontty/wand 1.6.1 → 1.7.0

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.
package/dist/config.js CHANGED
@@ -79,6 +79,28 @@ export async function saveConfig(configPath, config) {
79
79
  await mkdir(path.dirname(configPath), { recursive: true });
80
80
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
81
81
  }
82
+ function normalizeStructuredChatPersona(input) {
83
+ if (!input || typeof input !== "object")
84
+ return undefined;
85
+ const normalizeRole = (roleInput) => {
86
+ if (!roleInput || typeof roleInput !== "object")
87
+ return undefined;
88
+ const role = roleInput;
89
+ const normalized = {
90
+ name: typeof role.name === "string" ? role.name.trim() : undefined,
91
+ avatar: typeof role.avatar === "string" ? role.avatar.trim() : undefined,
92
+ };
93
+ if (!normalized.name && !normalized.avatar)
94
+ return undefined;
95
+ return normalized;
96
+ };
97
+ const personaInput = input;
98
+ const user = normalizeRole(personaInput.user);
99
+ const assistant = normalizeRole(personaInput.assistant);
100
+ if (!user && !assistant)
101
+ return undefined;
102
+ return { user, assistant };
103
+ }
82
104
  function mergeWithDefaults(input) {
83
105
  const defaults = defaultConfig();
84
106
  return {
@@ -108,6 +130,7 @@ function mergeWithDefaults(input) {
108
130
  mode: isExecutionMode(preset.mode) ? preset.mode : undefined
109
131
  }))
110
132
  : defaults.commandPresets,
133
+ structuredChatPersona: normalizeStructuredChatPersona(input.structuredChatPersona),
111
134
  language: typeof input.language === "string" ? input.language.trim() : defaults.language,
112
135
  };
113
136
  }
@@ -0,0 +1,12 @@
1
+ import { SessionSnapshot } from "./types.js";
2
+ interface WorktreeSetupOptions {
3
+ cwd: string;
4
+ sessionId: string;
5
+ }
6
+ interface WorktreeSetupResult {
7
+ cwd: string;
8
+ worktreeEnabled: boolean;
9
+ worktree: NonNullable<SessionSnapshot["worktree"]>;
10
+ }
11
+ export declare function prepareSessionWorktree(options: WorktreeSetupOptions): WorktreeSetupResult;
12
+ export {};
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import path from "node:path";
4
+ function runGit(args, cwd) {
5
+ return execFileSync("git", args, {
6
+ cwd,
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ }).trim();
10
+ }
11
+ function sanitizeBranchSegment(value) {
12
+ return value
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9._-]+/g, "-")
15
+ .replace(/^-+|-+$/g, "")
16
+ .slice(0, 48) || "session";
17
+ }
18
+ function getCurrentBranch(repoRoot) {
19
+ const branch = runGit(["branch", "--show-current"], repoRoot);
20
+ return branch || "master";
21
+ }
22
+ export function prepareSessionWorktree(options) {
23
+ const resolvedCwd = path.resolve(options.cwd);
24
+ const repoRoot = runGit(["rev-parse", "--show-toplevel"], resolvedCwd);
25
+ if (!repoRoot || !existsSync(repoRoot)) {
26
+ throw new Error("当前目录不在 git 仓库中,无法启用 worktree 模式。");
27
+ }
28
+ const baseBranch = getCurrentBranch(repoRoot);
29
+ const branchSuffix = sanitizeBranchSegment(options.sessionId.split("-")[0] || options.sessionId);
30
+ const branchName = `wand/${sanitizeBranchSegment(baseBranch)}-${branchSuffix}`;
31
+ const worktreesRoot = path.join(repoRoot, ".wand-worktrees");
32
+ const worktreePath = path.join(worktreesRoot, branchName.replace(/\//g, "-"));
33
+ mkdirSync(worktreesRoot, { recursive: true });
34
+ runGit(["worktree", "add", "-b", branchName, worktreePath, "HEAD"], repoRoot);
35
+ return {
36
+ cwd: worktreePath,
37
+ worktreeEnabled: true,
38
+ worktree: {
39
+ branch: branchName,
40
+ path: worktreePath,
41
+ },
42
+ };
43
+ }
@@ -1,2 +1,2 @@
1
1
  import type { ChatMessage } from "./types.js";
2
- export declare function parseMessages(output: string): ChatMessage[];
2
+ export declare function parseMessages(output: string, command?: string): ChatMessage[];
@@ -1,8 +1,282 @@
1
1
  import { stripAnsi, isNoiseLine } from "./pty-text-utils.js";
2
- export function parseMessages(output) {
2
+ function isCodexCommand(command) {
3
+ return /^codex\b/.test((command ?? "").trim());
4
+ }
5
+ const CODEX_FOOTER_RE = /\bgpt-\d+(?:\.\d+)?(?:\s+[a-z0-9.-]+)?\s+·\s+\d+%\s+left\s+·\s+(?:\/|~\/).+/i;
6
+ const CODEX_ACTIVITY_RE = /^(?:thinking|working|running|planning|applying|reading|searching|inspecting|reviewing|summarizing|editing|updating|writing|completed)\b/i;
7
+ function stripCodexSegment(raw) {
8
+ return raw
9
+ .replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, "")
10
+ .replace(/\x1b\[(\d+)C/g, (_match, count) => " ".repeat(Number(count) || 1))
11
+ .replace(/\x1b\[[0-9;?]*[AB]/g, "\n")
12
+ .replace(/\x1b\[[0-9;?]*[su]/g, "")
13
+ .replace(/\x1b\[[0-9;?]*[HfJKr]/g, "\n")
14
+ .replace(/\x1bM/g, "\n")
15
+ .replace(/\x1b\[[0-9;?]*[ST]/g, "\n")
16
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
17
+ .replace(/\x1b[><=ePX^_]/g, "")
18
+ .replace(/[\u00a0\u200b-\u200d\ufeff]/g, " ")
19
+ // eslint-disable-next-line no-control-regex
20
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
21
+ .replace(/[ \t]+\n/g, "\n");
22
+ }
23
+ function normalizeCodexText(text) {
24
+ return text
25
+ .replace(/\s+/g, " ")
26
+ .replace(/[M]+$/g, "")
27
+ .trim();
28
+ }
29
+ function isLikelyAssistantTailArtifact(longer, shorter) {
30
+ if (!longer.startsWith(shorter))
31
+ return false;
32
+ const suffix = longer.slice(shorter.length);
33
+ return /^[a-z]{1,4}$/i.test(suffix);
34
+ }
35
+ function parseCodexMessages(output) {
36
+ const messages = [];
37
+ function normalizeCodexAssistantLine(line) {
38
+ return line
39
+ .replace(/^[•◦·]\s*/, "")
40
+ .replace(/^⏺\s+/, "")
41
+ .replace(/^│\s*/, "")
42
+ .trim();
43
+ }
44
+ function normalizeCodexPromptLine(line) {
45
+ return line
46
+ .replace(/^›\s*/, "")
47
+ .replace(/^>\s*/, "")
48
+ .trim();
49
+ }
50
+ function shouldIgnoreCodexLine(line) {
51
+ const trimmed = line.trim();
52
+ if (!trimmed)
53
+ return true;
54
+ if (isNoiseLine(trimmed))
55
+ return true;
56
+ if (CODEX_FOOTER_RE.test(trimmed))
57
+ return true;
58
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]/.test(trimmed))
59
+ return true;
60
+ if (/^\[>[0-9;?]*u$/i.test(trimmed))
61
+ return true;
62
+ if (/^M+$/i.test(trimmed))
63
+ return true;
64
+ if (/^(?:OpenAI Codex|Codex)\b/i.test(trimmed))
65
+ return true;
66
+ if (/^(?:tokens?|context window|remaining context|approvals?|sandbox|provider|session id):\s*/i.test(trimmed))
67
+ return true;
68
+ if (/^(?:thinking|working)\s*(?:\.\.\.|…)?$/i.test(trimmed))
69
+ return true;
70
+ if (/^[•◦·]\s+(?:thinking|working|running|planning|applying|reading|searching|inspecting|reviewing|summarizing|editing|updating|writing|completed)\b/i.test(trimmed))
71
+ return true;
72
+ if (/^(?:model|directory|tip|context|cwd|path):\s+/i.test(trimmed))
73
+ return true;
74
+ return false;
75
+ }
76
+ function extractCodexPromptCandidate(line) {
77
+ const trimmed = line.trim();
78
+ if (!/^›(?:\s|$)/.test(trimmed))
79
+ return null;
80
+ if (CODEX_FOOTER_RE.test(trimmed))
81
+ return null;
82
+ const prompt = normalizeCodexText(normalizeCodexPromptLine(trimmed));
83
+ if (!prompt || shouldIgnoreCodexLine(prompt))
84
+ return null;
85
+ return prompt;
86
+ }
87
+ function extractCodexAssistantCandidate(line) {
88
+ const trimmed = line.trim();
89
+ if (!/^[•◦·⏺]/.test(trimmed))
90
+ return null;
91
+ let assistant = normalizeCodexAssistantLine(trimmed);
92
+ if (!assistant || /^[•◦·⏺]$/.test(assistant))
93
+ return null;
94
+ assistant = assistant.replace(/\s*\(\d+[smh]?\s*•\s*esc to interrupt\)[\s\S]*$/i, "");
95
+ assistant = assistant.replace(/(?:[a-z]{1,6})?›[\s\S]*$/, "");
96
+ assistant = assistant.replace(/\s{2,}gpt-\d[\s\S]*$/i, "");
97
+ assistant = assistant.replace(/\b(?:OpenAI Codex|model:|directory:|Tip:)\b[\s\S]*$/i, "");
98
+ assistant = normalizeCodexText(assistant);
99
+ if (!assistant || assistant.length < 2 || CODEX_ACTIVITY_RE.test(assistant) || shouldIgnoreCodexLine(assistant)) {
100
+ return null;
101
+ }
102
+ return assistant;
103
+ }
104
+ function extractCodexEchoCandidate(line) {
105
+ const trimmed = normalizeCodexText(line);
106
+ if (!trimmed || shouldIgnoreCodexLine(trimmed))
107
+ return null;
108
+ if (/^[•◦·⏺›]/.test(trimmed))
109
+ return null;
110
+ if (/^[\[\]<>0-9;?]+u?$/i.test(trimmed))
111
+ return null;
112
+ if (/^[╭╰│┌└┐┘├┤┬┴┼─═]/.test(trimmed))
113
+ return null;
114
+ if (trimmed.length > 500)
115
+ return null;
116
+ return trimmed;
117
+ }
118
+ function collectCodexCandidates() {
119
+ const candidates = [];
120
+ let order = 0;
121
+ for (const rawSegment of output.replace(/\r\n?/g, "\n").split("\n")) {
122
+ const cleanedSegment = stripCodexSegment(rawSegment);
123
+ const pieces = cleanedSegment.split("\n");
124
+ for (const piece of pieces) {
125
+ const line = piece.trim();
126
+ if (!line)
127
+ continue;
128
+ const prompt = extractCodexPromptCandidate(line);
129
+ if (prompt) {
130
+ candidates.push({ kind: "user", order, text: prompt });
131
+ order += 1;
132
+ continue;
133
+ }
134
+ const assistant = extractCodexAssistantCandidate(line);
135
+ if (assistant) {
136
+ candidates.push({ kind: "assistant", order, text: assistant });
137
+ order += 1;
138
+ continue;
139
+ }
140
+ const echo = extractCodexEchoCandidate(line);
141
+ if (echo) {
142
+ candidates.push({ kind: "echo", order, text: echo });
143
+ order += 1;
144
+ }
145
+ }
146
+ }
147
+ return candidates.filter((candidate, index, list) => {
148
+ const previous = list[index - 1];
149
+ return !previous || previous.kind !== candidate.kind || previous.text !== candidate.text;
150
+ });
151
+ }
152
+ function coalesceAssistantLines(lines) {
153
+ const collected = [];
154
+ for (const line of lines) {
155
+ const normalized = normalizeCodexText(line);
156
+ if (!normalized || normalized.length < 2 || shouldIgnoreCodexLine(normalized))
157
+ continue;
158
+ const previous = collected[collected.length - 1];
159
+ if (!previous) {
160
+ collected.push(normalized);
161
+ continue;
162
+ }
163
+ if (normalized === previous)
164
+ continue;
165
+ if (normalized.startsWith(previous)) {
166
+ collected[collected.length - 1] = normalized;
167
+ continue;
168
+ }
169
+ if (previous.startsWith(normalized)) {
170
+ if (isLikelyAssistantTailArtifact(previous, normalized)) {
171
+ collected[collected.length - 1] = normalized;
172
+ }
173
+ continue;
174
+ }
175
+ collected.push(normalized);
176
+ }
177
+ return collected.join("\n").trim();
178
+ }
179
+ function extractVisiblePrompt(lines) {
180
+ for (let index = 0; index < lines.length; index += 1) {
181
+ const line = lines[index].trim();
182
+ if (!line)
183
+ continue;
184
+ const inlinePrompt = extractCodexPromptCandidate(line);
185
+ if (inlinePrompt)
186
+ return inlinePrompt;
187
+ if (line === "›") {
188
+ for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
189
+ const nextLine = normalizeCodexText(lines[nextIndex]);
190
+ if (!nextLine || CODEX_FOOTER_RE.test(nextLine) || shouldIgnoreCodexLine(nextLine))
191
+ continue;
192
+ return nextLine;
193
+ }
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+ function extractVisibleAssistantLines(lines) {
199
+ const assistantLines = [];
200
+ let collecting = false;
201
+ for (const rawLine of lines) {
202
+ const line = rawLine.trim();
203
+ if (!line) {
204
+ if (collecting)
205
+ break;
206
+ continue;
207
+ }
208
+ const assistant = extractCodexAssistantCandidate(line);
209
+ if (assistant) {
210
+ assistantLines.push(assistant);
211
+ collecting = true;
212
+ continue;
213
+ }
214
+ if (collecting) {
215
+ if (line === "›" ||
216
+ /^›(?:\s|$)/.test(line) ||
217
+ CODEX_FOOTER_RE.test(line) ||
218
+ shouldIgnoreCodexLine(line)) {
219
+ break;
220
+ }
221
+ assistantLines.push(normalizeCodexText(line));
222
+ }
223
+ }
224
+ return assistantLines;
225
+ }
226
+ const candidates = collectCodexCandidates();
227
+ const explicitUsers = candidates.filter((candidate) => candidate.kind === "user");
228
+ const assistantCandidates = candidates.filter((candidate) => candidate.kind === "assistant");
229
+ const echoCandidates = candidates.filter((candidate) => candidate.kind === "echo");
230
+ const strippedOutput = stripAnsi(output);
231
+ const strippedLines = strippedOutput.split("\n").map((line) => line.trimEnd());
232
+ const visiblePrompt = extractVisiblePrompt(strippedLines);
233
+ const latestExplicitUser = explicitUsers[explicitUsers.length - 1] ?? null;
234
+ const echoedUserCandidates = echoCandidates
235
+ .map((candidate) => candidate.text)
236
+ .filter((text) => text.length >= 3);
237
+ const latestEchoUser = [...echoedUserCandidates].reverse().find((text) => text !== visiblePrompt) ?? echoedUserCandidates[echoedUserCandidates.length - 1] ?? null;
238
+ const currentUser = latestExplicitUser?.text ?? latestEchoUser;
239
+ const rawAssistantLines = assistantCandidates
240
+ .filter((candidate) => !latestExplicitUser || candidate.order > latestExplicitUser.order)
241
+ .map((candidate) => candidate.text);
242
+ const visibleAssistantFallback = [...strippedOutput.matchAll(/^[ \t]*[•◦·⏺][ \t]*(.+)$/gm)]
243
+ .map((match) => normalizeCodexText(match[1] ?? ""))
244
+ .filter((line) => (!!line
245
+ && !CODEX_ACTIVITY_RE.test(line)
246
+ && !CODEX_FOOTER_RE.test(line)
247
+ && !/\b(?:OpenAI Codex|model:|directory:|Tip:|esc to interrupt)\b/i.test(line)));
248
+ const assistant = coalesceAssistantLines(rawAssistantLines)
249
+ || coalesceAssistantLines(extractVisibleAssistantLines(strippedLines))
250
+ || visibleAssistantFallback[visibleAssistantFallback.length - 1]
251
+ || null;
252
+ if (currentUser) {
253
+ messages.push({ role: "user", content: currentUser });
254
+ }
255
+ if (assistant) {
256
+ messages.push({ role: "assistant", content: assistant });
257
+ }
258
+ if (!messages.length && latestExplicitUser) {
259
+ messages.push({ role: "user", content: latestExplicitUser.text });
260
+ }
261
+ else if (!messages.length && latestEchoUser) {
262
+ messages.push({ role: "user", content: latestEchoUser });
263
+ }
264
+ const deduped = [];
265
+ for (const message of messages) {
266
+ const previous = deduped[deduped.length - 1];
267
+ if (!previous || previous.role !== message.role || previous.content !== message.content) {
268
+ deduped.push(message);
269
+ }
270
+ }
271
+ return deduped;
272
+ }
273
+ export function parseMessages(output, command) {
3
274
  const messages = [];
4
275
  if (!output)
5
276
  return messages;
277
+ if (isCodexCommand(command)) {
278
+ return parseCodexMessages(output);
279
+ }
6
280
  const stripped = stripAnsi(output).replace(/\r/g, "\n");
7
281
  const lines = stripped.split("\n");
8
282
  const cleaned = lines.filter((line) => !isNoiseLine(line.trim()));
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { WandStorage } from "./storage.js";
3
- import { ExecutionMode, ProcessEventHandler, SessionSnapshot, WandConfig } from "./types.js";
3
+ import { ExecutionMode, ProcessEventHandler, SessionProvider, SessionSnapshot, WandConfig } from "./types.js";
4
4
  export type { ProcessEvent, ProcessEventHandler } from "./types.js";
5
5
  /** Human-readable task information for the UI */
6
6
  export interface TaskInfo {
@@ -32,8 +32,8 @@ export declare class ProcessManager extends EventEmitter {
32
32
  private readonly lifecycleManager;
33
33
  /** Per-session debounce timers for throttled persist calls */
34
34
  private readonly persistDebounceTimers;
35
- /** Last persisted message count per session — used to skip redundant file writes */
36
- private readonly lastPersistedMessageCount;
35
+ /** Last persisted message state per session — used to skip redundant message writes */
36
+ private readonly lastPersistedMessageState;
37
37
  constructor(config: WandConfig, storage: WandStorage, configDir?: string);
38
38
  on(event: "process", listener: ProcessEventHandler): this;
39
39
  private emitEvent;
@@ -41,6 +41,8 @@ export declare class ProcessManager extends EventEmitter {
41
41
  start(command: string, cwd: string | undefined, mode: ExecutionMode, initialInput?: string, opts?: {
42
42
  resumedFromSessionId?: string;
43
43
  autoRecovered?: boolean;
44
+ worktreeEnabled?: boolean;
45
+ provider?: SessionProvider;
44
46
  }): SessionSnapshot;
45
47
  list(): SessionSnapshot[];
46
48
  hasClaudeSessionFile(cwd: string, claudeSessionId: string): boolean;
@@ -52,6 +54,7 @@ export declare class ProcessManager extends EventEmitter {
52
54
  cwd: string;
53
55
  }[]): number;
54
56
  get(id: string): SessionSnapshot | null;
57
+ getPtyTranscript(id: string): string | null;
55
58
  sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string): SessionSnapshot;
56
59
  /** Emit a task event for a session, debounced to avoid flooding */
57
60
  private emitTask;