aimux-cli 0.1.19 → 0.1.20
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/README.md +13 -4
- package/bin/aimux +4 -0
- package/bin/aimux-dev +2 -6
- package/dist/agent-output-parser-audit.d.ts +23 -0
- package/dist/agent-output-parser-audit.js +187 -0
- package/dist/agent-output-parser-contract.d.ts +9 -0
- package/dist/agent-output-parser-contract.js +33 -0
- package/dist/agent-output-parser-fixtures.d.ts +15 -0
- package/dist/agent-output-parser-fixtures.js +593 -0
- package/dist/agent-output-parser-harness.d.ts +21 -0
- package/dist/agent-output-parser-harness.js +43 -0
- package/dist/agent-output-parser-test-utils.d.ts +1 -0
- package/dist/agent-output-parser-test-utils.js +7 -0
- package/dist/agent-output-parser.js +215 -35
- package/dist/atomic-write.d.ts +15 -0
- package/dist/atomic-write.js +69 -4
- package/dist/attachment-store.d.ts +7 -0
- package/dist/attachment-store.js +64 -5
- package/dist/backend-session-discovery.d.ts +17 -0
- package/dist/backend-session-discovery.js +57 -0
- package/dist/config.js +9 -4
- package/dist/connection-targets.js +20 -1
- package/dist/context/context-bridge.js +4 -1
- package/dist/credentials.js +3 -6
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +16 -0
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.js +1 -0
- package/dist/dashboard/targets.js +14 -2
- package/dist/dashboard/ui-state-store.js +4 -3
- package/dist/last-used.js +3 -2
- package/dist/launcher-env.d.ts +4 -0
- package/dist/launcher-env.js +70 -0
- package/dist/main.js +16 -1
- package/dist/metadata-server.d.ts +13 -2
- package/dist/metadata-server.js +60 -4
- package/dist/metadata-store.js +4 -3
- package/dist/mobile-push-bridge.d.ts +8 -0
- package/dist/mobile-push-bridge.js +22 -0
- package/dist/mobile-push-throttle.d.ts +23 -0
- package/dist/mobile-push-throttle.js +53 -0
- package/dist/multiplexer/dashboard-model.js +3 -2
- package/dist/multiplexer/dashboard-ops.d.ts +3 -2
- package/dist/multiplexer/dashboard-ops.js +2 -2
- package/dist/multiplexer/dashboard-tail-methods.d.ts +3 -2
- package/dist/multiplexer/dashboard-tail-methods.js +2 -2
- package/dist/multiplexer/dashboard-view-methods.js +2 -0
- package/dist/multiplexer/index.d.ts +1 -1
- package/dist/multiplexer/index.js +4 -4
- package/dist/multiplexer/persistence-methods.js +2 -1
- package/dist/multiplexer/runtime-lifecycle-methods.js +6 -2
- package/dist/multiplexer/runtime-state.js +13 -1
- package/dist/multiplexer/service-state-snapshot.js +4 -2
- package/dist/multiplexer/services.js +5 -4
- package/dist/multiplexer/session-launch.d.ts +1 -1
- package/dist/multiplexer/session-launch.js +18 -6
- package/dist/multiplexer/session-runtime-core.js +9 -2
- package/dist/multiplexer/tool-picker.d.ts +2 -1
- package/dist/multiplexer/tool-picker.js +29 -21
- package/dist/notify.d.ts +1 -1
- package/dist/notify.js +8 -5
- package/dist/paths.js +50 -4
- package/dist/project-takeover.d.ts +1 -0
- package/dist/project-takeover.js +117 -0
- package/dist/relay-client.d.ts +10 -0
- package/dist/relay-client.js +5 -0
- package/dist/runtime-core/backend-id-reconcile.d.ts +13 -0
- package/dist/runtime-core/backend-id-reconcile.js +23 -0
- package/dist/runtime-core/exchange-store.js +3 -8
- package/dist/runtime-core/topology-store.js +3 -8
- package/dist/runtime-owner.d.ts +3 -0
- package/dist/runtime-owner.js +10 -0
- package/dist/shell-args.d.ts +13 -0
- package/dist/shell-args.js +25 -0
- package/dist/shell-hooks.d.ts +1 -0
- package/dist/shell-hooks.js +1 -0
- package/dist/team.js +4 -3
- package/dist/tmux/runtime-manager.js +2 -0
- package/dist/tui/screens/dashboard-renderers.js +6 -6
- package/dist/vitest.setup.d.ts +1 -0
- package/dist/vitest.setup.js +9 -0
- package/dist-ui/_expo/static/css/web-8782287775683e5a944b821b854d0f60.css +1 -0
- package/dist-ui/_expo/static/js/web/{entry-477c745b2adc79367a4380ecf07d9ff6.js → entry-90d00d223eefabe5cc21e4329b274fa5.js} +260 -252
- package/dist-ui/index.html +2 -2
- package/package.json +3 -1
- package/dist-ui/_expo/static/css/web-30453ede1678c16acb08b97e83e8646d.css +0 -1
|
@@ -1,5 +1,56 @@
|
|
|
1
|
+
const activityDurationPattern = String.raw `\d+(?:ms|s|m|h)(?:\s+\d+(?:ms|s|m|h))*`;
|
|
2
|
+
const activityForDurationRegex = new RegExp(String.raw `\bfor\s+${activityDurationPattern}(?:$|(?=\s*[·•.)]))`, "i");
|
|
3
|
+
const activityRestForDurationRegex = new RegExp(String.raw `^for\s+${activityDurationPattern}(?:$|(?=\s*[·•.)]))`, "i");
|
|
4
|
+
const activityParentheticalDurationRegex = new RegExp(String.raw `\([^)]*\b${activityDurationPattern}\b[^)]*\)`, "i");
|
|
5
|
+
const activityEllipsisRegex = /\.{3}|…/;
|
|
6
|
+
const activityLeadRegex = /^[\p{Lu}][\p{L}-]{2,}\b/u;
|
|
7
|
+
const looksLikeActivityProgressText = (text) => {
|
|
8
|
+
const trimmed = text.trim();
|
|
9
|
+
const lead = trimmed.match(activityLeadRegex)?.[0] ?? "";
|
|
10
|
+
if (!lead || !/(ed|ing)$/i.test(lead))
|
|
11
|
+
return false;
|
|
12
|
+
const rest = trimmed.slice(lead.length).trimStart();
|
|
13
|
+
if (!(activityRestForDurationRegex.test(rest) ||
|
|
14
|
+
activityParentheticalDurationRegex.test(rest) ||
|
|
15
|
+
activityEllipsisRegex.test(rest))) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return (activityForDurationRegex.test(trimmed) ||
|
|
19
|
+
activityParentheticalDurationRegex.test(trimmed) ||
|
|
20
|
+
activityEllipsisRegex.test(trimmed));
|
|
21
|
+
};
|
|
22
|
+
const looksLikeRanCommandText = (text) => {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
return (/^Ran\s+(?:aimux|bash|bun|cat|cd|curl|docker|find|gh|git|grep|ls|mkdir|mv|node|npm|pnpm|python3?|rg|rm|sed|sh|tsc|tsx|vitest|yarn)\b/i.test(trimmed) && !/[.!?]$/.test(trimmed));
|
|
25
|
+
};
|
|
26
|
+
const looksLikeToolActionText = (text) => {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
return false;
|
|
30
|
+
return (/^Bash\([^)]*$/i.test(trimmed) ||
|
|
31
|
+
/^(?:Bash|BashOutput|Edit|Explore|Glob|Grep|KillBash|LS|MultiEdit|NotebookEdit|Read|Task|TodoWrite|Update|WebFetch|WebSearch|Write)\s*(?:\([^)\n]*\)|\d+[^\n]*(?:ctrl\+o|to expand)|[^\n]*(?:Running in the background|exit code))\s*$/i.test(trimmed) ||
|
|
32
|
+
/^Background command\s+".+"\s+completed\s+\(exit code\s+\d+\)/i.test(trimmed) ||
|
|
33
|
+
looksLikeRanCommandText(trimmed) ||
|
|
34
|
+
/^Searched\s*for\s*\d+\s*patterns?/i.test(trimmed) ||
|
|
35
|
+
/^Read\s*\d+\s*files?/i.test(trimmed));
|
|
36
|
+
};
|
|
37
|
+
const inferAgentOutputTool = (raw) => {
|
|
38
|
+
const text = String(raw || "");
|
|
39
|
+
const hasCodexChrome = /(?:^|\n)\s*(?:│\s*)?>_\s*OpenAI Codex\b/im.test(text) ||
|
|
40
|
+
/(?:^|\n)\s*gpt-[\w.-]+\b.*(?:~\/|\/|permissions|context\))/im.test(text);
|
|
41
|
+
const hasClaudeChrome = /(?:^|\n)\s*(?:│\s*)?Claude Code\b/im.test(text) ||
|
|
42
|
+
/(?:^|\n)\s*claude\b.*(?:~\/|\/|permissions|context\))/im.test(text);
|
|
43
|
+
if (hasCodexChrome && !hasClaudeChrome) {
|
|
44
|
+
return "codex";
|
|
45
|
+
}
|
|
46
|
+
if (hasClaudeChrome && !hasCodexChrome) {
|
|
47
|
+
return "claude";
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
1
51
|
export function parseAgentOutput(raw, options = {}) {
|
|
2
|
-
const
|
|
52
|
+
const requestedTool = (options.tool || "").trim();
|
|
53
|
+
const tool = requestedTool && requestedTool !== "unknown" ? requestedTool : (inferAgentOutputTool(raw) ?? "unknown");
|
|
3
54
|
const lines = String(raw || "")
|
|
4
55
|
.replace(/\r/g, "")
|
|
5
56
|
.split("\n");
|
|
@@ -7,6 +58,7 @@ export function parseAgentOutput(raw, options = {}) {
|
|
|
7
58
|
let current = null;
|
|
8
59
|
let sawPrompt = false;
|
|
9
60
|
let expectingResponse = false;
|
|
61
|
+
let lastLineWasDivider = false;
|
|
10
62
|
const flush = () => {
|
|
11
63
|
if (!current)
|
|
12
64
|
return;
|
|
@@ -46,16 +98,40 @@ export function parseAgentOutput(raw, options = {}) {
|
|
|
46
98
|
/^[A-Za-z0-9._-]+@[^ ]+\s+(~\/|\/)/.test(trimmed) ||
|
|
47
99
|
(/^([›>]|▶)\s/.test(trimmed) && /(permissions|cycle|cwd|context)/i.test(trimmed)) ||
|
|
48
100
|
/^⏵⏵\s/.test(trimmed) ||
|
|
49
|
-
|
|
101
|
+
/^gpt-[\w.-]+\b.*(?:~\/|\/|context\)|permissions)/i.test(trimmed) ||
|
|
102
|
+
/^claude\b.*(?:~\/|\/|context\)|permissions)/i.test(trimmed) ||
|
|
103
|
+
/bypass permissions|shift\+tab|to cycle/i.test(trimmed));
|
|
104
|
+
};
|
|
105
|
+
const isCodexUiLine = (line) => {
|
|
106
|
+
const trimmed = line.trim();
|
|
107
|
+
return /^│/.test(trimmed) || /^╰/.test(trimmed) || /^╭/.test(trimmed);
|
|
50
108
|
};
|
|
51
109
|
const isStatusLine = (line) => {
|
|
52
110
|
const trimmed = line.trim();
|
|
53
111
|
if (!trimmed)
|
|
54
112
|
return false;
|
|
113
|
+
const dotBulletText = trimmed.replace(/^•\s?/, "");
|
|
114
|
+
const starBulletText = trimmed.replace(/^\*\s+/, "");
|
|
115
|
+
const dashBulletText = trimmed.replace(/^-\s+/, "");
|
|
116
|
+
const spinnerText = trimmed.replace(/^[✻✽✶]\s+/, "");
|
|
117
|
+
const conversationBulletText = trimmed.replace(/^(?:•|⏺)\s?/, "");
|
|
55
118
|
return (/^■\s?/.test(trimmed) ||
|
|
119
|
+
/^⏺\s*$/.test(trimmed) ||
|
|
120
|
+
/^⏺\s*[\u2500-\u257f\-_=\s]+Bash command\b/i.test(trimmed) ||
|
|
121
|
+
/^⏺\s*Bash\([^)\n]*terminal-notifier/i.test(trimmed) ||
|
|
122
|
+
/^└\s+/.test(trimmed) ||
|
|
123
|
+
looksLikeActivityProgressText(trimmed) ||
|
|
56
124
|
/^•\s?Working\b/.test(trimmed) ||
|
|
125
|
+
/^•\s?Starting MCP servers\b/.test(trimmed) ||
|
|
126
|
+
/^•\s?How is Claude doing this session\?\s*\(optional\)/i.test(trimmed) ||
|
|
127
|
+
looksLikeRanCommandText(trimmed) ||
|
|
128
|
+
looksLikeToolActionText(trimmed) ||
|
|
129
|
+
(/^(?:•|⏺)\s?/.test(trimmed) && looksLikeToolActionText(conversationBulletText)) ||
|
|
130
|
+
(/^•\s?/.test(trimmed) && looksLikeActivityProgressText(dotBulletText)) ||
|
|
57
131
|
/^⏵⏵\s/.test(trimmed) ||
|
|
58
|
-
/^\*\s
|
|
132
|
+
(/^\*\s+/.test(trimmed) && looksLikeActivityProgressText(starBulletText)) ||
|
|
133
|
+
(/^-\s+/.test(trimmed) && looksLikeActivityProgressText(dashBulletText)) ||
|
|
134
|
+
(/^[✻✽✶]\s+/.test(trimmed) && looksLikeActivityProgressText(spinnerText)) ||
|
|
59
135
|
/^[╰└]\s*Tip:/i.test(trimmed) ||
|
|
60
136
|
/^Tip:\s/i.test(trimmed) ||
|
|
61
137
|
/(Plan Mode|default permission mode)/i.test(trimmed) ||
|
|
@@ -69,13 +145,43 @@ export function parseAgentOutput(raw, options = {}) {
|
|
|
69
145
|
};
|
|
70
146
|
const stripPromptMarker = (line) => line.trimStart().replace(/^(›|>|❯)\s?/, "");
|
|
71
147
|
const stripResponseMarker = (line) => line.trimStart().replace(/^(•|⏺)\s?/, "");
|
|
72
|
-
const stripStatusMarker = (line) => line.trimStart().replace(/^(
|
|
148
|
+
const stripStatusMarker = (line) => line.trimStart().replace(/^(■|[-*✻✽✶]\s+)\s?/, "");
|
|
149
|
+
const isCodexPickerSelectionPrompt = (promptText) => {
|
|
150
|
+
if (tool !== "codex" || sawPrompt || (current?.type !== "response" && current?.type !== "raw"))
|
|
151
|
+
return false;
|
|
152
|
+
const activeText = current.lines.join("\n");
|
|
153
|
+
if (!/(?:Resume a previous session|Choose working directory to resume this session)/i.test(activeText)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return /^(?:now|\d+[smhd]\s+ago|\d+\.\s)/i.test(promptText.trim());
|
|
157
|
+
};
|
|
73
158
|
for (const line of lines) {
|
|
74
159
|
const trimmed = line.trimEnd();
|
|
75
|
-
if (
|
|
160
|
+
if (isCodexUiLine(trimmed)) {
|
|
161
|
+
lastLineWasDivider = false;
|
|
162
|
+
pushLine(sawPrompt ? "status" : "meta", trimmed);
|
|
76
163
|
continue;
|
|
164
|
+
}
|
|
165
|
+
if (isDivider(trimmed)) {
|
|
166
|
+
lastLineWasDivider = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
77
169
|
if (isPromptLine(trimmed)) {
|
|
78
170
|
const promptText = stripPromptMarker(trimmed);
|
|
171
|
+
if (lastLineWasDivider) {
|
|
172
|
+
if (promptText.trim())
|
|
173
|
+
pushLine("status", promptText);
|
|
174
|
+
lastLineWasDivider = false;
|
|
175
|
+
expectingResponse = false;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
lastLineWasDivider = false;
|
|
179
|
+
if (isCodexPickerSelectionPrompt(promptText)) {
|
|
180
|
+
if (promptText.trim())
|
|
181
|
+
pushLine("status", promptText);
|
|
182
|
+
expectingResponse = false;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
79
185
|
if (!promptText.trim()) {
|
|
80
186
|
flush();
|
|
81
187
|
expectingResponse = false;
|
|
@@ -83,10 +189,11 @@ export function parseAgentOutput(raw, options = {}) {
|
|
|
83
189
|
}
|
|
84
190
|
pushLine("prompt", promptText);
|
|
85
191
|
sawPrompt = true;
|
|
86
|
-
expectingResponse =
|
|
192
|
+
expectingResponse = false;
|
|
87
193
|
continue;
|
|
88
194
|
}
|
|
89
|
-
|
|
195
|
+
lastLineWasDivider = false;
|
|
196
|
+
if (/^(•|⏺)\s?/.test(trimmed) && !isStatusLine(trimmed)) {
|
|
90
197
|
pushLine("response", stripResponseMarker(trimmed));
|
|
91
198
|
sawPrompt = true;
|
|
92
199
|
expectingResponse = false;
|
|
@@ -110,11 +217,23 @@ export function parseAgentOutput(raw, options = {}) {
|
|
|
110
217
|
const active = current;
|
|
111
218
|
if (active && active.type !== "raw") {
|
|
112
219
|
active.lines.push("");
|
|
220
|
+
if (active.type === "prompt")
|
|
221
|
+
expectingResponse = true;
|
|
113
222
|
continue;
|
|
114
223
|
}
|
|
115
224
|
flush();
|
|
116
225
|
continue;
|
|
117
226
|
}
|
|
227
|
+
const promptBlock = current;
|
|
228
|
+
if (promptBlock?.type === "prompt" && !expectingResponse) {
|
|
229
|
+
promptBlock.lines.push(trimmed);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (promptBlock?.type === "prompt" && expectingResponse && /^\s+\S/.test(trimmed)) {
|
|
233
|
+
promptBlock.lines.push(trimmed);
|
|
234
|
+
expectingResponse = false;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
118
237
|
if (expectingResponse || current?.type === "response") {
|
|
119
238
|
pushLine("response", trimmed);
|
|
120
239
|
continue;
|
|
@@ -132,7 +251,7 @@ export function parseAgentOutput(raw, options = {}) {
|
|
|
132
251
|
}
|
|
133
252
|
flush();
|
|
134
253
|
return {
|
|
135
|
-
blocks: normalizeTranscriptBlocks(blocks.filter((block) => block.text.trim().length > 0)),
|
|
254
|
+
blocks: normalizeTranscriptBlocks(blocks.filter((block) => block.text.trim().length > 0), tool),
|
|
136
255
|
parser: {
|
|
137
256
|
tool,
|
|
138
257
|
version: 1,
|
|
@@ -140,8 +259,40 @@ export function parseAgentOutput(raw, options = {}) {
|
|
|
140
259
|
},
|
|
141
260
|
};
|
|
142
261
|
}
|
|
143
|
-
function normalizeTranscriptBlocks(blocks) {
|
|
262
|
+
function normalizeTranscriptBlocks(blocks, tool) {
|
|
144
263
|
const next = blocks.map((block) => ({ ...block }));
|
|
264
|
+
const looksLikeFooterStatus = (text) => {
|
|
265
|
+
return String(text || "")
|
|
266
|
+
.split("\n")
|
|
267
|
+
.some((line) => {
|
|
268
|
+
const trimmed = line.trim();
|
|
269
|
+
return ((/^([A-Za-z0-9._-]+@[^ ]+|~\/|\/)/.test(trimmed) && /(context\)|%\s|[$#]\s)/.test(trimmed)) ||
|
|
270
|
+
/^gpt-[\w.-]+\b.*(?:~\/|\/|context\)|permissions)/i.test(trimmed) ||
|
|
271
|
+
/^claude\b.*(?:~\/|\/|context\)|permissions)/i.test(trimmed) ||
|
|
272
|
+
/bypass permissions|shift\+tab|to cycle/i.test(trimmed));
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
const looksLikeActiveWorkStatus = (text) => String(text || "")
|
|
276
|
+
.split("\n")
|
|
277
|
+
.some((line) => {
|
|
278
|
+
const trimmed = line.trim();
|
|
279
|
+
return (/\bWorking \(\d+s\b.*\besc to interrupt\b/i.test(trimmed) ||
|
|
280
|
+
/^Starting MCP servers\b/i.test(trimmed) ||
|
|
281
|
+
looksLikeActivityProgressText(trimmed));
|
|
282
|
+
});
|
|
283
|
+
const normalizedPromptText = (text) => String(text || "")
|
|
284
|
+
.trim()
|
|
285
|
+
.replace(/\s+/g, " ");
|
|
286
|
+
const promptCounts = new Map();
|
|
287
|
+
for (const block of next) {
|
|
288
|
+
if (block.type !== "prompt")
|
|
289
|
+
continue;
|
|
290
|
+
const normalized = normalizedPromptText(block.text);
|
|
291
|
+
if (!normalized)
|
|
292
|
+
continue;
|
|
293
|
+
promptCounts.set(normalized, (promptCounts.get(normalized) ?? 0) + 1);
|
|
294
|
+
}
|
|
295
|
+
const isTemplatePrompt = (text) => /\{[A-Za-z][A-Za-z0-9_-]*\}/.test(text);
|
|
145
296
|
const looksLikeAssistantText = (text) => {
|
|
146
297
|
const trimmed = String(text || "").trim();
|
|
147
298
|
if (!trimmed)
|
|
@@ -154,6 +305,26 @@ function normalizeTranscriptBlocks(blocks) {
|
|
|
154
305
|
return false;
|
|
155
306
|
return /[A-Za-z]/.test(trimmed);
|
|
156
307
|
};
|
|
308
|
+
const looksLikeRuntimeNoiseText = (text) => {
|
|
309
|
+
const lines = String(text || "")
|
|
310
|
+
.split("\n")
|
|
311
|
+
.map((line) => line.trim())
|
|
312
|
+
.filter(Boolean);
|
|
313
|
+
const joined = lines.join("\n");
|
|
314
|
+
const runtimeLineCount = lines.filter((line) => {
|
|
315
|
+
return (/^[✢✳✶✻✽·]/.test(line) ||
|
|
316
|
+
/^\(thinking\)$/i.test(line) ||
|
|
317
|
+
/^Bash\([^)]*terminal-notifier/i.test(line));
|
|
318
|
+
}).length;
|
|
319
|
+
return (runtimeLineCount >= 2 ||
|
|
320
|
+
/terminal-notifier.*Running/i.test(joined) ||
|
|
321
|
+
(/terminal-notifier/i.test(joined) && /(?:Bash command|Thiscommandrequiresapproval|Doyouwanttoproceed)/i.test(joined)));
|
|
322
|
+
};
|
|
323
|
+
for (const block of next) {
|
|
324
|
+
if (block.type === "raw" && looksLikeRuntimeNoiseText(block.text)) {
|
|
325
|
+
block.type = "status";
|
|
326
|
+
}
|
|
327
|
+
}
|
|
157
328
|
for (let i = 0; i < next.length; i += 1) {
|
|
158
329
|
const current = next[i];
|
|
159
330
|
if (!current || current.type !== "raw")
|
|
@@ -165,8 +336,13 @@ function normalizeTranscriptBlocks(blocks) {
|
|
|
165
336
|
(following?.type === "prompt" || following?.type === "response");
|
|
166
337
|
const leadingAssistantCarryover = !prev && (following?.type === "prompt" || following?.type === "response" || following?.type === "status");
|
|
167
338
|
const leadingAssistantPrelude = !prev && nextConversationIndex !== -1;
|
|
339
|
+
const leadingAssistantAfterMetaPrelude = prev?.type === "meta" && nextConversationIndex !== -1;
|
|
168
340
|
const responseContinuation = prev?.type === "response";
|
|
169
|
-
if ((betweenConversationTurns ||
|
|
341
|
+
if ((betweenConversationTurns ||
|
|
342
|
+
leadingAssistantCarryover ||
|
|
343
|
+
leadingAssistantPrelude ||
|
|
344
|
+
leadingAssistantAfterMetaPrelude ||
|
|
345
|
+
responseContinuation) &&
|
|
170
346
|
looksLikeAssistantText(current.text)) {
|
|
171
347
|
current.type = "response";
|
|
172
348
|
}
|
|
@@ -191,6 +367,34 @@ function normalizeTranscriptBlocks(blocks) {
|
|
|
191
367
|
block.type = "response";
|
|
192
368
|
}
|
|
193
369
|
}
|
|
370
|
+
for (let i = 0; i < next.length; i += 1) {
|
|
371
|
+
const current = next[i];
|
|
372
|
+
if (!current || current.type !== "prompt")
|
|
373
|
+
continue;
|
|
374
|
+
const previous = next[i - 1] || null;
|
|
375
|
+
const following = next[i + 1] || null;
|
|
376
|
+
const normalized = normalizedPromptText(current.text);
|
|
377
|
+
const nextConversationIndex = next.findIndex((block, index) => index > i && (block.type === "prompt" || block.type === "response"));
|
|
378
|
+
const intervening = next.slice(i + 1, nextConversationIndex === -1 ? undefined : nextConversationIndex);
|
|
379
|
+
const hasActiveWorkBeforeNextTurn = intervening.some((block) => block.type === "status" && looksLikeActiveWorkStatus(block.text));
|
|
380
|
+
const repeatedPrompt = (promptCounts.get(normalized) ?? 0) > 1;
|
|
381
|
+
const templatePrompt = isTemplatePrompt(current.text);
|
|
382
|
+
const hasPriorConversationTurn = next
|
|
383
|
+
.slice(0, i)
|
|
384
|
+
.some((block) => block.type === "prompt" || block.type === "response");
|
|
385
|
+
const trailingFooterInput = nextConversationIndex === -1 && hasPriorConversationTurn;
|
|
386
|
+
if (tool === "codex" &&
|
|
387
|
+
following?.type === "status" &&
|
|
388
|
+
looksLikeFooterStatus(following.text) &&
|
|
389
|
+
(!hasActiveWorkBeforeNextTurn || repeatedPrompt || templatePrompt) &&
|
|
390
|
+
(repeatedPrompt ||
|
|
391
|
+
templatePrompt ||
|
|
392
|
+
trailingFooterInput ||
|
|
393
|
+
previous?.type === "response" ||
|
|
394
|
+
(previous?.type === "status" && looksLikeActiveWorkStatus(previous.text)))) {
|
|
395
|
+
current.type = "status";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
194
398
|
const merged = [];
|
|
195
399
|
for (const block of next) {
|
|
196
400
|
const previous = merged[merged.length - 1];
|
|
@@ -200,29 +404,5 @@ function normalizeTranscriptBlocks(blocks) {
|
|
|
200
404
|
}
|
|
201
405
|
merged.push(block);
|
|
202
406
|
}
|
|
203
|
-
return
|
|
204
|
-
}
|
|
205
|
-
function stripTrailingVisiblePrompt(blocks) {
|
|
206
|
-
const promptIndex = findLastIndex(blocks, (block) => block.type === "prompt");
|
|
207
|
-
if (promptIndex === -1) {
|
|
208
|
-
return blocks;
|
|
209
|
-
}
|
|
210
|
-
const hasResponseAfterPrompt = blocks.slice(promptIndex + 1).some((block) => block.type === "response");
|
|
211
|
-
if (hasResponseAfterPrompt) {
|
|
212
|
-
return blocks;
|
|
213
|
-
}
|
|
214
|
-
const trailingBlocks = blocks.slice(promptIndex + 1);
|
|
215
|
-
const hasOnlyNonConversationTail = trailingBlocks.every((block) => block.type === "status" || block.type === "meta" || block.type === "raw");
|
|
216
|
-
if (!hasOnlyNonConversationTail) {
|
|
217
|
-
return blocks;
|
|
218
|
-
}
|
|
219
|
-
return blocks.filter((_, index) => index !== promptIndex);
|
|
220
|
-
}
|
|
221
|
-
function findLastIndex(items, predicate) {
|
|
222
|
-
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
223
|
-
if (predicate(items[index])) {
|
|
224
|
-
return index;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
return -1;
|
|
407
|
+
return merged;
|
|
228
408
|
}
|
package/dist/atomic-write.d.ts
CHANGED
|
@@ -1 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe write: stage to a temp file, fsync its contents, atomically rename
|
|
3
|
+
* into place, then fsync the directory so the rename itself survives power loss.
|
|
4
|
+
* This is the single durable write path for persistent state.
|
|
5
|
+
*/
|
|
6
|
+
export declare function atomicWrite(path: string, data: string | Buffer, options?: {
|
|
7
|
+
mode?: number;
|
|
8
|
+
}): void;
|
|
1
9
|
export declare function writeJsonAtomic(path: string, value: unknown): void;
|
|
10
|
+
export declare function writeTextAtomic(path: string, text: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Move a corrupt/unparseable state file aside (preserving it for diagnosis)
|
|
13
|
+
* instead of silently letting a reader reset to empty and a later write
|
|
14
|
+
* overwrite the evidence. Best-effort; returns the quarantine path or null.
|
|
15
|
+
*/
|
|
16
|
+
export declare function quarantineCorruptFile(path: string): string | null;
|
package/dist/atomic-write.js
CHANGED
|
@@ -1,8 +1,73 @@
|
|
|
1
|
-
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
|
|
3
|
+
function fsyncDir(dir) {
|
|
4
|
+
let fd;
|
|
5
|
+
try {
|
|
6
|
+
fd = openSync(dir, "r");
|
|
7
|
+
fsyncSync(fd);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// Directory fsync is unsupported on some filesystems; the renamed file is
|
|
11
|
+
// still covered by the temp-file fsync above. Best-effort only.
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
if (fd !== undefined)
|
|
15
|
+
closeSync(fd);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Crash-safe write: stage to a temp file, fsync its contents, atomically rename
|
|
20
|
+
* into place, then fsync the directory so the rename itself survives power loss.
|
|
21
|
+
* This is the single durable write path for persistent state.
|
|
22
|
+
*/
|
|
23
|
+
export function atomicWrite(path, data, options) {
|
|
4
24
|
mkdirSync(dirname(path), { recursive: true });
|
|
5
25
|
const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
6
|
-
|
|
7
|
-
|
|
26
|
+
let fd;
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(tmpPath, data, options?.mode !== undefined ? { mode: options.mode } : undefined);
|
|
29
|
+
fd = openSync(tmpPath, "r");
|
|
30
|
+
fsyncSync(fd);
|
|
31
|
+
closeSync(fd);
|
|
32
|
+
fd = undefined;
|
|
33
|
+
renameSync(tmpPath, path);
|
|
34
|
+
fsyncDir(dirname(path));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (fd !== undefined) {
|
|
38
|
+
try {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
rmSync(tmpPath, { force: true });
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function writeJsonAtomic(path, value) {
|
|
51
|
+
atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
export function writeTextAtomic(path, text) {
|
|
54
|
+
atomicWrite(path, text);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Move a corrupt/unparseable state file aside (preserving it for diagnosis)
|
|
58
|
+
* instead of silently letting a reader reset to empty and a later write
|
|
59
|
+
* overwrite the evidence. Best-effort; returns the quarantine path or null.
|
|
60
|
+
*/
|
|
61
|
+
export function quarantineCorruptFile(path) {
|
|
62
|
+
try {
|
|
63
|
+
if (!existsSync(path))
|
|
64
|
+
return null;
|
|
65
|
+
const dest = `${path}.corrupt-${Date.now()}`;
|
|
66
|
+
renameSync(path, dest);
|
|
67
|
+
console.error(`aimux: quarantined corrupt state file ${path} -> ${dest}`);
|
|
68
|
+
return dest;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
8
73
|
}
|
|
@@ -20,9 +20,16 @@ export interface PublicAttachmentRecord {
|
|
|
20
20
|
source: "path" | "upload";
|
|
21
21
|
contentUrl: string;
|
|
22
22
|
}
|
|
23
|
+
export interface CreateUploadedAttachmentInput {
|
|
24
|
+
filename: string;
|
|
25
|
+
mimeType: string;
|
|
26
|
+
dataBase64: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function createUploadedAttachment(input: CreateUploadedAttachmentInput): PublicAttachmentRecord;
|
|
23
29
|
export declare function getAttachment(id: string): PublicAttachmentRecord | null;
|
|
24
30
|
export declare function getAttachmentContent(id: string): {
|
|
25
31
|
attachment: PublicAttachmentRecord;
|
|
26
32
|
contentPath: string;
|
|
27
33
|
buffer: Buffer;
|
|
28
34
|
} | null;
|
|
35
|
+
export declare function getAttachmentRecord(id: string): AttachmentRecord | null;
|
package/dist/attachment-store.js
CHANGED
|
@@ -1,12 +1,55 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
3
4
|
import { getAttachmentsDir } from "./paths.js";
|
|
5
|
+
import { atomicWrite, writeJsonAtomic } from "./atomic-write.js";
|
|
6
|
+
const maxUploadBytes = 10 * 1024 * 1024;
|
|
7
|
+
const allowedImageExtensions = new Map([
|
|
8
|
+
["image/png", ".png"],
|
|
9
|
+
["image/jpeg", ".jpg"],
|
|
10
|
+
["image/webp", ".webp"],
|
|
11
|
+
["image/gif", ".gif"],
|
|
12
|
+
]);
|
|
13
|
+
export function createUploadedAttachment(input) {
|
|
14
|
+
const mimeType = input.mimeType.trim().toLowerCase();
|
|
15
|
+
const extension = allowedImageExtensions.get(mimeType);
|
|
16
|
+
if (!extension) {
|
|
17
|
+
throw new Error("unsupported attachment mime type");
|
|
18
|
+
}
|
|
19
|
+
const filename = sanitizeFilename(input.filename);
|
|
20
|
+
const dataBase64 = normalizeBase64(input.dataBase64);
|
|
21
|
+
const buffer = Buffer.from(dataBase64, "base64");
|
|
22
|
+
if (buffer.length === 0) {
|
|
23
|
+
throw new Error("attachment content is required");
|
|
24
|
+
}
|
|
25
|
+
if (buffer.length > maxUploadBytes) {
|
|
26
|
+
throw new Error("attachment exceeds 10 MB");
|
|
27
|
+
}
|
|
28
|
+
const attachmentsDir = getAttachmentsDir();
|
|
29
|
+
mkdirSync(attachmentsDir, { recursive: true });
|
|
30
|
+
const id = `att_${randomUUID().replaceAll("-", "")}`;
|
|
31
|
+
const contentPath = join(attachmentsDir, `${id}${extension}`);
|
|
32
|
+
const record = {
|
|
33
|
+
id,
|
|
34
|
+
kind: "image",
|
|
35
|
+
filename,
|
|
36
|
+
mimeType,
|
|
37
|
+
sizeBytes: buffer.length,
|
|
38
|
+
sha256: createHash("sha256").update(buffer).digest("hex"),
|
|
39
|
+
createdAt: new Date().toISOString(),
|
|
40
|
+
source: "upload",
|
|
41
|
+
contentPath,
|
|
42
|
+
};
|
|
43
|
+
atomicWrite(contentPath, buffer);
|
|
44
|
+
writeJsonAtomic(join(attachmentsDir, `${id}.json`), record);
|
|
45
|
+
return toPublicAttachment(record);
|
|
46
|
+
}
|
|
4
47
|
export function getAttachment(id) {
|
|
5
|
-
const record =
|
|
48
|
+
const record = getAttachmentRecord(id);
|
|
6
49
|
return record ? toPublicAttachment(record) : null;
|
|
7
50
|
}
|
|
8
51
|
export function getAttachmentContent(id) {
|
|
9
|
-
const record =
|
|
52
|
+
const record = getAttachmentRecord(id);
|
|
10
53
|
if (!record)
|
|
11
54
|
return null;
|
|
12
55
|
return {
|
|
@@ -15,10 +58,12 @@ export function getAttachmentContent(id) {
|
|
|
15
58
|
buffer: readFileSync(record.contentPath),
|
|
16
59
|
};
|
|
17
60
|
}
|
|
18
|
-
function
|
|
61
|
+
export function getAttachmentRecord(id) {
|
|
19
62
|
const normalizedId = id.trim();
|
|
20
63
|
if (!normalizedId)
|
|
21
64
|
return null;
|
|
65
|
+
if (!/^[A-Za-z0-9_-]+$/.test(normalizedId))
|
|
66
|
+
return null;
|
|
22
67
|
const metadataPath = join(getAttachmentsDir(), `${normalizedId}.json`);
|
|
23
68
|
if (!existsSync(metadataPath)) {
|
|
24
69
|
return null;
|
|
@@ -29,6 +74,20 @@ function loadAttachmentRecord(id) {
|
|
|
29
74
|
}
|
|
30
75
|
return parsed;
|
|
31
76
|
}
|
|
77
|
+
function sanitizeFilename(filename) {
|
|
78
|
+
const safeName = basename(filename.trim()).replaceAll(/[\\/]/g, "").trim();
|
|
79
|
+
return safeName || "image";
|
|
80
|
+
}
|
|
81
|
+
function normalizeBase64(dataBase64) {
|
|
82
|
+
const normalized = dataBase64
|
|
83
|
+
.trim()
|
|
84
|
+
.replace(/^data:[^;]+;base64,/, "")
|
|
85
|
+
.replaceAll(/\s/g, "");
|
|
86
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
|
|
87
|
+
throw new Error("attachment content must be base64");
|
|
88
|
+
}
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
32
91
|
function toPublicAttachment(record) {
|
|
33
92
|
return {
|
|
34
93
|
id: record.id,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort recovery of a claude backend session id from its on-disk
|
|
3
|
+
* transcript store, for a session whose durable backend id was lost (e.g. a
|
|
4
|
+
* crash that killed the tmux pane before the id was captured). Returns the
|
|
5
|
+
* uuid only when the worktree's transcript directory holds exactly one
|
|
6
|
+
* candidate — an unambiguous match. If the directory is absent, empty, or
|
|
7
|
+
* holds several transcripts (e.g. the main repo where many agents ran over
|
|
8
|
+
* time), it refuses and returns null rather than guess the wrong session.
|
|
9
|
+
* This preserves the "exact id only" safety of the original resume path.
|
|
10
|
+
*/
|
|
11
|
+
export declare function discoverClaudeBackendSessionId(cwd: string, projectsDir?: string): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* Recover a backend session id from the tool's own on-disk session store when
|
|
14
|
+
* the durable topology record lost it. Only claude is supported today (codex
|
|
15
|
+
* carries its id in launch args); returns null for anything else.
|
|
16
|
+
*/
|
|
17
|
+
export declare function discoverBackendSessionId(toolConfigKey: string | undefined, cwd: string | undefined): string | null;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
+
function claudeProjectsDir() {
|
|
6
|
+
// Mirrors Claude Code's own config location, which honors CLAUDE_CONFIG_DIR.
|
|
7
|
+
const override = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
8
|
+
return join(override ? override : join(homedir(), ".claude"), "projects");
|
|
9
|
+
}
|
|
10
|
+
// Claude encodes a project directory by replacing "/" and "." in the cwd with
|
|
11
|
+
// "-", e.g. /Users/x/.aimux/wt -> -Users-x--aimux-wt.
|
|
12
|
+
function encodeClaudeProjectPath(cwd) {
|
|
13
|
+
return cwd.replace(/[/.]/g, "-");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort recovery of a claude backend session id from its on-disk
|
|
17
|
+
* transcript store, for a session whose durable backend id was lost (e.g. a
|
|
18
|
+
* crash that killed the tmux pane before the id was captured). Returns the
|
|
19
|
+
* uuid only when the worktree's transcript directory holds exactly one
|
|
20
|
+
* candidate — an unambiguous match. If the directory is absent, empty, or
|
|
21
|
+
* holds several transcripts (e.g. the main repo where many agents ran over
|
|
22
|
+
* time), it refuses and returns null rather than guess the wrong session.
|
|
23
|
+
* This preserves the "exact id only" safety of the original resume path.
|
|
24
|
+
*/
|
|
25
|
+
export function discoverClaudeBackendSessionId(cwd, projectsDir = claudeProjectsDir()) {
|
|
26
|
+
const dir = join(projectsDir, encodeClaudeProjectPath(cwd));
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
return null;
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = readdirSync(dir);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const ids = [];
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (!entry.endsWith(".jsonl"))
|
|
39
|
+
continue;
|
|
40
|
+
const id = entry.slice(0, -".jsonl".length);
|
|
41
|
+
if (UUID_RE.test(id))
|
|
42
|
+
ids.push(id);
|
|
43
|
+
}
|
|
44
|
+
return ids.length === 1 ? ids[0] : null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Recover a backend session id from the tool's own on-disk session store when
|
|
48
|
+
* the durable topology record lost it. Only claude is supported today (codex
|
|
49
|
+
* carries its id in launch args); returns null for anything else.
|
|
50
|
+
*/
|
|
51
|
+
export function discoverBackendSessionId(toolConfigKey, cwd) {
|
|
52
|
+
if (!cwd || !toolConfigKey)
|
|
53
|
+
return null;
|
|
54
|
+
if (toolConfigKey === "claude")
|
|
55
|
+
return discoverClaudeBackendSessionId(cwd);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getGlobalAimuxDir, getGlobalConfigPath, getLocalAimuxDir, getConfigPath, getProjectStateDir, } from "./paths.js";
|
|
4
|
+
import { quarantineCorruptFile, writeJsonAtomic } from "./atomic-write.js";
|
|
4
5
|
const DEFAULT_CONFIG = {
|
|
5
6
|
defaultTool: "claude",
|
|
6
7
|
contextMaxEntries: 20,
|
|
@@ -103,7 +104,9 @@ export function loadConfig(opts = {}) {
|
|
|
103
104
|
const globalRaw = JSON.parse(readFileSync(globalPath, "utf-8"));
|
|
104
105
|
config = deepMerge(config, globalRaw);
|
|
105
106
|
}
|
|
106
|
-
catch {
|
|
107
|
+
catch {
|
|
108
|
+
quarantineCorruptFile(globalPath);
|
|
109
|
+
}
|
|
107
110
|
}
|
|
108
111
|
// Layer 2: project config
|
|
109
112
|
const projectPath = getConfigPath();
|
|
@@ -112,7 +115,9 @@ export function loadConfig(opts = {}) {
|
|
|
112
115
|
const projectRaw = JSON.parse(readFileSync(projectPath, "utf-8"));
|
|
113
116
|
config = deepMerge(config, projectRaw);
|
|
114
117
|
}
|
|
115
|
-
catch {
|
|
118
|
+
catch {
|
|
119
|
+
quarantineCorruptFile(projectPath);
|
|
120
|
+
}
|
|
116
121
|
}
|
|
117
122
|
return normalizeConfig(config);
|
|
118
123
|
}
|
|
@@ -122,7 +127,7 @@ export function saveConfig(config) {
|
|
|
122
127
|
if (!existsSync(dir)) {
|
|
123
128
|
mkdirSync(dir, { recursive: true });
|
|
124
129
|
}
|
|
125
|
-
|
|
130
|
+
writeJsonAtomic(getConfigPath(), config);
|
|
126
131
|
}
|
|
127
132
|
/** Save config to global ~/.aimux/config.json */
|
|
128
133
|
export function saveGlobalConfig(config) {
|
|
@@ -130,7 +135,7 @@ export function saveGlobalConfig(config) {
|
|
|
130
135
|
if (!existsSync(dir)) {
|
|
131
136
|
mkdirSync(dir, { recursive: true });
|
|
132
137
|
}
|
|
133
|
-
|
|
138
|
+
writeJsonAtomic(getGlobalConfigPath(), config);
|
|
134
139
|
}
|
|
135
140
|
const GITIGNORE_CONTENTS = `# Runtime-private service/project state (lives in ~/.aimux/projects/)
|
|
136
141
|
state.json
|