@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.
- package/README.md +14 -2
- package/dist/agent/local-backend.js +1 -1
- package/dist/utils/agent-api-client.js +50 -7
- package/dist/utils/tool-operation-journal.js +82 -16
- package/dist/workspace-runtime/codex/app-server.js +135 -169
- package/dist/workspace-runtime/codex/jsonrpc-stdio.js +19 -2
- package/dist/workspace-runtime/codex/launcher-client.js +155 -0
- package/dist/workspace-runtime/codex/loopback.js +118 -37
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +213 -200
- package/dist/workspace-runtime/persistence/sqlite-client.js +61 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +1025 -0
- package/dist/workspace-runtime/serve.js +35 -1
- package/dist/workspace-runtime/server/desktop-host.js +314 -0
- package/dist/workspace-runtime/server/dispatch.js +108 -48
- package/dist/workspace-runtime/server/http.js +260 -12
- package/dist/workspace-runtime/server/terminal-host.js +243 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +276 -0
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +66 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +154 -0
- package/dist/workspace-runtime/workspaces/git.js +16 -3
- package/dist/workspace-runtime/workspaces/prepare.js +29 -172
- package/dist/workspace-runtime/workspaces/registry.js +12 -2
- package/package.json +7 -4
|
@@ -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 {
|
|
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
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
152
|
+
child.onExit(() => {
|
|
153
|
+
void current.rpc.close();
|
|
156
154
|
if (live === current) {
|
|
157
155
|
readyFlag = false;
|
|
158
|
-
|
|
156
|
+
// Retain the old handle until its entire execution group is confirmed stopped.
|
|
159
157
|
rpc = undefined;
|
|
160
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
thread
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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'
|
|
313
|
+
return { outcome: 'uncertain' };
|
|
321
314
|
if (threads.get(input.threadId)?.sessionId !== input.sessionId)
|
|
322
|
-
return { outcome: 'uncertain'
|
|
315
|
+
return { outcome: 'uncertain' };
|
|
323
316
|
const turnId = threads.get(input.threadId)?.lastTurnId;
|
|
324
317
|
if (!turnId)
|
|
325
|
-
return { outcome: 'uncertain'
|
|
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',
|
|
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
|
-
|
|
357
|
-
outcome: 'completed',
|
|
337
|
+
emitCollected(collectedEvent('notification', 'approval.resolved', {
|
|
358
338
|
approvalId: input.approvalId,
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
378
|
-
current.
|
|
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
|
-
|
|
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
|
+
}
|