@playcraft/cli 0.0.59 → 0.1.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.
@@ -1,19 +1,17 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { startLauncherProcess } from './launcher-client.js';
2
4
  import { readFile } from 'node:fs/promises';
3
5
  import { join } from 'node:path';
4
6
  import { writePlaycraftCodexConfig } from './config-toml.js';
5
7
  import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
6
- import { nativeEvent } from './native-adapter.js';
8
+ import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
9
+ import { collectedEvent } from './native-adapter.js';
7
10
  function asRecord(value) {
8
11
  return value && typeof value === 'object' && !Array.isArray(value)
9
12
  ? value
10
13
  : {};
11
14
  }
12
- function nestedId(value, key) {
13
- const record = asRecord(value);
14
- const nested = asRecord(record[key]);
15
- return typeof nested.id === 'string' ? nested.id : undefined;
16
- }
17
15
  function waitForChildExit(child) {
18
16
  if (child.exitCode !== null || child.signalCode !== null)
19
17
  return Promise.resolve();
@@ -50,7 +48,10 @@ async function readToken(options) {
50
48
  throw new Error('model_proxy_token_missing');
51
49
  }
52
50
  export function createCodexAppServerAdapter(options) {
53
- const homeDir = options.homeDir || process.env.PLAYCRAFT_CODEX_HOME || join(options.config.stateDir, 'codex-home');
51
+ const launcherSocket = options.launcherSocket || process.env.PLAYCRAFT_LAUNCHER_SOCKET;
52
+ const homeDir = options.homeDir || process.env.PLAYCRAFT_CODEX_HOME ||
53
+ (launcherSocket ? '/var/lib/playcraft/codex' : join(options.config.stateDir, 'codex-home'));
54
+ let executionEpoch = options.config.runtimeEpoch;
54
55
  const model = options.model || process.env.PLAYCRAFT_CODEX_MODEL || 'gpt-4o';
55
56
  const turnTimeoutMs = options.turnTimeoutMs ?? Number(process.env.PLAYCRAFT_CODEX_TURN_TIMEOUT_MS || 120_000);
56
57
  const threads = new Map();
@@ -62,14 +63,10 @@ export function createCodexAppServerAdapter(options) {
62
63
  let startPromise;
63
64
  let live;
64
65
  const exitListeners = new Set();
65
- const unmatchedTurns = new Map();
66
- const turnWaiters = new Map();
67
- function finishTurn(turnId, params, waiter) {
68
- turnWaiters.delete(turnId);
69
- unmatchedTurns.delete(turnId);
70
- const status = String(asRecord(asRecord(params).turn).status ?? 'completed');
71
- const outcome = status === 'interrupted' ? 'interrupted' : status === 'failed' ? 'failed' : 'completed';
72
- waiter.resolve({ outcome, turnId, events: waiter.events });
66
+ const collected = new Set();
67
+ function emitCollected(event) {
68
+ for (const listener of collected)
69
+ listener(event);
73
70
  }
74
71
  async function ensureStarted() {
75
72
  if (readyFlag && rpc)
@@ -79,44 +76,56 @@ export function createCodexAppServerAdapter(options) {
79
76
  }
80
77
  async function start() {
81
78
  const token = await readToken(options);
82
- await writePlaycraftCodexConfig({
83
- homeDir,
84
- modelProxyUrl: options.config.modelProxyUrl,
85
- model,
86
- });
87
- const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || 'codex';
79
+ const bin = options.command || process.env.PLAYCRAFT_CODEX_BIN || (launcherSocket ? '/usr/local/bin/codex' : 'codex');
88
80
  const { command, prefix } = spawnCommand(bin);
89
81
  const args = [...prefix, ...(options.args ?? ['app-server'])];
90
- child = spawn(command, args, {
91
- cwd: options.config.workspaceRoot,
92
- env: {
93
- ...process.env,
94
- ...options.env,
95
- CODEX_HOME: homeDir,
96
- OPENAI_API_KEY: token,
97
- PLAYCRAFT_SKIP_UPDATE_CHECK: '1',
98
- },
99
- stdio: ['pipe', 'pipe', 'pipe'],
100
- });
82
+ if (launcherSocket) {
83
+ // Configuration travels as non-secret overrides: control cannot write dev's home.
84
+ for (const [key, value] of Object.entries({
85
+ model,
86
+ model_provider: 'playcraft',
87
+ approval_policy: 'never',
88
+ sandbox_mode: 'danger-full-access',
89
+ 'model_providers.playcraft.name': 'PlayCraft Model Proxy',
90
+ 'model_providers.playcraft.base_url': options.config.modelProxyUrl.replace(/\/+$/, ''),
91
+ 'model_providers.playcraft.env_key': 'OPENAI_API_KEY',
92
+ 'model_providers.playcraft.wire_api': 'responses',
93
+ 'model_providers.playcraft.requires_openai_auth': false,
94
+ }))
95
+ args.push('-c', `${key}=${JSON.stringify(value)}`);
96
+ child = await startLauncherProcess({
97
+ socketPath: launcherSocket,
98
+ processId: `codex-${randomUUID()}`,
99
+ runtimeEpoch: executionEpoch,
100
+ argv: [command, ...args],
101
+ cwd: options.config.workspaceRoot,
102
+ env: { CODEX_HOME: homeDir, OPENAI_API_KEY: token },
103
+ });
104
+ }
105
+ else {
106
+ await writePlaycraftCodexConfig({ homeDir, modelProxyUrl: options.config.modelProxyUrl, model });
107
+ const local = spawn(command, args, {
108
+ cwd: options.config.workspaceRoot,
109
+ env: { ...process.env, ...options.env, CODEX_HOME: homeDir, OPENAI_API_KEY: token, PLAYCRAFT_SKIP_UPDATE_CHECK: '1' },
110
+ stdio: ['pipe', 'pipe', 'pipe'],
111
+ });
112
+ await new Promise((resolve, reject) => { local.once('spawn', resolve); local.once('error', reject); });
113
+ child = {
114
+ pid: local.pid, stdin: local.stdin, stdout: local.stdout, stderr: local.stderr,
115
+ onExit(listener) { local.once('exit', listener); },
116
+ async stop() {
117
+ if (local.exitCode === null && local.signalCode === null)
118
+ local.kill('SIGTERM');
119
+ await waitForChildExit(local);
120
+ },
121
+ };
122
+ }
101
123
  child.stderr?.on('data', (chunk) => {
102
124
  process.stderr.write(chunk);
103
125
  });
104
126
  rpc = createJsonRpcStdioClient(child);
105
127
  rpc.onNotification((method, params) => {
106
- const turnId = nestedId(params, 'turn') ?? String(asRecord(params).turnId ?? '');
107
- const event = nativeEvent(method, params);
108
- if (!turnId)
109
- return;
110
- const waiter = turnWaiters.get(turnId);
111
- if (waiter) {
112
- waiter.events.push(event);
113
- if (method === 'turn/completed')
114
- finishTurn(turnId, params, waiter);
115
- return;
116
- }
117
- const queued = unmatchedTurns.get(turnId) ?? [];
118
- queued.push(event);
119
- unmatchedTurns.set(turnId, queued);
128
+ emitCollected(collectedEvent('notification', method, params));
120
129
  });
121
130
  rpc.onServerRequest((id, method, params) => {
122
131
  const payload = asRecord(params);
@@ -127,19 +136,7 @@ export function createCodexAppServerAdapter(options) {
127
136
  threadId: String(payload.threadId ?? ''),
128
137
  turnId: turnId || undefined,
129
138
  });
130
- const event = nativeEvent(method, params);
131
- const waiter = turnId ? turnWaiters.get(turnId) : undefined;
132
- if (waiter) {
133
- waiter.events.push(event);
134
- turnWaiters.delete(turnId);
135
- waiter.resolve({ outcome: 'completed', turnId, approvalId, events: waiter.events });
136
- return;
137
- }
138
- if (turnId) {
139
- const queued = unmatchedTurns.get(turnId) ?? [];
140
- queued.push(event);
141
- unmatchedTurns.set(turnId, queued);
142
- }
139
+ emitCollected(collectedEvent('approval_request', method, { ...payload, itemId: approvalId }));
143
140
  });
144
141
  const pid = child.pid;
145
142
  if (!pid)
@@ -152,31 +149,36 @@ export function createCodexAppServerAdapter(options) {
152
149
  superseded: false,
153
150
  };
154
151
  live = current;
155
- child.once('exit', () => {
152
+ child.onExit(() => {
153
+ void current.rpc.close();
156
154
  if (live === current) {
157
155
  readyFlag = false;
158
- live = undefined;
156
+ // Retain the old handle until its entire execution group is confirmed stopped.
159
157
  rpc = undefined;
160
- child = undefined;
161
- for (const [turnId, waiter] of turnWaiters) {
162
- turnWaiters.delete(turnId);
163
- waiter.resolve({ outcome: 'uncertain', turnId, events: waiter.events });
164
- }
158
+ emitCollected(collectedEvent('process_exit', 'process.exited', { pid: current.pid }));
165
159
  }
166
160
  if (!current.superseded && !closed) {
167
161
  for (const listener of exitListeners)
168
162
  listener({ pid: current.pid });
169
163
  }
170
164
  });
171
- await rpc.request('initialize', {
172
- clientInfo: {
173
- name: 'playcraft_workspace_runtime',
174
- title: 'PlayCraft Workspace Runtime',
175
- version: options.config.cliVersion,
176
- },
177
- });
178
- rpc.notify('initialized', {});
179
- readyFlag = true;
165
+ try {
166
+ await rpc.request('initialize', {
167
+ clientInfo: {
168
+ name: 'playcraft_workspace_runtime',
169
+ title: 'PlayCraft Workspace Runtime',
170
+ version: options.config.cliVersion,
171
+ },
172
+ });
173
+ rpc.notify('initialized', {});
174
+ readyFlag = true;
175
+ }
176
+ catch (error) {
177
+ current.superseded = true;
178
+ await current.rpc.close();
179
+ await current.child.stop();
180
+ throw error;
181
+ }
180
182
  }
181
183
  return {
182
184
  name: 'codex-app-server',
@@ -189,6 +191,13 @@ export function createCodexAppServerAdapter(options) {
189
191
  onUnexpectedExit(listener) {
190
192
  exitListeners.add(listener);
191
193
  },
194
+ onCollected(listener) {
195
+ collected.add(listener);
196
+ return () => collected.delete(listener);
197
+ },
198
+ turnStartSends() {
199
+ return rpc?.turnStartSends() ?? 0;
200
+ },
192
201
  async replaceProcess() {
193
202
  const previous = live;
194
203
  if (previous)
@@ -197,14 +206,10 @@ export function createCodexAppServerAdapter(options) {
197
206
  startPromise = undefined;
198
207
  threads.clear();
199
208
  approvals.clear();
200
- turnWaiters.clear();
201
- unmatchedTurns.clear();
202
- if (previous && previous.child.exitCode === null && previous.child.signalCode === null) {
203
- previous.child.kill('SIGTERM');
204
- }
205
209
  await previous?.rpc.close().catch(() => undefined);
206
210
  if (previous)
207
- await waitForChildExit(previous.child);
211
+ await previous.child.stop();
212
+ executionEpoch += 1;
208
213
  if (closed)
209
214
  throw new Error('codex_not_started');
210
215
  try {
@@ -236,6 +241,22 @@ export function createCodexAppServerAdapter(options) {
236
241
  return false;
237
242
  }
238
243
  },
244
+ async readThread(input) {
245
+ try {
246
+ await ensureStarted();
247
+ if (!rpc)
248
+ return undefined;
249
+ const result = await rpc.request('thread/read', {
250
+ threadId: input.threadId,
251
+ includeTurns: true,
252
+ }, 30_000);
253
+ const projected = projectNativeThread(input.threadId, result.thread ?? result);
254
+ return projected;
255
+ }
256
+ catch {
257
+ return undefined;
258
+ }
259
+ },
239
260
  async captureNativeCheckpoint(input) {
240
261
  const thread = threads.get(input.threadId);
241
262
  if (!thread || thread.sessionId !== input.sessionId)
@@ -265,104 +286,61 @@ export function createCodexAppServerAdapter(options) {
265
286
  await ensureStarted();
266
287
  if (!rpc)
267
288
  throw new Error('codex_not_started');
268
- if (input.simulateLostReceipt) {
269
- return { outcome: 'uncertain', events: [] };
270
- }
271
289
  if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
272
- return {
273
- outcome: 'failed',
274
- events: [nativeEvent('turn.failed', { reason: 'unknown_thread' })],
275
- };
290
+ return { outcome: 'failed', error: 'unknown_thread' };
276
291
  }
277
- const started = await rpc.request('turn/start', {
278
- threadId: input.threadId,
279
- input: [{ type: 'text', text: input.text }],
280
- });
281
- const turnId = started.turn?.id;
282
- if (!turnId)
283
- return { outcome: 'uncertain', events: [] };
284
- const thread = threads.get(input.threadId);
285
- if (thread)
286
- thread.lastTurnId = turnId;
287
- const queued = unmatchedTurns.get(turnId) ?? [];
288
- unmatchedTurns.delete(turnId);
289
- const events = [nativeEvent('turn/started', started), ...queued];
290
- const alreadyDone = queued.find((event) => event.type === 'turn/completed');
291
- if (alreadyDone) {
292
- const status = String(asRecord(asRecord(alreadyDone.payload).turn).status ?? 'completed');
293
- const outcome = status === 'interrupted' ? 'interrupted' : status === 'failed' ? 'failed' : 'completed';
294
- return { outcome, turnId, events };
292
+ const requestId = rpc.reserveRequest();
293
+ await input.persistReservation({ nativeRequestId: String(requestId) });
294
+ if (input.simulateLostReceipt)
295
+ return { outcome: 'uncertain', threadId: input.threadId };
296
+ try {
297
+ const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [{ type: 'text', text: input.text }] }, turnTimeoutMs);
298
+ const turnId = started.turn?.id;
299
+ if (!turnId)
300
+ return { outcome: 'uncertain', threadId: input.threadId };
301
+ const thread = threads.get(input.threadId);
302
+ if (thread)
303
+ thread.lastTurnId = turnId;
304
+ return { outcome: 'accepted', threadId: input.threadId, turnId };
295
305
  }
296
- const approvalEvent = queued.find((event) => event.type.includes('requestApproval') || event.type.includes('requestUserInput'));
297
- if (approvalEvent) {
298
- const approvalId = String(asRecord(approvalEvent.payload).itemId ?? '');
299
- return { outcome: 'completed', turnId, approvalId, events };
306
+ catch {
307
+ return { outcome: 'uncertain', threadId: input.threadId };
300
308
  }
301
- return await new Promise((resolve) => {
302
- let timer;
303
- const waiter = {
304
- events,
305
- resolve: (result) => {
306
- clearTimeout(timer);
307
- resolve(result);
308
- },
309
- };
310
- timer = setTimeout(() => {
311
- turnWaiters.delete(turnId);
312
- resolve({ outcome: 'uncertain', turnId, events: waiter.events });
313
- }, turnTimeoutMs);
314
- turnWaiters.set(turnId, waiter);
315
- });
316
309
  },
317
310
  async interrupt(input) {
318
311
  await ensureStarted();
319
312
  if (!rpc)
320
- return { outcome: 'uncertain', events: [] };
313
+ return { outcome: 'uncertain' };
321
314
  if (threads.get(input.threadId)?.sessionId !== input.sessionId)
322
- return { outcome: 'uncertain', events: [] };
315
+ return { outcome: 'uncertain' };
323
316
  const turnId = threads.get(input.threadId)?.lastTurnId;
324
317
  if (!turnId)
325
- return { outcome: 'uncertain', events: [] };
318
+ return { outcome: 'uncertain' };
326
319
  try {
327
320
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
328
321
  }
329
322
  catch {
330
- return { outcome: 'uncertain', events: [nativeEvent('turn.interrupted', { threadId: input.threadId })] };
323
+ return { outcome: 'uncertain', turnId };
331
324
  }
332
- return {
333
- outcome: 'interrupted',
334
- turnId,
335
- events: [nativeEvent('turn.interrupted', { threadId: input.threadId, turnId })],
336
- };
325
+ return { outcome: 'accepted', turnId };
337
326
  },
338
327
  async respondApproval(input) {
339
- if (!rpc) {
340
- return {
341
- outcome: 'failed',
342
- events: [nativeEvent('approval.unknown', { approvalId: input.approvalId })],
343
- };
344
- }
328
+ if (!rpc)
329
+ return { outcome: 'failed', approvalId: input.approvalId };
345
330
  const threadId = [...threads].find(([, thread]) => thread.sessionId === input.sessionId)?.[0];
346
331
  const key = JSON.stringify([threadId, input.approvalId]);
347
332
  const pending = approvals.get(key);
348
- if (!pending) {
349
- return {
350
- outcome: 'failed',
351
- events: [nativeEvent('approval.unknown', { approvalId: input.approvalId })],
352
- };
353
- }
333
+ if (!pending)
334
+ return { outcome: 'failed', approvalId: input.approvalId };
354
335
  approvals.delete(key);
355
336
  rpc.respond(pending.rpcId, { decision: input.accepted ? 'accept' : 'decline' });
356
- return {
357
- outcome: 'completed',
337
+ emitCollected(collectedEvent('notification', 'approval.resolved', {
358
338
  approvalId: input.approvalId,
359
- events: [
360
- nativeEvent('approval.resolved', {
361
- approvalId: input.approvalId,
362
- accepted: input.accepted,
363
- }),
364
- ],
365
- };
339
+ accepted: input.accepted,
340
+ threadId,
341
+ turnId: pending.turnId,
342
+ }));
343
+ return { outcome: 'accepted', approvalId: input.approvalId, turnId: pending.turnId };
366
344
  },
367
345
  async close() {
368
346
  if (closed)
@@ -374,20 +352,8 @@ export function createCodexAppServerAdapter(options) {
374
352
  const current = child;
375
353
  await rpc?.close().catch(() => undefined);
376
354
  rpc = undefined;
377
- if (current && current.exitCode === null && !current.killed) {
378
- current.kill('SIGTERM');
379
- await new Promise((resolve) => {
380
- const timer = setTimeout(() => {
381
- if (current.exitCode === null)
382
- current.kill('SIGKILL');
383
- resolve();
384
- }, 3000);
385
- current.once('exit', () => {
386
- clearTimeout(timer);
387
- resolve();
388
- });
389
- });
390
- }
355
+ if (current)
356
+ await current.stop();
391
357
  child = undefined;
392
358
  live = undefined;
393
359
  },
@@ -7,6 +7,7 @@ export function createJsonRpcStdioClient(child) {
7
7
  const emitter = new EventEmitter();
8
8
  const pending = new Map();
9
9
  let nextId = 1;
10
+ let turnStarts = 0;
10
11
  let buffer = '';
11
12
  let closed = false;
12
13
  const onData = (chunk) => {
@@ -60,8 +61,16 @@ export function createJsonRpcStdioClient(child) {
60
61
  notify(method, params) {
61
62
  send({ method, params: params ?? {} });
62
63
  },
63
- request(method, params, timeoutMs = 20_000) {
64
- const id = nextId++;
64
+ reserveRequest() {
65
+ const id = nextId;
66
+ nextId += 1;
67
+ return id;
68
+ },
69
+ sendReserved(id, method, params, timeoutMs = 20_000) {
70
+ if (pending.has(id))
71
+ throw new Error('jsonrpc_request_in_use');
72
+ if (method === 'turn/start')
73
+ turnStarts += 1;
65
74
  return new Promise((resolve, reject) => {
66
75
  const timer = setTimeout(() => {
67
76
  pending.delete(id);
@@ -78,6 +87,14 @@ export function createJsonRpcStdioClient(child) {
78
87
  }
79
88
  });
80
89
  },
90
+ request(method, params, timeoutMs = 20_000) {
91
+ const id = nextId;
92
+ nextId += 1;
93
+ return this.sendReserved(id, method, params, timeoutMs);
94
+ },
95
+ turnStartSends() {
96
+ return turnStarts;
97
+ },
81
98
  onNotification(listener) {
82
99
  emitter.on('notification', listener);
83
100
  return () => emitter.off('notification', listener);
@@ -0,0 +1,155 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { createConnection } from 'node:net';
3
+ import { PassThrough, Writable } from 'node:stream';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ function request(socketPath, message) {
6
+ return new Promise((resolve, reject) => {
7
+ const socket = createConnection(socketPath);
8
+ let buffer = '';
9
+ socket.setEncoding('utf8');
10
+ const timer = setTimeout(() => socket.destroy(new Error('launcher_request_timeout')), 5000);
11
+ socket.on('connect', () => socket.write(`${JSON.stringify(message)}\n`));
12
+ socket.on('data', (chunk) => {
13
+ buffer += chunk;
14
+ if (buffer.length > 64 * 1024)
15
+ return socket.destroy(new Error('launcher_reply_too_large'));
16
+ if (!buffer.includes('\n'))
17
+ return;
18
+ try {
19
+ const reply = JSON.parse(buffer.split('\n')[0]);
20
+ if (!reply.ok)
21
+ throw new Error(reply.error || 'launcher_request_failed');
22
+ resolve(reply);
23
+ }
24
+ catch (error) {
25
+ reject(error);
26
+ }
27
+ socket.destroy();
28
+ });
29
+ socket.on('error', reject);
30
+ socket.on('close', () => {
31
+ clearTimeout(timer);
32
+ reject(new Error('launcher_connection_closed'));
33
+ });
34
+ });
35
+ }
36
+ export async function startLauncherProcess(input) {
37
+ const socket = createConnection(input.socketPath);
38
+ const stdout = new PassThrough();
39
+ const stderr = new PassThrough();
40
+ const events = new EventEmitter();
41
+ let exited = false;
42
+ let buffer = '';
43
+ let identity;
44
+ let stopPromise;
45
+ const stdin = new Writable({
46
+ write(chunk, _encoding, callback) {
47
+ socket.write(`${JSON.stringify({ action: 'stdin', data: chunk.toString('base64') })}\n`, callback);
48
+ },
49
+ final(callback) {
50
+ socket.write(`${JSON.stringify({ action: 'stdin-end' })}\n`, callback);
51
+ },
52
+ });
53
+ // Transport loss is reported through onExit, never an unhandled stream error.
54
+ stdin.on('error', () => socket.destroy());
55
+ function exit() {
56
+ if (exited)
57
+ return;
58
+ exited = true;
59
+ events.emit('exit');
60
+ }
61
+ socket.setEncoding('utf8');
62
+ const pid = await new Promise((resolve, reject) => {
63
+ const timer = setTimeout(() => socket.destroy(new Error('launcher_start_timeout')), 5000);
64
+ let started = false;
65
+ socket.on('connect', () => {
66
+ const { socketPath: _socketPath, ...payload } = input;
67
+ socket.write(`${JSON.stringify({ ...payload, action: 'start-stdio' })}\n`);
68
+ });
69
+ socket.on('data', (chunk) => {
70
+ buffer += chunk;
71
+ if (buffer.length > 8 * 1024 * 1024)
72
+ return socket.destroy(new Error('launcher_reply_too_large'));
73
+ let newline;
74
+ while ((newline = buffer.indexOf('\n')) >= 0) {
75
+ const line = buffer.slice(0, newline);
76
+ buffer = buffer.slice(newline + 1);
77
+ try {
78
+ const frame = JSON.parse(line);
79
+ if (!started) {
80
+ if (!frame.ok || !frame.pid || !frame.instanceId)
81
+ throw new Error(frame.error || 'launcher_start_failed');
82
+ identity = {
83
+ processId: input.processId,
84
+ runtimeEpoch: input.runtimeEpoch,
85
+ instanceId: frame.instanceId,
86
+ };
87
+ started = true;
88
+ clearTimeout(timer);
89
+ resolve(frame.pid);
90
+ }
91
+ else if (frame.channel === 'stdout' || frame.channel === 'stderr') {
92
+ if (typeof frame.data !== 'string')
93
+ throw new Error('launcher_invalid_frame');
94
+ const stream = frame.channel === 'stdout' ? stdout : stderr;
95
+ if (!stream.write(Buffer.from(frame.data, 'base64'))) {
96
+ socket.pause();
97
+ stream.once('drain', () => socket.resume());
98
+ }
99
+ }
100
+ else if (frame.event === 'exit')
101
+ exit();
102
+ else
103
+ throw new Error('launcher_invalid_frame');
104
+ }
105
+ catch (error) {
106
+ socket.destroy(error instanceof Error ? error : new Error('launcher_invalid_frame'));
107
+ return;
108
+ }
109
+ }
110
+ });
111
+ socket.on('error', (error) => {
112
+ clearTimeout(timer);
113
+ reject(error);
114
+ });
115
+ socket.on('close', () => {
116
+ clearTimeout(timer);
117
+ reject(new Error('launcher_connection_closed'));
118
+ stdout.end();
119
+ stderr.end();
120
+ stdin.destroy();
121
+ exit();
122
+ });
123
+ });
124
+ return {
125
+ pid,
126
+ stdin,
127
+ stdout,
128
+ stderr,
129
+ onExit(listener) {
130
+ if (exited)
131
+ queueMicrotask(listener);
132
+ else
133
+ events.once('exit', listener);
134
+ },
135
+ stop() {
136
+ stopPromise ??= (async () => {
137
+ // A new launcher's empty registry cannot confirm an old group's exit.
138
+ for (const signal of ['SIGTERM', 'SIGKILL']) {
139
+ await request(input.socketPath, { ...identity, action: 'stop', signal });
140
+ const deadline = Date.now() + 3000;
141
+ while (Date.now() < deadline) {
142
+ const status = await request(input.socketPath, { ...identity, action: 'status' });
143
+ if (status.running === false) {
144
+ socket.destroy();
145
+ return;
146
+ }
147
+ await delay(50);
148
+ }
149
+ }
150
+ throw new Error('codex_group_stop_unconfirmed');
151
+ })();
152
+ return stopPromise;
153
+ },
154
+ };
155
+ }