@playcraft/cli 0.1.0 → 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 CHANGED
@@ -192,13 +192,25 @@ echo '{"token":"<sandbox-jwt>","apiUrl":"https://..."}' > .playcraft.json
192
192
 
193
193
  业务工具写请求要求服务支持持久操作协议。命令行先保存请求编号、路径和输入摘要到 `~/.playcraft/tool-requests/`,再发送请求;记录不包含凭证或请求正文。可用绝对路径环境变量 `PLAYCRAFT_TOOL_REQUEST_DIR` 指定可持久保留的目录。
194
194
 
195
- 如果请求超时或连接中断,错误会显示请求编号。使用相同参数重跑命令,并设置该编号,只查询原操作的确定结果:
195
+ 原样重跑同一条命令(路径与参数都相同)时,命令行按上一次的结果决定是否沿用原请求编号:
196
+
197
+ | 上一次的结果 | 原样重跑时 |
198
+ | -------------------------------------------------------------------- | -------------------------------------------------------- |
199
+ | 成功(2xx) | 新操作 |
200
+ | 请求被拒绝(4xx,下列 409 除外):请求没有被执行 | 新操作 |
201
+ | 服务端错误(5xx)、超时或连接中断:结果未知 | 沿用原编号,不会另起一次操作;服务端按下文的恢复方式处理 |
202
+ | 409 `business_operation_in_progress`:原操作仍在执行 | 沿用原编号,稍后重跑取得结果 |
203
+ | 409 `business_operation_requires_reconciliation`:结果未知,需要核对 | 沿用原编号,不会再执行 |
204
+
205
+ 沿用原编号时,服务端按原操作的状态处理:已完成则返回原结果;仍在执行则返回 409 `business_operation_in_progress`;没有拿到结果就结束时,按该工具声明的恢复方式决定,可以安全重做的工具(生图、构建、发布到 COS 等)用原编号再执行一次,其余返回 409 `business_operation_requires_reconciliation`。原调用的执行租约有效期间(执行中每 20 秒续期,60 秒未续即过期),同一编号不会被再次执行;续期因服务或数据库故障中断超过 60 秒时,重做可能与仍在运行的原调用重叠。
206
+
207
+ 需要核对的操作,只有确认它没有生效后,才可设置 `PLAYCRAFT_TOOL_NEW_REQUEST=1` 强制发起新操作;不要删除记录来绕过。也可以显式指定编号,只查询原操作的确定结果(不会重新执行;声明了恢复方式的工具若上次报错,返回那次的错误)。只查询时即使是可以安全重做的工具,没有结果也返回需要核对;去掉 `PLAYCRAFT_TOOL_REQUEST_ID` 原样重跑即可续做:
196
208
 
197
209
  ```bash
198
210
  PLAYCRAFT_TOOL_REQUEST_ID=<错误中显示的请求编号> playcraft tools build-project --project-id <原项目编号>
199
211
  ```
200
212
 
201
- 恢复不会重新构建。操作尚未完成、结果未知或没有回执时会明确失败;不要换编号或删除记录来强行重试。只有错误明确说明能力检查失败且尚未发送操作时,才可在服务恢复后发起新的正常命令。服务不兼容时命令会在发送业务请求前停止;沙箱命令行与 Business 须排空旧调用后同步升级。
213
+ 能力检查失败时命令会明确说明尚未发送操作。服务不兼容时命令会在发送业务请求前停止;沙箱命令行与 Business 须排空旧调用后同步升级。
202
214
 
203
215
  ### 构建相关
204
216
 
@@ -72,7 +72,13 @@ export class AgentApiClient {
72
72
  const serializedBody = JSON.stringify(body);
73
73
  if (serializedBody === undefined)
74
74
  throw new Error('Tool request body is required');
75
- const operation = beginToolOperation(url.pathname, serializedBody, options?.requestId ?? process.env.PLAYCRAFT_TOOL_REQUEST_ID);
75
+ const recoverId = options?.requestId ?? process.env.PLAYCRAFT_TOOL_REQUEST_ID;
76
+ const operation = beginToolOperation(url.pathname, serializedBody, recoverId, {
77
+ newRequest: options?.newRequest ?? process.env.PLAYCRAFT_TOOL_NEW_REQUEST === '1',
78
+ });
79
+ const unknownOutcome = `Tool request ${operation.requestId}: the outcome is unknown. Rerun the same command to ` +
80
+ `continue this request: it returns the result if the request finished, or finishes it if ` +
81
+ `the tool can be safely redone; it never starts a second operation. Record: ${operation.file}.`;
76
82
  try {
77
83
  const capabilityResponse = await fetch(this.toolsRequestUrl(AGENT_TOOL_OPERATION_CAPABILITIES_PATH), {
78
84
  headers: this.authHeaders(),
@@ -81,12 +87,12 @@ export class AgentApiClient {
81
87
  });
82
88
  if (!capabilityResponse.ok) {
83
89
  await capabilityResponse.body?.cancel();
84
- throw new Error(`Business tool protocol unavailable (${capabilityResponse.status}); no operation was sent`);
90
+ throw new ToolRequestError(`Business tool protocol unavailable (${capabilityResponse.status}); no operation was sent`);
85
91
  }
86
92
  const capabilities = (await capabilityResponse.json());
87
93
  if (capabilities.protocol !== AGENT_TOOL_OPERATION_PROTOCOL ||
88
94
  capabilities.settledOnly !== true)
89
- throw new Error('Business tool protocol is incompatible; no operation was sent');
95
+ throw new ToolRequestError('Business tool protocol is incompatible; no operation was sent');
90
96
  const res = await fetch(url, {
91
97
  method: 'POST',
92
98
  redirect: 'error',
@@ -103,10 +109,31 @@ export class AgentApiClient {
103
109
  if (res.headers.get(AGENT_TOOL_OPERATION_HEADERS.protocol) !== AGENT_TOOL_OPERATION_PROTOCOL ||
104
110
  res.headers.get(AGENT_TOOL_OPERATION_HEADERS.requestId) !== operation.requestId) {
105
111
  await res.body?.cancel();
112
+ // Possibly an instance that ignores identities: a rerun may only read the receipt.
113
+ operation.requireReceipt();
106
114
  throw new Error('Business did not acknowledge a durable receipt; outcome remains unconfirmed');
107
115
  }
108
116
  if (!res.ok) {
109
117
  const text = await res.text().catch(() => res.statusText);
118
+ const code = errorCode(text);
119
+ if (res.status === 409 && code === 'business_operation_in_progress')
120
+ throw new ToolRequestError(`API 409: ${text}\nTool request ${operation.requestId} is still running. Rerun the ` +
121
+ 'same command later to get its result.');
122
+ if (res.status === 409 && code === 'business_operation_requires_reconciliation')
123
+ throw new ToolRequestError(recoverId !== undefined
124
+ ? // A receipt query never runs the request, even for a tool that can be safely redone.
125
+ `API 409: ${text}\nTool request ${operation.requestId} has no settled result yet. ` +
126
+ 'Rerun the same command without PLAYCRAFT_TOOL_REQUEST_ID: a tool that can be ' +
127
+ 'safely redone continues this request; any other tool then needs reconciliation.'
128
+ : `API 409: ${text}\nTool request ${operation.requestId} needs reconciliation: its ` +
129
+ 'outcome is unknown, and rerunning the same command will not run it again. Only ' +
130
+ 'after confirming it had no effect, rerun with PLAYCRAFT_TOOL_NEW_REQUEST=1 to ' +
131
+ 'start a new operation.');
132
+ if (res.status >= 400 && res.status < 500) {
133
+ // The request itself was refused and not carried out: a rerun is a new operation.
134
+ operation.reject();
135
+ throw new ToolRequestError(`API ${res.status}: ${text}`);
136
+ }
110
137
  throw new Error(`API ${res.status}: ${text}`);
111
138
  }
112
139
  const result = (await res.json());
@@ -114,10 +141,14 @@ export class AgentApiClient {
114
141
  return result;
115
142
  }
116
143
  catch (error) {
117
- throw new Error(`${error instanceof Error ? error.message : String(error)}\n` +
118
- `Tool request ${operation.requestId}; record: ${operation.file}. ` +
119
- `To query its settled result, rerun the same command with PLAYCRAFT_TOOL_REQUEST_ID=${operation.requestId}. ` +
120
- 'This recovery does not start another operation.');
144
+ if (error instanceof ToolRequestError)
145
+ throw error;
146
+ throw new Error(`${error instanceof Error ? error.message : String(error)}\n${operation.settledOnly
147
+ ? `Tool request ${operation.requestId}; record: ${operation.file}. Its settled result is not available yet.`
148
+ : unknownOutcome}`);
149
+ }
150
+ finally {
151
+ operation.release();
121
152
  }
122
153
  }
123
154
  async get(path, params) {
@@ -160,3 +191,15 @@ export class AgentApiClient {
160
191
  return { apiUrl: '', token: '' };
161
192
  }
162
193
  }
194
+ /** A failure whose meaning is settled; no recovery hint applies. */
195
+ class ToolRequestError extends Error {
196
+ }
197
+ function errorCode(text) {
198
+ try {
199
+ const message = JSON.parse(text).message;
200
+ return typeof message === 'string' ? message : undefined;
201
+ }
202
+ catch {
203
+ return undefined;
204
+ }
205
+ }
@@ -1,31 +1,61 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join } from 'node:path';
5
5
  import { isAgentToolRequestId } from '@playcraft/common/agent-tools';
6
+ /** Operations this process is sending now; never continued by another call of this process. */
7
+ const inFlight = new Set();
8
+ /** Linux start time of a process, so a reused pid is not taken for the process that held it. */
9
+ function startOf(pid) {
10
+ try {
11
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
12
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function holderAlive(holder) {
19
+ if (!holder || holder.pid === process.pid)
20
+ return false;
21
+ try {
22
+ process.kill(holder.pid, 0);
23
+ }
24
+ catch (error) {
25
+ if (error.code !== 'EPERM')
26
+ return false;
27
+ }
28
+ return holder.start === null || startOf(holder.pid) === holder.start;
29
+ }
6
30
  /** Persist an identity before sending. Store neither credentials nor request/response bodies. */
7
- export function beginToolOperation(path, serializedBody, recoverId) {
8
- const identifier = recoverId ?? randomUUID();
9
- if (!isAgentToolRequestId(identifier))
10
- throw new Error('Invalid PLAYCRAFT_TOOL_REQUEST_ID');
11
- const requestId = identifier.toLowerCase();
31
+ export function beginToolOperation(path, serializedBody, recoverId, options = {}) {
12
32
  const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
13
33
  if (!isAbsolute(directory))
14
34
  throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
15
35
  mkdirSync(directory, { recursive: true, mode: 0o700 });
16
- const file = join(directory, `${requestId}.json`);
17
36
  const inputHash = createHash('sha256').update(serializedBody).digest('hex');
37
+ // Rerunning the same command while its outcome is unknown continues that operation: the server
38
+ // answers the same identity with its receipt instead of running it again.
39
+ const unconfirmed = recoverId === undefined && !options.newRequest
40
+ ? findUnconfirmed(directory, path, inputHash)
41
+ : undefined;
42
+ const identifier = recoverId ?? unconfirmed?.requestId ?? randomUUID();
43
+ if (!isAgentToolRequestId(identifier))
44
+ throw new Error('Invalid PLAYCRAFT_TOOL_REQUEST_ID');
45
+ const requestId = identifier.toLowerCase();
46
+ const file = join(directory, `${requestId}.json`);
18
47
  const now = new Date().toISOString();
48
+ const holder = { pid: process.pid, start: startOf(process.pid) };
19
49
  const entry = {
20
50
  version: 1,
21
51
  requestId,
22
52
  path,
23
53
  inputHash,
24
54
  state: 'unconfirmed',
55
+ holder,
25
56
  createdAt: now,
26
57
  updatedAt: now,
27
58
  };
28
- let settledOnly = recoverId !== undefined;
29
59
  try {
30
60
  writeFileSync(file, JSON.stringify(entry), { flag: 'wx', mode: 0o600 });
31
61
  }
@@ -38,17 +68,53 @@ export function beginToolOperation(path, serializedBody, recoverId) {
38
68
  previous.path !== path ||
39
69
  previous.inputHash !== inputHash)
40
70
  throw new Error(`Tool request ${requestId} conflicts with its durable local record`);
41
- settledOnly = true;
42
71
  }
72
+ const update = (change) => {
73
+ const current = JSON.parse(readFileSync(file, 'utf8'));
74
+ const temporary = `${file}.${randomUUID()}.tmp`;
75
+ writeFileSync(temporary, JSON.stringify({ ...current, ...change, updatedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
76
+ renameSync(temporary, file);
77
+ };
78
+ // Only the sender holds an operation; a concurrent run of the same command gets its own.
79
+ inFlight.add(requestId);
80
+ if (unconfirmed || recoverId !== undefined)
81
+ update({ holder });
43
82
  return {
44
83
  requestId,
45
- settledOnly,
84
+ /** An explicit recovery, or a server that did not acknowledge the identity, reads only. */
85
+ settledOnly: recoverId !== undefined || unconfirmed?.receiptOnly === true,
46
86
  file,
47
- complete() {
48
- const current = JSON.parse(readFileSync(file, 'utf8'));
49
- const temporary = `${file}.${randomUUID()}.tmp`;
50
- writeFileSync(temporary, JSON.stringify({ ...current, state: 'completed', updatedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
51
- renameSync(temporary, file);
52
- },
87
+ complete: () => update({ state: 'completed' }),
88
+ /** A definite refusal: the request was not carried out, so a rerun is a new operation. */
89
+ reject: () => update({ state: 'rejected' }),
90
+ /** The outcome stays unknown and a rerun may only read the receipt. */
91
+ requireReceipt: () => update({ receiptOnly: true }),
92
+ /** This call is done with the operation; a later run of the command may continue it. */
93
+ release: () => inFlight.delete(requestId),
53
94
  };
54
95
  }
96
+ function findUnconfirmed(directory, path, inputHash) {
97
+ let latest;
98
+ for (const name of readdirSync(directory)) {
99
+ if (!name.endsWith('.json'))
100
+ continue;
101
+ try {
102
+ const entry = JSON.parse(readFileSync(join(directory, name), 'utf8'));
103
+ if (entry.version === 1 &&
104
+ entry.state === 'unconfirmed' &&
105
+ entry.path === path &&
106
+ entry.inputHash === inputHash &&
107
+ isAgentToolRequestId(entry.requestId) &&
108
+ (!latest || entry.updatedAt > latest.updatedAt) &&
109
+ // Still being sent, here or by another live process: that run is not interrupted.
110
+ // Checked last, so only this command's candidates cost a process probe.
111
+ !inFlight.has(entry.requestId) &&
112
+ !holderAlive(entry.holder))
113
+ latest = entry;
114
+ }
115
+ catch {
116
+ // A torn or foreign file is not an operation of this command.
117
+ }
118
+ }
119
+ return latest;
120
+ }
@@ -1,8 +1,11 @@
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';
8
+ import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
6
9
  import { collectedEvent } from './native-adapter.js';
7
10
  function asRecord(value) {
8
11
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -45,7 +48,10 @@ async function readToken(options) {
45
48
  throw new Error('model_proxy_token_missing');
46
49
  }
47
50
  export function createCodexAppServerAdapter(options) {
48
- 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;
49
55
  const model = options.model || process.env.PLAYCRAFT_CODEX_MODEL || 'gpt-4o';
50
56
  const turnTimeoutMs = options.turnTimeoutMs ?? Number(process.env.PLAYCRAFT_CODEX_TURN_TIMEOUT_MS || 120_000);
51
57
  const threads = new Map();
@@ -70,25 +76,50 @@ export function createCodexAppServerAdapter(options) {
70
76
  }
71
77
  async function start() {
72
78
  const token = await readToken(options);
73
- await writePlaycraftCodexConfig({
74
- homeDir,
75
- modelProxyUrl: options.config.modelProxyUrl,
76
- model,
77
- });
78
- 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');
79
80
  const { command, prefix } = spawnCommand(bin);
80
81
  const args = [...prefix, ...(options.args ?? ['app-server'])];
81
- child = spawn(command, args, {
82
- cwd: options.config.workspaceRoot,
83
- env: {
84
- ...process.env,
85
- ...options.env,
86
- CODEX_HOME: homeDir,
87
- OPENAI_API_KEY: token,
88
- PLAYCRAFT_SKIP_UPDATE_CHECK: '1',
89
- },
90
- stdio: ['pipe', 'pipe', 'pipe'],
91
- });
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
+ }
92
123
  child.stderr?.on('data', (chunk) => {
93
124
  process.stderr.write(chunk);
94
125
  });
@@ -118,12 +149,12 @@ export function createCodexAppServerAdapter(options) {
118
149
  superseded: false,
119
150
  };
120
151
  live = current;
121
- child.once('exit', () => {
152
+ child.onExit(() => {
153
+ void current.rpc.close();
122
154
  if (live === current) {
123
155
  readyFlag = false;
124
- live = undefined;
156
+ // Retain the old handle until its entire execution group is confirmed stopped.
125
157
  rpc = undefined;
126
- child = undefined;
127
158
  emitCollected(collectedEvent('process_exit', 'process.exited', { pid: current.pid }));
128
159
  }
129
160
  if (!current.superseded && !closed) {
@@ -131,15 +162,23 @@ export function createCodexAppServerAdapter(options) {
131
162
  listener({ pid: current.pid });
132
163
  }
133
164
  });
134
- await rpc.request('initialize', {
135
- clientInfo: {
136
- name: 'playcraft_workspace_runtime',
137
- title: 'PlayCraft Workspace Runtime',
138
- version: options.config.cliVersion,
139
- },
140
- });
141
- rpc.notify('initialized', {});
142
- 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
+ }
143
182
  }
144
183
  return {
145
184
  name: 'codex-app-server',
@@ -167,12 +206,10 @@ export function createCodexAppServerAdapter(options) {
167
206
  startPromise = undefined;
168
207
  threads.clear();
169
208
  approvals.clear();
170
- if (previous && previous.child.exitCode === null && previous.child.signalCode === null) {
171
- previous.child.kill('SIGTERM');
172
- }
173
209
  await previous?.rpc.close().catch(() => undefined);
174
210
  if (previous)
175
- await waitForChildExit(previous.child);
211
+ await previous.child.stop();
212
+ executionEpoch += 1;
176
213
  if (closed)
177
214
  throw new Error('codex_not_started');
178
215
  try {
@@ -204,6 +241,22 @@ export function createCodexAppServerAdapter(options) {
204
241
  return false;
205
242
  }
206
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
+ },
207
260
  async captureNativeCheckpoint(input) {
208
261
  const thread = threads.get(input.threadId);
209
262
  if (!thread || thread.sessionId !== input.sessionId)
@@ -299,20 +352,8 @@ export function createCodexAppServerAdapter(options) {
299
352
  const current = child;
300
353
  await rpc?.close().catch(() => undefined);
301
354
  rpc = undefined;
302
- if (current && current.exitCode === null && !current.killed) {
303
- current.kill('SIGTERM');
304
- await new Promise((resolve) => {
305
- const timer = setTimeout(() => {
306
- if (current.exitCode === null)
307
- current.kill('SIGKILL');
308
- resolve();
309
- }, 3000);
310
- current.once('exit', () => {
311
- clearTimeout(timer);
312
- resolve();
313
- });
314
- });
315
- }
355
+ if (current)
356
+ await current.stop();
316
357
  child = undefined;
317
358
  live = undefined;
318
359
  },
@@ -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
+ }