smol-symphony 0.1.0 → 0.2.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/AGENTS.md +105 -38
- package/PRODUCT.md +2 -1
- package/README.md +195 -98
- package/SPEC.md +543 -1915
- package/WORKFLOW.md +654 -179
- package/WORKFLOW.template.md +761 -121
- package/dist/acp-bridge.js +324 -0
- package/dist/acp-bridge.js.map +1 -0
- package/dist/actions/cache.js +191 -0
- package/dist/actions/cache.js.map +1 -0
- package/dist/actions/effects.js +41 -0
- package/dist/actions/effects.js.map +1 -0
- package/dist/actions/executor.js +570 -0
- package/dist/actions/executor.js.map +1 -0
- package/dist/actions/index.js +13 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/parsing.js +273 -0
- package/dist/actions/parsing.js.map +1 -0
- package/dist/actions/predicate-env.js +27 -0
- package/dist/actions/predicate-env.js.map +1 -0
- package/dist/actions/predicates.js +49 -0
- package/dist/actions/predicates.js.map +1 -0
- package/dist/actions/templating.js +66 -0
- package/dist/actions/templating.js.map +1 -0
- package/dist/actions/types.js +15 -0
- package/dist/actions/types.js.map +1 -0
- package/dist/agent/acp.js +232 -63
- package/dist/agent/acp.js.map +1 -1
- package/dist/agent/adapter-names.js +159 -0
- package/dist/agent/adapter-names.js.map +1 -0
- package/dist/agent/adapters.js +338 -102
- package/dist/agent/adapters.js.map +1 -1
- package/dist/agent/credential-extractors.js +342 -0
- package/dist/agent/credential-extractors.js.map +1 -0
- package/dist/agent/credential-secrets.js +628 -0
- package/dist/agent/credential-secrets.js.map +1 -0
- package/dist/agent/credential-ticker.js +57 -0
- package/dist/agent/credential-ticker.js.map +1 -0
- package/dist/agent/gondolin-creds-staging.js +356 -0
- package/dist/agent/gondolin-creds-staging.js.map +1 -0
- package/dist/agent/gondolin-dispatch.js +375 -0
- package/dist/agent/gondolin-dispatch.js.map +1 -0
- package/dist/agent/gondolin.js +124 -0
- package/dist/agent/gondolin.js.map +1 -0
- package/dist/agent/runner-decisions.js +134 -0
- package/dist/agent/runner-decisions.js.map +1 -0
- package/dist/agent/runner.js +1352 -290
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tool-call-summary.js +102 -0
- package/dist/agent/tool-call-summary.js.map +1 -0
- package/dist/agent/vm-acp-mapping.js +73 -0
- package/dist/agent/vm-acp-mapping.js.map +1 -0
- package/dist/agent/vm-guards.js +262 -0
- package/dist/agent/vm-guards.js.map +1 -0
- package/dist/agent/vm-port.js +22 -0
- package/dist/agent/vm-port.js.map +1 -0
- package/dist/agent/vm-process-registry.js +79 -0
- package/dist/agent/vm-process-registry.js.map +1 -0
- package/dist/bin/cli-args.js +105 -0
- package/dist/bin/cli-args.js.map +1 -0
- package/dist/bin/symphony.js +719 -130
- package/dist/bin/symphony.js.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-disk.js +135 -0
- package/dist/http-disk.js.map +1 -0
- package/dist/http-handlers.js +180 -0
- package/dist/http-handlers.js.map +1 -0
- package/dist/http.js +1476 -764
- package/dist/http.js.map +1 -1
- package/dist/issues.js +178 -0
- package/dist/issues.js.map +1 -0
- package/dist/logging.js +163 -5
- package/dist/logging.js.map +1 -1
- package/dist/mcp.js +391 -163
- package/dist/mcp.js.map +1 -1
- package/dist/memory.js +85 -0
- package/dist/memory.js.map +1 -0
- package/dist/orchestrator-decisions.js +331 -0
- package/dist/orchestrator-decisions.js.map +1 -0
- package/dist/orchestrator.js +1189 -303
- package/dist/orchestrator.js.map +1 -1
- package/dist/prompt.js +5 -5
- package/dist/prompt.js.map +1 -1
- package/dist/reconciler/cache.js +65 -0
- package/dist/reconciler/cache.js.map +1 -0
- package/dist/reconciler/index.js +448 -0
- package/dist/reconciler/index.js.map +1 -0
- package/dist/reconciler/ledger.js +131 -0
- package/dist/reconciler/ledger.js.map +1 -0
- package/dist/reconciler/pr-adapters.js +174 -0
- package/dist/reconciler/pr-adapters.js.map +1 -0
- package/dist/reconciler/pr-decide.js +167 -0
- package/dist/reconciler/pr-decide.js.map +1 -0
- package/dist/reconciler/pr.js +422 -0
- package/dist/reconciler/pr.js.map +1 -0
- package/dist/reconciler/types.js +12 -0
- package/dist/reconciler/types.js.map +1 -0
- package/dist/reconciler/vm.js +243 -0
- package/dist/reconciler/vm.js.map +1 -0
- package/dist/reconciler/workspace-defaults.js +83 -0
- package/dist/reconciler/workspace-defaults.js.map +1 -0
- package/dist/reconciler/workspace.js +272 -0
- package/dist/reconciler/workspace.js.map +1 -0
- package/dist/runlog.js +403 -0
- package/dist/runlog.js.map +1 -0
- package/dist/scaffold.js +165 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/trackers/local.js +234 -133
- package/dist/trackers/local.js.map +1 -1
- package/dist/trackers/types.js +1 -1
- package/dist/trackers/types.js.map +1 -1
- package/dist/types.js +1 -1
- package/dist/util/clock.js +12 -0
- package/dist/util/clock.js.map +1 -0
- package/dist/util/crypto.js +25 -0
- package/dist/util/crypto.js.map +1 -0
- package/dist/util/frontmatter.js +70 -0
- package/dist/util/frontmatter.js.map +1 -0
- package/dist/util/fs-issues.js +22 -0
- package/dist/util/fs-issues.js.map +1 -0
- package/dist/util/process.js +152 -0
- package/dist/util/process.js.map +1 -0
- package/dist/util/workspace-key.js +10 -0
- package/dist/util/workspace-key.js.map +1 -0
- package/dist/workflow-loader.js +147 -0
- package/dist/workflow-loader.js.map +1 -0
- package/dist/workflow.js +656 -219
- package/dist/workflow.js.map +1 -1
- package/dist/workspace-types.js +8 -0
- package/dist/workspace-types.js.map +1 -0
- package/dist/workspace.js +367 -120
- package/dist/workspace.js.map +1 -1
- package/package.json +14 -6
- package/scripts/vm-agent.mjs +211 -0
- package/dist/agent/codex.js +0 -439
- package/dist/agent/codex.js.map +0 -1
- package/dist/agent/smolvm.js +0 -174
- package/dist/agent/smolvm.js.map +0 -1
- package/scripts/build-vm.sh +0 -67
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// symphony-vm-agent — in-guest proxy that runs the ACP adapter and bridges its stdio
|
|
3
|
+
// to a TCP socket dialed back to the host orchestrator.
|
|
4
|
+
//
|
|
5
|
+
// Why TCP and not stdio: the previous version of this proxy bridged the in-VM adapter's
|
|
6
|
+
// stdio to the VM-exec stdio channel directly. That channel has a stdin-pump bug
|
|
7
|
+
// that wedges the adapter after the SDK's first `available_commands_update` notification
|
|
8
|
+
// (the in-guest reader never wakes for subsequent kernel events unless host stdin keeps
|
|
9
|
+
// writing). We worked around it for a while with a 1.5 s `\n` keepalive on the host
|
|
10
|
+
// side, but the proper fix is to stop piping ACP through the VM-exec stdio at all. This proxy
|
|
11
|
+
// dials back to a TCP listener on the host (`SYMPHONY_ACP_URL`), authenticates with a
|
|
12
|
+
// per-dispatch bearer token (`SYMPHONY_ACP_TOKEN`), and from then on talks ACP over a
|
|
13
|
+
// plain socket the host owns. the VM exec is reduced to a launcher: its stdio just
|
|
14
|
+
// carries this proxy's own diagnostic stderr and the adapter's stderr (`inherit`'d).
|
|
15
|
+
//
|
|
16
|
+
// The same proxy works under any sandbox tech that can launch a process with env vars
|
|
17
|
+
// and reach the host loopback — that's the portability win.
|
|
18
|
+
//
|
|
19
|
+
// Configuration (all required unless noted):
|
|
20
|
+
// SYMPHONY_ACP_URL — `tcp://host:port` to dial. `127.0.0.1:8788` under the Gondolin bridge mapping
|
|
21
|
+
// since the guest loopback is rewritten to the host
|
|
22
|
+
// loopback.
|
|
23
|
+
// SYMPHONY_ACP_TOKEN — opaque per-dispatch bearer; sent as `Bearer <token>\n`
|
|
24
|
+
// first line.
|
|
25
|
+
// SYMPHONY_ADAPTER_BIN — adapter executable to spawn (PATH-resolved).
|
|
26
|
+
// SYMPHONY_ADAPTER_ARGS — JSON array of extra argv. Optional; defaults to `[]`.
|
|
27
|
+
// SYMPHONY_VM_AGENT_DEBUG — optional; truthy → log lifecycle to stderr.
|
|
28
|
+
//
|
|
29
|
+
// Exit code: mirrors the adapter's exit when it exits, or 1 if the connection fails
|
|
30
|
+
// before the adapter starts.
|
|
31
|
+
|
|
32
|
+
import { connect as netConnect } from 'node:net';
|
|
33
|
+
import { spawn } from 'node:child_process';
|
|
34
|
+
import { constants as osConstants } from 'node:os';
|
|
35
|
+
import { URL } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const requiredEnv = (name) => {
|
|
38
|
+
const v = process.env[name];
|
|
39
|
+
if (!v) {
|
|
40
|
+
process.stderr.write(`symphony-vm-agent: ${name} is not set\n`);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const acpUrl = requiredEnv('SYMPHONY_ACP_URL');
|
|
47
|
+
const acpToken = requiredEnv('SYMPHONY_ACP_TOKEN');
|
|
48
|
+
const adapterBin = requiredEnv('SYMPHONY_ADAPTER_BIN');
|
|
49
|
+
|
|
50
|
+
let adapterArgs = [];
|
|
51
|
+
const rawArgs = process.env.SYMPHONY_ADAPTER_ARGS;
|
|
52
|
+
if (rawArgs && rawArgs.length > 0) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(rawArgs);
|
|
55
|
+
if (!Array.isArray(parsed) || !parsed.every((a) => typeof a === 'string')) {
|
|
56
|
+
throw new Error('must be a JSON array of strings');
|
|
57
|
+
}
|
|
58
|
+
adapterArgs = parsed;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
process.stderr.write(`symphony-vm-agent: SYMPHONY_ADAPTER_ARGS invalid: ${err.message}\n`);
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const debug = !!process.env.SYMPHONY_VM_AGENT_DEBUG;
|
|
66
|
+
const log = (msg) => {
|
|
67
|
+
if (debug) process.stderr.write(`symphony-vm-agent: ${msg}\n`);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = new URL(acpUrl);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
process.stderr.write(`symphony-vm-agent: SYMPHONY_ACP_URL invalid: ${err.message}\n`);
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
if (parsed.protocol !== 'tcp:') {
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
`symphony-vm-agent: SYMPHONY_ACP_URL must use tcp:// scheme (got ${parsed.protocol})\n`,
|
|
80
|
+
);
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
const acpHost = parsed.hostname;
|
|
84
|
+
const acpPort = parseInt(parsed.port, 10);
|
|
85
|
+
if (!Number.isFinite(acpPort) || acpPort <= 0) {
|
|
86
|
+
process.stderr.write(`symphony-vm-agent: SYMPHONY_ACP_URL has no port\n`);
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
log(`dialing ${acpHost}:${acpPort}`);
|
|
91
|
+
|
|
92
|
+
const socket = netConnect({ host: acpHost, port: acpPort, allowHalfOpen: false });
|
|
93
|
+
socket.setNoDelay(true);
|
|
94
|
+
|
|
95
|
+
// Pre-connect error handler: a TCP-level failure here means we couldn't reach the host
|
|
96
|
+
// bridge at all. Exit hard so symphony's host-side bridge-connect-timeout fires its own
|
|
97
|
+
// error path. We REMOVE this listener once `connect` fires; otherwise it would also kick
|
|
98
|
+
// in for post-auth errors and bypass startBridge()'s graceful adapter teardown.
|
|
99
|
+
const onPreConnectError = (err) => {
|
|
100
|
+
process.stderr.write(`symphony-vm-agent: socket error: ${err.message}\n`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
};
|
|
103
|
+
socket.once('error', onPreConnectError);
|
|
104
|
+
|
|
105
|
+
socket.once('connect', () => {
|
|
106
|
+
socket.off('error', onPreConnectError);
|
|
107
|
+
log('connected; sending bearer line');
|
|
108
|
+
// The bearer line MUST end with a bare \n; the host expects exactly that delimiter.
|
|
109
|
+
socket.write(`Bearer ${acpToken}\n`);
|
|
110
|
+
startBridge();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function startBridge() {
|
|
114
|
+
log(`spawn ${adapterBin} ${JSON.stringify(adapterArgs)}`);
|
|
115
|
+
const child = spawn(adapterBin, adapterArgs, {
|
|
116
|
+
// Adapter stdio: kernel pipes we fully own. Adapter stderr is inherited so any
|
|
117
|
+
// crashes / warnings show up on this proxy's stderr → VM-exec stderr → host
|
|
118
|
+
// orchestrator stderr capture. ACP frames flow ONLY over the TCP socket.
|
|
119
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
120
|
+
});
|
|
121
|
+
// Bridge socket ↔ adapter stdio with explicit `data` handlers + `write()` (NOT
|
|
122
|
+
// stream.pipe()): we observed during the bisect that pipe() can interact badly with
|
|
123
|
+
// certain transport quirks, so we forward bytes synchronously and let the kernel pipe
|
|
124
|
+
// buffer absorb any short-term mismatch. With a clean TCP socket this is moot but
|
|
125
|
+
// costs nothing and keeps the pattern uniform.
|
|
126
|
+
socket.on('data', (chunk) => {
|
|
127
|
+
try {
|
|
128
|
+
child.stdin.write(chunk);
|
|
129
|
+
} catch {
|
|
130
|
+
/* adapter stdin closed while we were writing — see exit handler */
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
child.stdout.on('data', (chunk) => {
|
|
134
|
+
try {
|
|
135
|
+
socket.write(chunk);
|
|
136
|
+
} catch {
|
|
137
|
+
/* host socket closed while we were writing — see exit handlers */
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
// Half-closes: when the host closes its write side, the adapter should see stdin EOF;
|
|
141
|
+
// when the adapter closes stdout, the host should see socket-half-close.
|
|
142
|
+
socket.on('end', () => {
|
|
143
|
+
log('host closed socket; ending adapter stdin');
|
|
144
|
+
try {
|
|
145
|
+
child.stdin.end();
|
|
146
|
+
} catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
child.stdout.on('end', () => {
|
|
151
|
+
log('adapter closed stdout; ending socket write side');
|
|
152
|
+
try {
|
|
153
|
+
socket.end();
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
child.stdin.on('error', (err) => {
|
|
159
|
+
if (err.code !== 'EPIPE') log(`adapter stdin error: ${err.message}`);
|
|
160
|
+
});
|
|
161
|
+
socket.on('error', (err) => {
|
|
162
|
+
log(`socket error after auth: ${err.message}`);
|
|
163
|
+
try {
|
|
164
|
+
child.kill('SIGTERM');
|
|
165
|
+
} catch {
|
|
166
|
+
/* ignore */
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
child.on('error', (err) => {
|
|
170
|
+
process.stderr.write(`symphony-vm-agent: failed to spawn adapter: ${err.message}\n`);
|
|
171
|
+
try {
|
|
172
|
+
socket.end();
|
|
173
|
+
} catch {
|
|
174
|
+
/* ignore */
|
|
175
|
+
}
|
|
176
|
+
process.exit(127);
|
|
177
|
+
});
|
|
178
|
+
// Use `close` not `exit`: `exit` fires when the adapter process terminates but its
|
|
179
|
+
// stdio pipes may still have buffered bytes our `data` handlers haven't drained yet.
|
|
180
|
+
// `close` fires only after all stdio streams have been closed AND drained, so by then
|
|
181
|
+
// every byte the adapter wrote has been forwarded to our socket.write() above. We
|
|
182
|
+
// still need to wait for the socket itself to flush those forwarded bytes to the
|
|
183
|
+
// kernel before exiting, hence the `socket.end(callback)` pattern: end() signals
|
|
184
|
+
// shutdown and the callback fires after the write buffer has drained.
|
|
185
|
+
child.on('close', (code, signal) => {
|
|
186
|
+
log(`adapter close code=${code} signal=${signal}`);
|
|
187
|
+
const finalize = () => {
|
|
188
|
+
if (signal) {
|
|
189
|
+
const sig = osConstants.signals[signal] ?? 0;
|
|
190
|
+
process.exit(128 + sig);
|
|
191
|
+
}
|
|
192
|
+
process.exit(code ?? 0);
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
socket.end(finalize);
|
|
196
|
+
} catch {
|
|
197
|
+
finalize();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
// Forward host-side termination signals to the adapter.
|
|
201
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
202
|
+
process.on(sig, () => {
|
|
203
|
+
log(`received ${sig}; forwarding to adapter`);
|
|
204
|
+
try {
|
|
205
|
+
child.kill(sig);
|
|
206
|
+
} catch {
|
|
207
|
+
/* ignore */
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
package/dist/agent/codex.js
DELETED
|
@@ -1,439 +0,0 @@
|
|
|
1
|
-
// JSON-RPC client for the Codex app-server protocol (SPEC §10).
|
|
2
|
-
//
|
|
3
|
-
// Transport: newline-delimited JSON over stdio. Each line is a JSON object that is either
|
|
4
|
-
// a request, a response, or a notification, as defined by the Codex protocol.
|
|
5
|
-
//
|
|
6
|
-
// This client takes a pre-existing stdio pair (e.g. the streams of a smolvm exec session)
|
|
7
|
-
// and drives the §10 lifecycle: initialize → thread/start → turn/start → process notifications
|
|
8
|
-
// until turn/completed. Approval requests are answered according to the documented high-trust
|
|
9
|
-
// posture (auto-approve). User-input-required, unsupported dynamic tools, and signal exits are
|
|
10
|
-
// surfaced as failures so a run never stalls (§10.5).
|
|
11
|
-
import { log } from '../logging.js';
|
|
12
|
-
export class CodexProtocolError extends Error {
|
|
13
|
-
code;
|
|
14
|
-
constructor(code, message) {
|
|
15
|
-
super(message);
|
|
16
|
-
this.code = code;
|
|
17
|
-
this.name = 'CodexProtocolError';
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
function nowIso() {
|
|
21
|
-
return new Date().toISOString();
|
|
22
|
-
}
|
|
23
|
-
function summarize(raw, max = 240) {
|
|
24
|
-
let s;
|
|
25
|
-
try {
|
|
26
|
-
s = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
s = String(raw);
|
|
30
|
-
}
|
|
31
|
-
if (s.length > max)
|
|
32
|
-
s = s.slice(0, max) + '…';
|
|
33
|
-
return s;
|
|
34
|
-
}
|
|
35
|
-
// Extract absolute token totals from a `thread/tokenUsage/updated` notification.
|
|
36
|
-
//
|
|
37
|
-
// The current Codex protocol nests totals under `tokenUsage.total.{inputTokens, outputTokens,
|
|
38
|
-
// totalTokens}` (see ThreadTokenUsageUpdatedNotification schema). We deliberately prefer the
|
|
39
|
-
// `total` breakdown over `last` so accumulation matches §13.5: "prefer absolute thread totals".
|
|
40
|
-
// Older payload shapes (`total_token_usage`, flat `usage`) are accepted as a fallback.
|
|
41
|
-
function extractTokenTotals(params) {
|
|
42
|
-
if (!params || typeof params !== 'object' || Array.isArray(params))
|
|
43
|
-
return null;
|
|
44
|
-
const obj = params;
|
|
45
|
-
const candidates = [];
|
|
46
|
-
const tokenUsage = obj['tokenUsage'];
|
|
47
|
-
if (tokenUsage && typeof tokenUsage === 'object' && !Array.isArray(tokenUsage)) {
|
|
48
|
-
const totalBreakdown = tokenUsage['total'];
|
|
49
|
-
if (totalBreakdown && typeof totalBreakdown === 'object' && !Array.isArray(totalBreakdown)) {
|
|
50
|
-
candidates.push(totalBreakdown);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
const totalTokenUsage = obj['total_token_usage'];
|
|
54
|
-
if (totalTokenUsage && typeof totalTokenUsage === 'object' && !Array.isArray(totalTokenUsage)) {
|
|
55
|
-
candidates.push(totalTokenUsage);
|
|
56
|
-
}
|
|
57
|
-
const usage = obj['usage'];
|
|
58
|
-
if (usage && typeof usage === 'object' && !Array.isArray(usage)) {
|
|
59
|
-
candidates.push(usage);
|
|
60
|
-
}
|
|
61
|
-
candidates.push(obj);
|
|
62
|
-
for (const c of candidates) {
|
|
63
|
-
const inRaw = c['input_tokens'] ?? c['inputTokens'];
|
|
64
|
-
const outRaw = c['output_tokens'] ?? c['outputTokens'];
|
|
65
|
-
const totRaw = c['total_tokens'] ?? c['totalTokens'];
|
|
66
|
-
if (inRaw === undefined && outRaw === undefined && totRaw === undefined)
|
|
67
|
-
continue;
|
|
68
|
-
const inN = Number(inRaw ?? 0);
|
|
69
|
-
const outN = Number(outRaw ?? 0);
|
|
70
|
-
const totN = Number(totRaw ?? inN + outN);
|
|
71
|
-
if (Number.isFinite(totN)) {
|
|
72
|
-
return { input: inN, output: outN, total: totN };
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
export class CodexClient {
|
|
78
|
-
opts;
|
|
79
|
-
nextId = 1;
|
|
80
|
-
pending = new Map();
|
|
81
|
-
notificationHandlers = new Map();
|
|
82
|
-
serverRequestHandlers = new Map();
|
|
83
|
-
buffer = '';
|
|
84
|
-
closed = false;
|
|
85
|
-
threadId = null;
|
|
86
|
-
turnWaiter = null;
|
|
87
|
-
turnTimer = null;
|
|
88
|
-
currentTurnId = null;
|
|
89
|
-
constructor(opts) {
|
|
90
|
-
this.opts = opts;
|
|
91
|
-
this.opts.stdout.setEncoding('utf8');
|
|
92
|
-
this.opts.stdout.on('data', (chunk) => this.onData(chunk));
|
|
93
|
-
this.opts.stdout.on('close', () => this.handleTransportClose('stdout_closed'));
|
|
94
|
-
this.opts.stdout.on('error', (e) => this.handleTransportClose(`stdout_error:${e.message}`));
|
|
95
|
-
this.opts.stdin.on('error', () => {
|
|
96
|
-
/* ignore: pipe errors surface via close */
|
|
97
|
-
});
|
|
98
|
-
this.installDefaultHandlers();
|
|
99
|
-
}
|
|
100
|
-
onData(chunk) {
|
|
101
|
-
this.buffer += chunk;
|
|
102
|
-
let nl;
|
|
103
|
-
while ((nl = this.buffer.indexOf('\n')) >= 0) {
|
|
104
|
-
const line = this.buffer.slice(0, nl).trim();
|
|
105
|
-
this.buffer = this.buffer.slice(nl + 1);
|
|
106
|
-
if (line.length === 0)
|
|
107
|
-
continue;
|
|
108
|
-
let msg;
|
|
109
|
-
try {
|
|
110
|
-
msg = JSON.parse(line);
|
|
111
|
-
}
|
|
112
|
-
catch (err) {
|
|
113
|
-
this.emitRuntime('malformed', `cannot parse: ${err.message}: ${summarize(line)}`);
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
this.dispatch(msg);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
dispatch(msg) {
|
|
120
|
-
// Response to one of our requests.
|
|
121
|
-
if (msg && 'id' in msg && msg.id !== undefined && (!('method' in msg) || msg.method === undefined)) {
|
|
122
|
-
const id = msg.id;
|
|
123
|
-
const pending = this.pending.get(id);
|
|
124
|
-
if (!pending) {
|
|
125
|
-
this.emitRuntime('other_message', `unmatched response id=${id}`);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
this.pending.delete(id);
|
|
129
|
-
clearTimeout(pending.timer);
|
|
130
|
-
const asResp = msg;
|
|
131
|
-
if (asResp.error) {
|
|
132
|
-
pending.reject(new CodexProtocolError('response_error', asResp.error.message));
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
pending.resolve((asResp.result ?? null));
|
|
136
|
-
}
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
// Server request — needs response (e.g. approvals, dynamic tools).
|
|
140
|
-
if (msg && 'method' in msg && 'id' in msg && msg.id !== undefined) {
|
|
141
|
-
const method = msg.method;
|
|
142
|
-
const params = (msg.params ?? null);
|
|
143
|
-
const handler = this.serverRequestHandlers.get(method);
|
|
144
|
-
void this.respondToServerRequest(msg.id, method, params, handler);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
// Notification.
|
|
148
|
-
if (msg && 'method' in msg && msg.method) {
|
|
149
|
-
const method = msg.method;
|
|
150
|
-
const params = (msg.params ?? null);
|
|
151
|
-
const handler = this.notificationHandlers.get(method);
|
|
152
|
-
if (handler)
|
|
153
|
-
handler(params);
|
|
154
|
-
else
|
|
155
|
-
this.emitRuntime('notification', `${method} ${summarize(params)}`);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
this.emitRuntime('other_message', summarize(msg));
|
|
159
|
-
}
|
|
160
|
-
async respondToServerRequest(id, method, params, handler) {
|
|
161
|
-
try {
|
|
162
|
-
let result;
|
|
163
|
-
if (handler) {
|
|
164
|
-
result = await handler(params);
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
// Unsupported server request: respond with structured error so the session doesn't
|
|
168
|
-
// stall (§10.5 — unsupported dynamic tool calls).
|
|
169
|
-
result = { error: { code: 'unsupported', message: `unsupported method ${method}` } };
|
|
170
|
-
this.emitRuntime('unsupported_tool_call', `${method} ${summarize(params)}`);
|
|
171
|
-
}
|
|
172
|
-
this.writeMessage({ jsonrpc: '2.0', id, result });
|
|
173
|
-
}
|
|
174
|
-
catch (err) {
|
|
175
|
-
this.writeMessage({
|
|
176
|
-
jsonrpc: '2.0',
|
|
177
|
-
id,
|
|
178
|
-
error: { code: -32000, message: err.message },
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
installDefaultHandlers() {
|
|
183
|
-
// Approvals — auto-approve under the documented high-trust posture.
|
|
184
|
-
//
|
|
185
|
-
// Method names and decision values come from the Codex v2 protocol:
|
|
186
|
-
// - item/commandExecution/requestApproval → CommandExecutionApprovalDecision
|
|
187
|
-
// - item/fileChange/requestApproval → FileChangeApprovalDecision
|
|
188
|
-
// - item/permissions/requestApproval → PermissionsRequestApprovalDecision
|
|
189
|
-
// All three accept "acceptForSession" so future prompts for the same scope auto-pass.
|
|
190
|
-
const autoApproveSession = async (params) => {
|
|
191
|
-
this.emitRuntime('approval_auto_approved', summarize(params));
|
|
192
|
-
return { decision: 'acceptForSession' };
|
|
193
|
-
};
|
|
194
|
-
this.serverRequestHandlers.set('item/commandExecution/requestApproval', autoApproveSession);
|
|
195
|
-
this.serverRequestHandlers.set('item/fileChange/requestApproval', autoApproveSession);
|
|
196
|
-
this.serverRequestHandlers.set('item/permissions/requestApproval', autoApproveSession);
|
|
197
|
-
// Legacy v1 request names retained for transitional compatibility.
|
|
198
|
-
this.serverRequestHandlers.set('execCommandApproval', autoApproveSession);
|
|
199
|
-
this.serverRequestHandlers.set('applyPatchApproval', autoApproveSession);
|
|
200
|
-
this.serverRequestHandlers.set('fileChangeRequestApproval', autoApproveSession);
|
|
201
|
-
this.serverRequestHandlers.set('commandExecutionRequestApproval', autoApproveSession);
|
|
202
|
-
this.serverRequestHandlers.set('permissionsRequestApproval', autoApproveSession);
|
|
203
|
-
this.serverRequestHandlers.set('mcpServer/elicitation/request', autoApproveSession);
|
|
204
|
-
// User-input requests fail the run instead of stalling (§10.5).
|
|
205
|
-
this.serverRequestHandlers.set('item/tool/requestUserInput', async (params) => {
|
|
206
|
-
this.emitRuntime('turn_input_required', summarize(params));
|
|
207
|
-
this.completeTurn({
|
|
208
|
-
turnId: this.currentTurnId ?? '',
|
|
209
|
-
reason: 'turn_input_required',
|
|
210
|
-
message: summarize(params),
|
|
211
|
-
});
|
|
212
|
-
return { decision: 'reject', reason: 'user input not supported' };
|
|
213
|
-
});
|
|
214
|
-
// Dynamic tool calls — reject unsupported tools without stalling (§10.5).
|
|
215
|
-
this.serverRequestHandlers.set('item/tool/call', async (params) => {
|
|
216
|
-
const supported = new Set(this.opts.supportedTools ?? []);
|
|
217
|
-
const toolName = params && typeof params === 'object' && !Array.isArray(params)
|
|
218
|
-
? params['name']
|
|
219
|
-
: null;
|
|
220
|
-
if (typeof toolName === 'string' && supported.has(toolName)) {
|
|
221
|
-
// The runtime advertised it but has no in-process implementation yet — still fail
|
|
222
|
-
// gracefully rather than throw. Implementations override this handler to do real work.
|
|
223
|
-
return { isError: true, content: [{ type: 'text', text: `tool ${toolName} not implemented` }] };
|
|
224
|
-
}
|
|
225
|
-
this.emitRuntime('unsupported_tool_call', summarize(params));
|
|
226
|
-
return { isError: true, content: [{ type: 'text', text: `unsupported tool: ${toolName ?? '?'}` }] };
|
|
227
|
-
});
|
|
228
|
-
// Notifications — extract observability signal.
|
|
229
|
-
//
|
|
230
|
-
// Schema notes (Codex v2):
|
|
231
|
-
// thread/started.params = { thread: { id, ... } } — id nested under thread
|
|
232
|
-
// turn/started.params = { threadId, turn: { id, status, ... } }
|
|
233
|
-
// turn/completed.params = { threadId, turn: { id, status: "completed"|"failed"|... } }
|
|
234
|
-
// The turn/completed notification fires for every terminal status. We must look at
|
|
235
|
-
// turn.status to know whether the turn truly succeeded.
|
|
236
|
-
const idOfThread = (params) => {
|
|
237
|
-
if (params && typeof params === 'object' && !Array.isArray(params)) {
|
|
238
|
-
const thread = params['thread'];
|
|
239
|
-
if (thread && typeof thread === 'object' && !Array.isArray(thread)) {
|
|
240
|
-
const id = thread['id'];
|
|
241
|
-
if (typeof id === 'string')
|
|
242
|
-
return id;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
246
|
-
};
|
|
247
|
-
const turnFromParams = (params) => {
|
|
248
|
-
if (params && typeof params === 'object' && !Array.isArray(params)) {
|
|
249
|
-
const turn = params['turn'];
|
|
250
|
-
if (turn && typeof turn === 'object' && !Array.isArray(turn)) {
|
|
251
|
-
return turn;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return null;
|
|
255
|
-
};
|
|
256
|
-
this.notificationHandlers.set('thread/started', (params) => {
|
|
257
|
-
const tid = idOfThread(params);
|
|
258
|
-
if (tid)
|
|
259
|
-
this.threadId = tid;
|
|
260
|
-
this.emitRuntime('session_started', summarize(params));
|
|
261
|
-
});
|
|
262
|
-
this.notificationHandlers.set('turn/started', (params) => {
|
|
263
|
-
const turn = turnFromParams(params);
|
|
264
|
-
const tid = turn ? turn['id'] : null;
|
|
265
|
-
if (typeof tid === 'string')
|
|
266
|
-
this.currentTurnId = tid;
|
|
267
|
-
this.emitRuntime('turn_started', summarize(params));
|
|
268
|
-
});
|
|
269
|
-
this.notificationHandlers.set('turn/completed', (params) => {
|
|
270
|
-
const turn = turnFromParams(params);
|
|
271
|
-
const status = typeof turn?.['status'] === 'string' ? turn['status'] : 'completed';
|
|
272
|
-
const turnId = typeof turn?.['id'] === 'string' ? turn['id'] : this.currentTurnId ?? '';
|
|
273
|
-
if (status === 'completed') {
|
|
274
|
-
this.emitRuntime('turn_completed', summarize(params));
|
|
275
|
-
this.completeTurn({ turnId, reason: 'turn_completed', message: summarize(params) });
|
|
276
|
-
}
|
|
277
|
-
else if (status === 'interrupted') {
|
|
278
|
-
this.emitRuntime('turn_cancelled', summarize(params));
|
|
279
|
-
this.completeTurn({ turnId, reason: 'turn_cancelled', message: summarize(params) });
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
// failed or any other non-completed terminal status
|
|
283
|
-
this.emitRuntime('turn_failed', summarize(params));
|
|
284
|
-
this.completeTurn({ turnId, reason: 'turn_failed', message: summarize(params) });
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
this.notificationHandlers.set('thread/tokenUsage/updated', (params) => {
|
|
288
|
-
const totals = extractTokenTotals(params);
|
|
289
|
-
if (totals) {
|
|
290
|
-
this.opts.onTokenUsage({
|
|
291
|
-
input_tokens: totals.input,
|
|
292
|
-
output_tokens: totals.output,
|
|
293
|
-
total_tokens: totals.total,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
this.emitRuntime('thread_tokenUsage_updated', summarize(params));
|
|
297
|
-
});
|
|
298
|
-
this.notificationHandlers.set('account/rateLimits/updated', (params) => {
|
|
299
|
-
this.opts.onRateLimits(params);
|
|
300
|
-
this.emitRuntime('account_rateLimits_updated', summarize(params));
|
|
301
|
-
});
|
|
302
|
-
this.notificationHandlers.set('error', (params) => {
|
|
303
|
-
this.emitRuntime('error_notification', summarize(params));
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
handleTransportClose(reason) {
|
|
307
|
-
if (this.closed)
|
|
308
|
-
return;
|
|
309
|
-
this.closed = true;
|
|
310
|
-
const err = new CodexProtocolError('port_exit', reason);
|
|
311
|
-
for (const [, p] of this.pending) {
|
|
312
|
-
clearTimeout(p.timer);
|
|
313
|
-
p.reject(err);
|
|
314
|
-
}
|
|
315
|
-
this.pending.clear();
|
|
316
|
-
if (this.turnWaiter) {
|
|
317
|
-
const w = this.turnWaiter;
|
|
318
|
-
this.turnWaiter = null;
|
|
319
|
-
if (this.turnTimer) {
|
|
320
|
-
clearTimeout(this.turnTimer);
|
|
321
|
-
this.turnTimer = null;
|
|
322
|
-
}
|
|
323
|
-
w.resolve({
|
|
324
|
-
turnId: this.currentTurnId ?? '',
|
|
325
|
-
reason: 'subprocess_exit',
|
|
326
|
-
message: reason,
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
emitRuntime(event, message, raw) {
|
|
331
|
-
this.opts.onEvent({ at: nowIso(), event, message }, raw);
|
|
332
|
-
}
|
|
333
|
-
writeMessage(msg) {
|
|
334
|
-
if (this.closed)
|
|
335
|
-
return;
|
|
336
|
-
try {
|
|
337
|
-
this.opts.stdin.write(JSON.stringify(msg) + '\n');
|
|
338
|
-
}
|
|
339
|
-
catch (err) {
|
|
340
|
-
log.warn('codex write failed', { error: err.message });
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
async request(method, params) {
|
|
344
|
-
const id = this.nextId++;
|
|
345
|
-
const promise = new Promise((resolve, reject) => {
|
|
346
|
-
const timer = setTimeout(() => {
|
|
347
|
-
if (this.pending.has(id)) {
|
|
348
|
-
this.pending.delete(id);
|
|
349
|
-
reject(new CodexProtocolError('response_timeout', `request ${method} timed out`));
|
|
350
|
-
}
|
|
351
|
-
}, this.opts.readTimeoutMs);
|
|
352
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
353
|
-
});
|
|
354
|
-
this.writeMessage({ jsonrpc: '2.0', id, method, params });
|
|
355
|
-
return (await promise);
|
|
356
|
-
}
|
|
357
|
-
async initialize(params) {
|
|
358
|
-
return this.request('initialize', params);
|
|
359
|
-
}
|
|
360
|
-
async startThread(params) {
|
|
361
|
-
const result = await this.request('thread/start', params);
|
|
362
|
-
// Codex v2 ThreadStartResponse nests the id under `thread.id`.
|
|
363
|
-
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
364
|
-
const thread = result['thread'];
|
|
365
|
-
if (thread && typeof thread === 'object' && !Array.isArray(thread)) {
|
|
366
|
-
const id = thread['id'];
|
|
367
|
-
if (typeof id === 'string') {
|
|
368
|
-
this.threadId = id;
|
|
369
|
-
return id;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
// Legacy v1 shape: `{ threadId: "..." }`
|
|
373
|
-
const legacy = result['threadId'];
|
|
374
|
-
if (typeof legacy === 'string') {
|
|
375
|
-
this.threadId = legacy;
|
|
376
|
-
return legacy;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
throw new CodexProtocolError('response_error', 'thread/start did not return a thread id');
|
|
380
|
-
}
|
|
381
|
-
// Send turn/start and resolve when one of the turn-terminating notifications fires (or the
|
|
382
|
-
// subprocess exits, or the turn timeout elapses).
|
|
383
|
-
async runTurn(params) {
|
|
384
|
-
if (this.turnWaiter) {
|
|
385
|
-
throw new CodexProtocolError('turn_in_progress', 'another turn is already in flight');
|
|
386
|
-
}
|
|
387
|
-
const result = new Promise((resolve, reject) => {
|
|
388
|
-
this.turnWaiter = { resolve, reject };
|
|
389
|
-
this.turnTimer = setTimeout(() => {
|
|
390
|
-
if (this.turnWaiter) {
|
|
391
|
-
const w = this.turnWaiter;
|
|
392
|
-
this.turnWaiter = null;
|
|
393
|
-
w.resolve({
|
|
394
|
-
turnId: this.currentTurnId ?? '',
|
|
395
|
-
reason: 'turn_timeout',
|
|
396
|
-
message: `turn exceeded ${this.opts.turnTimeoutMs}ms`,
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
}, this.opts.turnTimeoutMs);
|
|
400
|
-
});
|
|
401
|
-
// Fire-and-forget: server will signal completion via a notification.
|
|
402
|
-
try {
|
|
403
|
-
await this.request('turn/start', params);
|
|
404
|
-
}
|
|
405
|
-
catch (err) {
|
|
406
|
-
this.completeTurn({
|
|
407
|
-
turnId: this.currentTurnId ?? '',
|
|
408
|
-
reason: 'turn_failed',
|
|
409
|
-
message: err.message,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
return result;
|
|
413
|
-
}
|
|
414
|
-
completeTurn(r) {
|
|
415
|
-
if (!this.turnWaiter)
|
|
416
|
-
return;
|
|
417
|
-
const w = this.turnWaiter;
|
|
418
|
-
this.turnWaiter = null;
|
|
419
|
-
if (this.turnTimer) {
|
|
420
|
-
clearTimeout(this.turnTimer);
|
|
421
|
-
this.turnTimer = null;
|
|
422
|
-
}
|
|
423
|
-
w.resolve(r);
|
|
424
|
-
}
|
|
425
|
-
async interrupt() {
|
|
426
|
-
if (!this.threadId)
|
|
427
|
-
return;
|
|
428
|
-
try {
|
|
429
|
-
await this.request('turn/interrupt', { threadId: this.threadId });
|
|
430
|
-
}
|
|
431
|
-
catch (err) {
|
|
432
|
-
log.debug('turn/interrupt failed', { error: err.message });
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
get currentThreadId() {
|
|
436
|
-
return this.threadId;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
//# sourceMappingURL=codex.js.map
|