@0xgeorgemathew/zcode 0.3.0

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.
@@ -0,0 +1,432 @@
1
+ 'use strict';
2
+ // NDJSON client for `zcode.cjs app-server --stdio` (DESIGN §9) plus the
3
+ // one-turn orchestration shared by `run` and `agent`.
4
+ //
5
+ // Wire facts (live-probed against engine v0.16.9; see DESIGN §9):
6
+ // - one JSON object per line, both directions; our request ids are numbers,
7
+ // server-directed request ids are strings ("server-2"); answers repeat
8
+ // THEIR id. A "jsonrpc" key must NOT be sent.
9
+ // - session/create result: result.session.{sessionId, mode, model, …}
10
+ // (mode param in create is NOT honored — session/setMode is required).
11
+ // - completion: session/event with params.type "turn.completed"
12
+ // (payload.response is the authoritative final text, payload.usage the
13
+ // usage); computer-use/operation-event kind "turn-completed" and
14
+ // v4/telemetry/event kind "turn.terminal" corroborate.
15
+ // - engine-reported model evidence: session/event params.type
16
+ // "session.updated" with payload.type "model_request_started"
17
+ // ({providerId, modelId, modelCall.reasoning.requestedLevel}).
18
+ const { spawn } = require('node:child_process');
19
+ const readline = require('node:readline');
20
+ const crypto = require('node:crypto');
21
+ const fs = require('node:fs');
22
+
23
+ const RUNTIME_PREFERENCES = {
24
+ nativeSearchEnhancementsEnabled: false,
25
+ memoryEnabled: false,
26
+ askUserQuestionAutoResolutionEnabled: true,
27
+ modelContextBudgetStrategy: 'preflight-v1',
28
+ };
29
+
30
+ class TurnError extends Error {
31
+ constructor(message, { kind = 'turn_failed' } = {}) {
32
+ super(message);
33
+ this.kind = kind;
34
+ }
35
+ }
36
+
37
+ class AppServerClient {
38
+ constructor({ nodeBin, enginePath, env, cwd, scrub = (s) => s }) {
39
+ this.nodeBin = nodeBin;
40
+ this.enginePath = enginePath;
41
+ this.env = env;
42
+ this.cwd = cwd;
43
+ this.scrub = scrub;
44
+ this.child = null;
45
+ this.nextId = 1;
46
+ this.pending = new Map();
47
+ this.onFrame = null; // (frame) => void for every notification
48
+ this.dead = false;
49
+ this.engineStderr = '';
50
+ }
51
+
52
+ start() {
53
+ this.child = spawn(this.nodeBin, [this.enginePath, 'app-server', '--stdio'], {
54
+ env: this.env,
55
+ cwd: this.cwd,
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ });
58
+ this.child.stderr.on('data', (d) => {
59
+ this.engineStderr += d;
60
+ if (this.engineStderr.length > 20000) {
61
+ this.engineStderr = this.engineStderr.slice(-20000);
62
+ }
63
+ });
64
+ this.child.on('exit', (code, signal) => {
65
+ this.dead = true;
66
+ const err = new Error(
67
+ `engine app-server exited (code=${code} signal=${signal})` +
68
+ (this.engineStderr ? `\nengine stderr tail: ${this.engineStderr.split('\n').slice(-8).join('\n')}` : '')
69
+ );
70
+ for (const { reject } of this.pending.values()) reject(err);
71
+ this.pending.clear();
72
+ if (this.onExit) this.onExit(err);
73
+ });
74
+ const rl = readline.createInterface({ input: this.child.stdout });
75
+ rl.on('line', (line) => this._handleLine(line));
76
+ return this;
77
+ }
78
+
79
+ _handleLine(line) {
80
+ if (!line.trim()) return;
81
+ let frame;
82
+ try {
83
+ frame = JSON.parse(line);
84
+ } catch {
85
+ return; // engine chatter we do not speak
86
+ }
87
+ if (frame && frame.method !== undefined && frame.id !== undefined) {
88
+ this._handleServerRequest(frame);
89
+ return;
90
+ }
91
+ if (frame && frame.id !== undefined && (frame.result !== undefined || frame.error !== undefined)) {
92
+ const p = this.pending.get(frame.id);
93
+ if (p) {
94
+ this.pending.delete(frame.id);
95
+ clearTimeout(p.timer);
96
+ if (frame.error) p.reject(new Error(`engine error on ${p.method}: ${JSON.stringify(frame.error)}`));
97
+ else p.resolve(frame.result);
98
+ }
99
+ return;
100
+ }
101
+ if (this.onFrame) this.onFrame(frame);
102
+ }
103
+
104
+ // Per-method policy table for server-directed requests (DESIGN §9).
105
+ _handleServerRequest(frame) {
106
+ const method = frame.method;
107
+ const id = frame.id;
108
+ const reply = (payload) =>
109
+ this.child.stdin.write(JSON.stringify({ id, result: payload }) + '\n');
110
+ const replyError = (code, message) =>
111
+ this.child.stdin.write(JSON.stringify({ id, error: { code, message } }) + '\n');
112
+
113
+ if (method === 'session/requestRuntimePreferences') {
114
+ reply(RUNTIME_PREFERENCES);
115
+ return;
116
+ }
117
+ if (method === 'interaction/requestPermission') {
118
+ // Allow-schema pinned from the engine bundle's own headless broker:
119
+ // {decision:"allow", reason, resolvedAt}.
120
+ reply({
121
+ decision: 'allow',
122
+ reason: 'zcode: headless auto-approve (yolo mode; --cwd is the blast radius)',
123
+ resolvedAt: new Date().toISOString(),
124
+ });
125
+ return;
126
+ }
127
+ if (method === 'interaction/requestUserInput') {
128
+ replyError(-32000, 'zcode: headless run requested user input');
129
+ if (this.onUserInputRequest) this.onUserInputRequest(frame);
130
+ return;
131
+ }
132
+ // Everything else — incl. interaction/requestOfficialMcpAuthHeaders,
133
+ // proven non-fatal with -32601 — gets method-not-found and the run
134
+ // continues.
135
+ replyError(-32601, `zcode: no policy for server request ${method}`);
136
+ if (this.onServerRequest) this.onServerRequest(frame);
137
+ }
138
+
139
+ request(method, params, { timeoutMs = 30000 } = {}) {
140
+ if (this.dead) return Promise.reject(new Error('engine app-server is dead'));
141
+ const id = this.nextId++;
142
+ this.child.stdin.write(JSON.stringify({ id, method, params }) + '\n');
143
+ return new Promise((resolve, reject) => {
144
+ const timer = setTimeout(() => {
145
+ this.pending.delete(id);
146
+ reject(new Error(`timeout (${timeoutMs}ms) waiting for ${method}`));
147
+ }, timeoutMs);
148
+ this.pending.set(id, { resolve, reject, method, timer });
149
+ });
150
+ }
151
+
152
+ kill() {
153
+ if (this.child && !this.dead) {
154
+ try {
155
+ this.child.kill('SIGKILL');
156
+ } catch {
157
+ /* already gone */
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ // --- turn orchestration -----------------------------------------------------
164
+
165
+ function workspaceKeyFor(workspacePath) {
166
+ // Deterministic; engine echoes it verbatim (semantics opaque, accepted
167
+ // live). sha256-of-realpath per DESIGN §9 risk R2.
168
+ const real = fs.realpathSync(workspacePath);
169
+ return crypto.createHash('sha256').update(real).digest('hex').slice(0, 16);
170
+ }
171
+
172
+ // MCP connections are asynchronous (stdio servers must be spawned) and —
173
+ // live-probed — only mcp/list mode:"connect" actually STARTS them;
174
+ // mode:"status" merely observes. Force the connection, then poll until
175
+ // every explicitly-added server settles (connected/failed/…), so the first
176
+ // turn always sees the caller's --mcp servers. Inherited servers are
177
+ // best-effort and never block the send.
178
+ async function waitForMcp(client, { workspacePath, required, timeoutMs = 20000, onEvent = () => {} }) {
179
+ if (!required || required.length === 0) return;
180
+ const ws = { workspace: { workspacePath, workspaceKey: workspaceKeyFor(workspacePath) } };
181
+ try {
182
+ await client.request('mcp/list', { ...ws, mode: 'connect' }, { timeoutMs: 15000 });
183
+ } catch {
184
+ /* best-effort trigger; the status loop below still reports */
185
+ }
186
+ const deadline = Date.now() + timeoutMs;
187
+ const settled = new Set();
188
+ while (Date.now() < deadline && settled.size < required.length) {
189
+ let statuses = {};
190
+ try {
191
+ const res = await client.request('mcp/list', { ...ws, mode: 'status' }, { timeoutMs: 5000 });
192
+ statuses = (res && res.statuses) || {};
193
+ } catch {
194
+ /* transient — retry */
195
+ }
196
+ for (const name of required) {
197
+ if (settled.has(name)) continue;
198
+ const entry = statuses[name];
199
+ const status = entry && entry.status;
200
+ if (status === 'connected' || status === 'failed' || status === 'disabled' || status === 'untrusted') {
201
+ settled.add(name);
202
+ onEvent({
203
+ type: 'mcp',
204
+ name,
205
+ status,
206
+ toolCount: (entry && entry.toolCount) ?? null,
207
+ failureKind: (entry && entry.failureKind) ?? null,
208
+ error: (entry && entry.error) ? String(typeof entry.error === 'string' ? entry.error : JSON.stringify(entry.error)).slice(0, 300) : null,
209
+ });
210
+ }
211
+ }
212
+ if (settled.size < required.length) {
213
+ await new Promise((r) => setTimeout(r, 750));
214
+ }
215
+ }
216
+ for (const name of required) {
217
+ if (!settled.has(name)) {
218
+ onEvent({ type: 'mcp', name, status: 'timeout', toolCount: null });
219
+ }
220
+ }
221
+ }
222
+
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, {
229
+ workspacePath,
230
+ modelId,
231
+ effort,
232
+ prompt,
233
+ timeoutMs,
234
+ resumeSessionId = null,
235
+ requiredMcp = [],
236
+ onEvent = () => {},
237
+ }) {
238
+ const started = Date.now();
239
+ const turn = {
240
+ sessionId: null,
241
+ resumed: false,
242
+ mode: null,
243
+ response: '',
244
+ usage: null,
245
+ engineModel: null,
246
+ engineProvider: null,
247
+ engineReasoningLevel: null,
248
+ terminalStatus: null,
249
+ streamingEvents: 0,
250
+ reasoningText: '',
251
+ toolCallCount: null,
252
+ toolEvents: 0,
253
+ userInputFailure: null,
254
+ };
255
+
256
+ const deadline = started + timeoutMs;
257
+ let completed = null; // {payload} from turn.completed
258
+ let failed = null;
259
+ let completer = null;
260
+ const completion = new Promise((resolve) => { completer = resolve; });
261
+
262
+ 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' }
267
+ );
268
+ completer();
269
+ };
270
+
271
+ client.onFrame = (frame) => {
272
+ const method = frame.method;
273
+ const params = frame.params || {};
274
+ if (method === 'session/event') {
275
+ const type = params.type;
276
+ if (type === 'model.streaming' && params.payload) {
277
+ turn.streamingEvents++;
278
+ if (params.payload.kind === 'reasoning_delta' && typeof params.payload.delta === 'string') {
279
+ turn.reasoningText += params.payload.delta;
280
+ }
281
+ onEvent({ type: 'stream', kind: params.payload.kind, delta: params.payload.delta });
282
+ } else if (type === 'tool.updated' && params.payload) {
283
+ // Engine tool lifecycle: scheduled|started|progress|result|error|…
284
+ turn.toolEvents++;
285
+ onEvent({
286
+ type: 'tool',
287
+ phase: params.payload.kind ?? null,
288
+ name: params.payload.toolName ?? null,
289
+ id: params.payload.toolCallId ?? null,
290
+ });
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
+ }
298
+ completer();
299
+ } else if (type === 'turn.started') {
300
+ 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;
306
+ onEvent({
307
+ type: 'model_request_started',
308
+ providerId: params.payload.providerId,
309
+ modelId: params.payload.modelId,
310
+ reasoningLevel: params.payload.modelCall?.reasoning?.requestedLevel ?? null,
311
+ });
312
+ }
313
+ } else if (method === 'state.updated') {
314
+ 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
+ }
319
+ } else if (method === 'v4/telemetry/event' && params.kind === 'turn.terminal') {
320
+ turn.terminalStatus = params.status ?? turn.terminalStatus;
321
+ if (params.status === 'failed') {
322
+ failed = new TurnError(
323
+ `turn.terminal failed: ${client.scrub(JSON.stringify(params.result || params).slice(0, 800))}`,
324
+ { kind: 'terminal_failed' }
325
+ );
326
+ completer();
327
+ }
328
+ }
329
+ };
330
+
331
+ 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 },
359
+ },
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
+ }
378
+ }
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();
404
+ }
405
+ }, 500);
406
+ try {
407
+ await completion;
408
+ } finally {
409
+ clearInterval(wait);
410
+ }
411
+
412
+ if (failed) throw failed;
413
+ if (!completed) throw new TurnError('turn ended without a completion event', { kind: 'no_completion' });
414
+ if (completed.resultType && completed.resultType !== 'success') {
415
+ throw new TurnError(`turn completed with resultType "${completed.resultType}"`, { kind: 'result_type' });
416
+ }
417
+ turn.durationMs = Date.now() - started;
418
+ return turn;
419
+ } finally {
420
+ client.onFrame = null;
421
+ client.onUserInputRequest = null;
422
+ }
423
+ }
424
+
425
+ module.exports = {
426
+ AppServerClient,
427
+ executeTurn,
428
+ TurnError,
429
+ workspaceKeyFor,
430
+ waitForMcp,
431
+ RUNTIME_PREFERENCES,
432
+ };
@@ -0,0 +1,89 @@
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).
5
+ const { resolveKey, NoKeyError } = require('./key.js');
6
+ const {
7
+ resolveEngine,
8
+ resolveBuiltinProviderConfig,
9
+ EngineNotFoundError,
10
+ BuiltinConfigNotFoundError,
11
+ } = require('./engine.js');
12
+ const { ensureIsolation, engineEnv } = require('./isolation.js');
13
+ const { applyMcpOverlay, applySkillsOverlay } = require('./overlay.js');
14
+ const { AppServerClient } = require('./appserver.js');
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).
19
+ function makeScrub(key) {
20
+ return (text) => String(text).split(key).join('***');
21
+ }
22
+
23
+ function bootstrap({
24
+ fresh = false,
25
+ mcpSpecs = [],
26
+ mcpInherit = false,
27
+ skillsDirs = [],
28
+ noSkills = false,
29
+ } = {}) {
30
+ let key;
31
+ try {
32
+ key = resolveKey(process.env);
33
+ } catch (err) {
34
+ if (err instanceof NoKeyError) {
35
+ process.stderr.write(`zcode: ${err.message}\n`);
36
+ process.exit(3);
37
+ }
38
+ throw err;
39
+ }
40
+ const scrub = makeScrub(key);
41
+
42
+ 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
+ let builtinConfig;
53
+ try {
54
+ builtinConfig = resolveBuiltinProviderConfig(enginePath, process.env);
55
+ } catch (err) {
56
+ if (err instanceof BuiltinConfigNotFoundError) {
57
+ process.stderr.write(`zcode: ${err.message}\n`);
58
+ process.exit(4);
59
+ }
60
+ throw err;
61
+ }
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,
81
+ enginePath,
82
+ env: childEnv,
83
+ cwd: process.cwd(),
84
+ scrub,
85
+ });
86
+ return { key, scrub, enginePath, builtinConfig, isolation, overlay, nodeBin, childEnv, client };
87
+ }
88
+
89
+ module.exports = { bootstrap, makeScrub };