@hegemonart/get-design-done 1.26.0 → 1.27.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +10 -8
- package/SKILL.md +3 -0
- package/agents/README.md +29 -0
- package/package.json +1 -1
- 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/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 +215 -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
|
@@ -50,6 +50,8 @@ const RUNTIMES = Object.freeze([
|
|
|
50
50
|
configDirFallback: '.gemini',
|
|
51
51
|
kind: 'agents-md',
|
|
52
52
|
files: ['GEMINI.md'],
|
|
53
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
54
|
+
peerBinary: process.platform === 'win32' ? 'gemini.cmd' : 'gemini',
|
|
53
55
|
},
|
|
54
56
|
{
|
|
55
57
|
id: 'kilo',
|
|
@@ -66,6 +68,8 @@ const RUNTIMES = Object.freeze([
|
|
|
66
68
|
configDirFallback: '.codex',
|
|
67
69
|
kind: 'agents-md',
|
|
68
70
|
files: ['AGENTS.md'],
|
|
71
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ASP protocol.
|
|
72
|
+
peerBinary: process.platform === 'win32' ? 'codex.cmd' : 'codex',
|
|
69
73
|
},
|
|
70
74
|
{
|
|
71
75
|
id: 'copilot',
|
|
@@ -74,6 +78,8 @@ const RUNTIMES = Object.freeze([
|
|
|
74
78
|
configDirFallback: '.copilot',
|
|
75
79
|
kind: 'agents-md',
|
|
76
80
|
files: ['AGENTS.md'],
|
|
81
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
82
|
+
peerBinary: process.platform === 'win32' ? 'copilot.cmd' : 'copilot',
|
|
77
83
|
},
|
|
78
84
|
{
|
|
79
85
|
id: 'cursor',
|
|
@@ -82,6 +88,8 @@ const RUNTIMES = Object.freeze([
|
|
|
82
88
|
configDirFallback: '.cursor',
|
|
83
89
|
kind: 'agents-md',
|
|
84
90
|
files: ['AGENTS.md'],
|
|
91
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
92
|
+
peerBinary: process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent',
|
|
85
93
|
},
|
|
86
94
|
{
|
|
87
95
|
id: 'windsurf',
|
|
@@ -122,6 +130,8 @@ const RUNTIMES = Object.freeze([
|
|
|
122
130
|
configDirFallback: '.qwen',
|
|
123
131
|
kind: 'agents-md',
|
|
124
132
|
files: ['AGENTS.md'],
|
|
133
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
134
|
+
peerBinary: process.platform === 'win32' ? 'qwen.cmd' : 'qwen',
|
|
125
135
|
},
|
|
126
136
|
{
|
|
127
137
|
id: 'codebuddy',
|
|
@@ -202,6 +212,52 @@ function _resetRuntimeModelsCache() {
|
|
|
202
212
|
_modelsCache.clear();
|
|
203
213
|
}
|
|
204
214
|
|
|
215
|
+
// Phase 27 (Plan 27-11) — peer-CLI detection helpers.
|
|
216
|
+
//
|
|
217
|
+
// `listPeerCapableRuntimes()` returns the entries that carry a `peerBinary`
|
|
218
|
+
// field — the 5 runtimes that gdd can DELEGATE to (codex, gemini, cursor,
|
|
219
|
+
// copilot, qwen). The other 9 runtimes (claude, opencode, kilo, windsurf,
|
|
220
|
+
// antigravity, augment, trae, codebuddy, cline) are install targets only.
|
|
221
|
+
//
|
|
222
|
+
// `detectInstalledPeers({ which? })` checks each peer-capable runtime's
|
|
223
|
+
// `peerBinary` against the system PATH and returns the IDs of the peers
|
|
224
|
+
// that are installed locally. The `which` parameter is injectable for
|
|
225
|
+
// tests — the production caller passes a real `which`/`where` shim.
|
|
226
|
+
|
|
227
|
+
function listPeerCapableRuntimes() {
|
|
228
|
+
return RUNTIMES.filter((r) => typeof r.peerBinary === 'string');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function detectInstalledPeers(opts) {
|
|
232
|
+
const opts2 = opts || {};
|
|
233
|
+
const whichFn = opts2.which || _defaultWhich;
|
|
234
|
+
const detected = [];
|
|
235
|
+
for (const r of listPeerCapableRuntimes()) {
|
|
236
|
+
try {
|
|
237
|
+
if (whichFn(r.peerBinary)) {
|
|
238
|
+
detected.push(r.id);
|
|
239
|
+
}
|
|
240
|
+
} catch (_e) {
|
|
241
|
+
// ENOENT / non-zero exit = not installed; never throw.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return detected;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _defaultWhich(binary) {
|
|
248
|
+
const { execSync } = require('node:child_process');
|
|
249
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
250
|
+
try {
|
|
251
|
+
const out = execSync(`${cmd} ${binary}`, {
|
|
252
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
253
|
+
encoding: 'utf8',
|
|
254
|
+
}).trim();
|
|
255
|
+
return out.length > 0 ? out.split(/\r?\n/)[0] : null;
|
|
256
|
+
} catch (_e) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
205
261
|
module.exports = {
|
|
206
262
|
RUNTIMES,
|
|
207
263
|
REPO,
|
|
@@ -211,5 +267,7 @@ module.exports = {
|
|
|
211
267
|
listRuntimes,
|
|
212
268
|
listRuntimeIds,
|
|
213
269
|
getRuntimeModels,
|
|
270
|
+
listPeerCapableRuntimes,
|
|
271
|
+
detectInstalledPeers,
|
|
214
272
|
_resetRuntimeModelsCache,
|
|
215
273
|
};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// scripts/lib/peer-cli/acp-client.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 27-01 — Agent Client Protocol (ACP) client.
|
|
4
|
+
//
|
|
5
|
+
// Protocol shape adapted from greenpolo/cc-multi-cli `acp-client.mjs`
|
|
6
|
+
// (Apache 2.0). See NOTICE for full attribution (added by Plan 27-12).
|
|
7
|
+
//
|
|
8
|
+
// ACP is the line-delimited JSON-RPC 2.0 transport spoken by the Gemini,
|
|
9
|
+
// Cursor, Copilot, and Qwen CLIs when launched in their `acp` mode. We
|
|
10
|
+
// drive a child process over stdio, send framed JSON requests on stdin,
|
|
11
|
+
// and read framed JSON responses + notifications off stdout. Every
|
|
12
|
+
// outbound request gets a numeric `id`; the server's reply is correlated
|
|
13
|
+
// back via that id. Server-pushed notifications (`agent_message_chunk`,
|
|
14
|
+
// `tool_call`, `file_change`, etc.) lack an id and are surfaced through
|
|
15
|
+
// the `onNotification` callback supplied to `prompt()`.
|
|
16
|
+
//
|
|
17
|
+
// Wire framing:
|
|
18
|
+
// send: `<json>\n` over stdin.
|
|
19
|
+
// recv: `<json>\n` over stdout, line-delimited.
|
|
20
|
+
//
|
|
21
|
+
// The framing layer must handle three real-world conditions:
|
|
22
|
+
// (a) multiple complete JSON messages arriving in one stdout chunk,
|
|
23
|
+
// (b) a single JSON message split across multiple stdout chunks, and
|
|
24
|
+
// (c) misbehaved peer never emitting a newline — we cap the buffered
|
|
25
|
+
// line length at 16 MiB and reject the active prompt rather than
|
|
26
|
+
// grow memory unbounded.
|
|
27
|
+
//
|
|
28
|
+
// Module exports:
|
|
29
|
+
// createAcpClient({command, args, cwd, env}) -> AcpClient
|
|
30
|
+
//
|
|
31
|
+
// AcpClient:
|
|
32
|
+
// initialize(params) -> Promise<result>
|
|
33
|
+
// First call after spawn. Sends the JSON-RPC `initialize` request
|
|
34
|
+
// with the negotiated `protocolVersion` + `clientCapabilities` and
|
|
35
|
+
// resolves with the server's capability reply.
|
|
36
|
+
//
|
|
37
|
+
// prompt(text, opts) -> Promise<result>
|
|
38
|
+
// Sends a `prompt` request. Notifications received between request
|
|
39
|
+
// send and response receive are forwarded to opts.onNotification(n).
|
|
40
|
+
// Resolves with the final `result` payload tied to the request id.
|
|
41
|
+
//
|
|
42
|
+
// close() -> Promise<void>
|
|
43
|
+
// Sends SIGTERM to the child, waits for exit, drains any in-flight
|
|
44
|
+
// promises with a "client closed" rejection.
|
|
45
|
+
//
|
|
46
|
+
// This module has no external dependencies — only Node built-ins
|
|
47
|
+
// (`child_process`, `events`).
|
|
48
|
+
|
|
49
|
+
'use strict';
|
|
50
|
+
|
|
51
|
+
const { spawn } = require('child_process');
|
|
52
|
+
const { EventEmitter } = require('events');
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Hard cap on the size of a single un-terminated line read from the
|
|
56
|
+
* peer's stdout. If the peer streams more than this without a `\n`, we
|
|
57
|
+
* treat it as a protocol violation and reject all pending requests.
|
|
58
|
+
* 16 MiB is well above any legitimate ACP payload (largest observed in
|
|
59
|
+
* cc-multi-cli traces is ~2 MiB for tool-call results with embedded
|
|
60
|
+
* file diffs) but small enough that a runaway peer can't OOM the host.
|
|
61
|
+
*/
|
|
62
|
+
const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Default ACP protocol version we negotiate with. Callers can override
|
|
66
|
+
* via `initialize({ protocolVersion: '...' })`. The current Gemini /
|
|
67
|
+
* Cursor / Copilot / Qwen CLIs all advertise `2025-06-18` as of writing.
|
|
68
|
+
*/
|
|
69
|
+
const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create an ACP client wrapping a freshly spawned peer-CLI process.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {string} opts.command Absolute path (or PATH-resolvable name)
|
|
76
|
+
* to the peer-CLI binary. The caller is responsible for resolving
|
|
77
|
+
* peer-binary location (Plan 27-11 ships `peerBinary` on runtimes.cjs
|
|
78
|
+
* for that). On Windows, `.cmd` shims need the spawn-cmd.cjs
|
|
79
|
+
* workaround (Plan 27-03) — this module does NOT apply that shim
|
|
80
|
+
* itself, callers must wrap.
|
|
81
|
+
* @param {string[]} [opts.args=[]] Extra args to the peer binary;
|
|
82
|
+
* typical value is `['acp']` to launch the peer in ACP mode.
|
|
83
|
+
* @param {string} [opts.cwd=process.cwd()] Working directory for the
|
|
84
|
+
* child process.
|
|
85
|
+
* @param {Record<string,string>} [opts.env] Environment overrides.
|
|
86
|
+
* Defaults to inheriting from process.env.
|
|
87
|
+
* @returns {{
|
|
88
|
+
* initialize: (params: object) => Promise<unknown>,
|
|
89
|
+
* prompt: (text: string, opts?: object) => Promise<unknown>,
|
|
90
|
+
* close: () => Promise<void>,
|
|
91
|
+
* on: (event: string, listener: Function) => void,
|
|
92
|
+
* pid: number | undefined,
|
|
93
|
+
* }}
|
|
94
|
+
*/
|
|
95
|
+
function createAcpClient(opts) {
|
|
96
|
+
if (!opts || typeof opts.command !== 'string' || opts.command.length === 0) {
|
|
97
|
+
throw new TypeError('createAcpClient: opts.command (string) is required');
|
|
98
|
+
}
|
|
99
|
+
const command = opts.command;
|
|
100
|
+
const args = Array.isArray(opts.args) ? opts.args : [];
|
|
101
|
+
const cwd = typeof opts.cwd === 'string' ? opts.cwd : process.cwd();
|
|
102
|
+
const env = opts.env && typeof opts.env === 'object' ? opts.env : process.env;
|
|
103
|
+
|
|
104
|
+
const events = new EventEmitter();
|
|
105
|
+
|
|
106
|
+
// Spawn the child. We use plain `spawn` (no shell) — Windows `.cmd`
|
|
107
|
+
// dispatch is the caller's responsibility (Plan 27-03 spawn-cmd.cjs).
|
|
108
|
+
const child = spawn(command, args, {
|
|
109
|
+
cwd,
|
|
110
|
+
env,
|
|
111
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Per-request correlation. Server replies carry the id we sent; we
|
|
116
|
+
// resolve/reject the matching pending entry. Notifications (no id)
|
|
117
|
+
// bypass this map and route to the active prompt's onNotification.
|
|
118
|
+
let nextRequestId = 1;
|
|
119
|
+
/** @type {Map<number, {resolve: Function, reject: Function, onNotification?: Function}>} */
|
|
120
|
+
const pending = new Map();
|
|
121
|
+
|
|
122
|
+
// The id of the request whose notifications we currently surface.
|
|
123
|
+
// ACP is half-duplex per stream — only one prompt is in flight at
|
|
124
|
+
// once from the host's perspective — so we track it as a single ref.
|
|
125
|
+
let activeNotificationTargetId = null;
|
|
126
|
+
|
|
127
|
+
// Closure / error state.
|
|
128
|
+
let closed = false;
|
|
129
|
+
/** @type {Error | null} */
|
|
130
|
+
let fatalError = null;
|
|
131
|
+
|
|
132
|
+
// Line-buffer state. Stdout chunks accumulate here; each `\n` flushes
|
|
133
|
+
// a complete JSON message into handleMessage().
|
|
134
|
+
let lineBuffer = '';
|
|
135
|
+
|
|
136
|
+
child.stdout.setEncoding('utf8');
|
|
137
|
+
child.stdout.on('data', onStdoutData);
|
|
138
|
+
child.stderr.setEncoding('utf8');
|
|
139
|
+
child.stderr.on('data', (chunk) => {
|
|
140
|
+
// Surface stderr verbatim so callers can wire it into Phase 22
|
|
141
|
+
// event chain or simple debug logging without us imposing a
|
|
142
|
+
// structure on it.
|
|
143
|
+
events.emit('stderr', chunk);
|
|
144
|
+
});
|
|
145
|
+
child.on('error', (err) => {
|
|
146
|
+
fail(err);
|
|
147
|
+
});
|
|
148
|
+
child.on('exit', (code, signal) => {
|
|
149
|
+
events.emit('exit', { code, signal });
|
|
150
|
+
if (!closed) {
|
|
151
|
+
// Unexpected exit → fail every in-flight request.
|
|
152
|
+
fail(new Error(`ACP peer exited unexpectedly (code=${code}, signal=${signal})`));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Append a stdout chunk to the line buffer and flush every complete
|
|
158
|
+
* line. A "line" is everything up to (but not including) a `\n`.
|
|
159
|
+
* Lines longer than MAX_LINE_BYTES indicate a malformed peer stream
|
|
160
|
+
* — we tear the client down with an error so callers don't OOM.
|
|
161
|
+
*/
|
|
162
|
+
function onStdoutData(chunk) {
|
|
163
|
+
if (fatalError) return;
|
|
164
|
+
lineBuffer += chunk;
|
|
165
|
+
|
|
166
|
+
if (lineBuffer.length > MAX_LINE_BYTES) {
|
|
167
|
+
// We measured length in code units (UTF-16) but the cap is in
|
|
168
|
+
// bytes; this is an over-eager check (UTF-16 length <= UTF-8
|
|
169
|
+
// byte count is not always true, but for our payloads of mostly
|
|
170
|
+
// ASCII JSON the two are within a small constant). The intent
|
|
171
|
+
// is "no peer should ever emit this much without a newline" —
|
|
172
|
+
// exact byte accounting isn't worth the Buffer churn here.
|
|
173
|
+
fail(new Error(
|
|
174
|
+
`ACP peer emitted ${lineBuffer.length} bytes without a newline ` +
|
|
175
|
+
`(cap: ${MAX_LINE_BYTES} bytes) — protocol violation`,
|
|
176
|
+
));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let newlineIdx;
|
|
181
|
+
while ((newlineIdx = lineBuffer.indexOf('\n')) !== -1) {
|
|
182
|
+
const line = lineBuffer.slice(0, newlineIdx);
|
|
183
|
+
lineBuffer = lineBuffer.slice(newlineIdx + 1);
|
|
184
|
+
if (line.length === 0) continue; // tolerate keep-alive blank lines
|
|
185
|
+
handleLine(line);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Parse one JSON-RPC frame and dispatch it to either the response
|
|
191
|
+
* correlation table (if it has an `id` matching a pending request)
|
|
192
|
+
* or the active notification sink.
|
|
193
|
+
*/
|
|
194
|
+
function handleLine(line) {
|
|
195
|
+
let msg;
|
|
196
|
+
try {
|
|
197
|
+
msg = JSON.parse(line);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Malformed JSON from the peer is a protocol violation but not
|
|
200
|
+
// necessarily fatal — the peer may recover on the next line.
|
|
201
|
+
// Surface it so callers can log, but keep going.
|
|
202
|
+
events.emit('parse_error', { line, error: err });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Standard JSON-RPC 2.0 dispatch:
|
|
207
|
+
// - response : has `id` AND (`result` OR `error`)
|
|
208
|
+
// - notification : has `method` AND no `id`
|
|
209
|
+
// - request from server (e.g. tool_call back-channel) : has `id`
|
|
210
|
+
// AND `method`. ACP peers can issue these; for v1 we treat
|
|
211
|
+
// them as notifications (callers handle via onNotification).
|
|
212
|
+
//
|
|
213
|
+
// We dispatch in priority: response first (id present + no method),
|
|
214
|
+
// then notifications (method present).
|
|
215
|
+
const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
|
|
216
|
+
const hasMethod = typeof msg.method === 'string';
|
|
217
|
+
|
|
218
|
+
if (hasId && !hasMethod) {
|
|
219
|
+
const entry = pending.get(msg.id);
|
|
220
|
+
if (!entry) {
|
|
221
|
+
// Unknown id — peer replied to a request we didn't send (or
|
|
222
|
+
// we already timed it out). Surface for diagnostic but ignore.
|
|
223
|
+
events.emit('orphan_response', msg);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
pending.delete(msg.id);
|
|
227
|
+
if (msg.id === activeNotificationTargetId) {
|
|
228
|
+
activeNotificationTargetId = null;
|
|
229
|
+
}
|
|
230
|
+
if (msg.error) {
|
|
231
|
+
const err = new Error(
|
|
232
|
+
(msg.error && msg.error.message) || 'ACP peer returned error',
|
|
233
|
+
);
|
|
234
|
+
// Preserve the JSON-RPC error envelope so callers can inspect
|
|
235
|
+
// .code and .data without losing typing.
|
|
236
|
+
err.code = msg.error.code;
|
|
237
|
+
err.data = msg.error.data;
|
|
238
|
+
entry.reject(err);
|
|
239
|
+
} else {
|
|
240
|
+
entry.resolve(msg.result);
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (hasMethod) {
|
|
246
|
+
// Notification (or server-issued request — same handling for v1).
|
|
247
|
+
if (activeNotificationTargetId !== null) {
|
|
248
|
+
const entry = pending.get(activeNotificationTargetId);
|
|
249
|
+
if (entry && typeof entry.onNotification === 'function') {
|
|
250
|
+
try {
|
|
251
|
+
entry.onNotification(msg);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// Caller's notification handler threw — surface but don't
|
|
254
|
+
// tear down the protocol stream.
|
|
255
|
+
events.emit('notification_handler_error', { error: err, notification: msg });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Always emit on the EventEmitter too, so callers without an
|
|
260
|
+
// active prompt (e.g. health monitor) can still observe.
|
|
261
|
+
events.emit('notification', msg);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Neither id+result nor method — malformed. Surface for diagnostic.
|
|
266
|
+
events.emit('protocol_violation', msg);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Send a JSON-RPC request and return a Promise resolving to its
|
|
271
|
+
* `result`. Caller-supplied `onNotification` is fired for every
|
|
272
|
+
* notification received between send and reply.
|
|
273
|
+
*/
|
|
274
|
+
function sendRequest(method, params, onNotification) {
|
|
275
|
+
if (closed) {
|
|
276
|
+
return Promise.reject(new Error('ACP client is closed'));
|
|
277
|
+
}
|
|
278
|
+
if (fatalError) {
|
|
279
|
+
return Promise.reject(fatalError);
|
|
280
|
+
}
|
|
281
|
+
const id = nextRequestId++;
|
|
282
|
+
const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
pending.set(id, { resolve, reject, onNotification });
|
|
285
|
+
activeNotificationTargetId = id;
|
|
286
|
+
const ok = child.stdin.write(frame + '\n', 'utf8', (err) => {
|
|
287
|
+
if (err) {
|
|
288
|
+
pending.delete(id);
|
|
289
|
+
if (activeNotificationTargetId === id) activeNotificationTargetId = null;
|
|
290
|
+
reject(err);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
// Backpressure: `write` returning false signals the kernel buffer
|
|
294
|
+
// is full. For a JSON-RPC line of typical size (<10 KiB) this is
|
|
295
|
+
// exceedingly rare; we don't block on `drain` but trust Node's
|
|
296
|
+
// internal queue. If a pathological peer stalls reads, the
|
|
297
|
+
// kernel buffer fills and write throws via the callback above.
|
|
298
|
+
void ok;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Tear down all in-flight promises with the given error. Safe to
|
|
304
|
+
* call multiple times — subsequent calls are no-ops.
|
|
305
|
+
*/
|
|
306
|
+
function fail(err) {
|
|
307
|
+
if (fatalError) return;
|
|
308
|
+
fatalError = err;
|
|
309
|
+
for (const [, entry] of pending) {
|
|
310
|
+
try { entry.reject(err); } catch { /* ignore */ }
|
|
311
|
+
}
|
|
312
|
+
pending.clear();
|
|
313
|
+
activeNotificationTargetId = null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** ACP `initialize` — first call after spawn. */
|
|
317
|
+
function initialize(params) {
|
|
318
|
+
const merged = Object.assign(
|
|
319
|
+
{ protocolVersion: DEFAULT_PROTOCOL_VERSION, clientCapabilities: {} },
|
|
320
|
+
params || {},
|
|
321
|
+
);
|
|
322
|
+
return sendRequest('initialize', merged);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** ACP `prompt` — primary turn-driver. */
|
|
326
|
+
function prompt(text, promptOpts) {
|
|
327
|
+
const params = Object.assign({ text }, (promptOpts && promptOpts.params) || {});
|
|
328
|
+
const onNotification = promptOpts && typeof promptOpts.onNotification === 'function'
|
|
329
|
+
? promptOpts.onNotification
|
|
330
|
+
: undefined;
|
|
331
|
+
return sendRequest('prompt', params, onNotification);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Gracefully terminate the peer; resolves once the child exits. */
|
|
335
|
+
function close() {
|
|
336
|
+
if (closed) return Promise.resolve();
|
|
337
|
+
closed = true;
|
|
338
|
+
return new Promise((resolve) => {
|
|
339
|
+
const onExit = () => {
|
|
340
|
+
fail(new Error('ACP client is closed'));
|
|
341
|
+
resolve();
|
|
342
|
+
};
|
|
343
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
344
|
+
// Already exited.
|
|
345
|
+
onExit();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
child.once('exit', onExit);
|
|
349
|
+
try {
|
|
350
|
+
child.stdin.end();
|
|
351
|
+
} catch { /* ignore */ }
|
|
352
|
+
// Give the peer a moment to exit on its own after stdin EOF.
|
|
353
|
+
// 500ms is well above ACP cleanup time observed in cc-multi-cli.
|
|
354
|
+
setTimeout(() => {
|
|
355
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
356
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
357
|
+
}
|
|
358
|
+
}, 500);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
initialize,
|
|
364
|
+
prompt,
|
|
365
|
+
close,
|
|
366
|
+
on: events.on.bind(events),
|
|
367
|
+
get pid() { return child.pid; },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
createAcpClient,
|
|
373
|
+
MAX_LINE_BYTES,
|
|
374
|
+
DEFAULT_PROTOCOL_VERSION,
|
|
375
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// scripts/lib/peer-cli/adapters/codex.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 27-04 — Per-peer adapter for the Codex CLI.
|
|
4
|
+
//
|
|
5
|
+
// Codex is the only peer in our matrix that speaks ASP (App Server
|
|
6
|
+
// Protocol) rather than ACP. The ASP transport is thread-oriented:
|
|
7
|
+
// every turn lives inside a `threadId` that we obtain via `threadStart`
|
|
8
|
+
// before driving the conversation with `turn(threadId, text)`. See
|
|
9
|
+
// scripts/lib/peer-cli/asp-client.cjs for the protocol details.
|
|
10
|
+
//
|
|
11
|
+
// Capability matrix (CONTEXT.md D-05):
|
|
12
|
+
// * Codex claims the `execute` role only.
|
|
13
|
+
//
|
|
14
|
+
// The slash-command translation layer is wafer-thin: when the registry
|
|
15
|
+
// dispatches role=`execute`, we prepend `/execute ` to the user's text
|
|
16
|
+
// so Codex routes the prompt through its execute pipeline. Roles the
|
|
17
|
+
// peer doesn't claim are rejected at `dispatch()` time — the registry
|
|
18
|
+
// (Plan 27-05) uses `claims(role)` to filter before reaching us, so
|
|
19
|
+
// hitting this branch indicates a registry bug or a misconfigured
|
|
20
|
+
// `delegate_to:` frontmatter.
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const { createAspClient } = require('../asp-client.cjs');
|
|
25
|
+
|
|
26
|
+
/** Roles this peer claims. Roles outside this set are refused. */
|
|
27
|
+
const ROLES_CLAIMED = Object.freeze(['execute']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Per-role prompt prefix. Codex understands `/execute` natively, so the
|
|
31
|
+
* prefix doubles as both a role marker and a Codex slash command.
|
|
32
|
+
*/
|
|
33
|
+
const ROLE_PREFIX = Object.freeze({
|
|
34
|
+
execute: '/execute ',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cheap predicate the registry consults to decide whether to dispatch
|
|
39
|
+
* a role to this peer. Pure / synchronous on purpose.
|
|
40
|
+
*/
|
|
41
|
+
function claims(role) {
|
|
42
|
+
return ROLES_CLAIMED.includes(role);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Drive a single Codex turn for the supplied role + text.
|
|
47
|
+
*
|
|
48
|
+
* @param {{command: string, args?: string[], cwd?: string, env?: object}} peer
|
|
49
|
+
* Spawn descriptor for the Codex binary. The registry resolves these
|
|
50
|
+
* from runtimes.cjs (Plan 27-11 ships `peerBinary`).
|
|
51
|
+
* @param {string} role Must satisfy `claims(role)`.
|
|
52
|
+
* @param {string} text User-supplied prompt; we prepend ROLE_PREFIX.
|
|
53
|
+
* @param {{onNotification?: (n: object) => void, threadId?: string}} [opts]
|
|
54
|
+
* `onNotification` is forwarded to the ASP turn for streaming
|
|
55
|
+
* visibility. `threadId` lets the broker (Plan 27-03) resume a thread
|
|
56
|
+
* instead of starting a fresh one — v1.27.0 always passes undefined.
|
|
57
|
+
* @returns {Promise<object>} The ASP `turn()` result envelope —
|
|
58
|
+
* `{status, content, usage, threadId, turnId, notifications}` — passed
|
|
59
|
+
* through unchanged so the caller can branch on `status === 'error'`.
|
|
60
|
+
*/
|
|
61
|
+
async function dispatch(peer, role, text, opts) {
|
|
62
|
+
if (!claims(role)) {
|
|
63
|
+
throw new Error(`codex adapter does not claim role: ${role}`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof text !== 'string') {
|
|
66
|
+
throw new TypeError('codex adapter: text must be a string');
|
|
67
|
+
}
|
|
68
|
+
const onNotification = opts && typeof opts.onNotification === 'function'
|
|
69
|
+
? opts.onNotification
|
|
70
|
+
: undefined;
|
|
71
|
+
const explicitThreadId = opts && typeof opts.threadId === 'string' && opts.threadId.length > 0
|
|
72
|
+
? opts.threadId
|
|
73
|
+
: null;
|
|
74
|
+
|
|
75
|
+
const client = createAspClient({
|
|
76
|
+
command: peer.command,
|
|
77
|
+
args: peer.args,
|
|
78
|
+
cwd: peer.cwd,
|
|
79
|
+
env: peer.env,
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
let threadId = explicitThreadId;
|
|
83
|
+
if (threadId === null) {
|
|
84
|
+
const started = await client.threadStart({ service_name: 'gdd_peer_delegation' });
|
|
85
|
+
threadId = started.threadId;
|
|
86
|
+
}
|
|
87
|
+
const prompt = ROLE_PREFIX[role] + text;
|
|
88
|
+
return await client.turn(threadId, prompt, { onNotification });
|
|
89
|
+
} finally {
|
|
90
|
+
await client.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
name: 'codex',
|
|
96
|
+
protocol: 'asp',
|
|
97
|
+
ROLES_CLAIMED,
|
|
98
|
+
ROLE_PREFIX,
|
|
99
|
+
claims,
|
|
100
|
+
dispatch,
|
|
101
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// scripts/lib/peer-cli/adapters/copilot.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 27-04 — Per-peer adapter for the GitHub Copilot CLI.
|
|
4
|
+
//
|
|
5
|
+
// Copilot speaks ACP (line-delimited JSON-RPC over stdio); see
|
|
6
|
+
// scripts/lib/peer-cli/acp-client.cjs.
|
|
7
|
+
//
|
|
8
|
+
// Capability matrix (CONTEXT.md D-05):
|
|
9
|
+
// * Copilot claims the `review` and `research` roles.
|
|
10
|
+
//
|
|
11
|
+
// Note: `research` overlaps with Gemini. The registry (Plan 27-05)
|
|
12
|
+
// arbitrates by per-peer health + posterior win-rate; this adapter
|
|
13
|
+
// just declares membership in both role pools.
|
|
14
|
+
//
|
|
15
|
+
// Copilot exposes `/review` and `/research` as slash commands.
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { createAcpClient } = require('../acp-client.cjs');
|
|
20
|
+
|
|
21
|
+
/** Roles this peer claims. */
|
|
22
|
+
const ROLES_CLAIMED = Object.freeze(['review', 'research']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Per-role slash-command prefix. Copilot recognizes `/review` and
|
|
26
|
+
* `/research` natively at the start of a prompt.
|
|
27
|
+
*/
|
|
28
|
+
const ROLE_PREFIX = Object.freeze({
|
|
29
|
+
review: '/review ',
|
|
30
|
+
research: '/research ',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function claims(role) {
|
|
34
|
+
return ROLES_CLAIMED.includes(role);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Drive one Copilot ACP `prompt` for the supplied role + text.
|
|
39
|
+
*
|
|
40
|
+
* @param {{command: string, args?: string[], cwd?: string, env?: object}} peer
|
|
41
|
+
* @param {string} role
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @param {{onNotification?: (n: object) => void}} [opts]
|
|
44
|
+
* @returns {Promise<object>}
|
|
45
|
+
*/
|
|
46
|
+
async function dispatch(peer, role, text, opts) {
|
|
47
|
+
if (!claims(role)) {
|
|
48
|
+
throw new Error(`copilot adapter does not claim role: ${role}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof text !== 'string') {
|
|
51
|
+
throw new TypeError('copilot adapter: text must be a string');
|
|
52
|
+
}
|
|
53
|
+
const onNotification = opts && typeof opts.onNotification === 'function'
|
|
54
|
+
? opts.onNotification
|
|
55
|
+
: undefined;
|
|
56
|
+
|
|
57
|
+
const client = createAcpClient({
|
|
58
|
+
command: peer.command,
|
|
59
|
+
args: peer.args,
|
|
60
|
+
cwd: peer.cwd,
|
|
61
|
+
env: peer.env,
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
await client.initialize({ protocolVersion: '2025-06-18' });
|
|
65
|
+
const prompt = ROLE_PREFIX[role] + text;
|
|
66
|
+
return await client.prompt(prompt, { onNotification });
|
|
67
|
+
} finally {
|
|
68
|
+
await client.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
name: 'copilot',
|
|
74
|
+
protocol: 'acp',
|
|
75
|
+
ROLES_CLAIMED,
|
|
76
|
+
ROLE_PREFIX,
|
|
77
|
+
claims,
|
|
78
|
+
dispatch,
|
|
79
|
+
};
|