combobulator 0.1.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/LICENSE +21 -0
- package/README.md +187 -0
- package/bin/combobulate.js +6 -0
- package/package.json +46 -0
- package/src/cli.js +92 -0
- package/src/codex-registry.js +73 -0
- package/src/codex-thread-db.js +72 -0
- package/src/commands/cleanup.js +104 -0
- package/src/commands/doctor.js +119 -0
- package/src/commands/fix-codex-projects.js +164 -0
- package/src/commands/install.js +87 -0
- package/src/commands/status.js +50 -0
- package/src/commands/sync.js +80 -0
- package/src/commands/uninstall.js +17 -0
- package/src/config.js +38 -0
- package/src/daemon.js +126 -0
- package/src/log.js +29 -0
- package/src/sinks/claude.js +166 -0
- package/src/sinks/codex.js +577 -0
- package/src/sources/claude.js +144 -0
- package/src/sources/codex.js +93 -0
- package/src/sources/cursor.js +102 -0
- package/src/state.js +70 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { PATHS, MIRROR_MARKER } from '../config.js';
|
|
5
|
+
import { registerCodexWorkspaceRoot } from '../codex-registry.js';
|
|
6
|
+
import { upsertCodexThread } from '../codex-thread-db.js';
|
|
7
|
+
|
|
8
|
+
// Generate a UUIDv7-ish string Codex would accept. Codex's session ids look like
|
|
9
|
+
// timestamp-millis hex + random; format is "xxxxxxxx-xxxx-7xxx-xxxx-xxxxxxxxxxxx".
|
|
10
|
+
function uuidv7() {
|
|
11
|
+
const ts = Date.now();
|
|
12
|
+
const tsHex = ts.toString(16).padStart(12, '0');
|
|
13
|
+
const rand = crypto.randomBytes(10);
|
|
14
|
+
// Set version (7) and variant bits.
|
|
15
|
+
rand[0] = (rand[0] & 0x0f) | 0x70;
|
|
16
|
+
rand[2] = (rand[2] & 0x3f) | 0x80;
|
|
17
|
+
const r = rand.toString('hex');
|
|
18
|
+
return `${tsHex.slice(0, 8)}-${tsHex.slice(8, 12)}-${r.slice(0, 4)}-${r.slice(4, 8)}-${r.slice(8, 20)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Write a Codex rollout file that mirrors a UnifiedSession from another tool.
|
|
22
|
+
// Codex sessions live at ~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl.
|
|
23
|
+
// We also append to ~/.codex/session_index.jsonl and ~/.codex/history.jsonl so the
|
|
24
|
+
// thread shows up in `codex resume` lists and up-arrow recall.
|
|
25
|
+
export function writeCodexMirror(session, { existingSessionId, existingFilePath } = {}) {
|
|
26
|
+
const isUpdate = !!existingSessionId;
|
|
27
|
+
const sessionId = existingSessionId || uuidv7();
|
|
28
|
+
const cwd = session.cwd && session.cwd.startsWith('/') ? session.cwd : PATHS.combobulateSynced;
|
|
29
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const tsIso = now.toISOString();
|
|
33
|
+
let filePath = existingFilePath;
|
|
34
|
+
if (!filePath) {
|
|
35
|
+
const yyyy = String(now.getUTCFullYear());
|
|
36
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
37
|
+
const dd = String(now.getUTCDate()).padStart(2, '0');
|
|
38
|
+
const dir = path.join(PATHS.codexSessions, yyyy, mm, dd);
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
const isoFs = tsIso.replace(/[:.]/g, '-').slice(0, 19);
|
|
41
|
+
filePath = path.join(dir, `rollout-${isoFs}-${sessionId}.jsonl`);
|
|
42
|
+
} else {
|
|
43
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Prepend a [<source>] tag to every mirrored thread title so you can tell at
|
|
47
|
+
// a glance which tool a chat came from. Keep the raw snippet for firstUserMessage.
|
|
48
|
+
const sourceLabel = sourceTag(session.source);
|
|
49
|
+
const _rawTitle = session.threadName || firstUserSnippet(session) || 'Synced';
|
|
50
|
+
const _threadNameForMarker = `${sourceLabel} ${_rawTitle}`.slice(0, 200);
|
|
51
|
+
const _fumForMarker = (firstUserSnippet(session) || '').slice(0, 2000);
|
|
52
|
+
|
|
53
|
+
// Build a rollout that *structurally* looks like one Codex's own CLI would write.
|
|
54
|
+
//
|
|
55
|
+
// The crucial detail (learned the hard way): each user turn is its own
|
|
56
|
+
// task_started/task_complete pair with a fresh turn_id. If we lump all messages
|
|
57
|
+
// under one task_started, Codex's UI renders the second+ user messages as
|
|
58
|
+
// "steered conversation" — it thinks they're mid-turn redirects, not new turns.
|
|
59
|
+
//
|
|
60
|
+
// The loop-prevention marker hides inside session_meta.payload.combobulate so
|
|
61
|
+
// Codex's parser doesn't reject the rollout (originator='combobulate' makes it
|
|
62
|
+
// flip source to 'unknown').
|
|
63
|
+
const lines = [];
|
|
64
|
+
|
|
65
|
+
// Use the source session's createdAt for session_meta so the thread shows
|
|
66
|
+
// up in Codex's "Recent" sort under the date the original conversation
|
|
67
|
+
// started, not the date we ran the mirror.
|
|
68
|
+
const sessionStartIso = session.createdAt ? new Date(session.createdAt).toISOString() : tsIso;
|
|
69
|
+
|
|
70
|
+
lines.push(JSON.stringify({
|
|
71
|
+
timestamp: sessionStartIso,
|
|
72
|
+
type: 'session_meta',
|
|
73
|
+
payload: {
|
|
74
|
+
id: sessionId,
|
|
75
|
+
timestamp: sessionStartIso,
|
|
76
|
+
cwd,
|
|
77
|
+
originator: 'Codex CLI',
|
|
78
|
+
cli_version: '0.1.0',
|
|
79
|
+
model_provider: 'openai',
|
|
80
|
+
combobulate: {
|
|
81
|
+
[MIRROR_MARKER]: true,
|
|
82
|
+
mirrorOf: `${session.source}/${session.sessionId}`,
|
|
83
|
+
mirroredAt: tsIso,
|
|
84
|
+
title: _threadNameForMarker,
|
|
85
|
+
firstUserMessage: _fumForMarker,
|
|
86
|
+
sourceCwd: session.cwd || null,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// Translate the source's typed event stream into native Codex events. Each
|
|
92
|
+
// "turn" starts with a real user message and ends just before the next real
|
|
93
|
+
// user message (or at the end of stream). Within a turn we may have
|
|
94
|
+
// alternating assistant text + tool calls (each with its paired tool result),
|
|
95
|
+
// mirroring how Codex's own CLI writes them.
|
|
96
|
+
//
|
|
97
|
+
// Tool translation:
|
|
98
|
+
// - Bash (or any shell-like Claude tool) → function_call name=exec_command
|
|
99
|
+
// + event_msg:exec_command_end + response_item:function_call_output
|
|
100
|
+
// - Edit / Write / MultiEdit → custom_tool_call name=apply_patch
|
|
101
|
+
// + event_msg:patch_apply_end (carries the unified diff that renders the
|
|
102
|
+
// little file-change card under the agent message) + custom_tool_call_output
|
|
103
|
+
// - Read / Glob / Grep / etc. → exec_command with a simulated command line
|
|
104
|
+
//
|
|
105
|
+
// Phase rules (Codex Desktop will silently drop messages with a bad phase):
|
|
106
|
+
// - phase = 'commentary' when the agent_message is followed by more work in
|
|
107
|
+
// this turn (more text or tool calls)
|
|
108
|
+
// - phase = 'final_answer' on the LAST agent_message of the turn — Codex
|
|
109
|
+
// keys "render this as the bottom bubble + diff card" off this value.
|
|
110
|
+
const events = session.events || legacyEventsFromMessages(session.messages);
|
|
111
|
+
const turns = groupEventsIntoTurns(events);
|
|
112
|
+
let lastAgentMessage = '';
|
|
113
|
+
let lastTs = session.createdAt || now.getTime();
|
|
114
|
+
|
|
115
|
+
for (const turn of turns) {
|
|
116
|
+
const turnId = uuidv7();
|
|
117
|
+
const userTs = turn.events.find((e) => e.kind === 'user')?.ts || lastTs;
|
|
118
|
+
const finalAgentTs = [...turn.events].reverse().find((e) => e.kind === 'assistant')?.ts
|
|
119
|
+
|| turn.events[turn.events.length - 1]?.ts || userTs;
|
|
120
|
+
const userIso = new Date(userTs).toISOString();
|
|
121
|
+
const finalIso = new Date(finalAgentTs).toISOString();
|
|
122
|
+
lastTs = finalAgentTs;
|
|
123
|
+
|
|
124
|
+
lines.push(JSON.stringify({
|
|
125
|
+
timestamp: userIso,
|
|
126
|
+
type: 'event_msg',
|
|
127
|
+
payload: {
|
|
128
|
+
type: 'task_started',
|
|
129
|
+
turn_id: turnId,
|
|
130
|
+
model_context_window: 258400,
|
|
131
|
+
collaboration_mode_kind: 'default',
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
lines.push(JSON.stringify({
|
|
136
|
+
timestamp: userIso,
|
|
137
|
+
type: 'turn_context',
|
|
138
|
+
payload: {
|
|
139
|
+
turn_id: turnId,
|
|
140
|
+
cwd,
|
|
141
|
+
approval_policy: 'never',
|
|
142
|
+
sandbox_policy: { type: 'danger-full-access' },
|
|
143
|
+
model: 'gpt-5.5',
|
|
144
|
+
effort: 'medium',
|
|
145
|
+
summary: 'none',
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
// Emit the user-side first: combine any user_text events in this turn.
|
|
150
|
+
const userParts = turn.events.filter((e) => e.kind === 'user').map((e) => e.text);
|
|
151
|
+
const userText = userParts.join('\n\n');
|
|
152
|
+
if (userText) {
|
|
153
|
+
lines.push(JSON.stringify({
|
|
154
|
+
timestamp: userIso,
|
|
155
|
+
type: 'response_item',
|
|
156
|
+
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: userText }] },
|
|
157
|
+
}));
|
|
158
|
+
lines.push(JSON.stringify({
|
|
159
|
+
timestamp: userIso,
|
|
160
|
+
type: 'event_msg',
|
|
161
|
+
payload: { type: 'user_message', message: userText, images: [] },
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Emit the assistant-side events in their source order: text → tool call →
|
|
166
|
+
// tool result → text → ... Determine which assistant text is the "final"
|
|
167
|
+
// one (last one of the turn) so we can label its phase correctly.
|
|
168
|
+
const assistantEventIdxs = turn.events
|
|
169
|
+
.map((e, i) => ({ e, i }))
|
|
170
|
+
.filter(({ e }) => e.kind === 'assistant')
|
|
171
|
+
.map(({ i }) => i);
|
|
172
|
+
const finalAssistantIdx = assistantEventIdxs[assistantEventIdxs.length - 1];
|
|
173
|
+
|
|
174
|
+
// Index tool_results by callId so we can pair them up.
|
|
175
|
+
const toolResults = new Map();
|
|
176
|
+
for (const e of turn.events) {
|
|
177
|
+
if (e.kind === 'tool_result' && e.callId) toolResults.set(e.callId, e);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < turn.events.length; i++) {
|
|
181
|
+
const e = turn.events[i];
|
|
182
|
+
if (e.kind === 'user' || e.kind === 'tool_result') continue;
|
|
183
|
+
const ts = new Date(e.ts).toISOString();
|
|
184
|
+
|
|
185
|
+
if (e.kind === 'assistant') {
|
|
186
|
+
const isFinal = i === finalAssistantIdx;
|
|
187
|
+
const phase = isFinal ? 'final_answer' : 'commentary';
|
|
188
|
+
|
|
189
|
+
// Reasoning stub before every agent_message — Codex's renderer expects it.
|
|
190
|
+
lines.push(JSON.stringify({
|
|
191
|
+
timestamp: ts,
|
|
192
|
+
type: 'response_item',
|
|
193
|
+
payload: { type: 'reasoning', summary: [], content: null, encrypted_content: '' },
|
|
194
|
+
}));
|
|
195
|
+
lines.push(JSON.stringify({
|
|
196
|
+
timestamp: ts,
|
|
197
|
+
type: 'event_msg',
|
|
198
|
+
payload: { type: 'agent_message', message: e.text, phase, memory_citation: null },
|
|
199
|
+
}));
|
|
200
|
+
lines.push(JSON.stringify({
|
|
201
|
+
timestamp: ts,
|
|
202
|
+
type: 'response_item',
|
|
203
|
+
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: e.text }], phase },
|
|
204
|
+
}));
|
|
205
|
+
lastAgentMessage = e.text;
|
|
206
|
+
} else if (e.kind === 'tool_call') {
|
|
207
|
+
const result = toolResults.get(e.callId);
|
|
208
|
+
emitToolCall(lines, { call: e, result, cwd, turnId, ts });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// If a turn has no assistant text at all (rare — Claude usually responds),
|
|
213
|
+
// still close with task_complete so Codex doesn't think the turn is open.
|
|
214
|
+
lines.push(JSON.stringify({
|
|
215
|
+
timestamp: finalIso,
|
|
216
|
+
type: 'event_msg',
|
|
217
|
+
payload: {
|
|
218
|
+
type: 'task_complete',
|
|
219
|
+
turn_id: turnId,
|
|
220
|
+
last_agent_message: lastAgentMessage.slice(0, 2000),
|
|
221
|
+
completed_at: Math.floor(finalAgentTs / 1000),
|
|
222
|
+
duration_ms: Math.max(0, finalAgentTs - userTs),
|
|
223
|
+
},
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fs.writeFileSync(filePath, lines.join('\n') + '\n');
|
|
228
|
+
|
|
229
|
+
// Only append to session_index + history on the FIRST mirror. Re-mirrors overwrite
|
|
230
|
+
// the rollout in place. (Codex's UI dedups by id, but appending repeatedly bloats
|
|
231
|
+
// history and shows duplicate entries in some views.)
|
|
232
|
+
// Same tagged title for the threads-table title field and session_index entry.
|
|
233
|
+
const threadName = `${sourceLabel} ${_rawTitle}`.slice(0, 80);
|
|
234
|
+
const firstUserMessage = firstUserSnippet(session) || '';
|
|
235
|
+
// Rough token estimate from the full transcript (~4 chars per token). Just
|
|
236
|
+
// needs to be nonzero so Codex Desktop doesn't treat the thread as empty.
|
|
237
|
+
const approxTokens = Math.max(1, Math.ceil(session.messages.reduce((n, m) => n + (m.text?.length || 0), 0) / 4));
|
|
238
|
+
|
|
239
|
+
// Upsert the threads-table row on every mirror (first AND update). This is
|
|
240
|
+
// what makes the chat visible in Codex Desktop's per-cwd sidebar. INSERT OR
|
|
241
|
+
// REPLACE refreshes title/preview/tokens as the source grows. Use source
|
|
242
|
+
// timestamps so the row sorts under the real chat date, not the mirror time.
|
|
243
|
+
try {
|
|
244
|
+
upsertCodexThread({
|
|
245
|
+
sessionId,
|
|
246
|
+
rolloutPath: filePath,
|
|
247
|
+
cwd: session.cwd && session.cwd.startsWith('/') ? session.cwd : PATHS.combobulateSynced,
|
|
248
|
+
title: threadName,
|
|
249
|
+
firstUserMessage,
|
|
250
|
+
createdAtMs: session.createdAt || now.getTime(),
|
|
251
|
+
updatedAtMs: session.updatedAt || now.getTime(),
|
|
252
|
+
approxTokens,
|
|
253
|
+
});
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
256
|
+
if (!isUpdate) {
|
|
257
|
+
// Ask Codex Desktop to register this cwd as a workspace root (via the
|
|
258
|
+
// `codex app <path>` CLI, which IPCs the running app). Skipped if already
|
|
259
|
+
// registered, so daemon-driven mirrors to known cwds don't steal focus.
|
|
260
|
+
// Fire-and-forget — the mirror file + SQLite row are written regardless.
|
|
261
|
+
if (session.cwd && session.cwd.startsWith('/')) {
|
|
262
|
+
registerCodexWorkspaceRoot(session.cwd).catch(() => {});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fs.mkdirSync(path.dirname(PATHS.codexSessionIndex), { recursive: true });
|
|
266
|
+
fs.appendFileSync(
|
|
267
|
+
PATHS.codexSessionIndex,
|
|
268
|
+
JSON.stringify({ id: sessionId, thread_name: threadName, updated_at: tsIso }) + '\n'
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const lastUser = [...session.messages].reverse().find((m) => m.role === 'user');
|
|
272
|
+
if (lastUser) {
|
|
273
|
+
const label = `[from ${session.source}${session.threadName ? `: ${session.threadName}` : ''}] `;
|
|
274
|
+
fs.mkdirSync(path.dirname(PATHS.codexHistory), { recursive: true });
|
|
275
|
+
fs.appendFileSync(
|
|
276
|
+
PATHS.codexHistory,
|
|
277
|
+
JSON.stringify({
|
|
278
|
+
session_id: sessionId,
|
|
279
|
+
ts: Math.floor(now.getTime() / 1000),
|
|
280
|
+
text: (label + lastUser.text).slice(0, 4000),
|
|
281
|
+
}) + '\n'
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { sessionId, filePath };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Human-readable label for the tool the mirror came from. Prepended to every
|
|
290
|
+
// title so Codex's chat list makes it obvious which tool originated the chat.
|
|
291
|
+
function sourceTag(source) {
|
|
292
|
+
switch (source) {
|
|
293
|
+
case 'claude': return '[Claude Code]';
|
|
294
|
+
case 'cursor': return '[Cursor]';
|
|
295
|
+
case 'codex': return '[Codex]';
|
|
296
|
+
default: return `[${source || 'synced'}]`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Fallback for sources that haven't been upgraded to emit the typed event
|
|
301
|
+
// stream (currently Cursor). Synthesizes a minimal user/assistant event list.
|
|
302
|
+
function legacyEventsFromMessages(messages) {
|
|
303
|
+
return (messages || []).map((m) => ({ kind: m.role, text: m.text, ts: m.ts }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Group a typed event stream into Codex-shaped turns. A turn starts at a
|
|
307
|
+
// `user` event (real human prompt) and runs until just before the next `user`
|
|
308
|
+
// event. `tool_result` events stay with the turn that produced their matching
|
|
309
|
+
// `tool_call`, not with whatever user message technically delivered them in
|
|
310
|
+
// the source format.
|
|
311
|
+
function groupEventsIntoTurns(events) {
|
|
312
|
+
const turns = [];
|
|
313
|
+
let cur = null;
|
|
314
|
+
for (const e of events) {
|
|
315
|
+
if (e.kind === 'user') {
|
|
316
|
+
if (cur) turns.push(cur);
|
|
317
|
+
cur = { events: [e] };
|
|
318
|
+
} else {
|
|
319
|
+
if (!cur) cur = { events: [] };
|
|
320
|
+
cur.events.push(e);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (cur) turns.push(cur);
|
|
324
|
+
return turns;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Translate a single Claude tool_use + its paired tool_result into the right
|
|
328
|
+
// Codex events. Bash → exec_command, Edit/Write/MultiEdit → apply_patch with a
|
|
329
|
+
// proper unified_diff so Codex's UI renders the per-message diff card.
|
|
330
|
+
//
|
|
331
|
+
// The call_id format MATTERS: Codex Desktop's renderer links function_call to
|
|
332
|
+
// function_call_output by exact-matching `^call_[A-Za-z0-9]{20,}$`. If the id
|
|
333
|
+
// doesn't match the expected shape, the output never associates with the call
|
|
334
|
+
// and the expanded details panel shows nothing. We generate ids that fit.
|
|
335
|
+
function emitToolCall(lines, { call, result, cwd, turnId, ts }) {
|
|
336
|
+
const callId = codexCallId();
|
|
337
|
+
const tool = call.tool || 'unknown';
|
|
338
|
+
const isEdit = /^(Edit|MultiEdit|Write|NotebookEdit)$/i.test(tool);
|
|
339
|
+
|
|
340
|
+
if (isEdit) {
|
|
341
|
+
const { patch, diffs, filePath } = renderApplyPatch(tool, call.input);
|
|
342
|
+
lines.push(JSON.stringify({
|
|
343
|
+
timestamp: ts,
|
|
344
|
+
type: 'response_item',
|
|
345
|
+
payload: { type: 'custom_tool_call', status: 'completed', call_id: callId, name: 'apply_patch', input: patch },
|
|
346
|
+
}));
|
|
347
|
+
const rawOut = result?.output || `Success. Updated the following files:\nM ${filePath || '(unknown)'}`;
|
|
348
|
+
lines.push(JSON.stringify({
|
|
349
|
+
timestamp: ts,
|
|
350
|
+
type: 'event_msg',
|
|
351
|
+
payload: {
|
|
352
|
+
type: 'patch_apply_end',
|
|
353
|
+
call_id: callId,
|
|
354
|
+
turn_id: turnId,
|
|
355
|
+
stdout: rawOut,
|
|
356
|
+
stderr: '',
|
|
357
|
+
success: !(result?.isError),
|
|
358
|
+
changes: diffs,
|
|
359
|
+
status: 'completed',
|
|
360
|
+
},
|
|
361
|
+
}));
|
|
362
|
+
lines.push(JSON.stringify({
|
|
363
|
+
timestamp: ts,
|
|
364
|
+
type: 'response_item',
|
|
365
|
+
payload: {
|
|
366
|
+
type: 'custom_tool_call_output',
|
|
367
|
+
call_id: callId,
|
|
368
|
+
output: JSON.stringify({ output: rawOut, metadata: { exit_code: result?.isError ? 1 : 0, duration_seconds: 0 } }),
|
|
369
|
+
},
|
|
370
|
+
}));
|
|
371
|
+
} else {
|
|
372
|
+
const cmd = renderExecCommand(tool, call.input);
|
|
373
|
+
const args = JSON.stringify({ cmd, workdir: cwd, yield_time_ms: 1000, max_output_tokens: 10000 });
|
|
374
|
+
lines.push(JSON.stringify({
|
|
375
|
+
timestamp: ts,
|
|
376
|
+
type: 'response_item',
|
|
377
|
+
payload: { type: 'function_call', name: 'exec_command', arguments: args, call_id: callId },
|
|
378
|
+
}));
|
|
379
|
+
const rawOut = result?.output || '';
|
|
380
|
+
lines.push(JSON.stringify({
|
|
381
|
+
timestamp: ts,
|
|
382
|
+
type: 'event_msg',
|
|
383
|
+
payload: {
|
|
384
|
+
type: 'exec_command_end',
|
|
385
|
+
call_id: callId,
|
|
386
|
+
process_id: '0',
|
|
387
|
+
turn_id: turnId,
|
|
388
|
+
command: ['/bin/zsh', '-lc', cmd],
|
|
389
|
+
cwd,
|
|
390
|
+
parsed_cmd: [{ type: 'unknown', cmd }],
|
|
391
|
+
source: 'unified_exec_startup',
|
|
392
|
+
stdout: '',
|
|
393
|
+
stderr: '',
|
|
394
|
+
aggregated_output: rawOut,
|
|
395
|
+
exit_code: result?.isError ? 1 : 0,
|
|
396
|
+
duration: { secs: 0, nanos: 0 },
|
|
397
|
+
formatted_output: '',
|
|
398
|
+
status: 'completed',
|
|
399
|
+
},
|
|
400
|
+
}));
|
|
401
|
+
// Wrap the function_call_output payload in Codex's standard envelope so
|
|
402
|
+
// the details panel renders the structured Chunk-ID / Process-exited /
|
|
403
|
+
// Output: header it expects.
|
|
404
|
+
lines.push(JSON.stringify({
|
|
405
|
+
timestamp: ts,
|
|
406
|
+
type: 'response_item',
|
|
407
|
+
payload: {
|
|
408
|
+
type: 'function_call_output',
|
|
409
|
+
call_id: callId,
|
|
410
|
+
output: formatCodexExecOutput(rawOut, result?.isError ? 1 : 0),
|
|
411
|
+
},
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Build the Codex-style function_call_output text envelope that the Desktop
|
|
417
|
+
// renderer parses to populate the expanded tool-output panel.
|
|
418
|
+
function formatCodexExecOutput(text, exitCode) {
|
|
419
|
+
const chunkId = Math.random().toString(36).slice(2, 8);
|
|
420
|
+
const tokens = Math.max(1, Math.ceil((text || '').length / 4));
|
|
421
|
+
return `Chunk ID: ${chunkId}\nWall time: 0.0000 seconds\nProcess exited with code ${exitCode}\nOriginal token count: ${tokens}\nOutput:\n${text}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Generate a call_id in Codex's expected shape: `call_` + 24 base62 chars.
|
|
425
|
+
function codexCallId() {
|
|
426
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
427
|
+
let out = 'call_';
|
|
428
|
+
for (let i = 0; i < 24; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Compose a Codex-style apply_patch input from Claude's Edit-family tool args.
|
|
433
|
+
// Returns the V4A patch string + a `changes` dict for patch_apply_end.
|
|
434
|
+
function renderApplyPatch(tool, input) {
|
|
435
|
+
input = input || {};
|
|
436
|
+
const filePath = input.file_path || input.path || input.notebook_path || '(unknown file)';
|
|
437
|
+
|
|
438
|
+
if (/^MultiEdit$/i.test(tool) && Array.isArray(input.edits)) {
|
|
439
|
+
let patch = `*** Begin Patch\n*** Update File: ${filePath}\n`;
|
|
440
|
+
const diffHunks = [];
|
|
441
|
+
for (const ed of input.edits) {
|
|
442
|
+
patch += '@@\n';
|
|
443
|
+
patch += unifiedHunkFromEdit(ed.old_string || '', ed.new_string || '');
|
|
444
|
+
diffHunks.push(unifiedHunkFromEdit(ed.old_string || '', ed.new_string || ''));
|
|
445
|
+
}
|
|
446
|
+
patch += '*** End Patch';
|
|
447
|
+
return {
|
|
448
|
+
patch,
|
|
449
|
+
filePath,
|
|
450
|
+
diffs: { [filePath]: { type: 'update', unified_diff: diffHunks.join(''), move_path: null } },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (/^Write$/i.test(tool)) {
|
|
455
|
+
const content = input.content || '';
|
|
456
|
+
const lines = content.split('\n');
|
|
457
|
+
const diff = lines.map((l) => `+${l}`).join('\n');
|
|
458
|
+
return {
|
|
459
|
+
patch: `*** Begin Patch\n*** Add File: ${filePath}\n${diff}\n*** End Patch`,
|
|
460
|
+
filePath,
|
|
461
|
+
diffs: { [filePath]: { type: 'add', unified_diff: `@@\n${diff}\n`, move_path: null } },
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Edit (single-edit). NotebookEdit uses similar shape.
|
|
466
|
+
const oldStr = input.old_string || '';
|
|
467
|
+
const newStr = input.new_string || '';
|
|
468
|
+
const hunk = unifiedHunkFromEdit(oldStr, newStr);
|
|
469
|
+
return {
|
|
470
|
+
patch: `*** Begin Patch\n*** Update File: ${filePath}\n@@\n${hunk}*** End Patch`,
|
|
471
|
+
filePath,
|
|
472
|
+
diffs: { [filePath]: { type: 'update', unified_diff: hunk, move_path: null } },
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Convert an Edit's (old_string, new_string) into a minimal unified-diff hunk.
|
|
477
|
+
// No surrounding context — Codex's renderer just needs the +/- lines to show
|
|
478
|
+
// the change card under the agent message.
|
|
479
|
+
function unifiedHunkFromEdit(oldStr, newStr) {
|
|
480
|
+
const oldLines = oldStr.split('\n').map((l) => `-${l}`);
|
|
481
|
+
const newLines = newStr.split('\n').map((l) => `+${l}`);
|
|
482
|
+
return [...oldLines, ...newLines].join('\n') + '\n';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Synthesize a shell command for non-Bash Claude tools so the exec_command
|
|
486
|
+
// rendering reads like a real terminal invocation rather than "[tool: Glob]".
|
|
487
|
+
function renderExecCommand(tool, input) {
|
|
488
|
+
input = input || {};
|
|
489
|
+
switch (tool) {
|
|
490
|
+
case 'Bash': return input.command || input.cmd || '';
|
|
491
|
+
case 'Read': return `cat ${input.file_path || ''}`.trim();
|
|
492
|
+
case 'Glob': return `find . -path '${input.pattern || ''}'`;
|
|
493
|
+
case 'Grep': {
|
|
494
|
+
const pattern = JSON.stringify(input.pattern || '');
|
|
495
|
+
const path_ = input.path ? ` ${input.path}` : '';
|
|
496
|
+
return `rg ${pattern}${path_}`;
|
|
497
|
+
}
|
|
498
|
+
case 'WebFetch': return `curl -L ${input.url || ''}`;
|
|
499
|
+
case 'WebSearch': return `# search: ${input.query || ''}`;
|
|
500
|
+
default: {
|
|
501
|
+
// Generic fallback: tool name + JSON-ish args, kept short.
|
|
502
|
+
const summary = JSON.stringify(input).slice(0, 200);
|
|
503
|
+
return `# ${tool} ${summary}`;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function cryptoRandomId() {
|
|
509
|
+
return Math.random().toString(36).slice(2, 14);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Group the normalized message stream into Codex-shaped turns. A "turn" is
|
|
513
|
+
// (optional user, optional assistant). Consecutive same-role messages get
|
|
514
|
+
// concatenated so each transitions user→assistant→user creates a new turn,
|
|
515
|
+
// matching how Codex thinks about the conversation.
|
|
516
|
+
//
|
|
517
|
+
// Each turn also carries the source timestamp of its first user and first
|
|
518
|
+
// assistant message so the rollout's per-event timestamps reflect when the
|
|
519
|
+
// original conversation actually happened (not when we mirrored it).
|
|
520
|
+
function groupIntoTurns(messages) {
|
|
521
|
+
const turns = [];
|
|
522
|
+
let cur = null;
|
|
523
|
+
let lastRole = null;
|
|
524
|
+
for (const m of messages) {
|
|
525
|
+
if (!m.text) continue;
|
|
526
|
+
if (m.role === 'user') {
|
|
527
|
+
if (cur && lastRole === 'assistant') {
|
|
528
|
+
turns.push(cur);
|
|
529
|
+
cur = null;
|
|
530
|
+
}
|
|
531
|
+
if (!cur) cur = { user: '', userTs: null, assistant: '', assistantTs: null };
|
|
532
|
+
cur.user = cur.user ? cur.user + '\n\n' + m.text : m.text;
|
|
533
|
+
if (!cur.userTs && m.ts) cur.userTs = m.ts;
|
|
534
|
+
lastRole = 'user';
|
|
535
|
+
} else if (m.role === 'assistant') {
|
|
536
|
+
if (!cur) cur = { user: '', userTs: null, assistant: '', assistantTs: null };
|
|
537
|
+
cur.assistant = cur.assistant ? cur.assistant + '\n\n' + m.text : m.text;
|
|
538
|
+
if (!cur.assistantTs && m.ts) cur.assistantTs = m.ts;
|
|
539
|
+
lastRole = 'assistant';
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (cur) turns.push(cur);
|
|
543
|
+
return turns;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Pick a clean title for the synced chat. Skip Claude Code's IDE-injected
|
|
547
|
+
// preambles (<ide_opened_file>...), system reminders, environment dumps,
|
|
548
|
+
// and the like — those make terrible thread names. Use the first user
|
|
549
|
+
// message that looks like an actual human prompt.
|
|
550
|
+
function firstUserSnippet(session) {
|
|
551
|
+
const looksInjected = (text) =>
|
|
552
|
+
/^\s*<(ide_opened_file|system-reminder|command-(name|message)|environment_context|local-command-stdout|command-stderr)\b/i.test(text) ||
|
|
553
|
+
/^\s*\[Pasted text/.test(text);
|
|
554
|
+
|
|
555
|
+
for (const m of session.messages) {
|
|
556
|
+
if (m.role !== 'user') continue;
|
|
557
|
+
if (looksInjected(m.text)) continue;
|
|
558
|
+
return m.text.split('\n').find((l) => l.trim()).slice(0, 80);
|
|
559
|
+
}
|
|
560
|
+
// fallback: any user message at all
|
|
561
|
+
const u = session.messages.find((m) => m.role === 'user');
|
|
562
|
+
return u ? u.text.split('\n')[0].slice(0, 80) : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function renderTranscript(session) {
|
|
566
|
+
const header =
|
|
567
|
+
`[Synced from ${session.source}${session.threadName ? `: ${session.threadName}` : ''} via combobulate]\n` +
|
|
568
|
+
`Source session: ${session.sessionId}\n` +
|
|
569
|
+
(session.cwd ? `Source cwd: ${session.cwd}\n` : '') +
|
|
570
|
+
`Synced: ${new Date().toISOString()}\n\n` +
|
|
571
|
+
`Below is the prior conversation. Continue from where it left off.\n` +
|
|
572
|
+
`---\n`;
|
|
573
|
+
const body = session.messages
|
|
574
|
+
.map((m) => `**${m.role === 'user' ? 'User' : 'Assistant'}:** ${m.text}`)
|
|
575
|
+
.join('\n\n');
|
|
576
|
+
return header + body;
|
|
577
|
+
}
|