@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/README.md +184 -138
- package/bin/zcode.js +181 -140
- package/lib/appserver.js +190 -135
- package/lib/bootstrap.js +39 -60
- package/lib/checks.js +103 -0
- package/lib/commands/doctor.js +43 -64
- package/lib/commands/exec.js +327 -0
- package/lib/commands/login.js +2 -9
- package/lib/commands/mcp-list.js +6 -14
- package/lib/commands/sessions.js +181 -0
- package/lib/contract.js +301 -0
- package/lib/executor.js +617 -0
- package/lib/install-skills.js +54 -0
- package/lib/isolation.js +233 -223
- package/lib/key.js +1 -10
- package/lib/store.js +276 -0
- package/lib/ui.js +200 -71
- package/lib/workspace.js +336 -0
- package/package.json +22 -7
- package/skills/README.md +34 -0
- package/skills/claude-code/zcode/SKILL.md +112 -0
- package/skills/codex/zcode/SKILL.md +114 -0
- package/skills/codex/zcode/agents/openai.yaml +4 -0
- package/lib/commands/agent.js +0 -203
- package/lib/commands/run.js +0 -126
- package/lib/overlay.js +0 -138
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())
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// on
|
|
226
|
-
//
|
|
227
|
-
|
|
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
|
-
|
|
353
|
+
userInput: null,
|
|
354
|
+
durationMs: 0,
|
|
254
355
|
};
|
|
255
356
|
|
|
256
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
+
const payload = params.payload;
|
|
381
|
+
if (type === 'model.streaming' && payload) {
|
|
277
382
|
turn.streamingEvents++;
|
|
278
|
-
if (
|
|
279
|
-
turn.
|
|
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:
|
|
282
|
-
} else if (type === 'tool.updated' &&
|
|
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:
|
|
288
|
-
name:
|
|
289
|
-
id:
|
|
393
|
+
phase: payload.kind ?? null,
|
|
394
|
+
name: payload.toolName ?? null,
|
|
395
|
+
id: payload.toolCallId ?? null,
|
|
290
396
|
});
|
|
291
|
-
} else if (type === 'turn.completed' &&
|
|
292
|
-
completed =
|
|
293
|
-
turn.response = typeof
|
|
294
|
-
turn.usage =
|
|
295
|
-
if (typeof
|
|
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' &&
|
|
302
|
-
turn.engineProvider =
|
|
303
|
-
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:
|
|
309
|
-
modelId:
|
|
310
|
-
reasoningLevel:
|
|
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
|
-
|
|
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 (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
381
|
-
'
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
13
|
-
const { applyMcpOverlay, applySkillsOverlay } = require('./overlay.js');
|
|
13
|
+
const { engineEnv } = require('./isolation.js');
|
|
14
14
|
const { AppServerClient } = require('./appserver.js');
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
41
|
+
enginePath = resolveEngine(env);
|
|
42
|
+
builtinConfig = resolveBuiltinProviderConfig(enginePath, env);
|
|
55
43
|
} catch (err) {
|
|
56
|
-
if (err instanceof BuiltinConfigNotFoundError) {
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 = {
|
|
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 };
|