@co0ontty/wand 1.6.2 → 1.9.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 +40 -0
- package/dist/git-worktree.d.ts +28 -0
- package/dist/git-worktree.js +287 -0
- package/dist/message-parser.d.ts +1 -1
- package/dist/message-parser.js +275 -1
- package/dist/process-manager.d.ts +6 -3
- package/dist/process-manager.js +135 -81
- package/dist/pty-text-utils.js +79 -29
- package/dist/server-session-routes.js +192 -7
- package/dist/server.js +38 -1
- package/dist/session-logger.d.ts +2 -0
- package/dist/session-logger.js +23 -0
- package/dist/storage.d.ts +1 -0
- package/dist/storage.js +258 -58
- package/dist/structured-session-manager.d.ts +5 -0
- package/dist/structured-session-manager.js +107 -43
- package/dist/types.d.ts +65 -0
- package/dist/web-ui/content/scripts.js +2251 -500
- package/dist/web-ui/content/styles.css +1154 -119
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -16,6 +16,20 @@ export const defaultConfig = () => ({
|
|
|
16
16
|
allowedCommandPrefixes: [],
|
|
17
17
|
shortcutLogMaxBytes: 10 * 1024 * 1024,
|
|
18
18
|
language: "",
|
|
19
|
+
uiPreferences: {
|
|
20
|
+
defaultPanelState: {
|
|
21
|
+
sessionsDrawerOpen: false,
|
|
22
|
+
filePanelOpen: false,
|
|
23
|
+
shortcutsExpanded: false,
|
|
24
|
+
claudeHistoryExpanded: true,
|
|
25
|
+
chatMessageExpanded: true,
|
|
26
|
+
structuredThinkingExpanded: true,
|
|
27
|
+
structuredToolGroupExpanded: false,
|
|
28
|
+
structuredInlineToolExpanded: false,
|
|
29
|
+
structuredTerminalExpanded: false,
|
|
30
|
+
structuredToolCardExpanded: false,
|
|
31
|
+
}
|
|
32
|
+
},
|
|
19
33
|
commandPresets: [
|
|
20
34
|
{
|
|
21
35
|
label: "Claude",
|
|
@@ -101,8 +115,26 @@ function normalizeStructuredChatPersona(input) {
|
|
|
101
115
|
return undefined;
|
|
102
116
|
return { user, assistant };
|
|
103
117
|
}
|
|
118
|
+
function normalizeDefaultPanelState(input) {
|
|
119
|
+
if (!input || typeof input !== "object")
|
|
120
|
+
return undefined;
|
|
121
|
+
const state = input;
|
|
122
|
+
return {
|
|
123
|
+
sessionsDrawerOpen: typeof state.sessionsDrawerOpen === "boolean" ? state.sessionsDrawerOpen : undefined,
|
|
124
|
+
filePanelOpen: typeof state.filePanelOpen === "boolean" ? state.filePanelOpen : undefined,
|
|
125
|
+
shortcutsExpanded: typeof state.shortcutsExpanded === "boolean" ? state.shortcutsExpanded : undefined,
|
|
126
|
+
claudeHistoryExpanded: typeof state.claudeHistoryExpanded === "boolean" ? state.claudeHistoryExpanded : undefined,
|
|
127
|
+
chatMessageExpanded: typeof state.chatMessageExpanded === "boolean" ? state.chatMessageExpanded : undefined,
|
|
128
|
+
structuredThinkingExpanded: typeof state.structuredThinkingExpanded === "boolean" ? state.structuredThinkingExpanded : undefined,
|
|
129
|
+
structuredToolGroupExpanded: typeof state.structuredToolGroupExpanded === "boolean" ? state.structuredToolGroupExpanded : undefined,
|
|
130
|
+
structuredInlineToolExpanded: typeof state.structuredInlineToolExpanded === "boolean" ? state.structuredInlineToolExpanded : undefined,
|
|
131
|
+
structuredTerminalExpanded: typeof state.structuredTerminalExpanded === "boolean" ? state.structuredTerminalExpanded : undefined,
|
|
132
|
+
structuredToolCardExpanded: typeof state.structuredToolCardExpanded === "boolean" ? state.structuredToolCardExpanded : undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
104
135
|
function mergeWithDefaults(input) {
|
|
105
136
|
const defaults = defaultConfig();
|
|
137
|
+
const normalizedPanelState = normalizeDefaultPanelState(input.uiPreferences?.defaultPanelState);
|
|
106
138
|
return {
|
|
107
139
|
...defaults,
|
|
108
140
|
...input,
|
|
@@ -132,6 +164,14 @@ function mergeWithDefaults(input) {
|
|
|
132
164
|
: defaults.commandPresets,
|
|
133
165
|
structuredChatPersona: normalizeStructuredChatPersona(input.structuredChatPersona),
|
|
134
166
|
language: typeof input.language === "string" ? input.language.trim() : defaults.language,
|
|
167
|
+
uiPreferences: {
|
|
168
|
+
...defaults.uiPreferences,
|
|
169
|
+
...(input.uiPreferences || {}),
|
|
170
|
+
defaultPanelState: {
|
|
171
|
+
...defaults.uiPreferences?.defaultPanelState,
|
|
172
|
+
...(normalizedPanelState || {}),
|
|
173
|
+
}
|
|
174
|
+
},
|
|
135
175
|
};
|
|
136
176
|
}
|
|
137
177
|
export function isExecutionMode(value) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { SessionSnapshot, WorktreeMergeCheckResult, WorktreeMergeResult } 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
|
+
interface WorktreeOperationOptions {
|
|
12
|
+
worktree: NonNullable<SessionSnapshot["worktree"]>;
|
|
13
|
+
}
|
|
14
|
+
interface WorktreeMergeOptions extends WorktreeOperationOptions {
|
|
15
|
+
targetBranch?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare class WorktreeMergeError extends Error {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
readonly result?: Partial<WorktreeMergeResult> | undefined;
|
|
20
|
+
constructor(code: string, message: string, result?: Partial<WorktreeMergeResult> | undefined);
|
|
21
|
+
}
|
|
22
|
+
export declare function getDefaultBaseBranch(repoRoot: string): string;
|
|
23
|
+
export declare function getWorktreeMergeErrorCode(error: unknown): string | undefined;
|
|
24
|
+
export declare function checkSessionWorktreeMergeability(options: WorktreeMergeOptions): WorktreeMergeCheckResult;
|
|
25
|
+
export declare function cleanupSessionWorktree(options: WorktreeOperationOptions): boolean;
|
|
26
|
+
export declare function mergeSessionWorktree(options: WorktreeMergeOptions): WorktreeMergeResult;
|
|
27
|
+
export declare function prepareSessionWorktree(options: WorktreeSetupOptions): WorktreeSetupResult;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const WORKTREE_MERGE_ERROR_CODES = {
|
|
5
|
+
MISSING: "WORKTREE_MISSING",
|
|
6
|
+
DIRTY: "WORKTREE_DIRTY",
|
|
7
|
+
TARGET_MISSING: "TARGET_BRANCH_MISSING",
|
|
8
|
+
NOTHING_TO_MERGE: "NOTHING_TO_MERGE",
|
|
9
|
+
CONFLICT: "WORKTREE_MERGE_CONFLICT",
|
|
10
|
+
CLEANUP_FAILED: "WORKTREE_CLEANUP_FAILED",
|
|
11
|
+
};
|
|
12
|
+
export class WorktreeMergeError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
result;
|
|
15
|
+
constructor(code, message, result) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.result = result;
|
|
19
|
+
this.name = "WorktreeMergeError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function runGit(args, cwd) {
|
|
23
|
+
return execFileSync("git", args, {
|
|
24
|
+
cwd,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
}).trim();
|
|
28
|
+
}
|
|
29
|
+
function sanitizeBranchSegment(value) {
|
|
30
|
+
return value
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "")
|
|
34
|
+
.slice(0, 48) || "session";
|
|
35
|
+
}
|
|
36
|
+
function getCurrentBranch(repoRoot) {
|
|
37
|
+
const branch = runGit(["branch", "--show-current"], repoRoot);
|
|
38
|
+
return branch || "master";
|
|
39
|
+
}
|
|
40
|
+
function isGitCommandError(error) {
|
|
41
|
+
return error instanceof Error;
|
|
42
|
+
}
|
|
43
|
+
function getGitCommandMessage(error) {
|
|
44
|
+
if (isGitCommandError(error)) {
|
|
45
|
+
return error.stderr?.trim() || error.stdout?.trim() || error.message;
|
|
46
|
+
}
|
|
47
|
+
return String(error);
|
|
48
|
+
}
|
|
49
|
+
function refExists(repoRoot, ref) {
|
|
50
|
+
try {
|
|
51
|
+
runGit(["rev-parse", "--verify", ref], repoRoot);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function getRepoRootFromWorktree(worktreePath) {
|
|
59
|
+
const repoRoot = runGit(["rev-parse", "--show-toplevel"], worktreePath);
|
|
60
|
+
if (!repoRoot || !existsSync(repoRoot)) {
|
|
61
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.MISSING, "Worktree 仓库根目录不存在。", {
|
|
62
|
+
cleanupDone: false,
|
|
63
|
+
conflict: false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return repoRoot;
|
|
67
|
+
}
|
|
68
|
+
function ensureWorktreePath(worktree) {
|
|
69
|
+
const worktreePath = path.resolve(worktree.path);
|
|
70
|
+
if (!existsSync(worktreePath)) {
|
|
71
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.MISSING, "Worktree 目录不存在,可能已被手动删除。", {
|
|
72
|
+
cleanupDone: false,
|
|
73
|
+
conflict: false,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return worktreePath;
|
|
77
|
+
}
|
|
78
|
+
function getMainRepoRoot(repoRoot) {
|
|
79
|
+
const commonDir = runGit(["rev-parse", "--git-common-dir"], repoRoot);
|
|
80
|
+
if (commonDir) {
|
|
81
|
+
const maybeRoot = path.resolve(repoRoot, commonDir, "..");
|
|
82
|
+
if (existsSync(maybeRoot)) {
|
|
83
|
+
return maybeRoot;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return repoRoot;
|
|
87
|
+
}
|
|
88
|
+
export function getDefaultBaseBranch(repoRoot) {
|
|
89
|
+
try {
|
|
90
|
+
const symbolicRef = runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], repoRoot);
|
|
91
|
+
const match = symbolicRef.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
92
|
+
if (match && match[1]) {
|
|
93
|
+
return match[1];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// ignore and fallback below
|
|
98
|
+
}
|
|
99
|
+
const candidates = ["master", "main", getCurrentBranch(repoRoot)];
|
|
100
|
+
for (const candidate of candidates) {
|
|
101
|
+
if (candidate && refExists(repoRoot, candidate)) {
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return "master";
|
|
106
|
+
}
|
|
107
|
+
function getMainRepoContext(options) {
|
|
108
|
+
const worktreePath = ensureWorktreePath(options.worktree);
|
|
109
|
+
const worktreeRepoRoot = getRepoRootFromWorktree(worktreePath);
|
|
110
|
+
const repoRoot = getMainRepoRoot(worktreeRepoRoot);
|
|
111
|
+
const sourceBranch = options.worktree.branch;
|
|
112
|
+
if (!refExists(repoRoot, sourceBranch)) {
|
|
113
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.MISSING, "Worktree 分支不存在,可能已被手动删除。", {
|
|
114
|
+
cleanupDone: false,
|
|
115
|
+
conflict: false,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const targetBranch = options.targetBranch?.trim() || getDefaultBaseBranch(repoRoot);
|
|
119
|
+
if (!refExists(repoRoot, targetBranch)) {
|
|
120
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.TARGET_MISSING, `目标分支不存在:${targetBranch}`, {
|
|
121
|
+
cleanupDone: false,
|
|
122
|
+
conflict: false,
|
|
123
|
+
targetBranch,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { repoRoot, worktreePath, sourceBranch, targetBranch };
|
|
127
|
+
}
|
|
128
|
+
function hasUncommittedChanges(worktreePath) {
|
|
129
|
+
const worktreeRepoRoot = getRepoRootFromWorktree(worktreePath);
|
|
130
|
+
return runGit(["status", "--porcelain"], worktreeRepoRoot).length > 0;
|
|
131
|
+
}
|
|
132
|
+
function getAheadCount(repoRoot, targetBranch, sourceBranch) {
|
|
133
|
+
const count = runGit(["rev-list", "--count", `${targetBranch}..${sourceBranch}`], repoRoot);
|
|
134
|
+
return Number.parseInt(count || "0", 10) || 0;
|
|
135
|
+
}
|
|
136
|
+
function checkConflicts(repoRoot, targetBranch, sourceBranch) {
|
|
137
|
+
try {
|
|
138
|
+
runGit(["merge-tree", targetBranch, sourceBranch], repoRoot);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function ensureMergeableContext(options) {
|
|
146
|
+
const context = getMainRepoContext(options);
|
|
147
|
+
if (hasUncommittedChanges(context.worktreePath)) {
|
|
148
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.DIRTY, "Worktree 中仍有未提交改动,请先提交后再合并。", {
|
|
149
|
+
sourceBranch: context.sourceBranch,
|
|
150
|
+
targetBranch: context.targetBranch,
|
|
151
|
+
repoRoot: context.repoRoot,
|
|
152
|
+
cleanupDone: false,
|
|
153
|
+
conflict: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const aheadCount = getAheadCount(context.repoRoot, context.targetBranch, context.sourceBranch);
|
|
157
|
+
if (aheadCount <= 0) {
|
|
158
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.NOTHING_TO_MERGE, "当前 worktree 没有可合并到主分支的新提交。", {
|
|
159
|
+
sourceBranch: context.sourceBranch,
|
|
160
|
+
targetBranch: context.targetBranch,
|
|
161
|
+
repoRoot: context.repoRoot,
|
|
162
|
+
cleanupDone: false,
|
|
163
|
+
conflict: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return { ...context, aheadCount };
|
|
167
|
+
}
|
|
168
|
+
function buildCheckResult(context, aheadCount, hasDirtyChanges, hasConflicts) {
|
|
169
|
+
const ok = !hasDirtyChanges && aheadCount > 0 && !hasConflicts;
|
|
170
|
+
return {
|
|
171
|
+
ok,
|
|
172
|
+
sourceBranch: context.sourceBranch,
|
|
173
|
+
targetBranch: context.targetBranch,
|
|
174
|
+
worktreePath: context.worktreePath,
|
|
175
|
+
repoRoot: context.repoRoot,
|
|
176
|
+
hasUncommittedChanges: hasDirtyChanges,
|
|
177
|
+
aheadCount,
|
|
178
|
+
hasConflicts,
|
|
179
|
+
recommendedAction: hasConflicts ? "resolve-conflict" : aheadCount > 0 ? "merge" : "noop",
|
|
180
|
+
reason: hasDirtyChanges
|
|
181
|
+
? "Worktree 中仍有未提交改动。"
|
|
182
|
+
: aheadCount <= 0
|
|
183
|
+
? "当前 worktree 没有可合并的新提交。"
|
|
184
|
+
: hasConflicts
|
|
185
|
+
? "检测到潜在冲突,请先处理。"
|
|
186
|
+
: undefined,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function getHeadCommit(repoRoot) {
|
|
190
|
+
return runGit(["rev-parse", "HEAD"], repoRoot);
|
|
191
|
+
}
|
|
192
|
+
function cleanupMergedWorktree(context) {
|
|
193
|
+
runGit(["worktree", "remove", context.worktreePath], context.repoRoot);
|
|
194
|
+
runGit(["branch", "-d", context.sourceBranch], context.repoRoot);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
export function getWorktreeMergeErrorCode(error) {
|
|
198
|
+
return error instanceof WorktreeMergeError ? error.code : undefined;
|
|
199
|
+
}
|
|
200
|
+
export function checkSessionWorktreeMergeability(options) {
|
|
201
|
+
const context = getMainRepoContext(options);
|
|
202
|
+
const hasDirtyChanges = hasUncommittedChanges(context.worktreePath);
|
|
203
|
+
const aheadCount = getAheadCount(context.repoRoot, context.targetBranch, context.sourceBranch);
|
|
204
|
+
const hasConflicts = !hasDirtyChanges && aheadCount > 0
|
|
205
|
+
? checkConflicts(context.repoRoot, context.targetBranch, context.sourceBranch)
|
|
206
|
+
: false;
|
|
207
|
+
return buildCheckResult(context, aheadCount, hasDirtyChanges, hasConflicts);
|
|
208
|
+
}
|
|
209
|
+
export function cleanupSessionWorktree(options) {
|
|
210
|
+
const context = getMainRepoContext({ worktree: options.worktree });
|
|
211
|
+
return cleanupMergedWorktree(context);
|
|
212
|
+
}
|
|
213
|
+
export function mergeSessionWorktree(options) {
|
|
214
|
+
const context = ensureMergeableContext(options);
|
|
215
|
+
const hasConflicts = checkConflicts(context.repoRoot, context.targetBranch, context.sourceBranch);
|
|
216
|
+
if (hasConflicts) {
|
|
217
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.CONFLICT, "合并检测到冲突,请先手动处理。", {
|
|
218
|
+
sourceBranch: context.sourceBranch,
|
|
219
|
+
targetBranch: context.targetBranch,
|
|
220
|
+
repoRoot: context.repoRoot,
|
|
221
|
+
cleanupDone: false,
|
|
222
|
+
conflict: true,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
runGit(["checkout", context.targetBranch], context.repoRoot);
|
|
227
|
+
runGit(["merge", "--no-ff", "--no-edit", context.sourceBranch], context.repoRoot);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
const message = getGitCommandMessage(error);
|
|
231
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.CONFLICT, message || "合并失败,可能存在冲突。", {
|
|
232
|
+
sourceBranch: context.sourceBranch,
|
|
233
|
+
targetBranch: context.targetBranch,
|
|
234
|
+
repoRoot: context.repoRoot,
|
|
235
|
+
cleanupDone: false,
|
|
236
|
+
conflict: true,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const mergedAt = new Date().toISOString();
|
|
240
|
+
const mergeCommit = getHeadCommit(context.repoRoot);
|
|
241
|
+
try {
|
|
242
|
+
cleanupMergedWorktree(context);
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
sourceBranch: context.sourceBranch,
|
|
246
|
+
targetBranch: context.targetBranch,
|
|
247
|
+
repoRoot: context.repoRoot,
|
|
248
|
+
mergeCommit,
|
|
249
|
+
mergedAt,
|
|
250
|
+
cleanupDone: true,
|
|
251
|
+
conflict: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
throw new WorktreeMergeError(WORKTREE_MERGE_ERROR_CODES.CLEANUP_FAILED, getGitCommandMessage(error) || "已合并,但清理 worktree 失败。", {
|
|
256
|
+
sourceBranch: context.sourceBranch,
|
|
257
|
+
targetBranch: context.targetBranch,
|
|
258
|
+
repoRoot: context.repoRoot,
|
|
259
|
+
mergeCommit,
|
|
260
|
+
mergedAt,
|
|
261
|
+
cleanupDone: false,
|
|
262
|
+
conflict: false,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export function prepareSessionWorktree(options) {
|
|
267
|
+
const resolvedCwd = path.resolve(options.cwd);
|
|
268
|
+
const repoRoot = runGit(["rev-parse", "--show-toplevel"], resolvedCwd);
|
|
269
|
+
if (!repoRoot || !existsSync(repoRoot)) {
|
|
270
|
+
throw new Error("当前目录不在 git 仓库中,无法启用 worktree 模式。");
|
|
271
|
+
}
|
|
272
|
+
const baseBranch = getCurrentBranch(repoRoot);
|
|
273
|
+
const branchSuffix = sanitizeBranchSegment(options.sessionId.split("-")[0] || options.sessionId);
|
|
274
|
+
const branchName = `wand/${sanitizeBranchSegment(baseBranch)}-${branchSuffix}`;
|
|
275
|
+
const worktreesRoot = path.join(repoRoot, ".wand-worktrees");
|
|
276
|
+
const worktreePath = path.join(worktreesRoot, branchName.replace(/\//g, "-"));
|
|
277
|
+
mkdirSync(worktreesRoot, { recursive: true });
|
|
278
|
+
runGit(["worktree", "add", "-b", branchName, worktreePath, "HEAD"], repoRoot);
|
|
279
|
+
return {
|
|
280
|
+
cwd: worktreePath,
|
|
281
|
+
worktreeEnabled: true,
|
|
282
|
+
worktree: {
|
|
283
|
+
branch: branchName,
|
|
284
|
+
path: worktreePath,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
package/dist/message-parser.d.ts
CHANGED
|
@@ -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[];
|
package/dist/message-parser.js
CHANGED
|
@@ -1,8 +1,282 @@
|
|
|
1
1
|
import { stripAnsi, isNoiseLine } from "./pty-text-utils.js";
|
|
2
|
-
|
|
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()));
|