cli-relay 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/cli-relay.mjs +481 -0
- package/package.json +33 -0
- package/src/adapter-loader.mjs +138 -0
- package/src/adapters/agy.mjs +21 -0
- package/src/adapters/claude-code.mjs +18 -0
- package/src/adapters/codex.mjs +58 -0
- package/src/adapters/command-code.mjs +18 -0
- package/src/commands/doctor.mjs +59 -0
- package/src/commands/list.mjs +52 -0
- package/src/commands/pin.mjs +37 -0
- package/src/commands/pins.mjs +27 -0
- package/src/commands/reset.mjs +36 -0
- package/src/commands/unpin.mjs +32 -0
- package/src/config.mjs +84 -0
- package/src/core/adapter-env.mjs +3 -0
- package/src/core/env.mjs +7 -0
- package/src/core/errors.mjs +8 -0
- package/src/core/lock.mjs +123 -0
- package/src/core/map-store.mjs +20 -0
- package/src/core/parse-json-result.mjs +24 -0
- package/src/core/pins.mjs +24 -0
- package/src/core/thread-lookup.mjs +12 -0
package/cli-relay.mjs
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli-relay.mjs — persistent CLI router: resume-by-reference across pluggable backends.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* cli-relay [--dry-run|--print-command] <backend> <thread> <fresh|resume> <prompt...>
|
|
7
|
+
* cli-relay list
|
|
8
|
+
* cli-relay doctor
|
|
9
|
+
* cli-relay reset <thread>
|
|
10
|
+
* cli-relay pin <thread> "<fact>"
|
|
11
|
+
* cli-relay unpin <thread> <index>
|
|
12
|
+
* cli-relay pins <thread>
|
|
13
|
+
*
|
|
14
|
+
* Exit codes:
|
|
15
|
+
* 0 success
|
|
16
|
+
* 1 general error or backend spawn failure
|
|
17
|
+
* 2 usage error
|
|
18
|
+
* 3 backend produced an id/exit-0-shaped result but no usable answer
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { cmdDoctor } from './src/commands/doctor.mjs';
|
|
23
|
+
import { cmdList } from './src/commands/list.mjs';
|
|
24
|
+
import { cmdPin } from './src/commands/pin.mjs';
|
|
25
|
+
import { cmdPins } from './src/commands/pins.mjs';
|
|
26
|
+
import { cmdReset } from './src/commands/reset.mjs';
|
|
27
|
+
import { cmdUnpin } from './src/commands/unpin.mjs';
|
|
28
|
+
import {
|
|
29
|
+
LOCK_STALE_MS,
|
|
30
|
+
MAP_PATH,
|
|
31
|
+
RESUME_FAILURE_THRESHOLD,
|
|
32
|
+
RESUME_WARNING_THRESHOLD,
|
|
33
|
+
SPAWN_KILL_GRACE_MS,
|
|
34
|
+
SPAWN_TIMEOUT_MS,
|
|
35
|
+
} from './src/config.mjs';
|
|
36
|
+
import { scrubEnv } from './src/core/env.mjs';
|
|
37
|
+
import { RelayError } from './src/core/errors.mjs';
|
|
38
|
+
import { withLock } from './src/core/lock.mjs';
|
|
39
|
+
import { loadMap, saveMap } from './src/core/map-store.mjs';
|
|
40
|
+
import { buildPinnedBlock } from './src/core/pins.mjs';
|
|
41
|
+
import { withThreadSuggestions } from './src/core/thread-lookup.mjs';
|
|
42
|
+
|
|
43
|
+
let activeChildPgid = null;
|
|
44
|
+
let childHasFinished = false;
|
|
45
|
+
let userInterrupted = false;
|
|
46
|
+
let interruptSignal = null;
|
|
47
|
+
|
|
48
|
+
function interruptExitCode() {
|
|
49
|
+
return 128 + (interruptSignal === 'SIGTERM' ? 15 : 2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runChild(argv, env) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
55
|
+
env,
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
detached: true,
|
|
58
|
+
});
|
|
59
|
+
const pgid = child.pid;
|
|
60
|
+
activeChildPgid = pgid;
|
|
61
|
+
let out = '';
|
|
62
|
+
let err = '';
|
|
63
|
+
let timedOut = false;
|
|
64
|
+
let cancelled = false;
|
|
65
|
+
child.stdout.on('data', (data) => { out += data; });
|
|
66
|
+
child.stderr.on('data', (data) => { err += data; });
|
|
67
|
+
|
|
68
|
+
const killGroup = (signal) => {
|
|
69
|
+
try { process.kill(-pgid, signal); } catch {}
|
|
70
|
+
};
|
|
71
|
+
let killTimer = null;
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
cancelled = true;
|
|
75
|
+
killGroup('SIGTERM');
|
|
76
|
+
killTimer = setTimeout(() => killGroup('SIGKILL'), SPAWN_KILL_GRACE_MS);
|
|
77
|
+
}, SPAWN_TIMEOUT_MS);
|
|
78
|
+
|
|
79
|
+
const clearAllTimers = () => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
if (killTimer) clearTimeout(killTimer);
|
|
82
|
+
};
|
|
83
|
+
child.on('error', (error) => {
|
|
84
|
+
clearAllTimers();
|
|
85
|
+
activeChildPgid = null;
|
|
86
|
+
childHasFinished = true;
|
|
87
|
+
resolve({
|
|
88
|
+
code: null,
|
|
89
|
+
signal: null,
|
|
90
|
+
out,
|
|
91
|
+
err: `${err}\nspawn error: ${error.message}`,
|
|
92
|
+
timedOut: false,
|
|
93
|
+
cancelled: userInterrupted,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
child.on('exit', (code, signal) => {
|
|
97
|
+
clearAllTimers();
|
|
98
|
+
activeChildPgid = null;
|
|
99
|
+
childHasFinished = true;
|
|
100
|
+
resolve({
|
|
101
|
+
code,
|
|
102
|
+
signal,
|
|
103
|
+
out,
|
|
104
|
+
err,
|
|
105
|
+
timedOut,
|
|
106
|
+
cancelled: cancelled || userInterrupted,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runHousekeeping(cliArgs) {
|
|
113
|
+
if (cliArgs[0] === 'list') {
|
|
114
|
+
cmdList();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
if (cliArgs[0] === 'reset') {
|
|
118
|
+
await cmdReset(cliArgs[1]);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (cliArgs[0] === 'pin') {
|
|
122
|
+
await cmdPin(cliArgs[1], cliArgs.slice(2).join(' '));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
if (cliArgs[0] === 'unpin') {
|
|
126
|
+
await cmdUnpin(cliArgs[1], cliArgs[2]);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (cliArgs[0] === 'pins') {
|
|
130
|
+
cmdPins(cliArgs[1]);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function printUsage(backends) {
|
|
137
|
+
console.error(
|
|
138
|
+
'usage: cli-relay [--dry-run|--print-command] ' +
|
|
139
|
+
'<backend> <thread> <fresh|resume> <prompt...>',
|
|
140
|
+
);
|
|
141
|
+
console.error(' cli-relay list');
|
|
142
|
+
console.error(' cli-relay doctor');
|
|
143
|
+
console.error(' cli-relay reset <thread>');
|
|
144
|
+
console.error(' cli-relay pin <thread> "<fact>"');
|
|
145
|
+
console.error(' cli-relay unpin <thread> <index>');
|
|
146
|
+
console.error(' cli-relay pins <thread>');
|
|
147
|
+
console.error(`backends: ${Object.keys(backends).join(', ')}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sessionForInvocation(map, backend, thread, mode, adapter) {
|
|
151
|
+
const existing = map.sessions[thread];
|
|
152
|
+
const session = existing ?? {
|
|
153
|
+
backend,
|
|
154
|
+
native_session_id: null,
|
|
155
|
+
confirmed: false,
|
|
156
|
+
};
|
|
157
|
+
if (session.backend !== backend) {
|
|
158
|
+
throw new RelayError(
|
|
159
|
+
'THREAD_OWNERSHIP_MISMATCH',
|
|
160
|
+
`thread "${thread}" belongs to backend "${session.backend}", not "${backend}" — ` +
|
|
161
|
+
'pick a new thread name',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (mode === 'resume') {
|
|
165
|
+
if (!adapter.resume) {
|
|
166
|
+
throw new RelayError(
|
|
167
|
+
'RESUME_UNSUPPORTED',
|
|
168
|
+
`"${backend}" has no supported resume command in this router — must run fresh`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (!session.confirmed || !session.native_session_id) {
|
|
172
|
+
const message = `no confirmed session for thread "${thread}" — ` +
|
|
173
|
+
'run fresh first; refusing to guess';
|
|
174
|
+
throw new RelayError(
|
|
175
|
+
'NO_CONFIRMED_SESSION',
|
|
176
|
+
existing ? message : withThreadSuggestions(message, map.sessions, thread),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return session;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Shared by --dry-run and critical section 1 — a dry-run preview must refuse the exact
|
|
184
|
+
// same cases a real run would refuse (found in review: dry-run intentionally skips the
|
|
185
|
+
// lock, but that means it also silently skipped this check, printing a preview for a
|
|
186
|
+
// command that would actually be refused).
|
|
187
|
+
function assertNotInFlight(session, thread) {
|
|
188
|
+
if (session.status !== 'running') return;
|
|
189
|
+
const ageMs = Date.now() - Date.parse(session.run_started_iso || 0);
|
|
190
|
+
if (Number.isFinite(ageMs) && ageMs < LOCK_STALE_MS) {
|
|
191
|
+
throw new RelayError(
|
|
192
|
+
'RUN_IN_FLIGHT',
|
|
193
|
+
`thread "${thread}" has a run already in flight (started ` +
|
|
194
|
+
`${session.run_started_iso}) — refusing a concurrent turn on the same native session`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
console.error(
|
|
198
|
+
`warning: thread "${thread}" was left mid-run (started ${session.run_started_iso}, ` +
|
|
199
|
+
`stale) — the previous invocation likely crashed. Proceeding from its last confirmed id.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function main() {
|
|
204
|
+
const cliArgs = process.argv.slice(2);
|
|
205
|
+
|
|
206
|
+
// Adapter loading is intentionally below housekeeping dispatch. A malformed optional user
|
|
207
|
+
// adapter must not prevent map-only recovery commands from listing, fixing, or resetting state.
|
|
208
|
+
if (await runHousekeeping(cliArgs)) process.exit(0);
|
|
209
|
+
|
|
210
|
+
const { loadAdapters } = await import('./src/adapter-loader.mjs');
|
|
211
|
+
const adapters = await loadAdapters();
|
|
212
|
+
if (cliArgs[0] === 'doctor') {
|
|
213
|
+
await cmdDoctor(adapters);
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const dryRun = cliArgs.includes('--dry-run') || cliArgs.includes('--print-command');
|
|
218
|
+
const routingArgs = cliArgs.filter(
|
|
219
|
+
(argument) => argument !== '--dry-run' && argument !== '--print-command',
|
|
220
|
+
);
|
|
221
|
+
const [backend, thread, mode, ...rest] = routingArgs;
|
|
222
|
+
const prompt = rest.join(' ');
|
|
223
|
+
|
|
224
|
+
if (!backend || !thread || !mode || !prompt) {
|
|
225
|
+
printUsage(adapters);
|
|
226
|
+
process.exit(2);
|
|
227
|
+
}
|
|
228
|
+
const adapter = adapters[backend];
|
|
229
|
+
if (!adapter) {
|
|
230
|
+
throw new RelayError(
|
|
231
|
+
'BACKEND_NOT_FOUND',
|
|
232
|
+
`unknown backend "${backend}" — choose one of: ${Object.keys(adapters).join(', ')}`,
|
|
233
|
+
{ exitCode: 2 },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (mode !== 'fresh' && mode !== 'resume') {
|
|
237
|
+
throw new RelayError(
|
|
238
|
+
'INVALID_MODE',
|
|
239
|
+
`mode must be "fresh" or "resume", got "${mode}"`,
|
|
240
|
+
{ exitCode: 2 },
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (dryRun) {
|
|
245
|
+
const map = loadMap();
|
|
246
|
+
const session = sessionForInvocation(map, backend, thread, mode, adapter);
|
|
247
|
+
assertNotInFlight(session, thread);
|
|
248
|
+
const augmentedPrompt = buildPinnedBlock(session.pinned_facts) + prompt;
|
|
249
|
+
const argv = mode === 'resume'
|
|
250
|
+
? adapter.resume(session.native_session_id, augmentedPrompt)
|
|
251
|
+
: adapter.fresh(augmentedPrompt);
|
|
252
|
+
console.log(JSON.stringify(argv));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Critical section 1: validate and mark running before spawn.
|
|
257
|
+
const record = await withLock(() => {
|
|
258
|
+
const map = loadMap();
|
|
259
|
+
const session = sessionForInvocation(map, backend, thread, mode, adapter);
|
|
260
|
+
if (mode === 'fresh' && session.confirmed && session.native_session_id) {
|
|
261
|
+
console.error(
|
|
262
|
+
`warning: thread "${thread}" already had a confirmed session ` +
|
|
263
|
+
`(${session.native_session_id}) — starting fresh replaces the pointer; the old session ` +
|
|
264
|
+
`is no longer reachable from this thread name. Pinned facts (if any) are NOT cleared — ` +
|
|
265
|
+
'they carry forward into the new session.',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
assertNotInFlight(session, thread);
|
|
269
|
+
if (mode === 'fresh') {
|
|
270
|
+
session.turn_count = 1;
|
|
271
|
+
session.created_iso = new Date().toISOString();
|
|
272
|
+
delete session.compaction_detected;
|
|
273
|
+
} else {
|
|
274
|
+
session.turn_count = (session.turn_count ?? 1) + 1;
|
|
275
|
+
if (session.turn_count >= RESUME_WARNING_THRESHOLD) {
|
|
276
|
+
console.error(
|
|
277
|
+
`warning: thread "${thread}" is on turn ${session.turn_count} (advisory threshold ` +
|
|
278
|
+
`${RESUME_WARNING_THRESHOLD}) — long-running threads risk silent context compaction ` +
|
|
279
|
+
`inside the backend itself, where an earlier stale fact can outweigh a later ` +
|
|
280
|
+
`correction. Not blocking; pin anything load-bearing now (cli-relay pin "${thread}" ` +
|
|
281
|
+
`"...") if you haven't, then a fresh restart carries it forward automatically.`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
session.status = 'running';
|
|
287
|
+
session.run_started_iso = new Date().toISOString();
|
|
288
|
+
map.sessions[thread] = session;
|
|
289
|
+
saveMap(map);
|
|
290
|
+
return { ...session };
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const augmentedPrompt = buildPinnedBlock(record.pinned_facts) + prompt;
|
|
294
|
+
const argv = mode === 'resume'
|
|
295
|
+
? adapter.resume(record.native_session_id, augmentedPrompt)
|
|
296
|
+
: adapter.fresh(augmentedPrompt);
|
|
297
|
+
const env = scrubEnv(adapter.env);
|
|
298
|
+
const { code, signal, out, err, timedOut, cancelled } = await runChild(argv, env);
|
|
299
|
+
const parsed = adapter.parse(out);
|
|
300
|
+
let newId = null;
|
|
301
|
+
|
|
302
|
+
const touchedId = parsed.id ?? record.native_session_id;
|
|
303
|
+
let compactionDetected = null;
|
|
304
|
+
try {
|
|
305
|
+
const detected = await adapter.checkCompaction(touchedId, out);
|
|
306
|
+
compactionDetected = detected === true ? true : detected === false ? false : null;
|
|
307
|
+
} catch {
|
|
308
|
+
// Detection is advisory and must never turn a completed backend call into a router failure.
|
|
309
|
+
}
|
|
310
|
+
if (compactionDetected === true) {
|
|
311
|
+
console.error(
|
|
312
|
+
`warning: "${backend}" appears to have compacted its context on thread "${thread}" — ` +
|
|
313
|
+
`earlier facts may have been summarized or reordered. If a recent correction matters, ` +
|
|
314
|
+
`re-state it explicitly rather than trusting it's still accurately in view; consider a ` +
|
|
315
|
+
`fresh restart with a curated recap for anything load-bearing.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (mode === 'fresh' && (!parsed.id || !parsed.answer)) {
|
|
320
|
+
await withLock(() => {
|
|
321
|
+
const map = loadMap();
|
|
322
|
+
const session = map.sessions[thread];
|
|
323
|
+
if (!session) {
|
|
324
|
+
// A concurrent `cli-relay reset <thread>` deleted this thread while the backend
|
|
325
|
+
// call was in flight (the lock is intentionally released during the spawn — see
|
|
326
|
+
// runChild). Don't resurrect a thread the user just told the router to forget;
|
|
327
|
+
// the outcome has nowhere left to attach to.
|
|
328
|
+
console.error(
|
|
329
|
+
`warning: thread "${thread}" no longer exists in ${MAP_PATH} (reset while this ` +
|
|
330
|
+
`run was in flight) — outcome not recorded.`,
|
|
331
|
+
);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
session.status = 'ready';
|
|
335
|
+
session.last_run_iso = new Date().toISOString();
|
|
336
|
+
session.last_exit_code = code;
|
|
337
|
+
session.last_signal = signal;
|
|
338
|
+
session.last_timed_out = timedOut;
|
|
339
|
+
session.last_cancelled_by_wrapper = cancelled;
|
|
340
|
+
if (compactionDetected === true) session.compaction_detected = true;
|
|
341
|
+
map.sessions[thread] = session;
|
|
342
|
+
saveMap(map);
|
|
343
|
+
});
|
|
344
|
+
const reason = !parsed.id
|
|
345
|
+
? 'no parseable session id'
|
|
346
|
+
: 'a session id but no usable answer (possibly an error response — check stdout_tail)';
|
|
347
|
+
console.error(
|
|
348
|
+
`"${backend}" gave ${reason} on a fresh run — NOT marking confirmed.\n` +
|
|
349
|
+
`stderr tail:\n${err.slice(-2000)}\nstdout tail:\n${out.slice(-1000)}`,
|
|
350
|
+
);
|
|
351
|
+
process.exit(userInterrupted ? interruptExitCode() : (parsed.id ? 3 : 1));
|
|
352
|
+
}
|
|
353
|
+
if (mode === 'fresh') newId = parsed.id;
|
|
354
|
+
|
|
355
|
+
// Critical section 2: record outcome facts and enforce the resume circuit breaker.
|
|
356
|
+
let autoUnconfirmed = false;
|
|
357
|
+
let resumeFailureCount = 0;
|
|
358
|
+
await withLock(() => {
|
|
359
|
+
const map = loadMap();
|
|
360
|
+
const session = map.sessions[thread];
|
|
361
|
+
if (!session) {
|
|
362
|
+
// Same race as the fresh-failure branch above: a concurrent `reset` deleted this
|
|
363
|
+
// thread mid-run. Warn instead of resurrecting it — this is the case that matters
|
|
364
|
+
// most, since `newId` may hold a genuinely successful fresh run's session id that
|
|
365
|
+
// would otherwise be silently lost with no trace it ever existed.
|
|
366
|
+
console.error(
|
|
367
|
+
`warning: thread "${thread}" no longer exists in ${MAP_PATH} (reset while this run ` +
|
|
368
|
+
`was in flight) — outcome${newId ? ` (including native id ${newId})` : ''} not recorded.`,
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (newId) {
|
|
373
|
+
session.native_session_id = newId;
|
|
374
|
+
session.confirmed = true;
|
|
375
|
+
session.consecutive_resume_failures = 0;
|
|
376
|
+
}
|
|
377
|
+
if (mode === 'resume') {
|
|
378
|
+
if (parsed.answer) {
|
|
379
|
+
session.consecutive_resume_failures = 0;
|
|
380
|
+
} else {
|
|
381
|
+
session.consecutive_resume_failures = (session.consecutive_resume_failures ?? 0) + 1;
|
|
382
|
+
if (session.confirmed &&
|
|
383
|
+
session.consecutive_resume_failures >= RESUME_FAILURE_THRESHOLD) {
|
|
384
|
+
session.confirmed = false;
|
|
385
|
+
autoUnconfirmed = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
resumeFailureCount = session.consecutive_resume_failures;
|
|
389
|
+
}
|
|
390
|
+
session.status = 'ready';
|
|
391
|
+
session.last_run_iso = new Date().toISOString();
|
|
392
|
+
session.last_exit_code = code;
|
|
393
|
+
session.last_signal = signal;
|
|
394
|
+
session.last_timed_out = timedOut;
|
|
395
|
+
session.last_cancelled_by_wrapper = cancelled;
|
|
396
|
+
if (compactionDetected === true) session.compaction_detected = true;
|
|
397
|
+
map.sessions[thread] = session;
|
|
398
|
+
saveMap(map);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
if (autoUnconfirmed) {
|
|
402
|
+
console.error(
|
|
403
|
+
`"${backend}" thread "${thread}": ${resumeFailureCount} consecutive resume failures ` +
|
|
404
|
+
`(threshold ${RESUME_FAILURE_THRESHOLD}) — auto-un-confirmed. The id is still recorded ` +
|
|
405
|
+
`(see "cli-relay list") but resume is now refused; run fresh to continue this thread.`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const payload = {
|
|
410
|
+
backend,
|
|
411
|
+
thread,
|
|
412
|
+
native_session_id: newId ?? record.native_session_id,
|
|
413
|
+
exit_code: code,
|
|
414
|
+
signal: signal ?? null,
|
|
415
|
+
timed_out: timedOut,
|
|
416
|
+
cancelled_by_wrapper: cancelled,
|
|
417
|
+
answer_parsed: parsed.answer != null,
|
|
418
|
+
answer: parsed.answer,
|
|
419
|
+
resume_failure_count: mode === 'resume' ? resumeFailureCount : undefined,
|
|
420
|
+
auto_unconfirmed: autoUnconfirmed,
|
|
421
|
+
turn_count: record.turn_count,
|
|
422
|
+
pins_injected: record.pinned_facts?.length ?? 0,
|
|
423
|
+
compaction_detected: compactionDetected,
|
|
424
|
+
stdout_tail: out.slice(-4000),
|
|
425
|
+
};
|
|
426
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
427
|
+
|
|
428
|
+
if (mode === 'resume' && !parsed.answer) {
|
|
429
|
+
console.error(
|
|
430
|
+
`"${backend}" resume produced no parseable answer (child exit ${code}) — ` +
|
|
431
|
+
'see stdout_tail above',
|
|
432
|
+
);
|
|
433
|
+
process.exit(userInterrupted ? interruptExitCode() : (code === 0 ? 3 : (code ?? 1)));
|
|
434
|
+
}
|
|
435
|
+
if (userInterrupted) process.exit(interruptExitCode());
|
|
436
|
+
process.exit(code === null ? 1 : code);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function onSignal(signal) {
|
|
440
|
+
if (userInterrupted) {
|
|
441
|
+
if (activeChildPgid) {
|
|
442
|
+
try { process.kill(-activeChildPgid, 'SIGKILL'); } catch {}
|
|
443
|
+
}
|
|
444
|
+
process.exit(interruptExitCode());
|
|
445
|
+
}
|
|
446
|
+
userInterrupted = true;
|
|
447
|
+
interruptSignal = signal;
|
|
448
|
+
if (activeChildPgid) {
|
|
449
|
+
console.error(
|
|
450
|
+
`\ncli-relay: ${signal} received — terminating child and recording outcome ` +
|
|
451
|
+
'(Ctrl-C again to force)...',
|
|
452
|
+
);
|
|
453
|
+
try { process.kill(-activeChildPgid, 'SIGTERM'); } catch {}
|
|
454
|
+
setTimeout(() => {
|
|
455
|
+
if (activeChildPgid) {
|
|
456
|
+
try { process.kill(-activeChildPgid, 'SIGKILL'); } catch {}
|
|
457
|
+
}
|
|
458
|
+
}, SPAWN_KILL_GRACE_MS);
|
|
459
|
+
} else if (childHasFinished) {
|
|
460
|
+
console.error(
|
|
461
|
+
`\ncli-relay: ${signal} received — finishing in-flight bookkeeping before exit ` +
|
|
462
|
+
'(Ctrl-C again to force)...',
|
|
463
|
+
);
|
|
464
|
+
} else {
|
|
465
|
+
console.error(`\ncli-relay: ${signal} received, nothing spawned yet — exiting.`);
|
|
466
|
+
process.exit(interruptExitCode());
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
471
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
472
|
+
|
|
473
|
+
main().catch((error) => {
|
|
474
|
+
if (error instanceof RelayError) {
|
|
475
|
+
const prefix = error.exitCode === 2 ? '' : 'cli-relay error: ';
|
|
476
|
+
console.error(`${prefix}${error.message}`);
|
|
477
|
+
process.exit(error.exitCode);
|
|
478
|
+
}
|
|
479
|
+
console.error(`cli-relay error: ${error.message}`);
|
|
480
|
+
process.exit(1);
|
|
481
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cli-relay",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Persistent CLI router for resuming named sessions across AI coding-agent backends.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cli-relay": "cli-relay.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18.17.0"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/chsharoze/cli-relay.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai",
|
|
19
|
+
"cli",
|
|
20
|
+
"coding-agents",
|
|
21
|
+
"session-resume",
|
|
22
|
+
"router",
|
|
23
|
+
"codex",
|
|
24
|
+
"claude-code"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"cli-relay.mjs",
|
|
28
|
+
"src/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"dependencies": {}
|
|
33
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { USER_ADAPTERS_DIR } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
const BUILTIN_ADAPTERS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'adapters');
|
|
7
|
+
const SUPPORTED_EXTENSIONS = new Set(['.mjs', '.js', '.cjs']);
|
|
8
|
+
export const EXPECTED_BACKENDS = Object.freeze([
|
|
9
|
+
'codex',
|
|
10
|
+
'agy',
|
|
11
|
+
'claude-code',
|
|
12
|
+
'command-code',
|
|
13
|
+
]);
|
|
14
|
+
const EXPECTED_BACKEND_SET = new Set(EXPECTED_BACKENDS);
|
|
15
|
+
|
|
16
|
+
function adapterFiles(directory) {
|
|
17
|
+
if (!existsSync(directory)) return [];
|
|
18
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
19
|
+
.filter((entry) =>
|
|
20
|
+
(entry.isFile() || entry.isSymbolicLink()) && SUPPORTED_EXTENSIONS.has(extname(entry.name)))
|
|
21
|
+
.map((entry) => join(directory, entry.name))
|
|
22
|
+
.sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateAdapter(adapter, sourcePath) {
|
|
26
|
+
const fallbackName = basename(sourcePath, extname(sourcePath));
|
|
27
|
+
const name = adapter?.name ?? fallbackName;
|
|
28
|
+
if (!adapter || typeof adapter !== 'object') {
|
|
29
|
+
throw new Error(`adapter ${sourcePath} must export an adapter object as default`);
|
|
30
|
+
}
|
|
31
|
+
if (!name || typeof name !== 'string') {
|
|
32
|
+
throw new Error(`adapter ${sourcePath} must have a string name`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof adapter.fresh !== 'function') {
|
|
35
|
+
throw new Error(`adapter "${name}" (${sourcePath}) must provide fresh(prompt)`);
|
|
36
|
+
}
|
|
37
|
+
const resume = adapter.resume ?? null;
|
|
38
|
+
if (resume !== null && typeof resume !== 'function') {
|
|
39
|
+
throw new Error(`adapter "${name}" (${sourcePath}) resume must be a function or null`);
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(adapter.env)) {
|
|
42
|
+
throw new Error(`adapter "${name}" (${sourcePath}) must provide an env array`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof adapter.parse !== 'function') {
|
|
45
|
+
throw new Error(`adapter "${name}" (${sourcePath}) must provide parse(stdout)`);
|
|
46
|
+
}
|
|
47
|
+
if (adapter.checkCompaction != null && typeof adapter.checkCompaction !== 'function') {
|
|
48
|
+
throw new Error(`adapter "${name}" (${sourcePath}) checkCompaction must be a function`);
|
|
49
|
+
}
|
|
50
|
+
if (adapter.binaryCandidates != null &&
|
|
51
|
+
(!Array.isArray(adapter.binaryCandidates) || adapter.binaryCandidates.length === 0 ||
|
|
52
|
+
adapter.binaryCandidates.some((candidate) => typeof candidate !== 'string' || !candidate))) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`adapter "${name}" (${sourcePath}) binaryCandidates must be a non-empty string array`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
...adapter,
|
|
59
|
+
name,
|
|
60
|
+
resume,
|
|
61
|
+
checkCompaction: adapter.checkCompaction ?? (() => null),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function loadDirectory(directory, adapters, registryIssues) {
|
|
66
|
+
const loadedAdapters = [];
|
|
67
|
+
for (const file of adapterFiles(directory)) {
|
|
68
|
+
const fallbackName = basename(file, extname(file));
|
|
69
|
+
let suspectedName = fallbackName;
|
|
70
|
+
try {
|
|
71
|
+
const loaded = await import(pathToFileURL(file).href);
|
|
72
|
+
const exported = loaded.default ?? loaded.adapter;
|
|
73
|
+
if (typeof exported?.name === 'string') suspectedName = exported.name;
|
|
74
|
+
const adapter = validateAdapter(exported, file);
|
|
75
|
+
registryIssues.delete(adapter.name);
|
|
76
|
+
loadedAdapters.push(adapter);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Any adapter file's load/validation failure is recorded, not fatal — a broken
|
|
79
|
+
// built-in-name override is what assertAdapterRegistry's completeness check reports;
|
|
80
|
+
// a broken CUSTOM-named user adapter isn't one of the 4 required backends, so it's
|
|
81
|
+
// not blocking by definition, but it was previously re-thrown here and crashed the
|
|
82
|
+
// entire load anyway — taking down `doctor`, the one command you'd reach for to
|
|
83
|
+
// diagnose exactly this, along with every other backend (found in review).
|
|
84
|
+
if (EXPECTED_BACKEND_SET.has(suspectedName)) delete adapters[suspectedName];
|
|
85
|
+
registryIssues.set(suspectedName, error.message);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
loadedAdapters.sort((left, right) => (left.order ?? 1_000) - (right.order ?? 1_000));
|
|
89
|
+
for (const adapter of loadedAdapters) {
|
|
90
|
+
adapters[adapter.name] = adapter;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assertAdapterRegistry(adapters, registryIssues = new Map()) {
|
|
95
|
+
const problems = [];
|
|
96
|
+
for (const name of EXPECTED_BACKENDS) {
|
|
97
|
+
if (registryIssues.has(name)) {
|
|
98
|
+
problems.push(`${name} (malformed: ${registryIssues.get(name)})`);
|
|
99
|
+
} else if (!adapters[name]) {
|
|
100
|
+
problems.push(`${name} (missing)`);
|
|
101
|
+
} else {
|
|
102
|
+
const adapter = adapters[name];
|
|
103
|
+
const malformed = [];
|
|
104
|
+
if (typeof adapter.fresh !== 'function') malformed.push('fresh must be a function');
|
|
105
|
+
if (typeof adapter.parse !== 'function') malformed.push('parse must be a function');
|
|
106
|
+
if (!Array.isArray(adapter.env)) malformed.push('env must be an array');
|
|
107
|
+
if (adapter.resume != null && typeof adapter.resume !== 'function') {
|
|
108
|
+
malformed.push('resume must be a function or null');
|
|
109
|
+
}
|
|
110
|
+
if (adapter.checkCompaction != null && typeof adapter.checkCompaction !== 'function') {
|
|
111
|
+
malformed.push('checkCompaction must be a function when provided');
|
|
112
|
+
}
|
|
113
|
+
if (malformed.length > 0) problems.push(`${name} (malformed: ${malformed.join(', ')})`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (problems.length > 0) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Adapter registry incomplete: missing or malformed adapter(s) for ${problems.join('; ')}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function loadAdapters() {
|
|
124
|
+
const adapters = Object.create(null);
|
|
125
|
+
const registryIssues = new Map();
|
|
126
|
+
await loadDirectory(BUILTIN_ADAPTERS_DIR, adapters, registryIssues);
|
|
127
|
+
await loadDirectory(USER_ADAPTERS_DIR, adapters, registryIssues);
|
|
128
|
+
// assertAdapterRegistry only enforces the 4 required backends — a broken adapter under
|
|
129
|
+
// a custom name isn't required, so it must not block anything, but it should still be
|
|
130
|
+
// visible rather than silently dropped.
|
|
131
|
+
for (const [name, message] of registryIssues) {
|
|
132
|
+
if (!EXPECTED_BACKEND_SET.has(name)) {
|
|
133
|
+
console.error(`warning: adapter "${name}" failed to load and was skipped: ${message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
assertAdapterRegistry(adapters, registryIssues);
|
|
137
|
+
return adapters;
|
|
138
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ENV_BASE } from '../core/adapter-env.mjs';
|
|
2
|
+
import { parseJsonResult } from '../core/parse-json-result.mjs';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'agy',
|
|
6
|
+
order: 20,
|
|
7
|
+
binaryCandidates: ['agy'],
|
|
8
|
+
fresh: (prompt) => [
|
|
9
|
+
'agy', '--dangerously-skip-permissions', '--print-timeout', '10m',
|
|
10
|
+
'--model', 'gemini-3.6-flash-medium', '--add-dir', process.cwd(),
|
|
11
|
+
'--output-format', 'json', '-p', prompt,
|
|
12
|
+
],
|
|
13
|
+
resume: (id, prompt) => [
|
|
14
|
+
'agy', '--dangerously-skip-permissions', '--print-timeout', '10m',
|
|
15
|
+
'--model', 'gemini-3.6-flash-medium', '--add-dir', process.cwd(),
|
|
16
|
+
'--output-format', 'json', '--conversation', id, '-p', prompt,
|
|
17
|
+
],
|
|
18
|
+
env: ENV_BASE,
|
|
19
|
+
parse: (stdout) => parseJsonResult(stdout, { id: 'conversation_id', answer: 'response' }),
|
|
20
|
+
checkCompaction: () => null,
|
|
21
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ENV_BASE } from '../core/adapter-env.mjs';
|
|
2
|
+
import { parseJsonResult } from '../core/parse-json-result.mjs';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'claude-code',
|
|
6
|
+
order: 30,
|
|
7
|
+
binaryCandidates: ['claude'],
|
|
8
|
+
fresh: (prompt) => [
|
|
9
|
+
'claude', '-p', prompt, '--output-format', 'json', '--dangerously-skip-permissions',
|
|
10
|
+
],
|
|
11
|
+
resume: (id, prompt) => [
|
|
12
|
+
'claude', '-r', id, '-p', prompt, '--output-format', 'json',
|
|
13
|
+
'--dangerously-skip-permissions',
|
|
14
|
+
],
|
|
15
|
+
env: ENV_BASE,
|
|
16
|
+
parse: (stdout) => parseJsonResult(stdout, { id: 'session_id', answer: 'result' }),
|
|
17
|
+
checkCompaction: () => null,
|
|
18
|
+
};
|