@0xgeorgemathew/zcode 0.3.2 → 0.4.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/lib/appserver.js CHANGED
@@ -50,10 +50,14 @@ class AppServerClient {
50
50
  }
51
51
 
52
52
  start() {
53
+ // Own process group, so kill() takes the engine's tool subprocesses and
54
+ // stdio MCP servers with it (a runaway shell command must not keep
55
+ // writing to the workspace after cancel/timeout).
53
56
  this.child = spawn(this.nodeBin, [this.enginePath, 'app-server', '--stdio'], {
54
57
  env: this.env,
55
58
  cwd: this.cwd,
56
59
  stdio: ['pipe', 'pipe', 'pipe'],
60
+ detached: true,
57
61
  });
58
62
  this.child.stderr.on('data', (d) => {
59
63
  this.engineStderr += d;
@@ -61,13 +65,30 @@ class AppServerClient {
61
65
  this.engineStderr = this.engineStderr.slice(-20000);
62
66
  }
63
67
  });
68
+ // EPIPE on a dying engine and spawn failures (bad ZAI_CODE_NODE) must
69
+ // surface as a failed request, never as an uncaught 'error' event.
70
+ this.child.stdin.on('error', () => {});
71
+ this.child.on('error', (err) => {
72
+ if (this.dead) return;
73
+ this.dead = true;
74
+ const wrapped = new Error(`engine app-server failed to start: ${err.message}`);
75
+ for (const { reject, timer } of this.pending.values()) {
76
+ clearTimeout(timer);
77
+ reject(wrapped);
78
+ }
79
+ this.pending.clear();
80
+ if (this.onExit) this.onExit(wrapped);
81
+ });
64
82
  this.child.on('exit', (code, signal) => {
65
83
  this.dead = true;
66
84
  const err = new Error(
67
85
  `engine app-server exited (code=${code} signal=${signal})` +
68
86
  (this.engineStderr ? `\nengine stderr tail: ${this.engineStderr.split('\n').slice(-8).join('\n')}` : '')
69
87
  );
70
- for (const { reject } of this.pending.values()) reject(err);
88
+ for (const { reject, timer } of this.pending.values()) {
89
+ clearTimeout(timer);
90
+ reject(err);
91
+ }
71
92
  this.pending.clear();
72
93
  if (this.onExit) this.onExit(err);
73
94
  });
@@ -139,8 +160,14 @@ class AppServerClient {
139
160
  request(method, params, { timeoutMs = 30000 } = {}) {
140
161
  if (this.dead) return Promise.reject(new Error('engine app-server is dead'));
141
162
  const id = this.nextId++;
142
- this.child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
143
163
  return new Promise((resolve, reject) => {
164
+ this.child.stdin.write(JSON.stringify({ id, method, params }) + '\n', (err) => {
165
+ if (err && this.pending.has(id)) {
166
+ clearTimeout(this.pending.get(id).timer);
167
+ this.pending.delete(id);
168
+ reject(new Error(`engine stdin closed while sending ${method}`));
169
+ }
170
+ });
144
171
  const timer = setTimeout(() => {
145
172
  this.pending.delete(id);
146
173
  reject(new Error(`timeout (${timeoutMs}ms) waiting for ${method}`));
@@ -149,12 +176,29 @@ class AppServerClient {
149
176
  });
150
177
  }
151
178
 
179
+ // Asks the engine to abort the active prompt (session/stop). Best-effort
180
+ // and bounded: a wedged engine must not block cancellation.
181
+ async stopSession(sessionId, { timeoutMs = 3000 } = {}) {
182
+ if (this.dead || !sessionId) return false;
183
+ try {
184
+ await this.request('session/stop', { sessionId }, { timeoutMs });
185
+ return true;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
152
191
  kill() {
153
- if (this.child && !this.dead) {
154
- try {
155
- this.child.kill('SIGKILL');
156
- } catch {
157
- /* already gone */
192
+ if (!this.child || !this.child.pid) return;
193
+ try {
194
+ process.kill(-this.child.pid, 'SIGKILL'); // the whole engine group
195
+ } catch {
196
+ if (!this.dead) {
197
+ try {
198
+ this.child.kill('SIGKILL');
199
+ } catch {
200
+ /* already gone */
201
+ }
158
202
  }
159
203
  }
160
204
  }
@@ -220,26 +264,81 @@ async function waitForMcp(client, { workspacePath, required, timeoutMs = 20000,
220
264
  }
221
265
  }
222
266
 
223
- // Runs one full turn: create (+setMode ladder) or resume → subscribe → send
224
- // → wait for terminal. Resolves a turn result object; rejects with TurnError
225
- // on failure. `resumeSessionId` continues a persisted session (the engine
226
- // stores sessions in SQLite under the scratch HOME, so a new engine process
227
- // picks up where the previous CLI invocation stopped).
228
- async function executeTurn(client, {
267
+ // Opens the engine session for an invocation: create (+setMode ladder) or
268
+ // resume a persisted one, subscribe, and wait for explicit MCP servers.
269
+ // Everything here is side-effect free on the workspace, so a failure in
270
+ // this phase is safe to retry with a fresh engine.
271
+ async function openSession(client, {
229
272
  workspacePath,
230
273
  modelId,
231
274
  effort,
232
- prompt,
233
- timeoutMs,
234
275
  resumeSessionId = null,
235
276
  requiredMcp = [],
236
277
  onEvent = () => {},
278
+ }) {
279
+ const session = { sessionId: null, resumed: false, mode: null };
280
+ if (resumeSessionId) {
281
+ const resumed = await client.request(
282
+ 'session/resume',
283
+ { sessionId: resumeSessionId, thoughtLevel: effort },
284
+ { timeoutMs: 30000 }
285
+ );
286
+ const s = resumed.session || resumed.value || {};
287
+ session.sessionId = s.sessionId || resumeSessionId;
288
+ session.resumed = true;
289
+ session.mode = s.mode || null;
290
+ await client.request('session/setMode', { sessionId: session.sessionId, mode: 'yolo' }, { timeoutMs: 15000 });
291
+ session.mode = 'yolo';
292
+ } else {
293
+ const created = await client.request(
294
+ 'session/create',
295
+ {
296
+ workspace: { workspacePath, workspaceKey: workspaceKeyFor(workspacePath) },
297
+ mode: 'yolo',
298
+ model: { providerId: 'zai-api', modelId, options: { reasoningLevel: effort } },
299
+ thoughtLevel: effort,
300
+ // Persist the session row NOW: the default (deferred) loses it to
301
+ // our SIGKILL on exit, and resume then 404s (-32004).
302
+ persistence: 'immediate',
303
+ },
304
+ { timeoutMs: 30000 }
305
+ );
306
+ const s = created.session || created.value || {};
307
+ session.sessionId = s.sessionId;
308
+ session.mode = s.mode || null;
309
+ if (!session.sessionId) throw new TurnError('session/create returned no session.sessionId');
310
+ // §9.1 mode ladder: create's mode param is not honored (live-probed).
311
+ if (s.mode !== 'yolo') {
312
+ await client.request('session/setMode', { sessionId: session.sessionId, mode: 'yolo' }, { timeoutMs: 15000 });
313
+ session.mode = 'yolo';
314
+ }
315
+ }
316
+ await client.request(
317
+ 'session/subscribe',
318
+ { sessionId: session.sessionId, deliveryKind: 'desktop-continuous' },
319
+ { timeoutMs: 15000 }
320
+ );
321
+ await waitForMcp(client, { workspacePath, required: requiredMcp, onEvent });
322
+ return session;
323
+ }
324
+
325
+ // Sends one prompt into an open session and waits for its terminal event.
326
+ // Resolves the turn facts; rejects with TurnError (kind: timeout |
327
+ // cancelled | user_input | terminal_failed | result_type | no_completion).
328
+ // On timeout or abort the engine is told to stop the active prompt
329
+ // (session/stop) before we give up on it, so no tool keeps running behind
330
+ // our back while the caller tears the engine down.
331
+ async function runTurn(client, session, {
332
+ modelId,
333
+ effort,
334
+ prompt,
335
+ deadline,
336
+ signal = null,
337
+ keepReasoning = false,
338
+ onEvent = () => {},
237
339
  }) {
238
340
  const started = Date.now();
239
341
  const turn = {
240
- sessionId: null,
241
- resumed: false,
242
- mode: null,
243
342
  response: '',
244
343
  usage: null,
245
344
  engineModel: null,
@@ -247,25 +346,30 @@ async function executeTurn(client, {
247
346
  engineReasoningLevel: null,
248
347
  terminalStatus: null,
249
348
  streamingEvents: 0,
349
+ reasoningChars: 0,
250
350
  reasoningText: '',
251
351
  toolCallCount: null,
252
352
  toolEvents: 0,
253
- userInputFailure: null,
353
+ userInput: null,
354
+ durationMs: 0,
254
355
  };
255
356
 
256
- const deadline = started + timeoutMs;
257
- let completed = null; // {payload} from turn.completed
357
+ let completed = null;
258
358
  let failed = null;
259
359
  let completer = null;
260
360
  const completion = new Promise((resolve) => { completer = resolve; });
361
+ const fail = (err) => {
362
+ if (!failed && !completed) failed = err;
363
+ completer();
364
+ };
261
365
 
262
366
  client.onUserInputRequest = (frame) => {
263
- turn.userInputFailure = frame;
264
- failed = new TurnError(
265
- 'headless run requested user input: ' + client.scrub(JSON.stringify(frame.params || {}).slice(0, 800)),
266
- { kind: 'user_input' }
367
+ const params = frame.params || {};
368
+ const question = client.scrub(
369
+ String(params.message || params.prompt || params.question || JSON.stringify(params)).slice(0, 800)
267
370
  );
268
- completer();
371
+ turn.userInput = question;
372
+ fail(new TurnError(`headless run requested user input: ${question}`, { kind: 'user_input' }));
269
373
  };
270
374
 
271
375
  client.onFrame = (frame) => {
@@ -273,158 +377,109 @@ async function executeTurn(client, {
273
377
  const params = frame.params || {};
274
378
  if (method === 'session/event') {
275
379
  const type = params.type;
276
- if (type === 'model.streaming' && params.payload) {
380
+ const payload = params.payload;
381
+ if (type === 'model.streaming' && payload) {
277
382
  turn.streamingEvents++;
278
- if (params.payload.kind === 'reasoning_delta' && typeof params.payload.delta === 'string') {
279
- turn.reasoningText += params.payload.delta;
383
+ if (payload.kind === 'reasoning_delta' && typeof payload.delta === 'string') {
384
+ turn.reasoningChars += payload.delta.length;
385
+ if (keepReasoning) turn.reasoningText += payload.delta;
280
386
  }
281
- onEvent({ type: 'stream', kind: params.payload.kind, delta: params.payload.delta });
282
- } else if (type === 'tool.updated' && params.payload) {
387
+ onEvent({ type: 'stream', kind: payload.kind, delta: payload.delta });
388
+ } else if (type === 'tool.updated' && payload) {
283
389
  // Engine tool lifecycle: scheduled|started|progress|result|error|…
284
390
  turn.toolEvents++;
285
391
  onEvent({
286
392
  type: 'tool',
287
- phase: params.payload.kind ?? null,
288
- name: params.payload.toolName ?? null,
289
- id: params.payload.toolCallId ?? null,
393
+ phase: payload.kind ?? null,
394
+ name: payload.toolName ?? null,
395
+ id: payload.toolCallId ?? null,
290
396
  });
291
- } else if (type === 'turn.completed' && params.payload) {
292
- completed = params.payload;
293
- turn.response = typeof params.payload.response === 'string' ? params.payload.response : turn.response;
294
- turn.usage = params.payload.usage || turn.usage;
295
- if (typeof params.payload.toolCallCount === 'number') {
296
- turn.toolCallCount = params.payload.toolCallCount;
297
- }
397
+ } else if (type === 'turn.completed' && payload) {
398
+ completed = payload;
399
+ turn.response = typeof payload.response === 'string' ? payload.response : turn.response;
400
+ turn.usage = payload.usage || turn.usage;
401
+ if (typeof payload.toolCallCount === 'number') turn.toolCallCount = payload.toolCallCount;
298
402
  completer();
299
403
  } else if (type === 'turn.started') {
300
404
  onEvent({ type: 'turn', phase: 'started', turnId: params.turnId });
301
- } else if (type === 'session.updated' && params.payload && params.payload.type === 'model_request_started') {
302
- turn.engineProvider = params.payload.providerId ?? turn.engineProvider;
303
- turn.engineModel = params.payload.modelId ?? turn.engineModel;
304
- turn.engineReasoningLevel =
305
- params.payload.modelCall?.reasoning?.requestedLevel ?? turn.engineReasoningLevel;
405
+ } else if (type === 'session.updated' && payload && payload.type === 'model_request_started') {
406
+ turn.engineProvider = payload.providerId ?? turn.engineProvider;
407
+ turn.engineModel = payload.modelId ?? turn.engineModel;
408
+ turn.engineReasoningLevel = payload.modelCall?.reasoning?.requestedLevel ?? turn.engineReasoningLevel;
306
409
  onEvent({
307
410
  type: 'model_request_started',
308
- providerId: params.payload.providerId,
309
- modelId: params.payload.modelId,
310
- reasoningLevel: params.payload.modelCall?.reasoning?.requestedLevel ?? null,
411
+ providerId: payload.providerId,
412
+ modelId: payload.modelId,
413
+ reasoningLevel: payload.modelCall?.reasoning?.requestedLevel ?? null,
311
414
  });
312
415
  }
313
416
  } else if (method === 'state.updated') {
314
417
  const patch = params.patch || {};
315
- if (patch.mode && patch.mode.current) {
316
- turn.mode = patch.mode.current;
317
- onEvent({ type: 'state', mode: patch.mode.current });
318
- }
418
+ if (patch.mode && patch.mode.current) onEvent({ type: 'state', mode: patch.mode.current });
319
419
  } else if (method === 'v4/telemetry/event' && params.kind === 'turn.terminal') {
320
420
  turn.terminalStatus = params.status ?? turn.terminalStatus;
321
421
  if (params.status === 'failed') {
322
- failed = new TurnError(
422
+ fail(new TurnError(
323
423
  `turn.terminal failed: ${client.scrub(JSON.stringify(params.result || params).slice(0, 800))}`,
324
424
  { kind: 'terminal_failed' }
325
- );
326
- completer();
425
+ ));
327
426
  }
328
427
  }
329
428
  };
429
+ const prevOnExit = client.onExit;
430
+ client.onExit = (err) => fail(new TurnError(client.scrub(err.message), { kind: 'engine_exit' }));
431
+
432
+ const remaining = Math.max(0, deadline - Date.now());
433
+ const timer = setTimeout(
434
+ () => fail(new TurnError('run exceeded --timeout-ms', { kind: 'timeout' })),
435
+ remaining
436
+ );
437
+ const onAbort = () => fail(new TurnError('cancelled', { kind: 'cancelled' }));
438
+ if (signal) {
439
+ if (signal.aborted) onAbort();
440
+ else signal.addEventListener('abort', onAbort, { once: true });
441
+ }
330
442
 
331
443
  try {
332
- if (resumeSessionId) {
333
- // Continue a persisted session from a previous CLI invocation.
334
- const resumed = await client.request(
335
- 'session/resume',
336
- { sessionId: resumeSessionId, thoughtLevel: effort },
337
- { timeoutMs: 30000 }
338
- );
339
- const session = resumed.session || resumed.value || {};
340
- turn.sessionId = session.sessionId || resumeSessionId;
341
- turn.resumed = true;
342
- turn.mode = session.mode || null;
343
- await client.request(
344
- 'session/setMode',
345
- { sessionId: turn.sessionId, mode: 'yolo' },
346
- { timeoutMs: 15000 }
347
- );
348
- turn.mode = 'yolo';
349
- } else {
350
- const created = await client.request(
351
- 'session/create',
352
- {
353
- workspace: { workspacePath, workspaceKey: workspaceKeyFor(workspacePath) },
354
- mode: 'yolo',
355
- model: {
356
- providerId: 'zai-api',
357
- modelId,
358
- options: { reasoningLevel: effort },
444
+ if (!failed) {
445
+ await Promise.race([
446
+ client.request(
447
+ 'session/send',
448
+ {
449
+ sessionId: session.sessionId,
450
+ content: prompt,
451
+ modelSelection: { providerId: 'zai-api', modelId, options: { reasoningLevel: effort } },
359
452
  },
360
- thoughtLevel: effort,
361
- // Persist the session row NOW: the default (deferred) loses it to
362
- // our SIGKILL on exit, and --session resume then 404s (-32004).
363
- persistence: 'immediate',
364
- },
365
- { timeoutMs: 30000 }
366
- );
367
- const session = created.session || created.value || {};
368
- turn.sessionId = session.sessionId;
369
- turn.mode = session.mode || null;
370
- if (!turn.sessionId) throw new TurnError('session/create returned no session.sessionId');
371
-
372
- // §9.1 mode ladder: create's mode param is not honored (live-probed);
373
- // force yolo and record it.
374
- if (session.mode !== 'yolo') {
375
- await client.request('session/setMode', { sessionId: turn.sessionId, mode: 'yolo' }, { timeoutMs: 15000 });
376
- turn.mode = 'yolo';
377
- }
453
+ { timeoutMs: 30000 }
454
+ ),
455
+ completion,
456
+ ]);
378
457
  }
379
-
380
- await client.request(
381
- 'session/subscribe',
382
- { sessionId: turn.sessionId, deliveryKind: 'desktop-continuous' },
383
- { timeoutMs: 15000 }
384
- );
385
- await waitForMcp(client, { workspacePath, required: requiredMcp, onEvent });
386
- await client.request(
387
- 'session/send',
388
- {
389
- sessionId: turn.sessionId,
390
- content: prompt,
391
- modelSelection: {
392
- providerId: 'zai-api',
393
- modelId,
394
- options: { reasoningLevel: effort },
395
- },
396
- },
397
- { timeoutMs: 30000 }
398
- );
399
-
400
- const wait = setInterval(() => {
401
- if (Date.now() > deadline && !completed && !failed) {
402
- failed = new TurnError(`run exceeded --timeout-ms ${timeoutMs}`, { kind: 'timeout' });
403
- completer();
458
+ await completion;
459
+ if (failed) {
460
+ if (['timeout', 'cancelled', 'user_input'].includes(failed.kind)) {
461
+ await client.stopSession(session.sessionId);
404
462
  }
405
- }, 500);
406
- try {
407
- await completion;
408
- } finally {
409
- clearInterval(wait);
463
+ throw failed;
410
464
  }
411
-
412
- if (failed) throw failed;
413
- if (!completed) throw new TurnError('turn ended without a completion event', { kind: 'no_completion' });
414
465
  if (completed.resultType && completed.resultType !== 'success') {
415
466
  throw new TurnError(`turn completed with resultType "${completed.resultType}"`, { kind: 'result_type' });
416
467
  }
417
468
  turn.durationMs = Date.now() - started;
418
469
  return turn;
419
470
  } finally {
471
+ clearTimeout(timer);
472
+ if (signal) signal.removeEventListener('abort', onAbort);
420
473
  client.onFrame = null;
421
474
  client.onUserInputRequest = null;
475
+ client.onExit = prevOnExit;
422
476
  }
423
477
  }
424
478
 
425
479
  module.exports = {
426
480
  AppServerClient,
427
- executeTurn,
481
+ openSession,
482
+ runTurn,
428
483
  TurnError,
429
484
  workspaceKeyFor,
430
485
  waitForMcp,
package/lib/bootstrap.js CHANGED
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
- // Shared bootstrap for turn commands (run/agent): resolves key, engine,
3
- // builtin config and the scratch HOME, then builds an AppServerClient.
4
- // Exits with the documented codes (3 no key, 4 no engine/builtin config).
2
+ // Resolves everything an engine spawn needs — key, engine bundle, builtin
3
+ // provider config, node binary — and builds engine clients. Throws
4
+ // ConfigError carrying the documented exit code (3 no key, 4 no engine /
5
+ // builtin config) instead of exiting, so callers decide how to report.
5
6
  const { resolveKey, NoKeyError } = require('./key.js');
6
7
  const {
7
8
  resolveEngine,
@@ -9,81 +10,59 @@ const {
9
10
  EngineNotFoundError,
10
11
  BuiltinConfigNotFoundError,
11
12
  } = require('./engine.js');
12
- const { ensureIsolation, engineEnv } = require('./isolation.js');
13
- const { applyMcpOverlay, applySkillsOverlay } = require('./overlay.js');
13
+ const { engineEnv } = require('./isolation.js');
14
14
  const { AppServerClient } = require('./appserver.js');
15
15
 
16
- // Returns a function that replaces the API key with *** in any text that is
17
- // about to be printed or dumped (defense in depth — the engine never echoes
18
- // the key, but exit-5 dumps raw frames).
16
+ class ConfigError extends Error {
17
+ constructor(message, exitCode) {
18
+ super(message);
19
+ this.exitCode = exitCode;
20
+ }
21
+ }
22
+
23
+ // Replaces the API key with *** in any text that is about to be printed or
24
+ // stored (defense in depth — the engine never echoes the key, but error
25
+ // paths dump raw frames).
19
26
  function makeScrub(key) {
20
- return (text) => String(text).split(key).join('***');
27
+ return (text) => (key ? String(text).split(key).join('***') : String(text));
21
28
  }
22
29
 
23
- function bootstrap({
24
- fresh = false,
25
- mcpSpecs = [],
26
- mcpInherit = false,
27
- skillsDirs = [],
28
- noSkills = false,
29
- } = {}) {
30
+ function resolveRuntime(env = process.env) {
30
31
  let key;
31
32
  try {
32
- key = resolveKey(process.env);
33
+ key = resolveKey(env);
33
34
  } catch (err) {
34
- if (err instanceof NoKeyError) {
35
- process.stderr.write(`zcode: ${err.message}\n`);
36
- process.exit(3);
37
- }
35
+ if (err instanceof NoKeyError) throw new ConfigError(err.message, 3);
38
36
  throw err;
39
37
  }
40
- const scrub = makeScrub(key);
41
-
42
38
  let enginePath;
43
- try {
44
- enginePath = resolveEngine(process.env);
45
- } catch (err) {
46
- if (err instanceof EngineNotFoundError) {
47
- process.stderr.write(`zcode: ${err.message}\n`);
48
- process.exit(4);
49
- }
50
- throw err;
51
- }
52
39
  let builtinConfig;
53
40
  try {
54
- builtinConfig = resolveBuiltinProviderConfig(enginePath, process.env);
41
+ enginePath = resolveEngine(env);
42
+ builtinConfig = resolveBuiltinProviderConfig(enginePath, env);
55
43
  } catch (err) {
56
- if (err instanceof BuiltinConfigNotFoundError) {
57
- process.stderr.write(`zcode: ${err.message}\n`);
58
- process.exit(4);
44
+ if (err instanceof EngineNotFoundError || err instanceof BuiltinConfigNotFoundError) {
45
+ throw new ConfigError(err.message, 4);
59
46
  }
60
47
  throw err;
61
48
  }
62
-
63
- const isolation = ensureIsolation(key, { fresh, env: process.env });
64
- // Per-run overlays: recomputed from read-only live sources + flags, so
65
- // runs never inherit a previous run's additions (DESIGN §6 extension).
66
- const mcp = applyMcpOverlay({ home: isolation.home, inherit: mcpInherit, mcpSpecs });
67
- const skills = applySkillsOverlay({
68
- rootDir: isolation.root,
69
- home: isolation.home,
70
- skillsDirs,
71
- noSkills,
72
- });
73
- const overlay = { mcp, skills };
74
- const nodeBin = process.env.ZAI_CODE_NODE || process.execPath;
75
- const childEnv = engineEnv({
76
- home: isolation.home,
77
- builtinProviderConfig: builtinConfig,
78
- });
79
- const client = new AppServerClient({
80
- nodeBin,
49
+ return {
50
+ key,
51
+ scrub: makeScrub(key),
81
52
  enginePath,
82
- env: childEnv,
83
- cwd: process.cwd(),
84
- scrub,
53
+ builtinConfig,
54
+ nodeBin: env.ZAI_CODE_NODE || process.execPath,
55
+ };
56
+ }
57
+
58
+ function makeClient(runtime, { home, cwd }) {
59
+ return new AppServerClient({
60
+ nodeBin: runtime.nodeBin,
61
+ enginePath: runtime.enginePath,
62
+ env: engineEnv({ home, builtinProviderConfig: runtime.builtinConfig }),
63
+ cwd,
64
+ scrub: runtime.scrub,
85
65
  });
86
- return { key, scrub, enginePath, builtinConfig, isolation, overlay, nodeBin, childEnv, client };
87
66
  }
88
67
 
89
- module.exports = { bootstrap, makeScrub };
68
+ module.exports = { resolveRuntime, makeClient, makeScrub, ConfigError };
package/lib/checks.js ADDED
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ // Acceptance checks: shell commands from the task contract that zcode runs
3
+ // ITSELF after the model's turn — the model's own claims are never the
4
+ // verification evidence. Each check runs in its own process group (so a
5
+ // timeout or cancel kills the whole tree), with a per-check timeout and a
6
+ // bounded output tail. CI=1 is set when unset so test runners do not enter
7
+ // watch/interactive mode.
8
+ const { spawn } = require('node:child_process');
9
+
10
+ const OUTPUT_TAIL = 4000;
11
+
12
+ function killGroup(child) {
13
+ try {
14
+ process.kill(-child.pid, 'SIGKILL');
15
+ } catch {
16
+ try {
17
+ child.kill('SIGKILL');
18
+ } catch {
19
+ /* gone */
20
+ }
21
+ }
22
+ }
23
+
24
+ function runCheck(command, { cwd, timeoutMs = 300000, signal, env = process.env } = {}) {
25
+ const started = Date.now();
26
+ return new Promise((resolve) => {
27
+ let output = '';
28
+ let timedOut = false;
29
+ let cancelled = false;
30
+ const keep = (chunk) => {
31
+ output += chunk;
32
+ if (output.length > OUTPUT_TAIL * 2) output = output.slice(-OUTPUT_TAIL);
33
+ };
34
+ let child;
35
+ try {
36
+ child = spawn('/bin/sh', ['-c', command], {
37
+ cwd,
38
+ env: { ...env, CI: env.CI ?? '1' },
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ detached: true,
41
+ });
42
+ } catch (err) {
43
+ resolve({ command, ok: false, exitCode: null, timedOut: false, durationMs: 0, output: String(err.message) });
44
+ return;
45
+ }
46
+ child.stdout.setEncoding('utf8');
47
+ child.stderr.setEncoding('utf8');
48
+ child.stdout.on('data', keep);
49
+ child.stderr.on('data', keep);
50
+ const timer = setTimeout(() => {
51
+ timedOut = true;
52
+ killGroup(child);
53
+ }, timeoutMs);
54
+ const onAbort = () => {
55
+ cancelled = true;
56
+ killGroup(child);
57
+ };
58
+ if (signal) {
59
+ if (signal.aborted) onAbort();
60
+ else signal.addEventListener('abort', onAbort, { once: true });
61
+ }
62
+ const finish = (exitCode, errText) => {
63
+ clearTimeout(timer);
64
+ if (signal) signal.removeEventListener('abort', onAbort);
65
+ const tail = (output + (errText ? `\n${errText}` : '')).slice(-OUTPUT_TAIL);
66
+ resolve({
67
+ command,
68
+ ok: exitCode === 0 && !timedOut && !cancelled,
69
+ exitCode,
70
+ timedOut,
71
+ cancelled,
72
+ durationMs: Date.now() - started,
73
+ output: tail,
74
+ });
75
+ };
76
+ child.on('error', (err) => finish(null, err.message));
77
+ child.on('close', (code) => finish(code));
78
+ });
79
+ }
80
+
81
+ // Runs every check in order (later checks still run when an earlier one
82
+ // fails, so the handback carries complete evidence); stops on cancel.
83
+ async function runChecks(commands, { cwd, timeoutMs, signal, onEvent = () => {} } = {}) {
84
+ const results = [];
85
+ for (const command of commands) {
86
+ if (signal && signal.aborted) break;
87
+ onEvent({ type: 'check', phase: 'started', command });
88
+ const r = await runCheck(command, { cwd, timeoutMs, signal });
89
+ results.push(r);
90
+ onEvent({
91
+ type: 'check',
92
+ phase: 'finished',
93
+ command,
94
+ ok: r.ok,
95
+ exitCode: r.exitCode,
96
+ timedOut: r.timedOut,
97
+ durationMs: r.durationMs,
98
+ });
99
+ }
100
+ return results;
101
+ }
102
+
103
+ module.exports = { runCheck, runChecks, OUTPUT_TAIL };