@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.
- package/README.md +179 -130
- 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/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 +9 -5
- package/skills/README.md +32 -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/store.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Session store: one directory per zcode session under <root>/sessions/<id>.
|
|
3
|
+
//
|
|
4
|
+
// session.json lifecycle record (atomic tmp+rename writes; the owning
|
|
5
|
+
// process is the only writer while it is alive)
|
|
6
|
+
// events.ndjson bounded activity log (phases, tools, checks — no deltas)
|
|
7
|
+
// result.json the final handback of the latest invocation
|
|
8
|
+
// home/ the session's own engine HOME (engine DB, subagent dirs)
|
|
9
|
+
// pending.json a detached invocation waiting for its worker
|
|
10
|
+
// stdout.ndjson / stderr.log output of detached workers
|
|
11
|
+
//
|
|
12
|
+
// Lifecycle: queued → running → finished (status: completed | unverified |
|
|
13
|
+
// partial | blocked | failed | cancelled). A record that says queued/running
|
|
14
|
+
// whose pid is gone reads as "interrupted" — resumable, never silently
|
|
15
|
+
// "running" forever.
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const crypto = require('node:crypto');
|
|
19
|
+
const { rootDir } = require('./isolation.js');
|
|
20
|
+
|
|
21
|
+
const RECORD_SCHEMA = 'zcode.session/1';
|
|
22
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
23
|
+
const MAX_EVENTS = 2000;
|
|
24
|
+
const ACTIVE_STATES = new Set(['queued', 'running']);
|
|
25
|
+
|
|
26
|
+
class SessionExistsError extends Error {
|
|
27
|
+
constructor(id) {
|
|
28
|
+
super(
|
|
29
|
+
`session "${id}" already exists. Inspect it with "zcode status ${id}" ` +
|
|
30
|
+
`or continue it with "zcode resume ${id}".`
|
|
31
|
+
);
|
|
32
|
+
this.code = 'ZCODE_SESSION_EXISTS';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sessionsDir(env = process.env) {
|
|
37
|
+
return path.join(rootDir(env), 'sessions');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sessionDir(id, env = process.env) {
|
|
41
|
+
return path.join(sessionsDir(env), id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validId(id) {
|
|
45
|
+
return typeof id === 'string' && ID_RE.test(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function newId() {
|
|
49
|
+
return `zc-${crypto.randomBytes(4).toString('hex')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeJsonAtomic(file, value) {
|
|
53
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
54
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
|
|
55
|
+
fs.renameSync(tmp, file);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readJson(file) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Claims the id atomically (mkdir fails if it exists) and writes the first
|
|
67
|
+
// record. Throws SessionExistsError on collision — an orchestrator retrying
|
|
68
|
+
// a start with the same --id never runs the work twice.
|
|
69
|
+
function createSession(id, fields, env = process.env) {
|
|
70
|
+
fs.mkdirSync(sessionsDir(env), { recursive: true, mode: 0o700 });
|
|
71
|
+
const dir = sessionDir(id, env);
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.code === 'EEXIST') throw new SessionExistsError(id);
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
const record = {
|
|
80
|
+
schema: RECORD_SCHEMA,
|
|
81
|
+
id,
|
|
82
|
+
createdAt: now,
|
|
83
|
+
updatedAt: now,
|
|
84
|
+
state: 'queued',
|
|
85
|
+
status: null,
|
|
86
|
+
phase: 'queued',
|
|
87
|
+
pid: process.pid,
|
|
88
|
+
turns: 0,
|
|
89
|
+
activity: { toolCalls: 0, subagentsStarted: 0, lastTool: null, lastEventAt: now },
|
|
90
|
+
...fields,
|
|
91
|
+
};
|
|
92
|
+
writeJsonAtomic(path.join(dir, 'session.json'), record);
|
|
93
|
+
return record;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readSession(id, env = process.env) {
|
|
97
|
+
if (!validId(id)) return null;
|
|
98
|
+
return readJson(path.join(sessionDir(id, env), 'session.json'));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Read-modify-write; `patch` is an object or (record) => object.
|
|
102
|
+
function updateSession(id, patch, env = process.env) {
|
|
103
|
+
const file = path.join(sessionDir(id, env), 'session.json');
|
|
104
|
+
const current = readJson(file);
|
|
105
|
+
if (!current) throw new Error(`session "${id}" has no record`);
|
|
106
|
+
const delta = typeof patch === 'function' ? patch(current) : patch;
|
|
107
|
+
const next = { ...current, ...delta, updatedAt: new Date().toISOString() };
|
|
108
|
+
writeJsonAtomic(file, next);
|
|
109
|
+
return next;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function deleteSession(id, env = process.env) {
|
|
113
|
+
if (!validId(id)) return;
|
|
114
|
+
fs.rmSync(sessionDir(id, env), { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeResult(id, result, env = process.env) {
|
|
118
|
+
writeJsonAtomic(path.join(sessionDir(id, env), 'result.json'), result);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readResult(id, env = process.env) {
|
|
122
|
+
if (!validId(id)) return null;
|
|
123
|
+
return readJson(path.join(sessionDir(id, env), 'result.json'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Bounded append-only activity log. Counting lines once per process keeps
|
|
127
|
+
// appends O(1).
|
|
128
|
+
const eventCounts = new Map();
|
|
129
|
+
function appendEvent(id, event, env = process.env) {
|
|
130
|
+
const file = path.join(sessionDir(id, env), 'events.ndjson');
|
|
131
|
+
let count = eventCounts.get(file);
|
|
132
|
+
if (count === undefined) {
|
|
133
|
+
try {
|
|
134
|
+
count = fs.readFileSync(file, 'utf8').split('\n').length - 1;
|
|
135
|
+
} catch {
|
|
136
|
+
count = 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (count >= MAX_EVENTS && event.type !== 'phase' && event.type !== 'result') return;
|
|
140
|
+
fs.appendFileSync(file, JSON.stringify({ t: new Date().toISOString(), ...event }) + '\n', {
|
|
141
|
+
mode: 0o600,
|
|
142
|
+
});
|
|
143
|
+
eventCounts.set(file, count + 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readEvents(id, { tail = 20 } = {}, env = process.env) {
|
|
147
|
+
try {
|
|
148
|
+
const lines = fs
|
|
149
|
+
.readFileSync(path.join(sessionDir(id, env), 'events.ndjson'), 'utf8')
|
|
150
|
+
.split('\n')
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
return lines.slice(-tail).map((l) => JSON.parse(l));
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pidAlive(pid) {
|
|
159
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
160
|
+
try {
|
|
161
|
+
process.kill(pid, 0);
|
|
162
|
+
return true;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return err.code === 'EPERM';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The record as callers should see it: an active state whose owner process
|
|
169
|
+
// is gone is reported as "interrupted".
|
|
170
|
+
function viewSession(record) {
|
|
171
|
+
if (!record) return null;
|
|
172
|
+
if (ACTIVE_STATES.has(record.state) && !pidAlive(record.pid)) {
|
|
173
|
+
return { ...record, state: 'interrupted', live: false };
|
|
174
|
+
}
|
|
175
|
+
return { ...record, live: ACTIVE_STATES.has(record.state) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function listSessions(env = process.env) {
|
|
179
|
+
let names = [];
|
|
180
|
+
try {
|
|
181
|
+
names = fs.readdirSync(sessionsDir(env));
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const name of names) {
|
|
187
|
+
const record = readSession(name, env);
|
|
188
|
+
if (record) out.push(viewSession(record));
|
|
189
|
+
}
|
|
190
|
+
return out.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Accepts a zcode id or an engine session id (sess_…, as printed by 0.3.x
|
|
194
|
+
// and still reported as result.sessionId). Returns the zcode id or null.
|
|
195
|
+
function resolveSessionId(ref, env = process.env) {
|
|
196
|
+
if (validId(ref) && readSession(ref, env)) return ref;
|
|
197
|
+
for (const record of listSessions(env)) {
|
|
198
|
+
if (record.engineSessionId === ref) return record.id;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Exclusive pid lock (O_EXCL create). A lock whose pid is dead is stale and
|
|
204
|
+
// reclaimed; the content is re-read just before removal so a fresh lock
|
|
205
|
+
// taken by a racing reclaimer is not deleted. Returns release() or null.
|
|
206
|
+
function claimLockFile(file) {
|
|
207
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
208
|
+
try {
|
|
209
|
+
fs.writeFileSync(file, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
210
|
+
return () => {
|
|
211
|
+
try {
|
|
212
|
+
if (fs.readFileSync(file, 'utf8') === String(process.pid)) fs.rmSync(file, { force: true });
|
|
213
|
+
} catch {
|
|
214
|
+
/* already gone */
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (err.code !== 'EEXIST') throw err;
|
|
219
|
+
}
|
|
220
|
+
let holder;
|
|
221
|
+
try {
|
|
222
|
+
holder = fs.readFileSync(file, 'utf8');
|
|
223
|
+
} catch {
|
|
224
|
+
continue; // released in between: retry the create
|
|
225
|
+
}
|
|
226
|
+
if (pidAlive(Number(holder))) return null;
|
|
227
|
+
try {
|
|
228
|
+
if (fs.readFileSync(file, 'utf8') === holder) fs.rmSync(file, { force: true });
|
|
229
|
+
} catch {
|
|
230
|
+
/* someone else reclaimed it */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// One live process drives a session at a time (two racing `resume`s, or a
|
|
237
|
+
// resume racing a detached worker, cannot both run it).
|
|
238
|
+
function claimSession(id, env = process.env) {
|
|
239
|
+
return claimLockFile(path.join(sessionDir(id, env), 'owner.lock'));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Machine-wide soft cap on running sessions (ZAI_CODE_MAX_ACTIVE): N slot
|
|
243
|
+
// lock files; a session holds one while it runs.
|
|
244
|
+
function claimSlot(max, env = process.env) {
|
|
245
|
+
const dir = path.join(rootDir(env), 'slots');
|
|
246
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
247
|
+
for (let n = 0; n < max; n++) {
|
|
248
|
+
const release = claimLockFile(path.join(dir, `${n}.lock`));
|
|
249
|
+
if (release) return release;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
RECORD_SCHEMA,
|
|
256
|
+
SessionExistsError,
|
|
257
|
+
sessionsDir,
|
|
258
|
+
sessionDir,
|
|
259
|
+
validId,
|
|
260
|
+
newId,
|
|
261
|
+
createSession,
|
|
262
|
+
readSession,
|
|
263
|
+
updateSession,
|
|
264
|
+
deleteSession,
|
|
265
|
+
writeResult,
|
|
266
|
+
readResult,
|
|
267
|
+
appendEvent,
|
|
268
|
+
readEvents,
|
|
269
|
+
pidAlive,
|
|
270
|
+
viewSession,
|
|
271
|
+
listSessions,
|
|
272
|
+
resolveSessionId,
|
|
273
|
+
claimSession,
|
|
274
|
+
claimSlot,
|
|
275
|
+
writeJsonAtomic,
|
|
276
|
+
};
|
package/lib/ui.js
CHANGED
|
@@ -1,105 +1,234 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Plain-mode terminal UI
|
|
3
|
-
//
|
|
2
|
+
// Plain-mode terminal UI. stdout carries the RESULT (the answer for run,
|
|
3
|
+
// the handback summary for task); stderr carries the live feed. Nothing
|
|
4
|
+
// here writes ANSI unless stderr/stdout is a TTY and NO_COLOR / TERM=dumb
|
|
5
|
+
// are unset, and nothing animates — the feed is append-only lines, so a
|
|
6
|
+
// non-interactive log reads exactly like the terminal did.
|
|
4
7
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// session) - the session id is there so a plain-mode caller
|
|
12
|
-
// can pass it to --session without re-running with --json
|
|
13
|
-
//
|
|
14
|
-
// stdout is NEVER touched here. --quiet silences the whole feed.
|
|
8
|
+
// ▸ zcode zc-1a2b3c4d task GLM-5.3-Flash · effort max session header
|
|
9
|
+
// ● executing phase
|
|
10
|
+
// → Edit · ✓ Edit · ✗ Bash tools (dim)
|
|
11
|
+
// ⇢ subagent started / returned subagents
|
|
12
|
+
// ✓ npm test 3.2s · ✗ npm run lint exit 1 1.1s checks
|
|
13
|
+
// │ thinking … reasoning (opt-in)
|
|
15
14
|
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
function useColor(stream, env = process.env) {
|
|
16
|
+
return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
|
|
17
|
+
}
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
function painter(enabled) {
|
|
20
|
+
const wrap = (code) => (t) => (enabled ? `\x1b[${code}m${t}\x1b[0m` : String(t));
|
|
21
|
+
return { dim: wrap(2), bold: wrap(1), red: wrap(31), green: wrap(32), yellow: wrap(33), cyan: wrap(36) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const secs = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
25
|
+
|
|
26
|
+
function makeReporter({ quiet = false, thinking = true, stream = process.stderr, env = process.env } = {}) {
|
|
27
|
+
const c = painter(useColor(stream, env));
|
|
28
|
+
const out = (s) => {
|
|
29
|
+
if (!quiet) stream.write(s);
|
|
30
|
+
};
|
|
31
|
+
let thinkingOpen = false;
|
|
32
|
+
let atLineStart = true;
|
|
22
33
|
|
|
23
34
|
const closeThinking = () => {
|
|
24
35
|
if (thinkingOpen) {
|
|
25
36
|
thinkingOpen = false;
|
|
37
|
+
if (!atLineStart) out('\n');
|
|
26
38
|
atLineStart = true;
|
|
27
|
-
process.stderr.write('\n');
|
|
28
39
|
}
|
|
29
40
|
};
|
|
30
41
|
|
|
42
|
+
// One write per delta (not per character): prefix each new line with the
|
|
43
|
+
// quote bar, dim the whole chunk once.
|
|
31
44
|
const writeThinking = (delta) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (atLineStart) {
|
|
38
|
-
process.stderr.write(dim('│ '));
|
|
39
|
-
atLineStart = false;
|
|
40
|
-
}
|
|
41
|
-
if (ch === '\n') {
|
|
42
|
-
process.stderr.write('\n');
|
|
45
|
+
thinkingOpen = true;
|
|
46
|
+
let text = '';
|
|
47
|
+
for (const line of delta.split(/(\n)/)) {
|
|
48
|
+
if (line === '\n') {
|
|
49
|
+
text += '\n';
|
|
43
50
|
atLineStart = true;
|
|
44
|
-
} else {
|
|
45
|
-
|
|
51
|
+
} else if (line) {
|
|
52
|
+
text += (atLineStart ? '│ ' : '') + line;
|
|
53
|
+
atLineStart = false;
|
|
46
54
|
}
|
|
47
55
|
}
|
|
56
|
+
out(c.dim(text));
|
|
48
57
|
};
|
|
49
58
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
} else if (phase === 'result') {
|
|
54
|
-
process.stderr.write(dim(`✓ ${name} ok\n`));
|
|
55
|
-
} else if (phase === 'error') {
|
|
56
|
-
process.stderr.write(dim(`✗ ${name} error\n`));
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const mcpLine = (ev) => {
|
|
61
|
-
if (ev.status === 'connected') {
|
|
62
|
-
process.stderr.write(dim(`◇ mcp ${ev.name} connected\n`));
|
|
63
|
-
} else if (ev.status === 'failed' || ev.status === 'timeout') {
|
|
64
|
-
process.stderr.write(
|
|
65
|
-
dim(`✗ mcp ${ev.name} ${ev.status}${ev.error ? ` (${ev.error})` : ''}\n`)
|
|
66
|
-
);
|
|
67
|
-
}
|
|
59
|
+
const line = (s) => {
|
|
60
|
+
closeThinking();
|
|
61
|
+
out(`${s}\n`);
|
|
68
62
|
};
|
|
69
63
|
|
|
70
64
|
const onEvent = (ev) => {
|
|
71
65
|
if (quiet) return;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
66
|
+
switch (ev.type) {
|
|
67
|
+
case 'stream':
|
|
68
|
+
if (ev.kind === 'reasoning_delta' && ev.delta && thinking) writeThinking(ev.delta);
|
|
69
|
+
return;
|
|
70
|
+
case 'session': {
|
|
71
|
+
const head = `${c.bold('▸ zcode')} ${c.cyan(ev.id)} ${ev.kind}${ev.resumed ? ' (resumed)' : ''} ${c.dim(`${ev.model} · effort ${ev.effort}`)}`;
|
|
72
|
+
const scope = [
|
|
73
|
+
` cwd ${ev.cwd}`,
|
|
74
|
+
ev.owns && ev.owns.length ? `owns ${ev.owns.join(', ')}` : null,
|
|
75
|
+
ev.checks && ev.checks.length ? `${ev.checks.length} check${ev.checks.length === 1 ? '' : 's'}` : null,
|
|
76
|
+
].filter(Boolean);
|
|
77
|
+
line(head);
|
|
78
|
+
line(c.dim(scope.join(' · ')));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
case 'phase': {
|
|
82
|
+
const extra =
|
|
83
|
+
ev.phase === 'repairing' ? ` ${ev.attempt}/${ev.of}`
|
|
84
|
+
: ev.phase === 'checking' ? ` ${ev.count}`
|
|
85
|
+
: ev.reason ? ` (${ev.reason})` : '';
|
|
86
|
+
line(`${c.cyan('●')} ${ev.phase}${extra}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case 'tool':
|
|
90
|
+
if (ev.phase === 'started' && ev.name) line(c.dim(` → ${ev.name}`));
|
|
91
|
+
else if (ev.phase === 'error' && ev.name) line(c.dim(` ✗ ${ev.name} error`));
|
|
92
|
+
return;
|
|
93
|
+
case 'subagent':
|
|
94
|
+
line(c.dim(` ⇢ subagent ${ev.phase}`));
|
|
95
|
+
return;
|
|
96
|
+
case 'check':
|
|
97
|
+
if (ev.phase !== 'finished') return;
|
|
98
|
+
if (ev.ok) line(` ${c.green('✓')} ${ev.command} ${c.dim(secs(ev.durationMs))}`);
|
|
99
|
+
else line(` ${c.red('✗')} ${ev.command} ${c.dim(`${ev.timedOut ? 'timed out' : `exit ${ev.exitCode}`} ${secs(ev.durationMs)}`)}`);
|
|
100
|
+
return;
|
|
101
|
+
case 'mcp':
|
|
102
|
+
if (ev.status === 'connected') line(c.dim(` ◇ mcp ${ev.name} connected`));
|
|
103
|
+
else if (ev.status === 'failed' || ev.status === 'timeout') {
|
|
104
|
+
line(c.dim(` ✗ mcp ${ev.name} ${ev.status}${ev.error ? ` (${ev.error})` : ''}`));
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
case 'retry':
|
|
108
|
+
line(c.yellow(` ↻ retrying ${ev.phase}: ${ev.reason}`));
|
|
109
|
+
return;
|
|
110
|
+
default:
|
|
86
111
|
}
|
|
87
112
|
};
|
|
88
113
|
|
|
89
|
-
|
|
114
|
+
// run-mode footer: one dim status line after the answer.
|
|
115
|
+
const footer = (r) => {
|
|
90
116
|
if (quiet) return;
|
|
91
117
|
closeThinking();
|
|
92
118
|
const parts = [
|
|
93
|
-
model,
|
|
94
|
-
`effort ${effort}`,
|
|
95
|
-
typeof toolCalls === 'number' ? `${toolCalls} tool call${toolCalls === 1 ? '' : 's'}` : null,
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
r.model,
|
|
120
|
+
`effort ${r.effort}`,
|
|
121
|
+
typeof r.toolCalls === 'number' ? `${r.toolCalls} tool call${r.toolCalls === 1 ? '' : 's'}` : null,
|
|
122
|
+
secs(r.durationMs),
|
|
123
|
+
r.id,
|
|
98
124
|
].filter(Boolean);
|
|
99
|
-
|
|
125
|
+
out(c.dim(` ${parts.join(' · ')}\n`));
|
|
100
126
|
};
|
|
101
127
|
|
|
102
128
|
return { onEvent, closeThinking, footer };
|
|
103
129
|
}
|
|
104
130
|
|
|
105
|
-
|
|
131
|
+
const STATUS_MARK = {
|
|
132
|
+
completed: '✓',
|
|
133
|
+
unverified: '?',
|
|
134
|
+
partial: '◐',
|
|
135
|
+
blocked: '■',
|
|
136
|
+
failed: '✗',
|
|
137
|
+
cancelled: '■',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
function statusColor(c, status) {
|
|
141
|
+
if (status === 'completed') return c.green;
|
|
142
|
+
if (status === 'failed') return c.red;
|
|
143
|
+
return c.yellow;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The handback as a human reads it (task results, wait, resume). Ordered by
|
|
147
|
+
// what an orchestrator scans for: status, what changed, what was checked,
|
|
148
|
+
// what is wrong, how to continue.
|
|
149
|
+
function formatResult(r, { color = false } = {}) {
|
|
150
|
+
const c = painter(color);
|
|
151
|
+
const L = [];
|
|
152
|
+
const st = statusColor(c, r.status);
|
|
153
|
+
L.push(`${st(`${STATUS_MARK[r.status] || '•'} ${r.status}`)} ${c.bold(r.id)}${r.label ? ` ${r.label}` : ''} ${c.dim(secs(r.durationMs || 0))}`);
|
|
154
|
+
if (r.summary) L.push(` ${r.summary}`);
|
|
155
|
+
const files = (r.changes && r.changes.files) || [];
|
|
156
|
+
if (files.length) {
|
|
157
|
+
L.push(`changed ${files.length} file${files.length === 1 ? '' : 's'}`);
|
|
158
|
+
for (const f of files.slice(0, 30)) {
|
|
159
|
+
const mark = f.change === 'added' ? 'A' : f.change === 'deleted' ? 'D' : 'M';
|
|
160
|
+
const note = f.concurrentSession ? c.dim(` (session ${f.concurrentSession})`) : !f.owned ? c.red(' outside owned scope') : '';
|
|
161
|
+
L.push(` ${mark} ${f.path}${note}`);
|
|
162
|
+
}
|
|
163
|
+
if (files.length > 30) L.push(c.dim(` … ${files.length - 30} more`));
|
|
164
|
+
} else if (r.changes) {
|
|
165
|
+
L.push(`changed ${r.changes.tracking === 'unavailable' ? 'not tracked' : 'nothing'}`);
|
|
166
|
+
}
|
|
167
|
+
if (r.checks && r.checks.length) {
|
|
168
|
+
const passed = r.checks.filter((x) => x.ok).length;
|
|
169
|
+
L.push(`checks ${passed}/${r.checks.length} passed${r.repairs ? ` · ${r.repairs} repair turn${r.repairs === 1 ? '' : 's'}` : ''}`);
|
|
170
|
+
for (const x of r.checks) {
|
|
171
|
+
L.push(` ${x.ok ? c.green('✓') : c.red('✗')} ${x.command}${x.ok ? '' : c.dim(` ${x.timedOut ? 'timed out' : `exit ${x.exitCode}`}`)}`);
|
|
172
|
+
}
|
|
173
|
+
} else if (r.kind === 'task') {
|
|
174
|
+
L.push('checks none declared');
|
|
175
|
+
}
|
|
176
|
+
if (r.subagents && r.subagents.length) {
|
|
177
|
+
L.push(`subagents ${r.subagents.length}`);
|
|
178
|
+
for (const s of r.subagents) {
|
|
179
|
+
L.push(c.dim(` ${s.status === 'success' ? '✓' : '✗'} ${s.profileId || 'agent'}${s.title ? ` ${s.title}` : ''}${typeof s.durationMs === 'number' ? ` ${secs(s.durationMs)}` : ''}`));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const [label, items] of [
|
|
183
|
+
['issues', r.issues],
|
|
184
|
+
['questions', r.questions],
|
|
185
|
+
['unresolved', r.unresolved],
|
|
186
|
+
['not verified', r.notVerified],
|
|
187
|
+
]) {
|
|
188
|
+
if (items && items.length) {
|
|
189
|
+
L.push(`${label}`);
|
|
190
|
+
for (const i of items) L.push(` - ${i}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (r.error && r.error.message && !(r.issues || []).includes(r.error.message)) {
|
|
194
|
+
L.push(`error ${r.error.kind}: ${r.error.message.split('\n')[0]}`);
|
|
195
|
+
}
|
|
196
|
+
if (r.status !== 'completed' && r.status !== 'cancelled') {
|
|
197
|
+
L.push(c.dim(`next zcode resume ${r.id} "<message>"`));
|
|
198
|
+
}
|
|
199
|
+
return L.join('\n') + '\n';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// One line per session for `zcode status` without an id.
|
|
203
|
+
function formatSessionRow(s) {
|
|
204
|
+
const when = s.updatedAt ? s.updatedAt.replace('T', ' ').slice(0, 19) : '';
|
|
205
|
+
const state = s.state === 'finished' ? s.status || 'finished' : s.state;
|
|
206
|
+
return `${s.id.padEnd(18)} ${String(state).padEnd(11)} ${String(s.kind || '').padEnd(5)} ${when} ${s.label || s.cwd || ''}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatSessionView(v, { events = [] } = {}) {
|
|
210
|
+
const L = [];
|
|
211
|
+
const state = v.state === 'finished' ? `finished (${v.status})` : v.state;
|
|
212
|
+
L.push(`${v.id} ${v.kind}${v.label ? ` ${v.label}` : ''}`);
|
|
213
|
+
L.push(` state ${state}${v.live ? ` · phase ${v.phase} · pid ${v.pid}` : ''}`);
|
|
214
|
+
L.push(` cwd ${v.cwd}`);
|
|
215
|
+
if (v.owns && v.owns.length) L.push(` owns ${v.owns.join(', ')}`);
|
|
216
|
+
L.push(` model ${v.model} · effort ${v.effort} · turns ${v.turns || 0}`);
|
|
217
|
+
if (v.activity) {
|
|
218
|
+
L.push(` activity ${v.activity.toolCalls || 0} tool calls · ${v.activity.subagentsStarted || 0} subagents${v.activity.lastTool ? ` · last ${v.activity.lastTool}` : ''} · ${v.activity.lastEventAt || ''}`);
|
|
219
|
+
}
|
|
220
|
+
if (v.state === 'interrupted') L.push(` the owning process is gone; continue with: zcode resume ${v.id}`);
|
|
221
|
+
if (events.length) {
|
|
222
|
+
L.push(' recent');
|
|
223
|
+
for (const e of events) {
|
|
224
|
+
const what = e.type === 'phase' ? `● ${e.phase}`
|
|
225
|
+
: e.type === 'tool' ? `${e.phase} ${e.name}`
|
|
226
|
+
: e.type === 'check' ? `check ${e.command}${e.phase === 'finished' ? (e.ok ? ' ok' : ' failed') : ''}`
|
|
227
|
+
: e.type;
|
|
228
|
+
L.push(` ${String(e.t || '').slice(11, 19)} ${what}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return L.join('\n') + '\n';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { makeReporter, formatResult, formatSessionRow, formatSessionView, useColor };
|