wtt-connect 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/README.md +295 -0
- package/bin/wtt-connect.js +7 -0
- package/package.json +32 -0
- package/scripts/install-agent.sh +4 -0
- package/scripts/install-launchd.sh +51 -0
- package/scripts/install-systemd-user.sh +189 -0
- package/scripts/uninstall-launchd.sh +18 -0
- package/scripts/uninstall-systemd-user.sh +42 -0
- package/src/adapter-registry.js +58 -0
- package/src/adapters/acp.js +196 -0
- package/src/adapters/claude-code.js +161 -0
- package/src/adapters/codex.js +88 -0
- package/src/adapters/generic-cli.js +258 -0
- package/src/adapters/index.js +28 -0
- package/src/adapters/openclaw.js +32 -0
- package/src/artifacts.js +68 -0
- package/src/attachments.js +181 -0
- package/src/config.js +118 -0
- package/src/env.js +42 -0
- package/src/events.js +23 -0
- package/src/logger.js +18 -0
- package/src/main.js +143 -0
- package/src/mime.js +12 -0
- package/src/permissions.js +28 -0
- package/src/runner.js +562 -0
- package/src/runtime-info.js +71 -0
- package/src/service-manager.js +510 -0
- package/src/session-manager.js +31 -0
- package/src/setup.js +71 -0
- package/src/shell-runner.js +184 -0
- package/src/smoke.js +99 -0
- package/src/store.js +58 -0
- package/src/stt.js +146 -0
- package/src/terminal-session.js +152 -0
- package/src/tts.js +40 -0
- package/src/wtt-api.js +79 -0
- package/src/wtt-client.js +144 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createAdapter, normalizeAdapterName } from './adapters/index.js';
|
|
2
|
+
|
|
3
|
+
export class AdapterRegistry {
|
|
4
|
+
constructor(config, deps = {}) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.adapters = new Map();
|
|
7
|
+
const names = config.adapters?.length ? config.adapters : [config.adapter || 'codex'];
|
|
8
|
+
for (const name of names) {
|
|
9
|
+
const normalized = normalizeAdapterName(name);
|
|
10
|
+
const adapterConfig = { ...config, adapter: normalized };
|
|
11
|
+
this.adapters.set(normalized, createAdapter(adapterConfig, deps));
|
|
12
|
+
}
|
|
13
|
+
this.defaultName = normalizeAdapterName(config.adapter || names[0] || 'codex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
default() {
|
|
17
|
+
return this.get(this.defaultName);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get(name) {
|
|
21
|
+
const adapter = this.adapters.get(normalizeAdapterName(name)) || this.adapters.get(this.defaultName);
|
|
22
|
+
if (!adapter) throw new Error(`adapter not configured: ${name}`);
|
|
23
|
+
return adapter;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
select(input = {}) {
|
|
27
|
+
const explicit = String(input.adapter || input.exec_mode || input.execMode || '').trim().toLowerCase();
|
|
28
|
+
if (explicit && this.adapters.has(normalizeAdapterName(explicit))) return this.get(normalizeAdapterName(explicit));
|
|
29
|
+
const type = String(input.task_type || input.taskType || '').trim().toLowerCase();
|
|
30
|
+
if (type && this.config.adapterPolicy?.[type]) return this.get(this.config.adapterPolicy[type]);
|
|
31
|
+
const text = `${input.title || ''}\n${input.description || ''}\n${input.content || ''}`.toLowerCase();
|
|
32
|
+
for (const alias of adapterMentionAliases()) {
|
|
33
|
+
if (text.includes(`@${alias}`) && this.adapters.has(normalizeAdapterName(alias))) return this.get(alias);
|
|
34
|
+
}
|
|
35
|
+
return this.default();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
list() {
|
|
39
|
+
return [...this.adapters.keys()];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function adapterMentionAliases() {
|
|
44
|
+
return [
|
|
45
|
+
'codex',
|
|
46
|
+
'claude', 'claude-code',
|
|
47
|
+
'cursor', 'cursor-agent',
|
|
48
|
+
'gemini',
|
|
49
|
+
'qoder',
|
|
50
|
+
'opencode', 'crush',
|
|
51
|
+
'iflow',
|
|
52
|
+
'kimi',
|
|
53
|
+
'pi',
|
|
54
|
+
'acp',
|
|
55
|
+
'devin',
|
|
56
|
+
'openclaw',
|
|
57
|
+
];
|
|
58
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { log } from '../logger.js';
|
|
4
|
+
|
|
5
|
+
export class AcpAdapter {
|
|
6
|
+
constructor(config, deps = {}) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.store = deps.store;
|
|
9
|
+
this.name = config.adapter === 'devin' ? 'devin' : 'acp';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async run(prompt, context = {}) {
|
|
13
|
+
const sessionKey = context.sessionKey || 'default';
|
|
14
|
+
const stored = this.store?.getSession(sessionKey) || {};
|
|
15
|
+
const bin = this.config.acpCommand || this.config.acpBin || 'acp-agent';
|
|
16
|
+
const args = this.config.acpArgs || [];
|
|
17
|
+
const resumeSessionId = stored.acpSessionId || (this.name === 'devin' ? stored.devinSessionId : '');
|
|
18
|
+
log('info', `${this.name} launch`, { sessionKey, resume: Boolean(resumeSessionId), command: bin });
|
|
19
|
+
const result = await runAcpTurn({
|
|
20
|
+
bin,
|
|
21
|
+
args,
|
|
22
|
+
cwd: this.config.workDir,
|
|
23
|
+
prompt,
|
|
24
|
+
resumeSessionId,
|
|
25
|
+
timeoutMs: this.config.taskTimeoutSeconds * 1000,
|
|
26
|
+
mode: this.config.mode,
|
|
27
|
+
displayName: this.config.acpDisplayName || (this.name === 'devin' ? 'Devin' : 'ACP Agent'),
|
|
28
|
+
context,
|
|
29
|
+
});
|
|
30
|
+
if (result.sessionId) {
|
|
31
|
+
const patch = { acpSessionId: result.sessionId, adapter: this.name };
|
|
32
|
+
if (this.name === 'devin') patch.devinSessionId = result.sessionId;
|
|
33
|
+
this.store?.patchSession(sessionKey, patch);
|
|
34
|
+
}
|
|
35
|
+
return result.text.trim();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runAcpTurn({ bin, args, cwd, prompt, resumeSessionId, timeoutMs, mode, displayName, context }) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } });
|
|
42
|
+
const rpc = new JsonRpc(child);
|
|
43
|
+
let stderr = '';
|
|
44
|
+
let sessionId = '';
|
|
45
|
+
const chunks = [];
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
child.kill('SIGTERM');
|
|
48
|
+
reject(new Error(`${bin} timed out after ${timeoutMs}ms`));
|
|
49
|
+
}, timeoutMs);
|
|
50
|
+
|
|
51
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
52
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
53
|
+
child.on('close', (code) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
if (code !== 0 && !rpc.resolved) reject(new Error(`${bin} exited ${code}: ${stderr.trim()}`));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
rpc.onNotification = (method, params) => {
|
|
59
|
+
if (method !== 'session/update') return;
|
|
60
|
+
const text = extractAcpUpdateText(params);
|
|
61
|
+
if (text) chunks.push(text);
|
|
62
|
+
if (context.onProgress) context.onProgress({ method, params }).catch(() => {});
|
|
63
|
+
};
|
|
64
|
+
rpc.onRequest = async (method, id) => {
|
|
65
|
+
// Keep the MVP fail-closed for permission prompts; concrete permission
|
|
66
|
+
// brokering can be layered in the same way Codex modes are brokered.
|
|
67
|
+
if (method === 'session/request_permission') {
|
|
68
|
+
await rpc.respond(id, { outcome: { outcome: 'cancelled' } });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await rpc.respondError(id, -32601, `method not implemented: ${method}`);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
(async () => {
|
|
75
|
+
try {
|
|
76
|
+
const init = await rpc.call('initialize', {
|
|
77
|
+
protocolVersion: 1,
|
|
78
|
+
clientCapabilities: {
|
|
79
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
80
|
+
terminal: false,
|
|
81
|
+
},
|
|
82
|
+
clientInfo: { name: 'wtt-connect', version: '0.1.0' },
|
|
83
|
+
});
|
|
84
|
+
const canLoad = Boolean(init?.agentCapabilities?.loadSession || init?.agentCapabilities?.sessionCapabilities?.load);
|
|
85
|
+
let session;
|
|
86
|
+
if (resumeSessionId && canLoad) {
|
|
87
|
+
try {
|
|
88
|
+
session = await rpc.call('session/load', { sessionId: resumeSessionId, cwd, mcpServers: [] });
|
|
89
|
+
} catch {
|
|
90
|
+
session = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!session) session = await rpc.call('session/new', { cwd, mcpServers: [] });
|
|
94
|
+
sessionId = session?.sessionId;
|
|
95
|
+
if (!sessionId) throw new Error('acp: empty sessionId');
|
|
96
|
+
await maybeSetMode(rpc, session, sessionId, mode);
|
|
97
|
+
const fullPrompt = appendAttachmentRefs(prompt, context);
|
|
98
|
+
await rpc.call('session/prompt', {
|
|
99
|
+
sessionId,
|
|
100
|
+
prompt: [{ type: 'text', text: fullPrompt }],
|
|
101
|
+
});
|
|
102
|
+
rpc.resolved = true;
|
|
103
|
+
child.kill('SIGTERM');
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
resolve({ text: chunks.join('').trim(), sessionId });
|
|
106
|
+
} catch (err) {
|
|
107
|
+
child.kill('SIGTERM');
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
reject(err);
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class JsonRpc {
|
|
116
|
+
constructor(child) {
|
|
117
|
+
this.child = child;
|
|
118
|
+
this.nextId = 1;
|
|
119
|
+
this.pending = new Map();
|
|
120
|
+
this.onNotification = () => {};
|
|
121
|
+
this.onRequest = async () => {};
|
|
122
|
+
this.resolved = false;
|
|
123
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
124
|
+
rl.on('line', (line) => this.handleLine(line));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
call(method, params) {
|
|
128
|
+
const id = this.nextId++;
|
|
129
|
+
this.write({ jsonrpc: '2.0', id, method, params });
|
|
130
|
+
return new Promise((resolve, reject) => this.pending.set(String(id), { resolve, reject }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
respond(id, result) {
|
|
134
|
+
this.write({ jsonrpc: '2.0', id, result });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
respondError(id, code, message) {
|
|
138
|
+
this.write({ jsonrpc: '2.0', id, error: { code, message } });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
write(payload) {
|
|
142
|
+
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
handleLine(line) {
|
|
146
|
+
if (!line.trim()) return;
|
|
147
|
+
let msg;
|
|
148
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
149
|
+
if (Object.hasOwn(msg, 'id') && (Object.hasOwn(msg, 'result') || Object.hasOwn(msg, 'error'))) {
|
|
150
|
+
const pending = this.pending.get(String(msg.id));
|
|
151
|
+
if (!pending) return;
|
|
152
|
+
this.pending.delete(String(msg.id));
|
|
153
|
+
if (msg.error) pending.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
154
|
+
else pending.resolve(msg.result);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (msg.method && Object.hasOwn(msg, 'id')) {
|
|
158
|
+
this.onRequest(msg.method, msg.id, msg.params).catch(() => {});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (msg.method) this.onNotification(msg.method, msg.params);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function maybeSetMode(rpc, session, sessionId, mode) {
|
|
166
|
+
const normalized = String(mode || '').trim().toLowerCase().replace(/_/g, '-');
|
|
167
|
+
if (!normalized || normalized === 'default' || normalized === 'full-auto') return;
|
|
168
|
+
const modes = session?.modes?.availableModes || session?.modes?.modes || [];
|
|
169
|
+
const ids = Array.isArray(modes) ? modes.map((m) => m.id || m.modeId || m.name).filter(Boolean) : [];
|
|
170
|
+
if (ids.length && !ids.includes(normalized)) return;
|
|
171
|
+
try { await rpc.call('session/set_mode', { sessionId, modeId: normalized }); } catch {}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function extractAcpUpdateText(params) {
|
|
175
|
+
const update = params?.update || params?.sessionUpdate || params;
|
|
176
|
+
if (!update) return '';
|
|
177
|
+
const kind = update.sessionUpdate || update.type || update.kind;
|
|
178
|
+
if (kind === 'agent_message_chunk' || kind === 'reasoning_chunk') return update.content || update.text || update.delta || '';
|
|
179
|
+
if (kind === 'agent_message') return valueToText(update.content || update.message || update.text);
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function valueToText(value) {
|
|
184
|
+
if (!value) return '';
|
|
185
|
+
if (typeof value === 'string') return value;
|
|
186
|
+
if (Array.isArray(value)) return value.map(valueToText).join('');
|
|
187
|
+
if (typeof value === 'object') return value.text || value.content || '';
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function appendAttachmentRefs(prompt, context = {}) {
|
|
192
|
+
const refs = [];
|
|
193
|
+
if (context.images?.length) refs.push(`Attached images: ${context.images.join(', ')}`);
|
|
194
|
+
if (context.files?.length) refs.push(`Attached files: ${context.files.join(', ')}`);
|
|
195
|
+
return refs.length ? `${prompt}\n\n${refs.join('\n')}` : prompt;
|
|
196
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { log } from '../logger.js';
|
|
4
|
+
|
|
5
|
+
export class ClaudeCodeAdapter {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.name = 'claude-code';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async run(prompt, context = {}) {
|
|
12
|
+
if (context.images?.length && this.config.codexBin) {
|
|
13
|
+
log('info', 'claude-code image fallback via codex', { sessionKey: context.sessionKey || 'default', images: context.images.length });
|
|
14
|
+
return runCodexVision(this.config.codexBin, prompt, context.images, this.config.workDir, this.config.taskTimeoutSeconds * 1000, this.config);
|
|
15
|
+
}
|
|
16
|
+
// Claude Code 2.x requires --verbose when stream-json is used with --print/-p.
|
|
17
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
|
|
18
|
+
if (this.config.mode === 'yolo') args.push('--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions');
|
|
19
|
+
if (this.config.model) args.push('--model', this.config.model);
|
|
20
|
+
log('info', 'claude-code launch', { sessionKey: context.sessionKey || 'default' });
|
|
21
|
+
return runClaude(this.config.claudeBin, args, this.config.workDir, this.config.taskTimeoutSeconds * 1000);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function runClaude(bin, args, cwd, timeoutMs) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
|
|
28
|
+
let stderr = '';
|
|
29
|
+
let finalResult = '';
|
|
30
|
+
let streamError = '';
|
|
31
|
+
const rawLines = [];
|
|
32
|
+
const assistantTexts = [];
|
|
33
|
+
const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error(`${bin} timed out`)); }, timeoutMs);
|
|
34
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
35
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
36
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
37
|
+
rl.on('line', (line) => {
|
|
38
|
+
try {
|
|
39
|
+
rawLines.push(line);
|
|
40
|
+
if (rawLines.length > 20) rawLines.shift();
|
|
41
|
+
const ev = JSON.parse(line);
|
|
42
|
+
const evError = extractEventError(ev);
|
|
43
|
+
if (evError) streamError = evError;
|
|
44
|
+
if (typeof ev.result === 'string' && ev.result.trim()) {
|
|
45
|
+
finalResult = ev.result.trim();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const t = extractAssistantText(ev);
|
|
49
|
+
if (t) assistantTexts.push(t);
|
|
50
|
+
} catch {}
|
|
51
|
+
});
|
|
52
|
+
child.on('close', (code) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (code !== 0) reject(new Error(`${bin} exited ${code}: ${errorDetail({ stderr, streamError, finalResult, rawLines })}`));
|
|
55
|
+
else resolve((finalResult || dedupeAdjacent(assistantTexts).join('\n')).trim());
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runCodexVision(bin, prompt, images, cwd, timeoutMs, config = {}) {
|
|
61
|
+
const args = ['exec', '--skip-git-repo-check', '--json', '--cd', cwd];
|
|
62
|
+
if (config.mode === 'yolo') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
63
|
+
for (const img of images || []) args.push('--image', img);
|
|
64
|
+
args.push('-');
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } });
|
|
67
|
+
let stderr = '';
|
|
68
|
+
const messages = [];
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
child.kill('SIGTERM');
|
|
71
|
+
reject(new Error(`${bin} vision fallback timed out after ${timeoutMs}ms`));
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
74
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
75
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
76
|
+
rl.on('line', (line) => {
|
|
77
|
+
if (!line.trim()) return;
|
|
78
|
+
try {
|
|
79
|
+
const event = JSON.parse(line);
|
|
80
|
+
const text = extractCodexText(event);
|
|
81
|
+
if (text) messages.push(text);
|
|
82
|
+
} catch {}
|
|
83
|
+
});
|
|
84
|
+
child.on('close', (code) => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
if (code !== 0) reject(new Error(`${bin} vision fallback exited ${code}: ${truncate(stderr.trim(), 1200)}`));
|
|
87
|
+
else {
|
|
88
|
+
const finalMessages = dedupeAdjacent(messages);
|
|
89
|
+
resolve((finalMessages[finalMessages.length - 1] || '').trim());
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
child.stdin.end(prompt);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extractCodexText(event) {
|
|
97
|
+
if (event.type !== 'item.completed' || !event.item) return '';
|
|
98
|
+
const item = event.item;
|
|
99
|
+
if (!['agent_message', 'message'].includes(item.type)) return '';
|
|
100
|
+
if (typeof item.text === 'string') return item.text;
|
|
101
|
+
if (typeof item.content === 'string') return item.content;
|
|
102
|
+
if (typeof item.output_text === 'string') return item.output_text;
|
|
103
|
+
if (Array.isArray(item.content)) return item.content.map((p) => p?.text || p?.content || '').filter(Boolean).join('\n');
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractEventError(ev) {
|
|
108
|
+
if (!ev) return '';
|
|
109
|
+
if (typeof ev.error === 'string') return ev.error;
|
|
110
|
+
if (ev.error?.message) return ev.error.message;
|
|
111
|
+
if (ev.is_error && typeof ev.result === 'string') return ev.result;
|
|
112
|
+
const text = extractAssistantText(ev);
|
|
113
|
+
if (text && /^api error:|^error:/i.test(text.trim())) return text.trim();
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function errorDetail({ stderr, streamError, finalResult, rawLines }) {
|
|
118
|
+
const detail = [stderr, streamError, finalResult].map((x) => String(x || '').trim()).find(Boolean)
|
|
119
|
+
|| summarizeRawLines(rawLines)
|
|
120
|
+
|| 'no stderr/stdout detail';
|
|
121
|
+
return truncate(detail, 1200);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function summarizeRawLines(rawLines = []) {
|
|
125
|
+
const lines = rawLines.map((line) => {
|
|
126
|
+
try {
|
|
127
|
+
const ev = JSON.parse(line);
|
|
128
|
+
return extractEventError(ev) || extractAssistantText(ev) || ev.type || '';
|
|
129
|
+
} catch {
|
|
130
|
+
return line;
|
|
131
|
+
}
|
|
132
|
+
}).map((x) => String(x || '').trim()).filter(Boolean);
|
|
133
|
+
return dedupeAdjacent(lines).join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractAssistantText(ev) {
|
|
137
|
+
if (!ev || ev.type !== 'assistant') return '';
|
|
138
|
+
const parts = ev.message?.content;
|
|
139
|
+
if (Array.isArray(parts)) {
|
|
140
|
+
return parts
|
|
141
|
+
.filter((x) => x?.type === 'text')
|
|
142
|
+
.map((x) => x.text || '')
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join('');
|
|
145
|
+
}
|
|
146
|
+
if (typeof ev.content === 'string') return ev.content;
|
|
147
|
+
return '';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function dedupeAdjacent(texts) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const text of texts.map((x) => String(x || '').trim()).filter(Boolean)) {
|
|
153
|
+
if (out[out.length - 1] !== text) out.push(text);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function truncate(text, max) {
|
|
159
|
+
const s = String(text || '');
|
|
160
|
+
return s.length > max ? `${s.slice(0, max)}...` : s;
|
|
161
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { log } from '../logger.js';
|
|
4
|
+
|
|
5
|
+
export class CodexAdapter {
|
|
6
|
+
constructor(config, deps = {}) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.permissions = deps.permissions;
|
|
9
|
+
this.store = deps.store;
|
|
10
|
+
this.name = 'codex';
|
|
11
|
+
this.threadBySession = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async run(prompt, context = {}) {
|
|
15
|
+
const sessionKey = context.sessionKey || 'default';
|
|
16
|
+
const stored = this.store?.getSession(sessionKey) || {};
|
|
17
|
+
const threadId = this.threadBySession.get(sessionKey) || stored.codexThreadId;
|
|
18
|
+
const args = this.buildArgs(threadId, context);
|
|
19
|
+
log('info', 'codex launch', { sessionKey, resume: Boolean(threadId), mode: this.config.mode });
|
|
20
|
+
const result = await runJsonCli(this.config.codexBin, args, prompt, this.config.workDir, (event) => {
|
|
21
|
+
const eventThreadId = event.thread_id || event.session_id || event.conversation_id;
|
|
22
|
+
if ((event.type === 'thread.started' || event.type === 'session.started') && eventThreadId) {
|
|
23
|
+
this.threadBySession.set(sessionKey, eventThreadId);
|
|
24
|
+
this.store?.patchSession(sessionKey, { codexThreadId: eventThreadId, adapter: this.name });
|
|
25
|
+
}
|
|
26
|
+
if (this.config.publishProgress && context.onProgress) context.onProgress(event).catch(() => {});
|
|
27
|
+
}, this.config.taskTimeoutSeconds * 1000);
|
|
28
|
+
if (result.threadId) {
|
|
29
|
+
this.threadBySession.set(sessionKey, result.threadId);
|
|
30
|
+
this.store?.patchSession(sessionKey, { codexThreadId: result.threadId, adapter: this.name });
|
|
31
|
+
}
|
|
32
|
+
return result.text.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
buildArgs(threadId, context = {}) {
|
|
36
|
+
const options = ['--skip-git-repo-check', '--json'];
|
|
37
|
+
options.push(...(this.permissions?.codexArgs() || []));
|
|
38
|
+
if (this.config.model) options.push('--model', this.config.model);
|
|
39
|
+
for (const img of context.images || []) options.push('--image', img);
|
|
40
|
+
if (this.config.reasoningEffort) options.push('-c', `model_reasoning_effort=${JSON.stringify(this.config.reasoningEffort)}`);
|
|
41
|
+
if (threadId) return ['exec', 'resume', ...options, threadId, '-'];
|
|
42
|
+
return ['exec', ...options, '--cd', this.config.workDir, '-'];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runJsonCli(bin, args, stdinText, cwd, onEvent, timeoutMs) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } });
|
|
49
|
+
let stderr = '';
|
|
50
|
+
let threadId = '';
|
|
51
|
+
const messages = [];
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
child.kill('SIGTERM');
|
|
54
|
+
reject(new Error(`${bin} timed out after ${timeoutMs}ms`));
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
57
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
58
|
+
const rl = readline.createInterface({ input: child.stdout });
|
|
59
|
+
rl.on('line', (line) => {
|
|
60
|
+
if (!line.trim()) return;
|
|
61
|
+
let event;
|
|
62
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
63
|
+
if ((event.type === 'thread.started' || event.type === 'session.started') && (event.thread_id || event.session_id || event.conversation_id)) threadId = event.thread_id || event.session_id || event.conversation_id;
|
|
64
|
+
const text = extractText(event);
|
|
65
|
+
if (text) messages.push(text);
|
|
66
|
+
onEvent?.(event);
|
|
67
|
+
});
|
|
68
|
+
child.on('close', (code) => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (code !== 0) reject(new Error(`${bin} exited ${code}: ${stderr.trim()}`));
|
|
71
|
+
else resolve({ text: messages.join('\n').trim(), threadId });
|
|
72
|
+
});
|
|
73
|
+
child.stdin.end(stdinText);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractText(event) {
|
|
78
|
+
if (event.type !== 'item.completed' || !event.item) return '';
|
|
79
|
+
const item = event.item;
|
|
80
|
+
if (!['agent_message', 'message'].includes(item.type)) return '';
|
|
81
|
+
if (typeof item.text === 'string') return item.text;
|
|
82
|
+
if (typeof item.content === 'string') return item.content;
|
|
83
|
+
if (typeof item.output_text === 'string') return item.output_text;
|
|
84
|
+
if (Array.isArray(item.content)) {
|
|
85
|
+
return item.content.map((p) => p?.text || p?.content || '').filter(Boolean).join('\n');
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|