@nonbot/cli 0.5.13

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,106 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ function configDir() {
5
+ const override = process.env.NONBOT_CONFIG_DIR;
6
+ if (override && override.length > 0)
7
+ return override;
8
+ return path.join(homedir(), '.config', 'nonbot');
9
+ }
10
+ const PROFILE_NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
11
+ let activeProfileOverride;
12
+ export function setActiveProfile(name) {
13
+ if (!PROFILE_NAME_RE.test(name)) {
14
+ throw new Error(`invalid profile name "${name}" — use 1-32 chars of [a-zA-Z0-9_-]`);
15
+ }
16
+ activeProfileOverride = name;
17
+ }
18
+ export function getActiveProfile() {
19
+ if (activeProfileOverride)
20
+ return activeProfileOverride;
21
+ const env = process.env.NONBOT_PROFILE;
22
+ if (env && env.length > 0) {
23
+ if (!PROFILE_NAME_RE.test(env)) {
24
+ throw new Error(`invalid NONBOT_PROFILE "${env}" — use 1-32 chars of [a-zA-Z0-9_-]`);
25
+ }
26
+ return env;
27
+ }
28
+ return 'default';
29
+ }
30
+ function profilesDir() {
31
+ return path.join(configDir(), 'profiles');
32
+ }
33
+ function authPath() {
34
+ const profile = getActiveProfile();
35
+ if (profile === 'default')
36
+ return path.join(configDir(), 'auth.json');
37
+ return path.join(profilesDir(), `${profile}.json`);
38
+ }
39
+ export async function listProfiles() {
40
+ const found = [];
41
+ try {
42
+ await fs.access(path.join(configDir(), 'auth.json'));
43
+ found.push('default');
44
+ }
45
+ catch {
46
+ }
47
+ try {
48
+ const entries = await fs.readdir(profilesDir());
49
+ for (const entry of entries) {
50
+ if (!entry.endsWith('.json'))
51
+ continue;
52
+ const name = entry.slice(0, -'.json'.length);
53
+ if (PROFILE_NAME_RE.test(name))
54
+ found.push(name);
55
+ }
56
+ }
57
+ catch {
58
+ }
59
+ return found;
60
+ }
61
+ export async function loadAuth() {
62
+ try {
63
+ const raw = await fs.readFile(authPath(), 'utf-8');
64
+ const parsed = JSON.parse(raw);
65
+ if (typeof parsed.pat !== 'string' ||
66
+ typeof parsed.baseUrl !== 'string' ||
67
+ typeof parsed.email !== 'string' ||
68
+ typeof parsed.userId !== 'string' ||
69
+ typeof parsed.savedAt !== 'number') {
70
+ return null;
71
+ }
72
+ return parsed;
73
+ }
74
+ catch (e) {
75
+ const err = e;
76
+ if (err && err.code === 'ENOENT')
77
+ return null;
78
+ return null;
79
+ }
80
+ }
81
+ export async function saveAuth(auth) {
82
+ await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
83
+ if (getActiveProfile() !== 'default') {
84
+ await fs.mkdir(profilesDir(), { recursive: true, mode: 0o700 });
85
+ }
86
+ const file = authPath();
87
+ const tmp = file + '.tmp';
88
+ await fs.writeFile(tmp, JSON.stringify(auth, null, 2), { mode: 0o600 });
89
+ await fs.chmod(tmp, 0o600);
90
+ await fs.rename(tmp, file);
91
+ await fs.chmod(file, 0o600);
92
+ }
93
+ export async function clearAuth() {
94
+ try {
95
+ await fs.unlink(authPath());
96
+ }
97
+ catch (e) {
98
+ const err = e;
99
+ if (err && err.code === 'ENOENT')
100
+ return;
101
+ throw e;
102
+ }
103
+ }
104
+ export function _authPathForTests() {
105
+ return authPath();
106
+ }
@@ -0,0 +1,94 @@
1
+ export const ANSI = {
2
+ reset: '\x1b[0m',
3
+ bold: '\x1b[1m',
4
+ dim: '\x1b[2m',
5
+ green: '\x1b[32m',
6
+ red: '\x1b[31m',
7
+ cyan: '\x1b[36m',
8
+ yellow: '\x1b[33m',
9
+ primary: '\x1b[38;5;141m',
10
+ };
11
+ const BOX = {
12
+ tl: '╔', tr: '╗', bl: '╚', br: '╝',
13
+ h: '═', v: '║',
14
+ };
15
+ const BOX_ASCII = {
16
+ tl: '+', tr: '+', bl: '+', br: '+',
17
+ h: '-', v: '|',
18
+ };
19
+ export const BANNER_WIDTH = 64;
20
+ function boxChars(asciiOnly) {
21
+ return asciiOnly ? BOX_ASCII : BOX;
22
+ }
23
+ function isAsciiOnly(env = process.env) {
24
+ return env.NONBOT_ASCII_ONLY === '1' || env.NONBOT_ASCII_ONLY === 'true';
25
+ }
26
+ export function renderBannerHeader(title, opts) {
27
+ const ascii = opts?.asciiOnly ?? isAsciiOnly();
28
+ const c = boxChars(ascii);
29
+ const inner = BANNER_WIDTH - 2;
30
+ const top = c.tl + c.h.repeat(inner) + c.tr;
31
+ const bot = c.bl + c.h.repeat(inner) + c.br;
32
+ const visibleLabel = ` non.bot ▶ ${title}`;
33
+ const colorisedLabel = ascii
34
+ ? ` non.bot > ${title}`
35
+ : ` ${ANSI.bold}non.bot${ANSI.reset} ${ANSI.primary}▶${ANSI.reset} ${ANSI.bold}${title}${ANSI.reset}`;
36
+ const visibleCount = Array.from(visibleLabel).length;
37
+ const pad = Math.max(0, inner - visibleCount);
38
+ const middle = c.v + colorisedLabel + ' '.repeat(pad) + c.v;
39
+ if (ascii)
40
+ return `${top}\n${middle}\n${bot}`;
41
+ return `${ANSI.primary}${top}${ANSI.reset}\n${middle}\n${ANSI.primary}${bot}${ANSI.reset}`;
42
+ }
43
+ export function renderKeyValueRow(key, value, opts) {
44
+ const ascii = opts?.asciiOnly ?? isAsciiOnly();
45
+ const KEY_WIDTH = 12;
46
+ const paddedKey = key.padEnd(KEY_WIDTH, ' ');
47
+ if (ascii)
48
+ return ` ${paddedKey} ${value}`;
49
+ return ` ${ANSI.cyan}${paddedKey}${ANSI.reset} ${ANSI.dim}${value}${ANSI.reset}`;
50
+ }
51
+ export function renderSeparator(label, opts) {
52
+ const ascii = opts?.asciiOnly ?? isAsciiOnly();
53
+ const rule = ascii ? '-' : '─';
54
+ const labelStr = ` ${label} `;
55
+ const sideWidth = Math.max(2, Math.floor((BANNER_WIDTH - labelStr.length) / 2));
56
+ const left = rule.repeat(sideWidth);
57
+ const right = rule.repeat(Math.max(2, BANNER_WIDTH - sideWidth - labelStr.length));
58
+ if (ascii)
59
+ return ` ${left}${labelStr}${right}`;
60
+ return ` ${ANSI.dim}${left}${ANSI.reset}${ANSI.primary}${labelStr}${ANSI.reset}${ANSI.dim}${right}${ANSI.reset}`;
61
+ }
62
+ export function renderDiagnosticBanner(opts) {
63
+ const ascii = opts?.asciiOnly ?? isAsciiOnly();
64
+ const check = ascii ? '+' : '✓';
65
+ const okMark = ascii ? check : `${ANSI.green}${check}${ANSI.reset}`;
66
+ const now = new Date();
67
+ const ts = now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
68
+ const cwd = process.cwd();
69
+ const terminal = process.env.TERM_PROGRAM || process.env.TERM || 'unknown';
70
+ const header = renderBannerHeader('Test Run', { asciiOnly: ascii });
71
+ const lines = [
72
+ '',
73
+ header,
74
+ '',
75
+ renderKeyValueRow('Activation', '(local — no activation row created)', { asciiOnly: ascii }),
76
+ renderKeyValueRow('Repo', cwd, { asciiOnly: ascii }),
77
+ renderKeyValueRow('Terminal', terminal, { asciiOnly: ascii }),
78
+ renderKeyValueRow('Time', ts, { asciiOnly: ascii }),
79
+ '',
80
+ ` ${ascii ? 'Status ' : `${ANSI.cyan}Status ${ANSI.reset}`} ${okMark} CLI host installed`,
81
+ ` ${okMark} Banner rendered locally — you're reading this`,
82
+ ` ${okMark} No server roundtrip`,
83
+ '',
84
+ ascii
85
+ ? ' Banner rendered locally by @nonbot/cli. Close this window when done.'
86
+ : ` ${ANSI.dim}Banner rendered locally by @nonbot/cli. Close this window when done.${ANSI.reset}`,
87
+ '',
88
+ ];
89
+ return lines.join('\n') + '\n';
90
+ }
91
+ export function printDiagnosticBanner(stream = process.stdout) {
92
+ stream.write(renderDiagnosticBanner());
93
+ }
94
+ export const DIAGNOSTIC_ASCII_ART = '';
@@ -0,0 +1,279 @@
1
+ const BANNER_WIDTH = 64;
2
+ const BOX_TL = '╔';
3
+ const BOX_TR = '╗';
4
+ const BOX_BL = '╚';
5
+ const BOX_BR = '╝';
6
+ const BOX_H = '═';
7
+ const BOX_V = '║';
8
+ const ESC_RESET = '\\033[0m';
9
+ const ESC_BOLD = '\\033[1m';
10
+ const ESC_DIM = '\\033[2m';
11
+ const ESC_GREEN = '\\033[32m';
12
+ const ESC_RED = '\\033[31m';
13
+ const ESC_CYAN = '\\033[36m';
14
+ const ESC_PRIMARY = '\\033[38;5;141m';
15
+ export const PROVIDER_PROFILES = {
16
+ claude: {
17
+ realCli: 'claude',
18
+ realPromptKind: 'agents-md',
19
+ },
20
+ codex: {
21
+ realCli: 'codex',
22
+ realPromptKind: 'agents-md',
23
+ },
24
+ 'gemini-cli': {
25
+ realCli: 'gemini',
26
+ realPromptKind: 'agents-md',
27
+ },
28
+ };
29
+ export const PROVIDER_TEST_PROFILES = {
30
+ claude: {
31
+ cli: 'claude',
32
+ invoke: (prompt) => `claude -p ${shellQuoteSingle(prompt)}`,
33
+ authHint: 'run `claude login` (or export ANTHROPIC_API_KEY)',
34
+ },
35
+ codex: {
36
+ cli: 'codex',
37
+ invoke: (prompt) => `codex exec ${shellQuoteSingle(prompt)}`,
38
+ authHint: 'run `codex login` (or export OPENAI_API_KEY)',
39
+ },
40
+ };
41
+ export function shellQuoteSingle(s) {
42
+ const escaped = String(s).replace(/'/g, `'\\''`);
43
+ return `'${escaped}'`;
44
+ }
45
+ function bannerHeader(title) {
46
+ const inner = BANNER_WIDTH - 2;
47
+ const top = BOX_TL + BOX_H.repeat(inner) + BOX_TR;
48
+ const bot = BOX_BL + BOX_H.repeat(inner) + BOX_BR;
49
+ const visible = ` non.bot ▶ ${title}`;
50
+ const visibleCount = Array.from(visible).length;
51
+ const pad = Math.max(0, inner - visibleCount);
52
+ const middle = BOX_V +
53
+ ' ' +
54
+ ESC_BOLD +
55
+ 'non.bot' +
56
+ ESC_RESET +
57
+ ' ' +
58
+ ESC_PRIMARY +
59
+ '▶' +
60
+ ESC_RESET +
61
+ ' ' +
62
+ ESC_BOLD +
63
+ title +
64
+ ESC_RESET +
65
+ ' '.repeat(pad) +
66
+ BOX_V;
67
+ return (ESC_PRIMARY + top + ESC_RESET + '\n' + middle + '\n' + ESC_PRIMARY + bot + ESC_RESET);
68
+ }
69
+ function kvRow(key, value) {
70
+ const KEY_WIDTH = 12;
71
+ const paddedKey = key.padEnd(KEY_WIDTH, ' ');
72
+ return ' ' + ESC_CYAN + paddedKey + ESC_RESET + ' ' + ESC_DIM + value + ESC_RESET;
73
+ }
74
+ function okLine(text) {
75
+ return ' ' + ESC_GREEN + '✓' + ESC_RESET + ' ' + text;
76
+ }
77
+ function separator(label) {
78
+ const rule = '─';
79
+ const labelStr = ` ${label} `;
80
+ const sideWidth = Math.max(2, Math.floor((BANNER_WIDTH - labelStr.length) / 2));
81
+ const left = rule.repeat(sideWidth);
82
+ const right = rule.repeat(Math.max(2, BANNER_WIDTH - sideWidth - labelStr.length));
83
+ return (' ' +
84
+ ESC_DIM +
85
+ left +
86
+ ESC_RESET +
87
+ ESC_PRIMARY +
88
+ labelStr +
89
+ ESC_RESET +
90
+ ESC_DIM +
91
+ right +
92
+ ESC_RESET);
93
+ }
94
+ function emitBanner(body) {
95
+ const inlined = body.replace(/\n/g, '\\n');
96
+ return `printf '%b' "${inlined}"`;
97
+ }
98
+ function capitalize(s) {
99
+ if (!s)
100
+ return s;
101
+ return s.charAt(0).toUpperCase() + s.slice(1);
102
+ }
103
+ export function briefPathFor(activationId) {
104
+ const safe = String(activationId || '').replace(/[^a-zA-Z0-9_-]/g, '');
105
+ const id = safe.length > 0 ? safe : 'unknown';
106
+ return `.nonbot/brief-${id}.md`;
107
+ }
108
+ export function buildAgentsMdHeredoc(agentsMd, activationId) {
109
+ const briefPath = briefPathFor(activationId);
110
+ return (`mkdir -p .nonbot && ` +
111
+ `cat > ${briefPath} <<'NONBOT_AGENTS_EOF'\n` +
112
+ `${agentsMd}\n` +
113
+ `NONBOT_AGENTS_EOF\n` +
114
+ `grep -qxF .nonbot/ .gitignore 2>/dev/null || printf '%s\\n' .nonbot/ >> .gitignore`);
115
+ }
116
+ export function buildDiagnosticCommand(params) {
117
+ const trimmed = typeof params.repoPath === 'string' ? params.repoPath.trim() : '';
118
+ const head = trimmed ? `cd ${shellQuoteSingle(trimmed)} && ` : '';
119
+ const activationId = typeof params.activationId === 'string' && params.activationId.length > 0
120
+ ? params.activationId
121
+ : '(diagnostic — local)';
122
+ const repoLabel = trimmed || '(current directory)';
123
+ const body = [
124
+ '',
125
+ bannerHeader('Test Run'),
126
+ '',
127
+ kvRow('Activation', activationId),
128
+ kvRow('Repo', repoLabel),
129
+ kvRow('Terminal', '${TERM_PROGRAM:-${TERM:-unknown}}'),
130
+ kvRow('Time', '$(date \'+%Y-%m-%d %H:%M:%S %Z\')'),
131
+ '',
132
+ ' ' +
133
+ ESC_CYAN +
134
+ 'Status '.padEnd(12, ' ') +
135
+ ESC_RESET +
136
+ okLine('daemon picked it up').slice(2),
137
+ ' ' + okLine('script materialised').slice(2),
138
+ ' ' + okLine("terminal opened — you're reading this in it").slice(2),
139
+ '',
140
+ ' ' +
141
+ ESC_DIM +
142
+ 'This was a synthetic banner test — no story, portfolio, or agent was touched.' +
143
+ ESC_RESET,
144
+ ' ' + ESC_DIM + 'Close this window when ready. No CLI was launched.' + ESC_RESET,
145
+ '',
146
+ ].join('\n');
147
+ return `${head}${emitBanner(body)}`;
148
+ }
149
+ export function buildProviderTestCommand(params) {
150
+ const profile = PROVIDER_TEST_PROFILES[params.provider];
151
+ if (!profile) {
152
+ throw new Error(`unknown provider for test: ${String(params.provider)}`);
153
+ }
154
+ const { cli, invoke, authHint } = profile;
155
+ const trimmed = typeof params.repoPath === 'string' ? params.repoPath.trim() : '';
156
+ const head = trimmed ? `cd ${shellQuoteSingle(trimmed)} && ` : '';
157
+ const activationId = typeof params.activationId === 'string' && params.activationId.length > 0
158
+ ? params.activationId
159
+ : '(provider test — local)';
160
+ const repoLabel = trimmed || '(current directory)';
161
+ const prompt = 'Reply with exactly this and nothing else: non.bot connection verified.';
162
+ const preBody = [
163
+ '',
164
+ bannerHeader(`Test ${capitalize(cli)} connection`),
165
+ '',
166
+ kvRow('Provider', cli),
167
+ kvRow('Command', invoke(prompt)),
168
+ kvRow('Repo', repoLabel),
169
+ kvRow('Activation', activationId),
170
+ '',
171
+ ' ' + ESC_CYAN + 'Watch for:' + ESC_RESET,
172
+ ' ' +
173
+ ESC_GREEN +
174
+ '✓' +
175
+ ESC_RESET +
176
+ ' green OK line below = ' +
177
+ cli +
178
+ ' CLI installed AND authenticated',
179
+ ' ' +
180
+ ESC_RED +
181
+ '✗' +
182
+ ESC_RESET +
183
+ ' red error = CLI missing, not logged in, or daemon cannot drive it',
184
+ '',
185
+ separator('output'),
186
+ '',
187
+ ].join('\n');
188
+ const okBody = [
189
+ '',
190
+ ' ' +
191
+ ESC_GREEN +
192
+ '✓ ' +
193
+ cli +
194
+ ' connection OK' +
195
+ ESC_RESET +
196
+ ' — ' +
197
+ ESC_DIM +
198
+ 'non.bot can drive the ' +
199
+ cli +
200
+ ' CLI' +
201
+ ESC_RESET,
202
+ ' ' + ESC_DIM + 'Close this window when done.' + ESC_RESET,
203
+ '',
204
+ ].join('\n');
205
+ const failBody = [
206
+ '',
207
+ ' ' + ESC_RED + '✗ ' + cli + ' connection test failed' + ESC_RESET,
208
+ ' ' +
209
+ ESC_DIM +
210
+ 'Make sure the ' +
211
+ cli +
212
+ ' CLI is installed and ' +
213
+ authHint +
214
+ ', then re-run this test from non.bot.' +
215
+ ESC_RESET,
216
+ '',
217
+ ].join('\n');
218
+ return (`${head}${emitBanner(preBody)}; ` +
219
+ `if ${invoke(prompt)}; then ` +
220
+ `${emitBanner(okBody)}; ` +
221
+ `else ` +
222
+ `${emitBanner(failBody)}; ` +
223
+ `fi`);
224
+ }
225
+ export function buildRealCommand(params) {
226
+ const trimmed = typeof params.repoPath === 'string' ? params.repoPath.trim() : '';
227
+ const head = trimmed ? `cd ${shellQuoteSingle(trimmed)} && ` : '';
228
+ const hasAgentsMd = typeof params.agentsMd === 'string' && params.agentsMd.length > 0;
229
+ const agentsMdPrefix = hasAgentsMd
230
+ ? `${buildAgentsMdHeredoc(params.agentsMd, params.activationId)} && `
231
+ : '';
232
+ const profile = PROVIDER_PROFILES[params.provider];
233
+ if (!profile) {
234
+ throw new Error(`unknown provider for real run: ${String(params.provider)}`);
235
+ }
236
+ const cli = profile.realCli;
237
+ const providerLabel = params.perStoryOverride ? `${cli} (per-story override)` : cli;
238
+ const repoLabel = trimmed || '(current directory)';
239
+ const titleLabel = typeof params.storyTitle === 'string' && params.storyTitle.length > 0
240
+ ? params.storyTitle
241
+ : '(no title)';
242
+ const bannerBody = [
243
+ '',
244
+ bannerHeader('Run'),
245
+ '',
246
+ kvRow('Story', titleLabel),
247
+ kvRow('Activation', params.activationId || '(unknown)'),
248
+ kvRow('Provider', providerLabel),
249
+ kvRow('Repo', repoLabel),
250
+ kvRow('Time', '$(date \'+%Y-%m-%d %H:%M:%S %Z\')'),
251
+ '',
252
+ separator(`starting ${cli}`),
253
+ '',
254
+ ].join('\n');
255
+ const bannerEmit = `${emitBanner(bannerBody)} && `;
256
+ const idLabel = params.activationId || '(unknown)';
257
+ const safeTitleLabel = titleLabel.replace(/[\r\n]+/g, ' ');
258
+ if (profile.realPromptKind !== 'agents-md') {
259
+ const _exhaustive = profile.realPromptKind;
260
+ throw new Error(`unhandled realPromptKind: ${String(_exhaustive)}`);
261
+ }
262
+ const briefPath = briefPathFor(params.activationId);
263
+ const prompt = `Read ${briefPath} (your per-run brief) and start work on activation ${idLabel} — story: ${safeTitleLabel}`;
264
+ return `${head}${agentsMdPrefix}${bannerEmit}${cli} ${shellQuoteSingle(prompt)}`;
265
+ }
266
+ export function buildCommandFromParams(params) {
267
+ switch (params.template) {
268
+ case 'diagnostic':
269
+ return buildDiagnosticCommand(params);
270
+ case 'provider-test':
271
+ return buildProviderTestCommand(params);
272
+ case 'real':
273
+ return buildRealCommand(params);
274
+ default: {
275
+ const _exhaustive = params;
276
+ throw new Error(`unknown template: ${JSON.stringify(_exhaustive)}`);
277
+ }
278
+ }
279
+ }
@@ -0,0 +1,67 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ export function listLivePaneIds(spawnImpl = nodeSpawnSync) {
3
+ try {
4
+ const r = spawnImpl('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
5
+ encoding: 'utf-8',
6
+ timeout: 1500,
7
+ windowsHide: true,
8
+ });
9
+ const out = typeof r.stdout === 'string' ? r.stdout : '';
10
+ if (r.status !== 0 && !out)
11
+ return new Set();
12
+ return new Set(out.split('\n').map((s) => s.trim()).filter((s) => /^%\d+$/.test(s)));
13
+ }
14
+ catch {
15
+ return new Set();
16
+ }
17
+ }
18
+ export function detectCompletedActivations(tracked, livePaneIds, killed) {
19
+ const done = [];
20
+ for (const [id, pane] of tracked) {
21
+ if (livePaneIds.has(pane))
22
+ continue;
23
+ if (killed.has(id))
24
+ continue;
25
+ done.push(id);
26
+ }
27
+ return done;
28
+ }
29
+ export async function postCompletion(baseUrl, pat, activationId, fetchImpl = fetch) {
30
+ try {
31
+ const res = await fetchImpl(`${baseUrl}/api/cli/activations/${activationId}/complete`, {
32
+ method: 'POST',
33
+ headers: {
34
+ Authorization: `Bearer ${pat}`,
35
+ 'Content-Type': 'application/json',
36
+ 'X-Requested-With': 'ConradPM-Native',
37
+ },
38
+ body: '{}',
39
+ });
40
+ return res.ok;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ export async function checkCompletions(opts) {
47
+ const { tracked, killed, baseUrl, pat } = opts;
48
+ if (tracked.size === 0)
49
+ return [];
50
+ const live = listLivePaneIds(opts.spawnImpl);
51
+ const completed = detectCompletedActivations(tracked, live, killed);
52
+ for (const [id, pane] of [...tracked.entries()]) {
53
+ if (!live.has(pane)) {
54
+ tracked.delete(id);
55
+ killed.delete(id);
56
+ }
57
+ }
58
+ const reported = [];
59
+ for (const id of completed) {
60
+ const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
61
+ if (ok) {
62
+ reported.push(id);
63
+ opts.log?.(`✓ ${id} · run completed (pane closed)\n`);
64
+ }
65
+ }
66
+ return reported;
67
+ }