@idl3/claude-control 1.3.0 → 1.4.3

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.
@@ -0,0 +1,719 @@
1
+ // lib/codex-rpc.js — experimental Codex app-server JSON-RPC transport.
2
+ //
3
+ // This keeps tmux as the visible process/session pin while moving actual Codex
4
+ // turn submission and approvals onto app-server's structured JSON-RPC channel.
5
+
6
+ import { EventEmitter } from 'node:events';
7
+ import net from 'node:net';
8
+ import { WebSocket } from 'ws';
9
+ import { parseCodexSubagentNotification } from './codex.js';
10
+
11
+ const REQUEST_TIMEOUT_MS = 30_000;
12
+ const CONNECT_TIMEOUT_MS = 10_000;
13
+ const LOCAL_WS_RE = /\bws:\/\/(?:127\.0\.0\.1|localhost|\[::1\]|::1):\d+\b/;
14
+
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ function removeNullish(obj) {
20
+ const out = {};
21
+ for (const [k, v] of Object.entries(obj || {})) {
22
+ if (v != null) out[k] = v;
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function textFromContent(content) {
28
+ if (!Array.isArray(content)) return '';
29
+ return content
30
+ .map((part) => (part?.type === 'text' ? String(part.text ?? '') : ''))
31
+ .filter(Boolean)
32
+ .join('');
33
+ }
34
+
35
+ function truncate(s, n = 180) {
36
+ const text = String(s ?? '').replace(/\s+/g, ' ').trim();
37
+ return text.length > n ? `${text.slice(0, n - 1)}...` : text;
38
+ }
39
+
40
+ function inputSummary(input) {
41
+ try {
42
+ return truncate(JSON.stringify(input), 120);
43
+ } catch {
44
+ return truncate(String(input), 120);
45
+ }
46
+ }
47
+
48
+ function promptForRequest(req) {
49
+ const p = req.params || {};
50
+ if (req.method === 'item/commandExecution/requestApproval') {
51
+ const command = p.command || '(command unavailable)';
52
+ const options = [
53
+ { key: '1', label: 'Yes, proceed' },
54
+ ];
55
+ if (p.proposedExecpolicyAmendment || p.proposedNetworkPolicyAmendments?.length) {
56
+ options.push({ key: '2', label: "Yes, and don't ask again" });
57
+ } else {
58
+ options.push({ key: '2', label: 'Yes for this session' });
59
+ }
60
+ options.push({ key: '3', label: 'No' });
61
+ return {
62
+ question: `Run command in ${p.cwd || 'the workspace'}?\n${command}${p.reason ? `\nReason: ${p.reason}` : ''}`,
63
+ options,
64
+ };
65
+ }
66
+
67
+ if (req.method === 'item/fileChange/requestApproval') {
68
+ const target = p.grantRoot ? ` under ${p.grantRoot}` : '';
69
+ return {
70
+ question: `Allow file changes${target}?${p.reason ? `\nReason: ${p.reason}` : ''}`,
71
+ options: [
72
+ { key: '1', label: 'Yes, proceed' },
73
+ { key: '2', label: 'Yes for this session' },
74
+ { key: '3', label: 'No' },
75
+ ],
76
+ };
77
+ }
78
+
79
+ if (req.method === 'item/permissions/requestApproval') {
80
+ return {
81
+ question: `Allow additional permissions in ${p.cwd || 'the workspace'}?${p.reason ? `\nReason: ${p.reason}` : ''}`,
82
+ options: [
83
+ { key: '1', label: 'Yes, for this turn' },
84
+ { key: '2', label: 'Yes, for this session' },
85
+ { key: '3', label: 'No' },
86
+ ],
87
+ };
88
+ }
89
+
90
+ if (req.method === 'item/tool/requestUserInput') {
91
+ const q = Array.isArray(p.questions) ? p.questions[0] : null;
92
+ const opts = Array.isArray(q?.options) ? q.options : [];
93
+ return {
94
+ question: q?.question || q?.header || 'Codex is asking for input',
95
+ options: opts.length
96
+ ? opts.map((o, i) => ({ key: String(i + 1), label: o?.label || o?.value || `Option ${i + 1}` }))
97
+ : [{ key: '1', label: 'Continue' }],
98
+ };
99
+ }
100
+
101
+ return {
102
+ question: `Codex request: ${req.method}`,
103
+ options: [
104
+ { key: '1', label: 'Continue' },
105
+ { key: '3', label: 'Cancel' },
106
+ ],
107
+ };
108
+ }
109
+
110
+ function responseForPrompt(req, key) {
111
+ const p = req.params || {};
112
+ const denied = key === '3';
113
+ const cancelled = key === 'Escape';
114
+ const session = key === '2';
115
+
116
+ if (req.method === 'item/commandExecution/requestApproval') {
117
+ if (cancelled) return { decision: 'cancel' };
118
+ if (denied) return { decision: 'decline' };
119
+ if (session && p.proposedExecpolicyAmendment) {
120
+ return {
121
+ decision: {
122
+ acceptWithExecpolicyAmendment: {
123
+ execpolicy_amendment: p.proposedExecpolicyAmendment,
124
+ },
125
+ },
126
+ };
127
+ }
128
+ if (session && Array.isArray(p.proposedNetworkPolicyAmendments) && p.proposedNetworkPolicyAmendments[0]) {
129
+ return {
130
+ decision: {
131
+ applyNetworkPolicyAmendment: {
132
+ network_policy_amendment: p.proposedNetworkPolicyAmendments[0],
133
+ },
134
+ },
135
+ };
136
+ }
137
+ return { decision: session ? 'acceptForSession' : 'accept' };
138
+ }
139
+
140
+ if (req.method === 'item/fileChange/requestApproval') {
141
+ if (cancelled) return { decision: 'cancel' };
142
+ if (denied) return { decision: 'decline' };
143
+ return { decision: session ? 'acceptForSession' : 'accept' };
144
+ }
145
+
146
+ if (req.method === 'item/permissions/requestApproval') {
147
+ if (cancelled || denied) return { permissions: {}, scope: 'turn' };
148
+ return {
149
+ permissions: removeNullish({
150
+ network: p.permissions?.network ?? null,
151
+ fileSystem: p.permissions?.fileSystem ?? null,
152
+ }),
153
+ scope: session ? 'session' : 'turn',
154
+ };
155
+ }
156
+
157
+ if (req.method === 'item/tool/requestUserInput') {
158
+ const questions = Array.isArray(p.questions) ? p.questions : [];
159
+ const first = questions[0];
160
+ const options = Array.isArray(first?.options) ? first.options : [];
161
+ const idx = Math.max(0, Number(key) - 1);
162
+ const selected = options[idx];
163
+ const questionId = first?.id || first?.question || 'answer';
164
+ return {
165
+ answers: {
166
+ [questionId]: {
167
+ answers: [String(selected?.label ?? selected?.value ?? selected ?? '')],
168
+ },
169
+ },
170
+ };
171
+ }
172
+
173
+ return {};
174
+ }
175
+
176
+ function normalizeServerMessage(msg) {
177
+ if (msg?.id == null || typeof msg.method !== 'string') return null;
178
+ if (
179
+ msg.method === 'item/commandExecution/requestApproval' ||
180
+ msg.method === 'item/fileChange/requestApproval' ||
181
+ msg.method === 'item/permissions/requestApproval' ||
182
+ msg.method === 'item/tool/requestUserInput'
183
+ ) {
184
+ return msg;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ export async function codexRpcEndpoint() {
190
+ return new Promise((resolve, reject) => {
191
+ const server = net.createServer();
192
+ server.once('error', reject);
193
+ server.listen(0, '127.0.0.1', () => {
194
+ const addr = server.address();
195
+ const port = typeof addr === 'object' && addr ? addr.port : null;
196
+ server.close(() => {
197
+ if (!port) reject(new Error('failed to allocate Codex RPC port'));
198
+ else resolve(`ws://127.0.0.1:${port}`);
199
+ });
200
+ });
201
+ });
202
+ }
203
+
204
+ export function parseCodexAppServerEndpoint(text) {
205
+ const m = LOCAL_WS_RE.exec(String(text || ''));
206
+ return m ? m[0] : null;
207
+ }
208
+
209
+ export function isCodexAppServerCapture(text) {
210
+ const s = String(text || '');
211
+ return !!parseCodexAppServerEndpoint(s) ||
212
+ /\bcodex\s+app-server\b/i.test(s) ||
213
+ /\bapp-server\s+--listen\b/i.test(s) ||
214
+ /\breadyz:\s*https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\]|::1):\d+\/readyz\b/i.test(s) ||
215
+ /\bhealthz:\s*https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\]|::1):\d+\/healthz\b/i.test(s);
216
+ }
217
+
218
+ const ACTIVE_STATUS_RE = /^(active|running|busy|working|thinking|processing|generating|streaming|executing|in[-_ ]?progress|started|starting)$/i;
219
+ const IDLE_STATUS_RE = /^(idle|inactive|sleeping|complete|completed|done|finished|stopped|ready)$/i;
220
+
221
+ function classifyStatusValue(value) {
222
+ if (value == null) return false;
223
+ if (typeof value === 'boolean') return value;
224
+ if (typeof value === 'string') {
225
+ const s = value.trim();
226
+ if (!s) return false;
227
+ if (IDLE_STATUS_RE.test(s)) return false;
228
+ if (ACTIVE_STATUS_RE.test(s)) return true;
229
+ return null;
230
+ }
231
+ if (Array.isArray(value)) {
232
+ let sawIdle = false;
233
+ for (const item of value) {
234
+ const classified = classifyStatusValue(item);
235
+ if (classified === true) return true;
236
+ if (classified === false) sawIdle = true;
237
+ }
238
+ return sawIdle ? false : null;
239
+ }
240
+ if (typeof value === 'object') {
241
+ if (
242
+ value.active === true ||
243
+ value.running === true ||
244
+ value.busy === true ||
245
+ value.working === true ||
246
+ value.thinking === true
247
+ ) {
248
+ return true;
249
+ }
250
+ if (
251
+ value.idle === true ||
252
+ value.sleeping === true ||
253
+ value.done === true ||
254
+ value.complete === true ||
255
+ value.completed === true
256
+ ) {
257
+ return false;
258
+ }
259
+
260
+ let sawIdle = false;
261
+ for (const key of ['type', 'status', 'state', 'phase', 'kind', 'name']) {
262
+ if (!(key in value)) continue;
263
+ const classified = classifyStatusValue(value[key]);
264
+ if (classified === true) return true;
265
+ if (classified === false) sawIdle = true;
266
+ }
267
+ return sawIdle ? false : null;
268
+ }
269
+ return null;
270
+ }
271
+
272
+ export function isCodexActiveStatus(status) {
273
+ return classifyStatusValue(status) === true;
274
+ }
275
+
276
+ function recoverableResumeError(err) {
277
+ const msg = String(err?.message || err || '').toLowerCase();
278
+ return (
279
+ msg.includes('state db missing rollout path for thread') ||
280
+ msg.includes('state db record_discrepancy') ||
281
+ msg.includes('thread not found') ||
282
+ msg.includes('no such thread') ||
283
+ msg.includes('rollout path') ||
284
+ msg.includes('not found')
285
+ );
286
+ }
287
+
288
+ function pathResumeFieldError(err) {
289
+ const msg = String(err?.message || err || '').toLowerCase();
290
+ return msg.includes('path') && (
291
+ msg.includes('unknown field') ||
292
+ msg.includes('invalid params') ||
293
+ msg.includes('invalid request') ||
294
+ msg.includes('unexpected')
295
+ );
296
+ }
297
+
298
+ export class CodexRpcClient extends EventEmitter {
299
+ constructor({ target, endpoint, cwd, resumeThreadId = null, transcriptPath = null }) {
300
+ super();
301
+ this.target = target;
302
+ this.endpoint = endpoint;
303
+ this.cwd = cwd;
304
+ this.resumeThreadId = resumeThreadId || null;
305
+ this.transcriptPath = transcriptPath || null;
306
+ this.threadId = null;
307
+ this.threadPath = null;
308
+ this.ws = null;
309
+ this.buffer = '';
310
+ this.nextId = 1;
311
+ this.pending = new Map();
312
+ this.messages = [];
313
+ this.currentPrompt = null;
314
+ this.currentRequest = null;
315
+ }
316
+
317
+ async connect() {
318
+ const started = Date.now();
319
+ let lastErr = null;
320
+ while (Date.now() - started < CONNECT_TIMEOUT_MS) {
321
+ try {
322
+ await this._connectOnce();
323
+ lastErr = null;
324
+ break;
325
+ } catch (err) {
326
+ lastErr = err;
327
+ await sleep(150);
328
+ }
329
+ }
330
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
331
+ throw lastErr || new Error(`timed out connecting to Codex app-server ${this.endpoint}`);
332
+ }
333
+
334
+ await this.request('initialize', {
335
+ clientInfo: {
336
+ name: 'claude-control',
337
+ title: 'claude-control',
338
+ version: '1.2.0',
339
+ },
340
+ capabilities: {
341
+ experimentalApi: true,
342
+ requestAttestation: false,
343
+ },
344
+ });
345
+
346
+ const openedThread = await this._openThread();
347
+ this.threadId = openedThread?.thread?.id || this.resumeThreadId || null;
348
+ this.threadPath = openedThread?.thread?.path || this.transcriptPath || null;
349
+ if (!this.threadId) throw new Error('Codex app-server did not return a thread id');
350
+ this.emit('thread', {
351
+ ...openedThread,
352
+ thread: {
353
+ ...(openedThread?.thread || {}),
354
+ id: this.threadId,
355
+ path: this.threadPath,
356
+ },
357
+ });
358
+ }
359
+
360
+ async _openThread() {
361
+ const startParams = {
362
+ cwd: this.cwd,
363
+ ephemeral: false,
364
+ threadSource: 'user',
365
+ sessionStartSource: 'startup',
366
+ };
367
+ const resumeParams = {
368
+ cwd: this.cwd,
369
+ };
370
+
371
+ if (this.resumeThreadId || this.transcriptPath) {
372
+ try {
373
+ return await this.request('thread/resume', removeNullish({
374
+ ...resumeParams,
375
+ threadId: this.resumeThreadId || '',
376
+ path: this.transcriptPath,
377
+ }));
378
+ } catch (err) {
379
+ if (this.resumeThreadId && this.transcriptPath && pathResumeFieldError(err)) {
380
+ return this.request('thread/resume', {
381
+ ...resumeParams,
382
+ threadId: this.resumeThreadId,
383
+ });
384
+ }
385
+ if (!recoverableResumeError(err)) throw err;
386
+ if (this.listenerCount('error') > 0) {
387
+ this.emit('error', new Error(`Codex RPC resume failed; starting a fresh thread: ${err.message}`));
388
+ }
389
+ }
390
+ }
391
+
392
+ return this.request('thread/start', startParams);
393
+ }
394
+
395
+ _connectOnce() {
396
+ return new Promise((resolve, reject) => {
397
+ const ws = new WebSocket(this.endpoint);
398
+ const timer = setTimeout(() => {
399
+ ws.terminate();
400
+ reject(new Error(`timed out connecting to Codex app-server ${this.endpoint}`));
401
+ }, CONNECT_TIMEOUT_MS);
402
+ ws.once('open', () => {
403
+ clearTimeout(timer);
404
+ this.ws = ws;
405
+ this._attachSocket(ws);
406
+ resolve();
407
+ });
408
+ ws.once('error', (err) => {
409
+ clearTimeout(timer);
410
+ reject(err);
411
+ });
412
+ });
413
+ }
414
+
415
+ close() {
416
+ try {
417
+ this.ws?.close();
418
+ this.ws?.terminate();
419
+ } catch {
420
+ // best effort
421
+ }
422
+ }
423
+
424
+ async submit(text, { cwd = this.cwd } = {}) {
425
+ if (!this.threadId) throw new Error('Codex RPC thread is not ready');
426
+ return this.request('turn/start', {
427
+ threadId: this.threadId,
428
+ input: [{ type: 'text', text: String(text ?? ''), text_elements: [] }],
429
+ cwd,
430
+ });
431
+ }
432
+
433
+ threadInfo() {
434
+ return {
435
+ threadId: this.threadId,
436
+ transcriptPath: this.threadPath,
437
+ endpoint: this.endpoint,
438
+ cwd: this.cwd,
439
+ };
440
+ }
441
+
442
+ answerPrompt(key) {
443
+ if (!this.currentRequest) throw new Error('no Codex RPC prompt is pending');
444
+ const req = this.currentRequest;
445
+ const result = responseForPrompt(req, key);
446
+ this.emit('raw', {
447
+ source: 'codex-rpc',
448
+ direction: 'out',
449
+ kind: 'prompt-answer',
450
+ method: req.method,
451
+ requestId: req.id,
452
+ summary: `answer ${key}`,
453
+ payload: { id: req.id, result },
454
+ });
455
+ this._send({ id: req.id, result });
456
+ this.currentRequest = null;
457
+ this.currentPrompt = null;
458
+ this.emit('prompt', null);
459
+ this.emit('pending', false);
460
+ }
461
+
462
+ request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
463
+ const id = this.nextId++;
464
+ this._send({ id, method, params });
465
+ return new Promise((resolve, reject) => {
466
+ const timer = setTimeout(() => {
467
+ this.pending.delete(id);
468
+ reject(new Error(`Codex RPC ${method} timed out`));
469
+ }, timeoutMs);
470
+ this.pending.set(id, { resolve, reject, timer, method });
471
+ });
472
+ }
473
+
474
+ _attachSocket(ws) {
475
+ ws.on('message', (data) => this._onData(`${data.toString()}\n`));
476
+ ws.on('error', (err) => this.emit('error', err));
477
+ ws.on('close', () => this.emit('close'));
478
+ }
479
+
480
+ _send(obj) {
481
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) throw new Error('Codex RPC WebSocket is closed');
482
+ this.emit('raw', {
483
+ source: 'codex-rpc',
484
+ direction: 'out',
485
+ kind: obj.method ? 'request' : 'response',
486
+ method: obj.method ?? null,
487
+ requestId: obj.id ?? null,
488
+ summary: obj.method ? `${obj.method} ${inputSummary(obj.params ?? {})}` : `response ${obj.id ?? ''}`.trim(),
489
+ payload: obj,
490
+ });
491
+ this.ws.send(JSON.stringify(obj));
492
+ }
493
+
494
+ _onData(chunk) {
495
+ this.buffer += chunk;
496
+ let idx;
497
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
498
+ const line = this.buffer.slice(0, idx);
499
+ this.buffer = this.buffer.slice(idx + 1);
500
+ if (!line.trim()) continue;
501
+ let msg;
502
+ try {
503
+ msg = JSON.parse(line);
504
+ } catch (err) {
505
+ this.emit('error', new Error(`invalid Codex RPC JSON: ${err.message}`));
506
+ continue;
507
+ }
508
+ this._onMessage(msg);
509
+ }
510
+ }
511
+
512
+ _onMessage(msg) {
513
+ this.emit('raw', {
514
+ source: 'codex-rpc',
515
+ direction: 'in',
516
+ kind: typeof msg.method === 'string' ? 'request-or-notification' : 'response',
517
+ method: msg.method ?? null,
518
+ requestId: msg.id ?? null,
519
+ summary: msg.method
520
+ ? `${msg.method} ${inputSummary(msg.params ?? {})}`
521
+ : msg.error
522
+ ? `error ${msg.error.message || msg.id || ''}`.trim()
523
+ : `response ${msg.id ?? ''}`.trim(),
524
+ payload: msg,
525
+ });
526
+
527
+ if (typeof msg.method === 'string') {
528
+ const serverRequest = normalizeServerMessage(msg);
529
+ if (serverRequest) {
530
+ this.currentRequest = serverRequest;
531
+ this.currentPrompt = promptForRequest(serverRequest);
532
+ this.emit('prompt', this.currentPrompt);
533
+ this.emit('pending', true);
534
+ return;
535
+ }
536
+ this._onNotification(msg.method, msg.params || {});
537
+ return;
538
+ }
539
+
540
+ if (msg.id != null && this.pending.has(msg.id)) {
541
+ const p = this.pending.get(msg.id);
542
+ this.pending.delete(msg.id);
543
+ clearTimeout(p.timer);
544
+ if (msg.error) {
545
+ p.reject(new Error(msg.error.message || `${p.method} failed`));
546
+ } else {
547
+ p.resolve(msg.result);
548
+ }
549
+ return;
550
+ }
551
+ }
552
+
553
+ _onNotification(method, params) {
554
+ if (method === 'serverRequest/resolved') {
555
+ this.currentRequest = null;
556
+ this.currentPrompt = null;
557
+ this.emit('prompt', null);
558
+ this.emit('pending', false);
559
+ return;
560
+ }
561
+
562
+ if (method === 'thread/status/changed') {
563
+ this.emit('status', params.status || null);
564
+ return;
565
+ }
566
+
567
+ if (method !== 'item/completed') return;
568
+ const item = params.item || {};
569
+ const ts = Number(params.completedAtMs || Date.now());
570
+
571
+ if (item.type === 'userMessage') {
572
+ const text = textFromContent(item.content);
573
+ if (!text) return;
574
+ this._appendMessage({
575
+ uuid: item.id || `rpc-user-${ts}`,
576
+ role: 'user',
577
+ ts,
578
+ blocks: [{ kind: 'text', text }],
579
+ rawType: 'rpc_userMessage',
580
+ });
581
+ return;
582
+ }
583
+
584
+ if (item.type === 'agentMessage') {
585
+ const text = String(item.text || '');
586
+ if (!text.trim()) return;
587
+ const subagent = parseCodexSubagentNotification(text);
588
+ if (subagent) {
589
+ this.emit('subagent', subagent);
590
+ this.emit('raw', {
591
+ source: 'codex-rpc',
592
+ direction: 'in',
593
+ kind: 'subagent',
594
+ method,
595
+ requestId: null,
596
+ summary: `${subagent.agentId} ${subagent.state}`,
597
+ });
598
+ return;
599
+ }
600
+ this._appendMessage({
601
+ uuid: item.id || `rpc-agent-${ts}`,
602
+ role: 'assistant',
603
+ ts,
604
+ blocks: [{ kind: 'text', text }],
605
+ rawType: 'rpc_agentMessage',
606
+ });
607
+ return;
608
+ }
609
+
610
+ if (item.type === 'functionCall' || item.type === 'customToolCall') {
611
+ const id = item.callId || item.call_id || item.id || `rpc-tool-${ts}`;
612
+ const input = item.arguments ?? item.input ?? {};
613
+ this._appendMessage({
614
+ uuid: id,
615
+ role: 'assistant',
616
+ ts,
617
+ blocks: [{
618
+ kind: 'tool_use',
619
+ id,
620
+ name: item.name || item.type,
621
+ input,
622
+ inputSummary: inputSummary(input),
623
+ }],
624
+ rawType: `rpc_${item.type}`,
625
+ });
626
+ }
627
+ }
628
+
629
+ _appendMessage(msg) {
630
+ this.messages.push(msg);
631
+ if (this.messages.length > 4000) this.messages.splice(0, this.messages.length - 4000);
632
+ this.emit('messages', [msg]);
633
+ }
634
+ }
635
+
636
+ export class CodexRpcManager extends EventEmitter {
637
+ constructor() {
638
+ super();
639
+ this.clients = new Map();
640
+ }
641
+
642
+ async prepareEndpoint() {
643
+ return codexRpcEndpoint();
644
+ }
645
+
646
+ async attach({ target, endpoint, cwd, resumeThreadId = null, transcriptPath = null }) {
647
+ const existing = this.clients.get(target);
648
+ if (existing) existing.close();
649
+ const client = new CodexRpcClient({ target, endpoint, cwd, resumeThreadId, transcriptPath });
650
+ this._bind(client);
651
+ try {
652
+ await client.connect();
653
+ } catch (err) {
654
+ client.close();
655
+ throw err;
656
+ }
657
+ this.clients.set(target, client);
658
+ return client;
659
+ }
660
+
661
+ async ensureAttached({ target, endpoint, cwd, resumeThreadId = null, transcriptPath = null }) {
662
+ const existing = this.clients.get(target);
663
+ if (existing) return existing;
664
+ return this.attach({ target, endpoint, cwd, resumeThreadId, transcriptPath });
665
+ }
666
+
667
+ has(target) {
668
+ return this.clients.has(target);
669
+ }
670
+
671
+ get(target) {
672
+ return this.clients.get(target) || null;
673
+ }
674
+
675
+ messages(target) {
676
+ return this.get(target)?.messages ?? [];
677
+ }
678
+
679
+ prompt(target) {
680
+ return this.get(target)?.currentPrompt ?? null;
681
+ }
682
+
683
+ threadInfo(target) {
684
+ return this.get(target)?.threadInfo() ?? null;
685
+ }
686
+
687
+ async submit(target, text, opts = {}) {
688
+ const client = this.get(target);
689
+ if (!client) throw new Error('Codex RPC client is not attached');
690
+ return client.submit(text, opts);
691
+ }
692
+
693
+ answerPrompt(target, key) {
694
+ const client = this.get(target);
695
+ if (!client) throw new Error('Codex RPC client is not attached');
696
+ return client.answerPrompt(key);
697
+ }
698
+
699
+ sweep(validTargets) {
700
+ const valid = new Set(validTargets || []);
701
+ for (const [target, client] of this.clients) {
702
+ if (valid.has(target)) continue;
703
+ client.close();
704
+ this.clients.delete(target);
705
+ }
706
+ }
707
+
708
+ _bind(client) {
709
+ client.on('messages', (messages) => this.emit('messages', client.target, messages));
710
+ client.on('thread', (thread) => this.emit('thread', client.target, thread));
711
+ client.on('prompt', (prompt) => this.emit('prompt', client.target, prompt));
712
+ client.on('pending', (pending) => this.emit('pending', client.target, pending));
713
+ client.on('status', (status) => this.emit('status', client.target, status));
714
+ client.on('subagent', (subagent) => this.emit('subagent', client.target, subagent));
715
+ client.on('raw', (event) => this.emit('raw', client.target, event));
716
+ client.on('error', (err) => this.emit('error', client.target, err));
717
+ client.on('close', () => this.emit('close', client.target));
718
+ }
719
+ }