@hegemonart/get-design-done 1.26.0 → 1.27.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +74 -0
- package/README.md +10 -8
- package/SKILL.md +3 -0
- package/agents/README.md +29 -0
- package/package.json +2 -2
- package/reference/peer-cli-capabilities.md +151 -0
- package/reference/peer-protocols.md +266 -0
- package/reference/registry.json +14 -0
- package/reference/runtime-models.md +3 -3
- package/scripts/install.cjs +100 -1
- package/scripts/lib/bandit-router.cjs +214 -7
- package/scripts/lib/budget-enforcer.cjs +69 -1
- package/scripts/lib/event-stream/index.ts +14 -1
- package/scripts/lib/event-stream/types.ts +125 -1
- package/scripts/lib/install/runtimes.cjs +58 -0
- package/scripts/lib/peer-cli/acp-client.cjs +375 -0
- package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
- package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
- package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
- package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
- package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
- package/scripts/lib/peer-cli/asp-client.cjs +587 -0
- package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
- package/scripts/lib/peer-cli/registry.cjs +434 -0
- package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
- package/scripts/lib/runtime-detect.cjs +1 -1
- package/scripts/lib/session-runner/index.ts +362 -0
- package/scripts/lib/session-runner/types.ts +60 -0
- package/scripts/validate-frontmatter.ts +159 -1
- package/skills/peer-cli-add/SKILL.md +170 -0
- package/skills/peer-cli-customize/SKILL.md +110 -0
- package/skills/peers/SKILL.md +101 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// scripts/lib/peer-cli/asp-client.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 27-02 — Codex App Server Protocol (ASP) client.
|
|
4
|
+
//
|
|
5
|
+
// Protocol shape adapted from greenpolo/cc-multi-cli `asp-client.mjs`
|
|
6
|
+
// (Apache 2.0). See NOTICE for full attribution.
|
|
7
|
+
//
|
|
8
|
+
// What this is:
|
|
9
|
+
// ASP is the wire protocol the Codex CLI exposes when invoked as
|
|
10
|
+
// `codex app-server`. Unlike ACP (which the four other peers speak —
|
|
11
|
+
// Gemini / Cursor / Copilot / Qwen — and treats each call as a one-shot
|
|
12
|
+
// prompt), ASP is THREAD-oriented: a long-lived conversation context
|
|
13
|
+
// that holds across many turns. Codex's tool calls, tool results, and
|
|
14
|
+
// reasoning traces all attach to a `threadId`.
|
|
15
|
+
//
|
|
16
|
+
// Wire format:
|
|
17
|
+
// * Outbound (client → Codex): one JSON object per line, terminated by
|
|
18
|
+
// `\n`, written to subprocess stdin. Same line-framing as ACP.
|
|
19
|
+
// * Inbound (Codex → client): one JSON object per line on subprocess
|
|
20
|
+
// stdout. Lines may be:
|
|
21
|
+
// - Method-call results: { id, result } ← correlated by `id`
|
|
22
|
+
// - Method-call errors: { id, error } ← correlated by `id`
|
|
23
|
+
// - Notifications: { method, params } ← no `id`, streaming
|
|
24
|
+
//
|
|
25
|
+
// Method surface this client exposes:
|
|
26
|
+
// * `threadStart(params)` → resolves to { threadId, ... }
|
|
27
|
+
// * `threadResume(threadId, params)` → resolves to thread state
|
|
28
|
+
// * `turn(threadId, text, opts)` → resolves to {status, content, usage}
|
|
29
|
+
// | {status: "error", error}
|
|
30
|
+
//
|
|
31
|
+
// Critical contract: turn() does NOT throw on Codex-reported errors.
|
|
32
|
+
// A `{status: "error"}` payload is a normal resolution path so the
|
|
33
|
+
// caller (registry / adapter / session-runner) can decide retry vs.
|
|
34
|
+
// fallback policy. turn() rejects only on transport-level breakage
|
|
35
|
+
// (process died mid-turn, line buffer overflow, malformed JSON the
|
|
36
|
+
// stream parser cannot recover from, an explicit `close()` call).
|
|
37
|
+
//
|
|
38
|
+
// Implementation details worth their salt:
|
|
39
|
+
//
|
|
40
|
+
// 1. Hand-rolled line-buffering. Node's built-in `readline` would work
|
|
41
|
+
// but adds an event-loop hop per line and copies the buffer; the
|
|
42
|
+
// hot path here is "10s of notifications per turn × N turns per
|
|
43
|
+
// cycle", so we keep it cheap by carrying a `tail` string forward
|
|
44
|
+
// across `data` events. Same approach the Phase 22 event-stream
|
|
45
|
+
// writer uses for its file-tail reader.
|
|
46
|
+
//
|
|
47
|
+
// 2. 16 MiB overflow cap on a single un-newline-terminated line. If a
|
|
48
|
+
// Codex tool dump (e.g. a giant file read) exceeds this, the client
|
|
49
|
+
// rejects all in-flight requests, kills the subprocess, and goes
|
|
50
|
+
// into a closed state. Without the cap a malformed `\n`-stripped
|
|
51
|
+
// line would grow the buffer until OOM. Cap chosen for parity with
|
|
52
|
+
// the ACP client (plan 27-01).
|
|
53
|
+
//
|
|
54
|
+
// 3. Per-turn notification fan-out. Each `turn()` registers a
|
|
55
|
+
// `(threadId, turnId)` mailbox. Notifications carrying that turnId
|
|
56
|
+
// are forwarded to the caller's `onNotification` hook (if any) and
|
|
57
|
+
// collected in `notifications[]` for the final result. The mailbox
|
|
58
|
+
// is torn down on completion regardless of status.
|
|
59
|
+
//
|
|
60
|
+
// 4. service_name is supplied by the caller on `threadStart`. The
|
|
61
|
+
// canonical value gdd uses is `"gdd_peer_delegation"` (per Plan
|
|
62
|
+
// 27-02 contract); the registry layer (Plan 27-05) sets it. We
|
|
63
|
+
// don't hard-code it here so unit tests can exercise multiple
|
|
64
|
+
// service names without monkey-patching.
|
|
65
|
+
//
|
|
66
|
+
// 5. experimentalRawEvents defaults to `false` per Plan 27-02 contract
|
|
67
|
+
// — gdd consumes structured turn output, not raw model tokens.
|
|
68
|
+
// Callers that want token-level streaming pass `true` explicitly.
|
|
69
|
+
//
|
|
70
|
+
// 6. threadResume API surface exists for v1.28+ cross-cycle context
|
|
71
|
+
// continuity. v1.27.0 always creates fresh threads via threadStart;
|
|
72
|
+
// the registry layer (27-05) doesn't call threadResume. We expose
|
|
73
|
+
// it now so future plans don't need an ASP-client breaking change.
|
|
74
|
+
//
|
|
75
|
+
// This module is `.cjs` (not `.ts`) per Phase 20-14 D-01 so it can be
|
|
76
|
+
// `require()`d from both the `.ts` runtime (session-runner, registry)
|
|
77
|
+
// and `.cjs` callers (broker-lifecycle in Plan 27-03) without needing
|
|
78
|
+
// `--experimental-strip-types` at every consumer site.
|
|
79
|
+
|
|
80
|
+
'use strict';
|
|
81
|
+
|
|
82
|
+
const { spawn } = require('node:child_process');
|
|
83
|
+
|
|
84
|
+
/** Per-line cap before we treat the stream as malformed. */
|
|
85
|
+
const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
86
|
+
|
|
87
|
+
/** Default options. Overridable per-call. */
|
|
88
|
+
const DEFAULTS = Object.freeze({
|
|
89
|
+
experimentalRawEvents: false,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create an ASP client wrapping a Codex subprocess.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {string} opts.command Path or command name (e.g. 'codex').
|
|
97
|
+
* @param {string[]} [opts.args] Default `['app-server']`.
|
|
98
|
+
* @param {string} [opts.cwd] Working directory for the subprocess.
|
|
99
|
+
* @param {object} [opts.env] Environment override.
|
|
100
|
+
* @param {object} [opts.spawn] Pre-built ChildProcess (test injection).
|
|
101
|
+
* @returns {{
|
|
102
|
+
* threadStart: (params?: object) => Promise<{threadId: string, [k:string]: unknown}>,
|
|
103
|
+
* threadResume: (threadId: string, params?: object) => Promise<object>,
|
|
104
|
+
* turn: (threadId: string, text: string, opts?: {onNotification?: (n: object) => void, signal?: AbortSignal}) => Promise<object>,
|
|
105
|
+
* close: () => Promise<void>,
|
|
106
|
+
* readonly closed: boolean,
|
|
107
|
+
* }}
|
|
108
|
+
*/
|
|
109
|
+
function createAspClient(opts) {
|
|
110
|
+
if (!opts || typeof opts !== 'object') {
|
|
111
|
+
throw new TypeError('createAspClient: opts is required');
|
|
112
|
+
}
|
|
113
|
+
if (typeof opts.command !== 'string' || opts.command.length === 0) {
|
|
114
|
+
throw new TypeError('createAspClient: opts.command must be a non-empty string');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const args = Array.isArray(opts.args) ? opts.args : ['app-server'];
|
|
118
|
+
const spawnOptions = {
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
};
|
|
121
|
+
if (typeof opts.cwd === 'string' && opts.cwd.length > 0) spawnOptions.cwd = opts.cwd;
|
|
122
|
+
if (opts.env && typeof opts.env === 'object') spawnOptions.env = opts.env;
|
|
123
|
+
|
|
124
|
+
// Test-injection seam: callers (or unit tests) can supply a pre-built
|
|
125
|
+
// ChildProcess so we don't actually fork a binary in tests. The mock
|
|
126
|
+
// server in tests/fixtures/peer-cli/mock-asp-server.cjs runs as a
|
|
127
|
+
// forked Node process and we wire it through this seam.
|
|
128
|
+
const child = (opts.spawn && typeof opts.spawn === 'object')
|
|
129
|
+
? opts.spawn
|
|
130
|
+
: spawn(opts.command, args, spawnOptions);
|
|
131
|
+
|
|
132
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/** Monotonic request-id counter. */
|
|
135
|
+
let nextId = 1;
|
|
136
|
+
|
|
137
|
+
/** id → { resolve, reject } for in-flight method calls. */
|
|
138
|
+
const pendingById = new Map();
|
|
139
|
+
|
|
140
|
+
/** turnId → { resolve, reject, onNotification, notifications[] } for in-flight turns. */
|
|
141
|
+
const turnsByTurnId = new Map();
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Method-call requests that are awaiting a turn-start response —
|
|
145
|
+
* keyed by request id. Once the response carries a turnId we
|
|
146
|
+
* promote the entry into `turnsByTurnId`.
|
|
147
|
+
*/
|
|
148
|
+
const turnsByRequestId = new Map();
|
|
149
|
+
|
|
150
|
+
/** Stdout line buffer (carried forward across `data` events). */
|
|
151
|
+
let stdoutBuf = '';
|
|
152
|
+
|
|
153
|
+
/** True once close() ran or the process died. */
|
|
154
|
+
let closed = false;
|
|
155
|
+
|
|
156
|
+
/** Last fatal error — used to reject newly-arriving requests. */
|
|
157
|
+
let fatalError = null;
|
|
158
|
+
|
|
159
|
+
// ── Stream wiring ──────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
if (!child || !child.stdin || !child.stdout) {
|
|
162
|
+
throw new Error('createAspClient: spawn() did not yield stdin/stdout streams');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
child.stdout.setEncoding('utf8');
|
|
166
|
+
child.stdout.on('data', onStdoutData);
|
|
167
|
+
child.stdout.on('end', () => onTransportClose(null));
|
|
168
|
+
|
|
169
|
+
// We don't act on stderr — the broker-lifecycle (Plan 27-03) and
|
|
170
|
+
// adapter layer (Plan 27-04) own logging policy. We DO consume it so
|
|
171
|
+
// the buffer doesn't fill and back-pressure the subprocess.
|
|
172
|
+
if (child.stderr && typeof child.stderr.resume === 'function') {
|
|
173
|
+
child.stderr.resume();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
child.on('error', (err) => onTransportClose(err));
|
|
177
|
+
child.on('exit', (code, signal) => {
|
|
178
|
+
const reason = (code === 0)
|
|
179
|
+
? null
|
|
180
|
+
: new Error(`asp-client: subprocess exited (code=${code}, signal=${signal ?? 'null'})`);
|
|
181
|
+
onTransportClose(reason);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Don't let an EPIPE on stdin crash the host process — the subprocess
|
|
185
|
+
// may close stdin first when shutting down, and we surface that as
|
|
186
|
+
// fatalError via the exit handler above.
|
|
187
|
+
child.stdin.on('error', () => { /* swallowed; exit handler is canonical */ });
|
|
188
|
+
|
|
189
|
+
// ── Public API ─────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Start a new conversation thread. Resolves to the server's response
|
|
193
|
+
* which MUST contain `threadId`.
|
|
194
|
+
*/
|
|
195
|
+
function threadStart(params) {
|
|
196
|
+
const merged = {
|
|
197
|
+
experimentalRawEvents: DEFAULTS.experimentalRawEvents,
|
|
198
|
+
...(params && typeof params === 'object' ? params : {}),
|
|
199
|
+
};
|
|
200
|
+
return sendRequest('threadStart', merged);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resume an existing thread. Resolves to the server's response.
|
|
205
|
+
*/
|
|
206
|
+
function threadResume(threadId, params) {
|
|
207
|
+
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
208
|
+
return Promise.reject(new TypeError('threadResume: threadId must be a non-empty string'));
|
|
209
|
+
}
|
|
210
|
+
const merged = {
|
|
211
|
+
threadId,
|
|
212
|
+
...(params && typeof params === 'object' ? params : {}),
|
|
213
|
+
};
|
|
214
|
+
return sendRequest('threadResume', merged);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Send one turn on a thread. Resolves to one of:
|
|
219
|
+
* { status: 'complete', content, usage, threadId, turnId, notifications }
|
|
220
|
+
* { status: 'error', error, threadId, turnId, notifications }
|
|
221
|
+
*
|
|
222
|
+
* Rejects only on transport breakage (subprocess died, client closed,
|
|
223
|
+
* line buffer overflow, AbortSignal triggered).
|
|
224
|
+
*/
|
|
225
|
+
function turn(threadId, text, callOpts) {
|
|
226
|
+
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
227
|
+
return Promise.reject(new TypeError('turn: threadId must be a non-empty string'));
|
|
228
|
+
}
|
|
229
|
+
if (typeof text !== 'string') {
|
|
230
|
+
return Promise.reject(new TypeError('turn: text must be a string'));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const onNotification = (callOpts && typeof callOpts.onNotification === 'function')
|
|
234
|
+
? callOpts.onNotification
|
|
235
|
+
: null;
|
|
236
|
+
const signal = (callOpts && callOpts.signal && typeof callOpts.signal.addEventListener === 'function')
|
|
237
|
+
? callOpts.signal
|
|
238
|
+
: null;
|
|
239
|
+
|
|
240
|
+
if (closed) {
|
|
241
|
+
return Promise.reject(fatalError || new Error('asp-client: client is closed'));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
const id = nextId++;
|
|
246
|
+
const entry = {
|
|
247
|
+
// Resolved either by a turn-completion notification (carrying
|
|
248
|
+
// status + content/error) or by the method-call response if the
|
|
249
|
+
// server chose to return the turn result inline.
|
|
250
|
+
resolve,
|
|
251
|
+
reject,
|
|
252
|
+
onNotification,
|
|
253
|
+
notifications: [],
|
|
254
|
+
threadId,
|
|
255
|
+
turnId: null, // populated by the method-response carrying turnId
|
|
256
|
+
settled: false,
|
|
257
|
+
signal,
|
|
258
|
+
onAbort: null,
|
|
259
|
+
};
|
|
260
|
+
turnsByRequestId.set(id, entry);
|
|
261
|
+
|
|
262
|
+
if (signal) {
|
|
263
|
+
if (signal.aborted) {
|
|
264
|
+
finalizeTurn(entry, /*reject*/ true, new Error('asp-client: turn aborted'));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
entry.onAbort = () => {
|
|
268
|
+
finalizeTurn(entry, /*reject*/ true, new Error('asp-client: turn aborted'));
|
|
269
|
+
};
|
|
270
|
+
signal.addEventListener('abort', entry.onAbort, { once: true });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const wireOk = writeJson({
|
|
274
|
+
jsonrpc: '2.0',
|
|
275
|
+
id,
|
|
276
|
+
method: 'turn',
|
|
277
|
+
params: { threadId, text },
|
|
278
|
+
});
|
|
279
|
+
if (!wireOk) {
|
|
280
|
+
finalizeTurn(entry, /*reject*/ true, fatalError || new Error('asp-client: stdin write failed'));
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Tear down the subprocess and reject all in-flight requests.
|
|
287
|
+
*/
|
|
288
|
+
function close() {
|
|
289
|
+
if (closed) return Promise.resolve();
|
|
290
|
+
onTransportClose(null);
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
// Give the subprocess a moment to flush; force-kill on the
|
|
293
|
+
// second tick if it's still alive. This mirrors the broker
|
|
294
|
+
// lifecycle's shutdown contract (Plan 27-03).
|
|
295
|
+
try { child.stdin.end(); } catch { /* already closed */ }
|
|
296
|
+
const t = setTimeout(() => {
|
|
297
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
298
|
+
}, 50);
|
|
299
|
+
// If the child was already gone, exit fired before close() and
|
|
300
|
+
// we resolve immediately on next tick.
|
|
301
|
+
child.once('exit', () => {
|
|
302
|
+
clearTimeout(t);
|
|
303
|
+
resolve();
|
|
304
|
+
});
|
|
305
|
+
// Belt-and-braces: if 'exit' already fired before this listener
|
|
306
|
+
// attaches, the callback above never runs. Resolve after a hard
|
|
307
|
+
// cap so close() never deadlocks.
|
|
308
|
+
setTimeout(resolve, 200).unref?.();
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Internals ──────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Send a request and return a promise that resolves with the result
|
|
316
|
+
* (or rejects on error / transport failure).
|
|
317
|
+
*/
|
|
318
|
+
function sendRequest(method, params) {
|
|
319
|
+
if (closed) {
|
|
320
|
+
return Promise.reject(fatalError || new Error('asp-client: client is closed'));
|
|
321
|
+
}
|
|
322
|
+
const id = nextId++;
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
pendingById.set(id, { resolve, reject });
|
|
325
|
+
const ok = writeJson({ jsonrpc: '2.0', id, method, params });
|
|
326
|
+
if (!ok) {
|
|
327
|
+
pendingById.delete(id);
|
|
328
|
+
reject(fatalError || new Error('asp-client: stdin write failed'));
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Serialize and write a single newline-terminated JSON line.
|
|
335
|
+
* Returns false on transport failure (caller is responsible for
|
|
336
|
+
* cleanup of any pending entry).
|
|
337
|
+
*/
|
|
338
|
+
function writeJson(obj) {
|
|
339
|
+
if (closed) return false;
|
|
340
|
+
let line;
|
|
341
|
+
try {
|
|
342
|
+
line = JSON.stringify(obj) + '\n';
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// Caller passed something non-serializable; surface as fatal so
|
|
345
|
+
// we don't silently drop a request. Synchronous failure path —
|
|
346
|
+
// we want the writer (sendRequest / turn) to reject promptly.
|
|
347
|
+
onTransportClose(err);
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
child.stdin.write(line);
|
|
352
|
+
return true;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
onTransportClose(err);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Stdout chunk handler. Splits on `\n`, parses each complete line
|
|
361
|
+
* as JSON, dispatches by id (response) or method (notification).
|
|
362
|
+
* Carries an unfinished tail forward.
|
|
363
|
+
*/
|
|
364
|
+
function onStdoutData(chunk) {
|
|
365
|
+
if (closed) return;
|
|
366
|
+
stdoutBuf += chunk;
|
|
367
|
+
if (stdoutBuf.length > MAX_LINE_BYTES && !stdoutBuf.includes('\n')) {
|
|
368
|
+
onTransportClose(new Error(
|
|
369
|
+
`asp-client: line buffer exceeded ${MAX_LINE_BYTES} bytes without newline`,
|
|
370
|
+
));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
let nlIdx;
|
|
375
|
+
// eslint-disable-next-line no-cond-assign
|
|
376
|
+
while ((nlIdx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
377
|
+
const line = stdoutBuf.slice(0, nlIdx);
|
|
378
|
+
stdoutBuf = stdoutBuf.slice(nlIdx + 1);
|
|
379
|
+
if (line.trim().length === 0) continue;
|
|
380
|
+
let msg;
|
|
381
|
+
try {
|
|
382
|
+
msg = JSON.parse(line);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
// Single malformed line — log via fatal close. We don't try to
|
|
385
|
+
// skip-and-continue because once framing alignment is lost we
|
|
386
|
+
// can't trust subsequent lines either.
|
|
387
|
+
onTransportClose(new Error(`asp-client: malformed JSON line: ${err.message}`));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
dispatch(msg);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Route one parsed message: method-result, method-error, or
|
|
396
|
+
* notification.
|
|
397
|
+
*/
|
|
398
|
+
function dispatch(msg) {
|
|
399
|
+
if (msg === null || typeof msg !== 'object') return;
|
|
400
|
+
|
|
401
|
+
// Notification (no `id`): carries a `method` and `params`.
|
|
402
|
+
if (typeof msg.method === 'string' && !('id' in msg)) {
|
|
403
|
+
const params = (msg.params && typeof msg.params === 'object') ? msg.params : {};
|
|
404
|
+
const turnId = (typeof params.turnId === 'string') ? params.turnId : null;
|
|
405
|
+
if (turnId !== null) {
|
|
406
|
+
const entry = turnsByTurnId.get(turnId);
|
|
407
|
+
if (entry) {
|
|
408
|
+
// Always record for the final result payload.
|
|
409
|
+
entry.notifications.push(msg);
|
|
410
|
+
if (entry.onNotification) {
|
|
411
|
+
try { entry.onNotification(msg); } catch { /* user hook errors don't kill the stream */ }
|
|
412
|
+
}
|
|
413
|
+
// Terminal notifications close the turn. Codex emits
|
|
414
|
+
// `turn.complete` (or `turn.error`) as the final notification
|
|
415
|
+
// in the stream; the method-response may also carry the
|
|
416
|
+
// result. We accept either path — whichever lands first wins.
|
|
417
|
+
if (msg.method === 'turn.complete' || msg.method === 'turnComplete') {
|
|
418
|
+
const result = {
|
|
419
|
+
status: 'complete',
|
|
420
|
+
content: params.content,
|
|
421
|
+
usage: params.usage,
|
|
422
|
+
threadId: entry.threadId,
|
|
423
|
+
turnId: entry.turnId,
|
|
424
|
+
notifications: entry.notifications,
|
|
425
|
+
};
|
|
426
|
+
finalizeTurn(entry, /*reject*/ false, result);
|
|
427
|
+
} else if (msg.method === 'turn.error' || msg.method === 'turnError') {
|
|
428
|
+
const result = {
|
|
429
|
+
status: 'error',
|
|
430
|
+
error: params.error || { code: 'unknown', message: 'turn errored without detail' },
|
|
431
|
+
threadId: entry.threadId,
|
|
432
|
+
turnId: entry.turnId,
|
|
433
|
+
notifications: entry.notifications,
|
|
434
|
+
};
|
|
435
|
+
// NOTE: resolves (does not reject) — Codex-reported errors
|
|
436
|
+
// are a normal control path.
|
|
437
|
+
finalizeTurn(entry, /*reject*/ false, result);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Method response (has `id`): result or error.
|
|
445
|
+
if (typeof msg.id === 'number' || typeof msg.id === 'string') {
|
|
446
|
+
const id = msg.id;
|
|
447
|
+
|
|
448
|
+
// Was this a turn() request? If so, the response carries turnId
|
|
449
|
+
// (and possibly the inline result if the server didn't stream).
|
|
450
|
+
const turnEntry = turnsByRequestId.get(id);
|
|
451
|
+
if (turnEntry) {
|
|
452
|
+
turnsByRequestId.delete(id);
|
|
453
|
+
if (msg.error) {
|
|
454
|
+
// Method-call-level error (e.g. invalid params). Distinct
|
|
455
|
+
// from a `{status: "error"}` turn result — this rejects.
|
|
456
|
+
finalizeTurn(turnEntry, /*reject*/ true, asError(msg.error));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const result = (msg.result && typeof msg.result === 'object') ? msg.result : {};
|
|
460
|
+
const turnId = typeof result.turnId === 'string' ? result.turnId : null;
|
|
461
|
+
if (turnId === null) {
|
|
462
|
+
finalizeTurn(turnEntry, /*reject*/ true,
|
|
463
|
+
new Error('asp-client: turn response missing turnId'));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
turnEntry.turnId = turnId;
|
|
467
|
+
// If the server inlined the final status in the method response,
|
|
468
|
+
// settle immediately. Otherwise wait for streaming notifications.
|
|
469
|
+
if (typeof result.status === 'string') {
|
|
470
|
+
if (result.status === 'complete') {
|
|
471
|
+
finalizeTurn(turnEntry, /*reject*/ false, {
|
|
472
|
+
status: 'complete',
|
|
473
|
+
content: result.content,
|
|
474
|
+
usage: result.usage,
|
|
475
|
+
threadId: turnEntry.threadId,
|
|
476
|
+
turnId,
|
|
477
|
+
notifications: turnEntry.notifications,
|
|
478
|
+
});
|
|
479
|
+
} else if (result.status === 'error') {
|
|
480
|
+
finalizeTurn(turnEntry, /*reject*/ false, {
|
|
481
|
+
status: 'error',
|
|
482
|
+
error: result.error || { code: 'unknown', message: 'turn errored without detail' },
|
|
483
|
+
threadId: turnEntry.threadId,
|
|
484
|
+
turnId,
|
|
485
|
+
notifications: turnEntry.notifications,
|
|
486
|
+
});
|
|
487
|
+
} else {
|
|
488
|
+
// Unknown status — register the entry so streaming
|
|
489
|
+
// notifications can settle it.
|
|
490
|
+
turnsByTurnId.set(turnId, turnEntry);
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
// No inline status: register for streaming completion.
|
|
494
|
+
turnsByTurnId.set(turnId, turnEntry);
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Plain method response (threadStart / threadResume).
|
|
500
|
+
const pending = pendingById.get(id);
|
|
501
|
+
if (!pending) return; // unsolicited response — drop
|
|
502
|
+
pendingById.delete(id);
|
|
503
|
+
if (msg.error) {
|
|
504
|
+
pending.reject(asError(msg.error));
|
|
505
|
+
} else {
|
|
506
|
+
pending.resolve(msg.result);
|
|
507
|
+
}
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Anything else (no method, no id) is silently ignored — Codex may
|
|
512
|
+
// emit progress envelopes we don't recognize, and we don't want to
|
|
513
|
+
// tear down on forward-compat noise.
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Resolve or reject the turn entry, removing all bookkeeping.
|
|
518
|
+
* `value` is either the result payload (when resolving) or an Error
|
|
519
|
+
* (when rejecting).
|
|
520
|
+
*/
|
|
521
|
+
function finalizeTurn(entry, isReject, value) {
|
|
522
|
+
if (entry.settled) return;
|
|
523
|
+
entry.settled = true;
|
|
524
|
+
if (entry.signal && entry.onAbort) {
|
|
525
|
+
try { entry.signal.removeEventListener('abort', entry.onAbort); } catch { /* noop */ }
|
|
526
|
+
}
|
|
527
|
+
if (entry.turnId !== null) turnsByTurnId.delete(entry.turnId);
|
|
528
|
+
// Remove the request-id mapping if it survived this far.
|
|
529
|
+
for (const [reqId, e] of turnsByRequestId.entries()) {
|
|
530
|
+
if (e === entry) { turnsByRequestId.delete(reqId); break; }
|
|
531
|
+
}
|
|
532
|
+
if (isReject) entry.reject(value);
|
|
533
|
+
else entry.resolve(value);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Convert an ASP `error` envelope into an Error with the original
|
|
538
|
+
* code/data attached for the caller's classifier.
|
|
539
|
+
*/
|
|
540
|
+
function asError(envelope) {
|
|
541
|
+
const code = (envelope && (typeof envelope.code === 'string' || typeof envelope.code === 'number'))
|
|
542
|
+
? envelope.code : 'unknown';
|
|
543
|
+
const message = (envelope && typeof envelope.message === 'string')
|
|
544
|
+
? envelope.message : 'asp-client: server returned error';
|
|
545
|
+
const e = new Error(`asp-client: ${message} (code=${code})`);
|
|
546
|
+
e.code = code;
|
|
547
|
+
e.data = envelope && envelope.data;
|
|
548
|
+
return e;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Mark the transport as closed and reject every in-flight call.
|
|
553
|
+
* `cause` may be null for a clean shutdown.
|
|
554
|
+
*/
|
|
555
|
+
function onTransportClose(cause) {
|
|
556
|
+
if (closed) return;
|
|
557
|
+
closed = true;
|
|
558
|
+
fatalError = cause;
|
|
559
|
+
|
|
560
|
+
const err = cause || new Error('asp-client: transport closed');
|
|
561
|
+
|
|
562
|
+
for (const [, p] of pendingById) {
|
|
563
|
+
try { p.reject(err); } catch { /* noop */ }
|
|
564
|
+
}
|
|
565
|
+
pendingById.clear();
|
|
566
|
+
|
|
567
|
+
for (const [, entry] of turnsByRequestId) {
|
|
568
|
+
try { finalizeTurn(entry, /*reject*/ true, err); } catch { /* noop */ }
|
|
569
|
+
}
|
|
570
|
+
turnsByRequestId.clear();
|
|
571
|
+
|
|
572
|
+
for (const [, entry] of turnsByTurnId) {
|
|
573
|
+
try { finalizeTurn(entry, /*reject*/ true, err); } catch { /* noop */ }
|
|
574
|
+
}
|
|
575
|
+
turnsByTurnId.clear();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
threadStart,
|
|
580
|
+
threadResume,
|
|
581
|
+
turn,
|
|
582
|
+
close,
|
|
583
|
+
get closed() { return closed; },
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
module.exports = { createAspClient, MAX_LINE_BYTES };
|