@0xgeorgemathew/zcode 0.3.1 → 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/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 (lib/ui.js): one place for the "live feed" an
3
- // operator watches on stderr while stdout carries only the answer.
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
- // Visual contract (deliberate, terminal-native):
6
- // thinking │ dimmed, every line prefixed "│ " - a quote block, clearly
7
- // not the answer
8
- // tool calls → started / ✓ ok / ✗ error, dimmed, one line each
9
- // mcp status ◇ connected / ✗ failed, dimmed
10
- // footer one dimmed status line (model, effort, tool calls, time,
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 makePlainReporter({ quiet = false } = {}) {
17
- const tty = process.stderr.isTTY;
18
- const dim = (t) => (tty ? `\x1b[2m${t}\x1b[0m` : t);
15
+ function useColor(stream, env = process.env) {
16
+ return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
17
+ }
19
18
 
20
- let thinkingOpen = false; // a thinking block is being streamed
21
- let atLineStart = true; // no "│ " written for this line yet
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
- if (!thinkingOpen) {
33
- thinkingOpen = true;
34
- atLineStart = true;
35
- }
36
- for (const ch of delta) {
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
- process.stderr.write(dim(ch));
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 toolLine = (name, phase) => {
51
- if (phase === 'started' || phase === 'scheduled') {
52
- process.stderr.write(dim(`→ ${name} started\n`));
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
- if (ev.type === 'stream' && ev.kind === 'reasoning_delta' && ev.delta) {
73
- writeThinking(ev.delta);
74
- return;
75
- }
76
- closeThinking();
77
- if (ev.type === 'stream' && ev.kind === 'text_delta' && ev.delta) {
78
- return; // the answer streams to stdout, not here
79
- }
80
- if (ev.type === 'tool' && ev.name) {
81
- toolLine(ev.name, ev.phase);
82
- return;
83
- }
84
- if (ev.type === 'mcp') {
85
- mcpLine(ev);
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
- const footer = ({ model, effort, toolCalls, durationMs, sessionId }) => {
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
- `${(durationMs / 1000).toFixed(1)}s`,
97
- sessionId ? sessionId.slice(0, 16) : null,
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
- process.stderr.write(dim(` ${parts.join(' · ')}\n`));
125
+ out(c.dim(` ${parts.join(' · ')}\n`));
100
126
  };
101
127
 
102
128
  return { onEvent, closeThinking, footer };
103
129
  }
104
130
 
105
- module.exports = { makePlainReporter };
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 };