@0xgeorgemathew/zcode 0.3.2 → 0.4.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,617 @@
1
+ 'use strict';
2
+ // One invocation, start to handback. Every command that talks to the
3
+ // engine (run, task, agent, resume, detached workers) goes through
4
+ // execute(), so lifecycle, isolation, cancellation and the result shape are
5
+ // identical across them.
6
+ //
7
+ // record → capacity → lease → engine HOME → snapshot → open session
8
+ // → turn → [task: handback, checks, bounded repair turns] → subagents
9
+ // → diff → status → result.json + final record
10
+ //
11
+ // Side-effect discipline: only the session-open phase is retried (it never
12
+ // touches the workspace). A prompt that reached the engine is never re-sent
13
+ // automatically; continuing is an explicit `zcode resume`, which tells the
14
+ // model the earlier work is already applied.
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const store = require('./store.js');
18
+ const { resolveRuntime, makeClient } = require('./bootstrap.js');
19
+ const { buildEngineHome, rootDir, liveDir } = require('./isolation.js');
20
+ const { openSession, runTurn, TurnError } = require('./appserver.js');
21
+ const { runChecks } = require('./checks.js');
22
+ const {
23
+ workspaceRoot,
24
+ isOwned,
25
+ acquireLease,
26
+ liveLeases,
27
+ snapshot,
28
+ diffSnapshots,
29
+ } = require('./workspace.js');
30
+ const {
31
+ buildTaskPrompt,
32
+ buildRepairPrompt,
33
+ buildResumePrompt,
34
+ HANDBACK_REQUEST,
35
+ parseHandback,
36
+ deriveStatus,
37
+ } = require('./contract.js');
38
+
39
+ const RESULT_SCHEMA = 'zcode.result/1';
40
+ const SUBAGENT_TOOLS = new Set(['Agent', 'Task']);
41
+
42
+ class ExecError extends Error {
43
+ constructor(message, { exitCode, kind }) {
44
+ super(message);
45
+ this.exitCode = exitCode;
46
+ this.kind = kind;
47
+ }
48
+ }
49
+
50
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
51
+
52
+ // Settings a session keeps across invocations (stored as invocation.json).
53
+ const SETTING_KEYS = [
54
+ 'kind', 'label', 'cwd', 'model', 'effort', 'timeoutMs', 'maxRepairs', 'checkTimeoutMs',
55
+ 'checks', 'owns', 'ownsDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit',
56
+ 'skillsDirs', 'noSkills', 'profile', 'includeReasoning', 'allowOverlap', 'stream',
57
+ ];
58
+
59
+ function settingsOf(inv) {
60
+ const out = {};
61
+ for (const k of SETTING_KEYS) if (inv[k] !== undefined) out[k] = inv[k];
62
+ return out;
63
+ }
64
+
65
+ function readSettings(id, env) {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(path.join(store.sessionDir(id, env), 'invocation.json'), 'utf8'));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function writeSettings(id, inv, env) {
74
+ store.writeJsonAtomic(path.join(store.sessionDir(id, env), 'invocation.json'), settingsOf(inv));
75
+ }
76
+
77
+ // Joins session/subagents with the per-session agents dir
78
+ // (<home>/.zcode/cli/agents/<parent>/agent_<uuid>/{metadata.json,output.txt}).
79
+ // NOTE (live-pinned): status "success" means the subagent RETURNED, not that
80
+ // its work is correct — the parent's integration checks are the gate.
81
+ async function collectSubagents(client, { sessionId, home }) {
82
+ const result = await client.request('session/subagents', { sessionId }, { timeoutMs: 15000 });
83
+ const items = (result && result.ended && result.ended.items) || [];
84
+ const running = (result && result.running) || [];
85
+ const agentsDir = path.join(home, '.zcode', 'cli', 'agents', sessionId);
86
+ const readJson = (f) => {
87
+ try {
88
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
89
+ } catch {
90
+ return null;
91
+ }
92
+ };
93
+ const subagents = items.map((item) => {
94
+ const dir = path.join(agentsDir, `agent_${String(item.agentId || '').replace(/^agent_/, '')}`);
95
+ const metadata = readJson(path.join(dir, 'metadata.json'));
96
+ let output = null;
97
+ try {
98
+ output = fs.readFileSync(path.join(dir, 'output.txt'), 'utf8');
99
+ } catch {
100
+ /* not flushed */
101
+ }
102
+ return {
103
+ agentId: item.agentId || (metadata && metadata.agentId) || null,
104
+ childSessionId: item.childSessionId || (metadata && metadata.childSessionId) || null,
105
+ profileId: (metadata && metadata.profileId) || item.subagentType || null,
106
+ title: item.title || (metadata && metadata.description) || null,
107
+ status: item.status || null,
108
+ summary: item.summary || null,
109
+ output,
110
+ startedAt: item.startedAt || null,
111
+ endedAt: item.endedAt || null,
112
+ durationMs: item.startedAt && item.endedAt ? item.endedAt - item.startedAt : null,
113
+ };
114
+ });
115
+ return { subagents, runningCount: running.length };
116
+ }
117
+
118
+ function sumUsage(turns) {
119
+ let input = null;
120
+ let output = null;
121
+ for (const t of turns) {
122
+ if (!t.usage) continue;
123
+ if (typeof t.usage.inputTokens === 'number') input = (input || 0) + t.usage.inputTokens;
124
+ if (typeof t.usage.outputTokens === 'number') output = (output || 0) + t.usage.outputTokens;
125
+ }
126
+ return input === null && output === null ? null : { inputTokens: input, outputTokens: output };
127
+ }
128
+
129
+ function exitCodeFor(result) {
130
+ switch (result.status) {
131
+ case 'completed':
132
+ return 0;
133
+ case 'unverified':
134
+ return 9;
135
+ case 'blocked':
136
+ return 10;
137
+ case 'partial':
138
+ return 8;
139
+ case 'cancelled':
140
+ return 130;
141
+ default: {
142
+ const kind = result.error && result.error.kind;
143
+ if (kind === 'timeout') return 124;
144
+ if (kind === 'no_subagent') return 6;
145
+ if (kind === 'checks_failed') return 8;
146
+ if (kind === 'conflict' || kind === 'busy') return 7;
147
+ return 5;
148
+ }
149
+ }
150
+ }
151
+
152
+ // Claims the id and writes the first record + settings (+ task.json).
153
+ // Used directly by detached starts so the id exists before the worker runs.
154
+ function prepareSession(inv, env = process.env) {
155
+ const record = store.createSession(inv.id, {
156
+ kind: inv.kind,
157
+ label: inv.label || null,
158
+ cwd: fs.realpathSync(inv.cwd),
159
+ workspaceRoot: workspaceRoot(inv.cwd),
160
+ owns: inv.ownsDisplay || [],
161
+ ownsRoot: inv.owns || [],
162
+ checks: inv.checks || [],
163
+ model: inv.model,
164
+ effort: inv.effort,
165
+ engineSessionId: null,
166
+ }, env);
167
+ writeSettings(inv.id, inv, env);
168
+ if (inv.kind === 'task') {
169
+ store.writeJsonAtomic(path.join(store.sessionDir(inv.id, env), 'task.json'), inv.contract);
170
+ }
171
+ return record;
172
+ }
173
+
174
+ // inv: {kind, id, resume?:{message}, adopt?, cwd, model, effort, timeoutMs,
175
+ // prompt (run/agent), contract (task), checks, owns, ownsDisplay, …}
176
+ // opts: {onEvent, signal, env, runtime}
177
+ // Returns the result object; throws ConfigError / ExecError / store errors
178
+ // for invocations that never started.
179
+ async function execute(inv, opts = {}) {
180
+ const env = opts.env || process.env;
181
+ const emit = opts.onEvent || (() => {});
182
+ const signal = opts.signal || null;
183
+ const started = Date.now();
184
+ const deadline = started + inv.timeoutMs;
185
+ const runtime = opts.runtime || resolveRuntime(env);
186
+ const { scrub } = runtime;
187
+ const id = inv.id;
188
+ const dir = store.sessionDir(id, env);
189
+ const home = path.join(dir, 'home');
190
+ const root = workspaceRoot(inv.cwd);
191
+
192
+ // --- record ---------------------------------------------------------------
193
+ const resuming = Boolean(inv.resume);
194
+ if (!resuming && !inv.adopt) prepareSession(inv, env);
195
+ const record = store.readSession(id, env);
196
+ if (!record) throw new ExecError(`no session "${id}"`, { exitCode: 2, kind: 'not_found' });
197
+ const busy = () => new ExecError(
198
+ `session "${id}" is still running (pid ${store.readSession(id, env).pid}). Wait for it or cancel it first.`,
199
+ { exitCode: 7, kind: 'busy' }
200
+ );
201
+ if (resuming && !inv.adopt) {
202
+ const view = store.viewSession(record);
203
+ if (view.live && record.pid !== process.pid) throw busy();
204
+ }
205
+ // Atomic: exactly one live process drives this session.
206
+ const releaseOwner = store.claimSession(id, env);
207
+ if (!releaseOwner) throw busy();
208
+ let releaseSlot = null;
209
+ const releaseClaims = () => {
210
+ if (releaseSlot) releaseSlot();
211
+ releaseOwner();
212
+ };
213
+ if (resuming) writeSettings(id, inv, env);
214
+ const isFirstInvocation = !record.engineSessionId;
215
+ const update = (patch) => store.updateSession(id, patch, env);
216
+
217
+ // --- activity relay ---------------------------------------------------------
218
+ let lastWrite = 0;
219
+ let toolCalls = record.activity ? record.activity.toolCalls || 0 : 0;
220
+ let subagentsStarted = record.activity ? record.activity.subagentsStarted || 0 : 0;
221
+ let lastTool = null;
222
+ const relay = (ev) => {
223
+ emit(ev);
224
+ if (ev.type === 'stream') return;
225
+ if (ev.type === 'tool') {
226
+ if (ev.phase === 'started') {
227
+ toolCalls++;
228
+ lastTool = ev.name;
229
+ if (SUBAGENT_TOOLS.has(ev.name)) {
230
+ subagentsStarted++;
231
+ emit({ type: 'subagent', phase: 'started', toolCallId: ev.id });
232
+ }
233
+ } else if ((ev.phase === 'result' || ev.phase === 'error') && SUBAGENT_TOOLS.has(ev.name)) {
234
+ emit({ type: 'subagent', phase: ev.phase === 'result' ? 'returned' : 'error', toolCallId: ev.id });
235
+ }
236
+ }
237
+ if (ev.type === 'model_request_started' || ev.type === 'state' || ev.type === 'turn') return;
238
+ try {
239
+ store.appendEvent(id, ev, env);
240
+ const now = Date.now();
241
+ if (now - lastWrite > 1000) {
242
+ lastWrite = now;
243
+ update({
244
+ activity: { toolCalls, subagentsStarted, lastTool, lastEventAt: new Date(now).toISOString() },
245
+ });
246
+ }
247
+ } catch {
248
+ /* activity is best-effort; never fail the run over it */
249
+ }
250
+ };
251
+ const setPhase = (phase, detail = {}) => {
252
+ const ev = { type: 'phase', phase, ...detail };
253
+ emit(ev);
254
+ try {
255
+ store.appendEvent(id, ev, env);
256
+ update({ phase, activity: { toolCalls, subagentsStarted, lastTool, lastEventAt: new Date().toISOString() } });
257
+ } catch {
258
+ /* best-effort */
259
+ }
260
+ };
261
+
262
+ emit({
263
+ type: 'session',
264
+ id,
265
+ kind: inv.kind,
266
+ label: inv.label || null,
267
+ resumed: resuming,
268
+ cwd: record.cwd,
269
+ owns: inv.ownsDisplay || [],
270
+ checks: inv.checks || [],
271
+ model: inv.model,
272
+ effort: inv.effort,
273
+ });
274
+
275
+ // --- capacity ---------------------------------------------------------------
276
+ const maxActive = Number(env.ZAI_CODE_MAX_ACTIVE) || 8;
277
+ update({ state: 'queued', phase: 'queued', pid: process.pid, status: null });
278
+ releaseSlot = store.claimSlot(maxActive, env);
279
+ if (!releaseSlot) {
280
+ setPhase('queued', { reason: `${maxActive} sessions already running (ZAI_CODE_MAX_ACTIVE)` });
281
+ while (!releaseSlot && !(signal && signal.aborted) && Date.now() < deadline) {
282
+ await sleep(1000);
283
+ releaseSlot = store.claimSlot(maxActive, env);
284
+ }
285
+ }
286
+
287
+ // --- lease ------------------------------------------------------------------
288
+ const lease = acquireLease({ id, root, owns: inv.owns || [], allowOverlap: inv.allowOverlap, env });
289
+ if (!lease.ok) {
290
+ const names = lease.conflicts
291
+ .map((c) => `${c.id} (owns ${c.owns && c.owns.length ? c.owns.join(', ') : 'the whole workspace'})`)
292
+ .join('; ');
293
+ const message =
294
+ `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
295
+ 'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
296
+ if (inv.adopt) {
297
+ // A detached worker: the caller is polling this id, so leave a result.
298
+ store.writeResult(id, {
299
+ type: 'result', schema: RESULT_SCHEMA, ok: false, status: 'failed', id, kind: inv.kind,
300
+ error: { kind: 'conflict', message }, issues: [message],
301
+ }, env);
302
+ update({ state: 'finished', phase: 'done', status: 'failed', outcome: { status: 'failed', issues: [message] } });
303
+ } else if (!resuming) {
304
+ store.deleteSession(id, env); // the id stays free for a later retry
305
+ } else {
306
+ update({ state: 'finished', phase: 'done', status: record.status });
307
+ }
308
+ releaseClaims();
309
+ throw new ExecError(message, { exitCode: 7, kind: 'conflict' });
310
+ }
311
+
312
+ update({ state: 'running', phase: 'starting' });
313
+
314
+ let client = null;
315
+ let session = null;
316
+ const turns = [];
317
+ let checks = [];
318
+ let repairs = 0;
319
+ let parsed = { handback: null, report: '', error: 'missing' };
320
+ let error = null;
321
+ let userInput = null;
322
+ let subagents = null;
323
+ let subagentNote = null;
324
+ let built = null;
325
+ let before = null;
326
+ const concurrent = new Map(lease.others.map((o) => [o.id, o.owns || []]));
327
+ const checkTouched = new Set(); // files written by zcode's own check runs
328
+ const invocationStart = new Date(started).toISOString();
329
+
330
+ try {
331
+ if (signal && signal.aborted) throw new TurnError('cancelled', { kind: 'cancelled' });
332
+ if (!releaseSlot) throw new TurnError('run exceeded --timeout-ms while queued', { kind: 'timeout' });
333
+
334
+ built = buildEngineHome({
335
+ home,
336
+ root: rootDir(env),
337
+ key: runtime.key,
338
+ live: liveDir(env),
339
+ mcpSpecs: inv.mcpSpecs || [],
340
+ mcpInherit: inv.mcpInherit,
341
+ skillsDirs: inv.skillsDirs || [],
342
+ noSkills: inv.noSkills,
343
+ });
344
+ before = snapshot(root);
345
+
346
+ // Open the engine session; retry once on engine timeouts/exits (safe:
347
+ // nothing has been sent to the model yet).
348
+ setPhase('starting');
349
+ for (let attempt = 1; ; attempt++) {
350
+ client = makeClient(runtime, { home, cwd: record.cwd });
351
+ client.start();
352
+ update({ enginePid: client.child.pid });
353
+ try {
354
+ session = await openSession(client, {
355
+ workspacePath: record.cwd,
356
+ modelId: inv.model,
357
+ effort: inv.effort,
358
+ resumeSessionId: record.engineSessionId || null,
359
+ requiredMcp: built.mcp.added,
360
+ onEvent: relay,
361
+ });
362
+ break;
363
+ } catch (err) {
364
+ client.kill();
365
+ const transient = /timeout|exited|failed to start|dead/.test(err.message);
366
+ if (attempt >= 2 || !transient || (signal && signal.aborted) || Date.now() >= deadline) throw err;
367
+ relay({ type: 'retry', phase: 'open_session', reason: scrub(err.message).slice(0, 300) });
368
+ }
369
+ }
370
+ update({ engineSessionId: session.sessionId });
371
+
372
+ // First prompt for this invocation.
373
+ let prompt;
374
+ if (inv.kind === 'task') {
375
+ prompt = isFirstInvocation
376
+ ? buildTaskPrompt(inv.contract, { ownsDisplay: inv.ownsDisplay || [] }) +
377
+ (resuming && inv.resume.message ? `\n\n## Message from the orchestrator\n${inv.resume.message}` : '')
378
+ : buildResumePrompt(inv.resume ? inv.resume.message : '', { kind: 'task' });
379
+ } else if (inv.kind === 'agent') {
380
+ prompt =
381
+ `You MUST use the Agent tool (profileId: "${inv.profile}") to accomplish the ` +
382
+ `task below — do not do the work yourself. When the subagent finishes, ` +
383
+ `report its agent id and result.\n\nTask: ${inv.resume ? inv.resume.message : inv.prompt}`;
384
+ } else {
385
+ prompt = inv.resume ? buildResumePrompt(inv.resume.message, { kind: 'run' }) : inv.prompt;
386
+ }
387
+
388
+ let askedForHandback = false;
389
+ let phase = 'executing';
390
+ for (;;) {
391
+ setPhase(phase, phase === 'repairing' ? { attempt: repairs, of: inv.maxRepairs } : {});
392
+ const turn = await runTurn(client, session, {
393
+ modelId: inv.model,
394
+ effort: inv.effort,
395
+ prompt,
396
+ deadline,
397
+ signal,
398
+ keepReasoning: inv.includeReasoning,
399
+ onEvent: relay,
400
+ });
401
+ turns.push(turn);
402
+ update({ turns: (record.turns || 0) + turns.length });
403
+ if (inv.kind !== 'task') break;
404
+
405
+ const next = parseHandback(turn.response);
406
+ // A handback-only follow-up keeps the substantive report from before.
407
+ parsed = { ...next, report: next.report || parsed.report };
408
+ if (parsed.error && !askedForHandback && Date.now() < deadline) {
409
+ askedForHandback = true;
410
+ prompt = HANDBACK_REQUEST;
411
+ phase = 'reporting';
412
+ continue;
413
+ }
414
+ if (parsed.handback && parsed.handback.status === 'blocked') break;
415
+ if (!inv.checks.length) break;
416
+
417
+ setPhase('checking', { count: inv.checks.length });
418
+ const preChecks = before ? snapshot(root) : null;
419
+ checks = await runChecks(inv.checks, {
420
+ cwd: record.cwd,
421
+ timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
422
+ signal,
423
+ onEvent: relay,
424
+ });
425
+ if (preChecks) {
426
+ for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
427
+ }
428
+ if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
429
+ throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
430
+ }
431
+ const failures = checks.filter((c) => !c.ok);
432
+ if (!failures.length || repairs >= inv.maxRepairs) break;
433
+ if ((signal && signal.aborted) || Date.now() >= deadline) break;
434
+ repairs++;
435
+ prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs });
436
+ phase = 'repairing';
437
+ }
438
+
439
+ if (inv.kind === 'task' || inv.kind === 'agent') {
440
+ try {
441
+ const collected = await collectSubagents(client, { sessionId: session.sessionId, home });
442
+ subagents = collected.subagents;
443
+ if (collected.runningCount) subagentNote = `${collected.runningCount} subagent(s) still running at handback`;
444
+ } catch (err) {
445
+ subagentNote = `subagent report unavailable: ${scrub(err.message).slice(0, 200)}`;
446
+ if (inv.kind === 'agent') throw new TurnError(subagentNote, { kind: 'subagents_rpc' });
447
+ }
448
+ }
449
+ } catch (err) {
450
+ error = err;
451
+ } finally {
452
+ if (client) client.kill();
453
+ lease.release();
454
+ releaseClaims();
455
+ }
456
+
457
+ // --- assemble the handback --------------------------------------------------
458
+ const cancelled = Boolean(signal && signal.aborted) || (error && error.kind === 'cancelled');
459
+ if (error && error.kind === 'user_input') {
460
+ userInput = error.message.replace(/^headless run requested user input: /, '');
461
+ }
462
+ for (const lease2 of liveLeases(root, env)) {
463
+ if (lease2.id !== id) concurrent.set(lease2.id, lease2.owns || []);
464
+ }
465
+ // Sessions in this workspace that were active at any point during this
466
+ // invocation, including ones that finished before us (their leases are
467
+ // gone): their changes are theirs, not scope violations of ours.
468
+ for (const other of store.listSessions(env)) {
469
+ if (other.id === id || other.workspaceRoot !== root || concurrent.has(other.id)) continue;
470
+ if (String(other.updatedAt) >= invocationStart && Array.isArray(other.ownsRoot)) {
471
+ concurrent.set(other.id, other.ownsRoot);
472
+ }
473
+ }
474
+ const after = before ? snapshot(root) : null;
475
+ const diff = before ? diffSnapshots(before, after) : null;
476
+ const owns = inv.owns || [];
477
+ const files = (diff || [])
478
+ .filter((f) => !f.path.startsWith('.zcode/'))
479
+ .map((f) => {
480
+ const owned = isOwned(f.path, owns);
481
+ const by = owned
482
+ ? null
483
+ : [...concurrent.entries()].find(([, o]) => o.length && isOwned(f.path, o));
484
+ const fromChecks = !owned && !by && checkTouched.has(f.path);
485
+ return { ...f, owned, ...(by ? { concurrentSession: by[0] } : {}), ...(fromChecks ? { fromChecks: true } : {}) };
486
+ });
487
+ const scopeViolations = files
488
+ .filter((f) => !f.owned && !f.concurrentSession && !f.fromChecks)
489
+ .map((f) => f.path);
490
+
491
+ const handback = parsed.handback;
492
+ let { status, issues } = deriveStatus({
493
+ kind: inv.kind,
494
+ error: error && !cancelled && error.kind !== 'user_input' ? error : null,
495
+ cancelled,
496
+ userInput,
497
+ handback,
498
+ handbackError: parsed.error,
499
+ checks,
500
+ scopeViolations,
501
+ });
502
+
503
+ let errorOut = null;
504
+ if (status === 'failed') {
505
+ if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
506
+ else if (checks.some((c) => !c.ok)) errorOut = { kind: 'checks_failed', message: issues[0] };
507
+ } else if (status === 'cancelled') {
508
+ errorOut = { kind: 'cancelled', message: 'cancelled' };
509
+ }
510
+ if (inv.kind === 'agent' && status === 'completed') {
511
+ const ok = subagents && subagents.length >= 1 && subagents.every((s) => s.status === 'success');
512
+ if (!ok) {
513
+ status = 'failed';
514
+ const why = !subagents || subagents.length === 0
515
+ ? 'no subagent was dispatched'
516
+ : `subagent statuses: ${subagents.map((s) => s.status).join(', ')}`;
517
+ issues = [why];
518
+ errorOut = { kind: 'no_subagent', message: why };
519
+ }
520
+ }
521
+
522
+ const notVerified = [];
523
+ if (inv.kind === 'task') {
524
+ if (!inv.checks.length) notVerified.push('no acceptance checks were declared; nothing was executed by zcode to verify the work');
525
+ if (inv.acceptanceDeclared) notVerified.push('prose acceptance criteria are confirmed only by the executor\'s own report');
526
+ if (subagents && subagents.length) notVerified.push('subagent "success" means the subagent returned; only the integrated result is verified');
527
+ }
528
+ if (before && !diff) notVerified.push('changed files could not be tracked in this workspace');
529
+ if (subagentNote) issues.push(subagentNote);
530
+
531
+ const last = turns[turns.length - 1] || null;
532
+ const response = last ? last.response : '';
533
+ const questions = [...(handback ? handback.questions : [])];
534
+ if (userInput) questions.push(userInput);
535
+
536
+ const result = {
537
+ type: 'result',
538
+ schema: RESULT_SCHEMA,
539
+ ok: status === 'completed',
540
+ status,
541
+ id,
542
+ kind: inv.kind,
543
+ label: inv.label || null,
544
+ summary: handback && handback.summary ? handback.summary : '',
545
+ response: inv.kind === 'task' ? parsed.report : response,
546
+ issues,
547
+ questions,
548
+ unresolved: handback ? handback.unresolved : [],
549
+ notVerified,
550
+ changes: {
551
+ tracking: before ? (diff ? before.kind : 'unavailable') : 'not-started',
552
+ files,
553
+ },
554
+ scopeViolations,
555
+ checks: checks.map((c) => ({
556
+ command: c.command,
557
+ ok: c.ok,
558
+ exitCode: c.exitCode,
559
+ timedOut: c.timedOut,
560
+ durationMs: c.durationMs,
561
+ output: scrub(c.output),
562
+ })),
563
+ checksPassed: checks.length ? checks.every((c) => c.ok) : null,
564
+ repairs,
565
+ handback,
566
+ subagents,
567
+ error: errorOut,
568
+ cwd: record.cwd,
569
+ workspaceRoot: root,
570
+ owns: inv.ownsDisplay || [],
571
+ sessionId: session ? session.sessionId : record.engineSessionId || null,
572
+ resumed: resuming || Boolean(session && session.resumed),
573
+ turns: turns.length,
574
+ model: (last && last.engineModel) || inv.model,
575
+ modelRequested: inv.model,
576
+ effort: (last && last.engineReasoningLevel) || inv.effort,
577
+ effortRequested: inv.effort,
578
+ mode: session ? session.mode : null,
579
+ usage: sumUsage(turns),
580
+ reasoningChars: turns.reduce((n, t) => n + t.reasoningChars, 0),
581
+ toolCalls: turns.some((t) => typeof t.toolCallCount === 'number')
582
+ ? turns.reduce((n, t) => n + (t.toolCallCount || 0), 0)
583
+ : null,
584
+ toolEvents: turns.reduce((n, t) => n + t.toolEvents, 0),
585
+ mcpServers: built ? Object.keys(built.mcp.servers) : [],
586
+ skillsInjected: built ? built.skillsInjected : [],
587
+ terminalStatus: last ? last.terminalStatus : null,
588
+ streamingEvents: turns.reduce((n, t) => n + t.streamingEvents, 0),
589
+ durationMs: Date.now() - started,
590
+ files: {
591
+ session: dir,
592
+ result: path.join(dir, 'result.json'),
593
+ events: path.join(dir, 'events.ndjson'),
594
+ },
595
+ };
596
+ if (inv.includeReasoning) result.reasoning = turns.map((t) => t.reasoningText).join('');
597
+
598
+ store.writeResult(id, result, env);
599
+ store.appendEvent(id, { type: 'result', status }, env);
600
+ update({
601
+ state: 'finished',
602
+ phase: 'done',
603
+ status,
604
+ activity: { toolCalls, subagentsStarted, lastTool, lastEventAt: new Date().toISOString() },
605
+ outcome: {
606
+ status,
607
+ summary: result.summary,
608
+ issues: issues.slice(0, 5),
609
+ checksPassed: result.checksPassed,
610
+ changedFiles: files.length,
611
+ durationMs: result.durationMs,
612
+ },
613
+ });
614
+ return result;
615
+ }
616
+
617
+ module.exports = { execute, prepareSession, exitCodeFor, ExecError, readSettings, RESULT_SCHEMA };