@voidlabs.gg/voidshift-agents 0.0.0-stage → 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,712 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { agentsDir, readAgentsConfig } from "./config.js";
6
+ import { readLinearKey } from "./keychain.js";
7
+ import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
8
+ import { plistPath, runnerLoaded, startRunner, stopRunner } from "./launchd.js";
9
+ import { createLinear } from "./linear.js";
10
+ import { parseStep, resolveName, stepNames } from "./names.js";
11
+ import { LABELS, isRoundLabel } from "./workflow.js";
12
+ import { cancelCard, tick } from "./runner.js";
13
+ const HOUR = 3_600_000;
14
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
15
+ const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
16
+ const ISO_LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+Z)\s*(.*)$/;
17
+ function linear() {
18
+ const key = readLinearKey();
19
+ if (!key)
20
+ throw new Error('No Linear API key. Run vsf agents key');
21
+ return createLinear(key);
22
+ }
23
+ const logPath = (configDir) => join(agentsDir(configDir), 'runner.log');
24
+ const program = (ctx) => [...ctx.selfCommand, 'agents', 'tick'];
25
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
26
+ const pad2 = (n) => String(n).padStart(2, '0');
27
+ const clock = (d) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
28
+ // "14d" or "36h" as milliseconds, null when it is neither.
29
+ export function parseAge(input) {
30
+ const m = /^(\d+)([dh])$/.exec(input.trim());
31
+ if (!m || !Number(m[1]))
32
+ return null;
33
+ return Number(m[1]) * (m[2] === 'd' ? 24 * HOUR : HOUR);
34
+ }
35
+ export function formatDuration(ms) {
36
+ const min = Math.round(ms / 60_000);
37
+ if (min < 1)
38
+ return '<1m';
39
+ return min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${min % 60 ? ` ${min % 60}m` : ''}`;
40
+ }
41
+ // Run folders are named like 2026-10-20T14-03-00-123Z.
42
+ export function stampDate(stamp) {
43
+ const m = /^(\d{4}-\d\d-\d\d)T(\d\d)-(\d\d)-(\d\d)-(\d+)Z$/.exec(stamp);
44
+ return new Date(m ? `${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z` : stamp);
45
+ }
46
+ const when = (d) => (isNaN(d.getTime()) ? '?' : `${MONTHS[d.getMonth()]} ${d.getDate()} ${clock(d)}`);
47
+ const VERDICTS = new Set(['SHIP', 'FIX', 'SKIP']);
48
+ // Times a check sent the work back. A round is a run of consecutive verdict steps.
49
+ export function countRounds(steps) {
50
+ let rounds = 0;
51
+ let inRound = false;
52
+ let hasFix = false;
53
+ for (const s of steps) {
54
+ const verdict = VERDICTS.has(s.result ?? '');
55
+ if (verdict && !inRound)
56
+ hasFix = false;
57
+ if (verdict && s.result === 'FIX' && !hasFix) {
58
+ hasFix = true;
59
+ rounds++;
60
+ }
61
+ inRound = verdict;
62
+ }
63
+ return rounds;
64
+ }
65
+ // A run that says 'running' but is not the live card was cut off.
66
+ export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !(live && (live.runDir ? live.runDir === e.dir : live.card === e.card));
67
+ export function formatRunLine(e, now = Date.now(), live = null) {
68
+ const r = e.record;
69
+ if (!r)
70
+ return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
71
+ const rounds = countRounds(r.steps);
72
+ const end = r.endedAt ? Date.parse(r.endedAt) : now;
73
+ const parts = [e.card, when(new Date(r.startedAt)), r.workflow, r.steps.map((s) => s.label).join(' → '), isInterrupted(e, live) ? 'interrupted' : r.outcome];
74
+ if (rounds)
75
+ parts.push(plural(rounds, 'round'));
76
+ parts.push(formatDuration(end - Date.parse(r.startedAt)));
77
+ return parts.join(' ');
78
+ }
79
+ // Lines from the last hour that look like failures.
80
+ export function scanRunnerErrors(text, now) {
81
+ const bad = [];
82
+ for (const line of text.split('\n')) {
83
+ const m = ISO_LINE.exec(line);
84
+ if (!m || now - Date.parse(m[1]) > HOUR || Date.parse(m[1]) > now + HOUR)
85
+ continue;
86
+ if (/Could not|Error|went wrong/.test(line))
87
+ bad.push(line);
88
+ }
89
+ return { count: bad.length, last: bad.at(-1) ?? '' };
90
+ }
91
+ // The <string> values of a launchd plist's ProgramArguments.
92
+ export function parsePlistArgs(plist) {
93
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1] ?? '';
94
+ const unxml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
95
+ return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1]));
96
+ }
97
+ function lastTickOf(text) {
98
+ for (const line of text.split('\n').reverse()) {
99
+ const m = ISO_LINE.exec(line);
100
+ if (m)
101
+ return { at: m[1], line: m[2] };
102
+ }
103
+ return null;
104
+ }
105
+ const readLog = (configDir) => {
106
+ try {
107
+ return readFileSync(logPath(configDir), 'utf8');
108
+ }
109
+ catch {
110
+ return '';
111
+ }
112
+ };
113
+ const USAGE = {
114
+ status: 'status [--json] Runner, current card, parked cards and workflows',
115
+ start: 'start Start the background runner',
116
+ stop: 'stop Stop the runner and cancel the card it is working on',
117
+ restart: 'restart Stop and start the runner',
118
+ pause: 'pause Pick up no new cards; a card in progress carries on',
119
+ resume: 'resume Pick up new cards again',
120
+ run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute',
121
+ cancel: 'cancel [CARD] Stop the card being worked on and clear its labels',
122
+ retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
123
+ runs: 'runs [CARD] [--limit N] [--json] Past runs, newest first',
124
+ show: 'show CARD [--json] Steps, outcome and labels of the latest run',
125
+ logs: 'logs [CARD] [--step N] [-f] [--lines N] Runner log, or a card\'s step logs',
126
+ clean: 'clean [--older 14d] [--yes] Remove old worktrees and run folders',
127
+ };
128
+ // One usage line per command, in the order help lists them.
129
+ export const controlUsage = USAGE;
130
+ // Bad input or --help ends the command: the number is its exit code.
131
+ function parse(name, args, options, ctx) {
132
+ const usage = `vsf agents ${USAGE[name]}`;
133
+ try {
134
+ const { values, positionals } = parseArgs({ args, options: { ...options, help: { type: 'boolean', short: 'h' } }, allowPositionals: true, strict: true });
135
+ if (values.help) {
136
+ ctx.log(usage);
137
+ return 0;
138
+ }
139
+ return { values, positionals };
140
+ }
141
+ catch (err) {
142
+ ctx.log(err.message.split('\n')[0]);
143
+ ctx.log(usage);
144
+ return 2;
145
+ }
146
+ }
147
+ const usageError = (name, ctx, why) => {
148
+ ctx.log(why);
149
+ ctx.log(`vsf agents ${USAGE[name]}`);
150
+ return 2;
151
+ };
152
+ const guard = (fn) => async (args, ctx) => {
153
+ try {
154
+ return await fn(args, ctx);
155
+ }
156
+ catch (err) {
157
+ ctx.log(err.message);
158
+ return 1;
159
+ }
160
+ };
161
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
162
+ function runnerState(ctx) {
163
+ if (!runnerLoaded())
164
+ return 'stopped';
165
+ return isPaused(ctx.configDir) ? 'paused' : 'running';
166
+ }
167
+ // Ends the work behind the lock. Only ever signals a pid that is our runner.
168
+ export async function cancelCurrent(ctx, c, deps) {
169
+ if (deps.ours(c.pid)) {
170
+ // The runner's own handler takes the working label off the card.
171
+ try {
172
+ deps.kill(c.pid, 'SIGTERM');
173
+ }
174
+ catch {
175
+ // already gone
176
+ }
177
+ for (let i = 0; i < 50 && deps.ours(c.pid); i++)
178
+ await deps.sleep(200);
179
+ if (deps.ours(c.pid)) {
180
+ try {
181
+ deps.kill(c.pid, 'SIGKILL');
182
+ }
183
+ catch {
184
+ // gone just now
185
+ }
186
+ }
187
+ ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check of Linear.');
188
+ return;
189
+ }
190
+ // Dead or a reused pid: no signal, just tidy up what it left behind.
191
+ if (c.card && c.runDir) {
192
+ try {
193
+ const run = listRuns(ctx.configDir, c.card).find((e) => e.dir === c.runDir)?.record;
194
+ if (run && run.outcome === 'running')
195
+ writeRun(c.runDir, { ...run, outcome: 'cancelled', endedAt: new Date().toISOString() });
196
+ }
197
+ catch {
198
+ // the record is only a nicety
199
+ }
200
+ }
201
+ if (c.card) {
202
+ const label = readAgentsConfig(ctx.configDir).workflows[c.workflow ?? '']?.triggers[0]?.label ?? "the workflow's label";
203
+ await deps.cancelCard(c.card, label);
204
+ ctx.log(`Cleared a stale lock and cancelled ${c.card}.`);
205
+ }
206
+ else
207
+ ctx.log('Cleared a stale lock.');
208
+ clearCurrent(ctx.configDir);
209
+ }
210
+ const realDeps = (ctx, l) => ({
211
+ ours,
212
+ kill: (pid, signal) => process.kill(pid, signal),
213
+ sleep,
214
+ cancelCard: (card, label) => cancelCard(l(), ctx.configDir, card, label),
215
+ });
216
+ async function startCmd(ctx) {
217
+ if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
218
+ ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
219
+ return 1;
220
+ }
221
+ try {
222
+ await linear().viewer();
223
+ }
224
+ catch (err) {
225
+ const msg = err.message;
226
+ ctx.log(/^No Linear API key/.test(msg) ? msg : `Linear did not accept the key: ${msg}. Run vsf agents key.`);
227
+ return 1;
228
+ }
229
+ setPaused(ctx.configDir, false);
230
+ if (!startRunner(ctx.home, program(ctx), logPath(ctx.configDir))) {
231
+ ctx.log('Could not start the runner. Check vsf agents logs.');
232
+ return 1;
233
+ }
234
+ return 0;
235
+ }
236
+ async function stopCmd(ctx) {
237
+ stopRunner(ctx.home);
238
+ // launchd may have stopped the pass; finish or tidy up whatever the lock still names.
239
+ const c = readCurrent(ctx.configDir);
240
+ if (c)
241
+ await cancelCurrent(ctx, c, realDeps(ctx, linear));
242
+ ctx.log('Runner stopped.');
243
+ return 0;
244
+ }
245
+ const status = guard(async (args, ctx) => {
246
+ const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
247
+ if (typeof p === 'number')
248
+ return p;
249
+ const config = readAgentsConfig(ctx.configDir);
250
+ const runner = runnerState(ctx);
251
+ const text = readLog(ctx.configDir);
252
+ const lastTick = lastTickOf(text);
253
+ const live = liveCurrent(ctx.configDir);
254
+ const current = live ? { ...live, checking: !live.card, elapsedMinutes: live.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(live.startedAt)) / 60_000)) : 0 } : null;
255
+ const seen = new Set();
256
+ const parked = [];
257
+ const interrupted = [];
258
+ for (const e of listRuns(ctx.configDir)) {
259
+ if (e.record?.outcome === 'running' && isInterrupted(e, live))
260
+ interrupted.push(e.card);
261
+ if (seen.has(e.card))
262
+ continue;
263
+ seen.add(e.card);
264
+ if (e.record?.outcome === 'parked')
265
+ parked.push({ card: e.card, reason: e.record.reason });
266
+ }
267
+ const workflows = Object.keys(config.workflows).length;
268
+ const projects = Object.keys(config.projects).length;
269
+ if (p.values.json) {
270
+ ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, parked, interrupted, workflows, projects }, null, 2));
271
+ return 0;
272
+ }
273
+ const working = current && !current.card
274
+ ? ' Checking Linear now.'
275
+ : current
276
+ ? ` Working on ${current.card ?? 'a card'}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round !== undefined ? `round ${current.round + 1}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`
277
+ : '';
278
+ if (runner === 'running')
279
+ ctx.log(`Runner: running.${working}`);
280
+ else if (runner === 'paused')
281
+ ctx.log(`Runner: paused, picks up no new cards. vsf agents resume to continue.${working}`);
282
+ else
283
+ ctx.log('Runner: stopped. vsf agents start to begin.');
284
+ if (lastTick)
285
+ ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
286
+ for (const card of interrupted)
287
+ ctx.log(`Interrupted: ${card}. vsf agents retry ${card} to run it again.`);
288
+ for (const k of parked)
289
+ ctx.log(`Parked: ${k.card}${k.reason ? ` (${k.reason.replace(/\.$/, '')})` : ''}. vsf agents retry ${k.card} after fixing the cause.`);
290
+ ctx.log(`${plural(workflows, 'workflow')}, ${plural(projects, 'project')}. vsf agents workflows lists them.`);
291
+ return 0;
292
+ });
293
+ const start = guard(async (args, ctx) => {
294
+ const p = parse('start', args, {}, ctx);
295
+ if (typeof p === 'number')
296
+ return p;
297
+ const code = await startCmd(ctx);
298
+ if (!code)
299
+ ctx.log('Runner started. It checks Linear every minute for cards that start your workflows.');
300
+ return code;
301
+ });
302
+ const stop = guard(async (args, ctx) => {
303
+ const p = parse('stop', args, {}, ctx);
304
+ return typeof p === 'number' ? p : stopCmd(ctx);
305
+ });
306
+ const restart = guard(async (args, ctx) => {
307
+ const p = parse('restart', args, {}, ctx);
308
+ if (typeof p === 'number')
309
+ return p;
310
+ await stopCmd(ctx);
311
+ const code = await startCmd(ctx);
312
+ if (!code)
313
+ ctx.log('Runner restarted.');
314
+ return code;
315
+ });
316
+ const pause = guard(async (args, ctx) => {
317
+ const p = parse('pause', args, {}, ctx);
318
+ if (typeof p === 'number')
319
+ return p;
320
+ setPaused(ctx.configDir, true);
321
+ ctx.log('Paused. The runner picks up no new cards; a card in progress carries on. vsf agents resume to continue.');
322
+ return 0;
323
+ });
324
+ const resume = guard(async (args, ctx) => {
325
+ const p = parse('resume', args, {}, ctx);
326
+ if (typeof p === 'number')
327
+ return p;
328
+ setPaused(ctx.configDir, false);
329
+ ctx.log('Resumed.');
330
+ return 0;
331
+ });
332
+ const run = guard(async (args, ctx) => {
333
+ const p = parse('run', args, { workflow: { type: 'string' }, from: { type: 'string' } }, ctx);
334
+ if (typeof p === 'number')
335
+ return p;
336
+ if (p.positionals.length > 1)
337
+ return usageError('run', ctx, 'Give one card at most.');
338
+ const card = p.positionals[0]?.toUpperCase();
339
+ if (card && !CARD_ID.test(card))
340
+ return usageError('run', ctx, `${p.positionals[0]} is not a card id like ENG-12.`);
341
+ if (p.values.from && !card)
342
+ return usageError('run', ctx, '--from needs a card.');
343
+ const config = readAgentsConfig(ctx.configDir);
344
+ let workflow;
345
+ if (p.values.workflow) {
346
+ const r = resolveName(Object.keys(config.workflows), p.values.workflow, 'workflow');
347
+ if ('error' in r) {
348
+ ctx.log(r.error);
349
+ return 1;
350
+ }
351
+ workflow = r.name;
352
+ }
353
+ let from;
354
+ if (p.values.from) {
355
+ from = parseStep(p.values.from);
356
+ if (!from) {
357
+ ctx.log(`Unknown step ${p.values.from}. Steps: ${stepNames()}.`);
358
+ return 2;
359
+ }
360
+ }
361
+ const code = await tick({ configDir: ctx.configDir, config, linear: linear(), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
362
+ if (!card && code === 0) {
363
+ if (isPaused(ctx.configDir))
364
+ ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
365
+ else
366
+ ctx.log('Nothing to do, or the card was verified.');
367
+ }
368
+ return code;
369
+ });
370
+ const cancel = guard(async (args, ctx) => {
371
+ const p = parse('cancel', args, {}, ctx);
372
+ if (typeof p === 'number')
373
+ return p;
374
+ const card = p.positionals[0]?.toUpperCase();
375
+ if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
376
+ return usageError('cancel', ctx, 'Give one card id like ENG-12.');
377
+ const c = readCurrent(ctx.configDir);
378
+ if (!c) {
379
+ ctx.log(card ? `${card} is not running.` : 'Nothing is running.');
380
+ return card ? 1 : 0;
381
+ }
382
+ if (card && c.card !== card) {
383
+ ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
384
+ return 1;
385
+ }
386
+ await cancelCurrent(ctx, c, realDeps(ctx, linear));
387
+ return 0;
388
+ });
389
+ const retry = guard(async (args, ctx) => {
390
+ const p = parse('retry', args, { workflow: { type: 'string' } }, ctx);
391
+ if (typeof p === 'number')
392
+ return p;
393
+ const id = p.positionals[0]?.toUpperCase();
394
+ if (p.positionals.length !== 1 || !CARD_ID.test(id))
395
+ return usageError('retry', ctx, 'Give one card id like ENG-12.');
396
+ if (liveCurrent(ctx.configDir)?.card === id) {
397
+ ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
398
+ return 1;
399
+ }
400
+ const config = readAgentsConfig(ctx.configDir);
401
+ const names = Object.keys(config.workflows);
402
+ const l = linear();
403
+ const card = await l.card(id);
404
+ const startsOn = (n) => config.workflows[n].triggers.some((t) => t.team === card.teamKey);
405
+ let chosen;
406
+ if (p.values.workflow) {
407
+ const r = resolveName(names, p.values.workflow, 'workflow');
408
+ if ('error' in r) {
409
+ ctx.log(r.error);
410
+ return 1;
411
+ }
412
+ if (!startsOn(r.name)) {
413
+ ctx.log(`No workflow starts on team ${card.teamKey}.`);
414
+ return 1;
415
+ }
416
+ chosen = r.name;
417
+ }
418
+ else {
419
+ const candidates = names.filter(startsOn);
420
+ if (!candidates.length) {
421
+ ctx.log(`No workflow starts on team ${card.teamKey}.`);
422
+ return 1;
423
+ }
424
+ if (candidates.length > 1) {
425
+ ctx.log(`More than one workflow starts on team ${card.teamKey}: ${candidates.join(', ')}.`);
426
+ ctx.log('Pass --workflow NAME.');
427
+ return 2;
428
+ }
429
+ chosen = candidates[0];
430
+ }
431
+ const label = config.workflows[chosen].triggers.find((t) => t.team === card.teamKey).label;
432
+ const drop = new Set([LABELS.parked, LABELS.changes, LABELS.verified, LABELS.working]);
433
+ const next = card.labels.filter((n) => !drop.has(n) && !isRoundLabel(n));
434
+ if (!next.includes(label))
435
+ next.push(label);
436
+ await l.setLabels(card, next);
437
+ const state = runnerState(ctx);
438
+ ctx.log(`${card.identifier} queued again for ${chosen}. The runner picks it up within a minute.` +
439
+ (state === 'paused' ? ' The runner is paused: vsf agents resume.' : state === 'stopped' ? ' The runner is stopped: vsf agents start.' : ''));
440
+ return 0;
441
+ });
442
+ const runs = guard(async (args, ctx) => {
443
+ const p = parse('runs', args, { limit: { type: 'string' }, json: { type: 'boolean' } }, ctx);
444
+ if (typeof p === 'number')
445
+ return p;
446
+ const card = p.positionals[0]?.toUpperCase();
447
+ if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
448
+ return usageError('runs', ctx, 'Give one card id like ENG-12.');
449
+ const limit = p.values.limit === undefined ? 20 : Number(p.values.limit);
450
+ if (!Number.isInteger(limit) || limit < 1)
451
+ return usageError('runs', ctx, '--limit needs a whole number above 0.');
452
+ const entries = listRuns(ctx.configDir, card).slice(0, limit);
453
+ if (p.values.json) {
454
+ ctx.log(JSON.stringify(entries.map((e) => ({ card: e.card, stamp: e.stamp, dir: e.dir, logs: e.logs, record: e.record })), null, 2));
455
+ return 0;
456
+ }
457
+ if (!entries.length)
458
+ ctx.log('No runs yet.');
459
+ const live = liveCurrent(ctx.configDir);
460
+ for (const e of entries)
461
+ ctx.log(formatRunLine(e, Date.now(), live));
462
+ return 0;
463
+ });
464
+ const oneLine = (s, max = 100) => {
465
+ const t = s.replace(/\s+/g, ' ').trim();
466
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
467
+ };
468
+ const show = guard(async (args, ctx) => {
469
+ const p = parse('show', args, { json: { type: 'boolean' } }, ctx);
470
+ if (typeof p === 'number')
471
+ return p;
472
+ const id = p.positionals[0]?.toUpperCase();
473
+ if (p.positionals.length !== 1 || !CARD_ID.test(id))
474
+ return usageError('show', ctx, 'Give one card id like ENG-12.');
475
+ const all = listRuns(ctx.configDir, id);
476
+ const latest = all[0]?.record;
477
+ if (!latest) {
478
+ ctx.log(`No runs for ${id}.`);
479
+ return 1;
480
+ }
481
+ let labels = null;
482
+ try {
483
+ labels = (await linear().card(id)).labels;
484
+ }
485
+ catch {
486
+ // live labels are optional
487
+ }
488
+ if (p.values.json) {
489
+ ctx.log(JSON.stringify({ card: id, latest, runs: all.length, labels }, null, 2));
490
+ return 0;
491
+ }
492
+ ctx.log(`${latest.card} ${latest.title}`);
493
+ ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveCurrent(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
494
+ if (labels)
495
+ ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
496
+ for (const s of latest.steps) {
497
+ const took = s.endedAt ? formatDuration(Date.parse(s.endedAt) - Date.parse(s.startedAt)) : 'running';
498
+ ctx.log(` ${s.label.padEnd(12)} ${(s.result ?? 'running').toLowerCase().padEnd(8)} ${took.padEnd(6)} ${oneLine(s.summary ?? '')}`.trimEnd());
499
+ }
500
+ if (latest.outcome === 'parked' && latest.reason)
501
+ ctx.log(`Parked: ${oneLine(latest.reason, 300)}`);
502
+ if (all.length > 1)
503
+ ctx.log(`${plural(all.length, 'run')} in total. vsf agents runs ${id} lists them.`);
504
+ return 0;
505
+ });
506
+ // Prints bytes appended to a file; a card's follow also hops to a newer log.
507
+ async function follow(file, offset, newer) {
508
+ for (;;) {
509
+ await sleep(1000);
510
+ const next = newer?.();
511
+ if (next && next !== file) {
512
+ file = next;
513
+ offset = 0;
514
+ }
515
+ let size;
516
+ try {
517
+ size = statSync(file).size;
518
+ }
519
+ catch {
520
+ continue;
521
+ }
522
+ if (size < offset)
523
+ offset = 0;
524
+ if (size === offset)
525
+ continue;
526
+ const fd = openSync(file, 'r');
527
+ const buf = Buffer.alloc(size - offset);
528
+ const n = readSync(fd, buf, 0, buf.length, offset);
529
+ closeSync(fd);
530
+ offset += n;
531
+ process.stdout.write(buf.subarray(0, n));
532
+ }
533
+ }
534
+ const logs = guard(async (args, ctx) => {
535
+ const p = parse('logs', args, { step: { type: 'string' }, follow: { type: 'boolean', short: 'f' }, lines: { type: 'string' } }, ctx);
536
+ if (typeof p === 'number')
537
+ return p;
538
+ const card = p.positionals[0]?.toUpperCase();
539
+ if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
540
+ return usageError('logs', ctx, 'Give one card id like ENG-12.');
541
+ const lines = p.values.lines === undefined ? undefined : Number(p.values.lines);
542
+ if (lines !== undefined && (!Number.isInteger(lines) || lines < 1))
543
+ return usageError('logs', ctx, '--lines needs a whole number above 0.');
544
+ const step = p.values.step === undefined ? undefined : Number(p.values.step);
545
+ if (step !== undefined && (!Number.isInteger(step) || step < 1))
546
+ return usageError('logs', ctx, '--step needs a whole number above 0.');
547
+ if (!card) {
548
+ const file = logPath(ctx.configDir);
549
+ if (!existsSync(file)) {
550
+ ctx.log('No runner log yet.');
551
+ return 1;
552
+ }
553
+ const text = readFileSync(file, 'utf8');
554
+ ctx.log(text.trimEnd().split('\n').slice(-(lines ?? 50)).join('\n'));
555
+ if (p.values.follow)
556
+ await follow(file, Buffer.byteLength(text));
557
+ return 0;
558
+ }
559
+ const entry = listRuns(ctx.configDir, card)[0];
560
+ const name = entry && (step ? entry.logs[step - 1] : entry.logs.at(-1));
561
+ if (!entry || !name) {
562
+ ctx.log(`No logs for ${card} yet.`);
563
+ return 1;
564
+ }
565
+ const file = join(entry.dir, name);
566
+ const text = readFileSync(file, 'utf8');
567
+ const shown = lines === undefined ? text : text.replace(/\n$/, '').split('\n').slice(-lines).join('\n') + '\n';
568
+ process.stdout.write(shown.endsWith('\n') || !shown ? shown : shown + '\n');
569
+ if (p.values.follow) {
570
+ const newest = () => {
571
+ if (step)
572
+ return null;
573
+ // A new run of the same card gets its own folder.
574
+ const dir = listRuns(ctx.configDir, card)[0]?.dir ?? entry.dir;
575
+ const all = readdirSync(dir).filter((f) => f.endsWith('.log')).sort();
576
+ return all.length ? join(dir, all.at(-1)) : null;
577
+ };
578
+ await follow(file, Buffer.byteLength(text), newest);
579
+ }
580
+ return 0;
581
+ });
582
+ const git = (args) => spawnSync('git', args, { encoding: 'utf8' });
583
+ function removeWorktree(wt) {
584
+ const common = git(['-C', wt, 'rev-parse', '--path-format=absolute', '--git-common-dir']);
585
+ const repo = common.status === 0 ? common.stdout.trim().replace(/\/\.git\/?$/, '') : '';
586
+ if (repo && git(['-C', repo, 'worktree', 'remove', '--force', wt]).status === 0)
587
+ return;
588
+ rmSync(wt, { recursive: true, force: true });
589
+ if (repo)
590
+ git(['-C', repo, 'worktree', 'prune']);
591
+ }
592
+ const mtime = (path) => {
593
+ try {
594
+ return statSync(path).mtimeMs;
595
+ }
596
+ catch {
597
+ return 0;
598
+ }
599
+ };
600
+ const clean = guard(async (args, ctx) => {
601
+ const p = parse('clean', args, { older: { type: 'string' }, yes: { type: 'boolean' } }, ctx);
602
+ if (typeof p === 'number')
603
+ return p;
604
+ const age = p.values.older ?? '14d';
605
+ const ms = parseAge(age);
606
+ if (ms === null)
607
+ return usageError('clean', ctx, `${age} is not an age. Use a number of days or hours, like 14d or 36h.`);
608
+ const cutoff = Date.now() - ms;
609
+ const live = liveCurrent(ctx.configDir);
610
+ const wtRoot = join(agentsDir(ctx.configDir), 'worktrees');
611
+ const worktrees = (existsSync(wtRoot) ? readdirSync(wtRoot) : [])
612
+ .filter((card) => card !== live?.card)
613
+ .filter((card) => {
614
+ const record = listRuns(ctx.configDir, card)[0]?.record;
615
+ const ended = record?.endedAt ? Date.parse(record.endedAt) : mtime(join(wtRoot, card));
616
+ return ended < cutoff;
617
+ })
618
+ .map((card) => join(wtRoot, card));
619
+ const old = listRuns(ctx.configDir).filter((e) => {
620
+ if (e.record?.outcome === 'running' && e.card === live?.card)
621
+ return false;
622
+ return (e.record?.endedAt ? Date.parse(e.record.endedAt) : mtime(e.dir)) < cutoff;
623
+ });
624
+ if (!worktrees.length && !old.length) {
625
+ ctx.log(`Nothing older than ${age}.`);
626
+ return 0;
627
+ }
628
+ ctx.log(`Removes ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')} older than ${age}.`);
629
+ if (!p.values.yes) {
630
+ if (!process.stdin.isTTY) {
631
+ ctx.log('Not removing without a terminal to confirm. Pass --yes to go ahead.');
632
+ return 2;
633
+ }
634
+ if (!(await ctx.ui.confirm('Remove them?', false))) {
635
+ ctx.log('Nothing removed.');
636
+ return 0;
637
+ }
638
+ }
639
+ for (const wt of worktrees)
640
+ removeWorktree(wt);
641
+ for (const e of old) {
642
+ rmSync(e.dir, { recursive: true, force: true });
643
+ const parent = join(e.dir, '..');
644
+ if (existsSync(parent) && !readdirSync(parent).length)
645
+ rmSync(parent, { recursive: true, force: true });
646
+ }
647
+ ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
648
+ return 0;
649
+ });
650
+ // Internal: launchd runs this every minute.
651
+ const tickCmd = async (_args, ctx) => {
652
+ const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
653
+ try {
654
+ const config = readAgentsConfig(ctx.configDir);
655
+ if (!readLinearKey()) {
656
+ stamped('No Linear API key. Run vsf agents key.');
657
+ return 1;
658
+ }
659
+ return await tick({ configDir: ctx.configDir, config, linear: linear(), log: stamped, selfCommand: ctx.selfCommand });
660
+ }
661
+ catch (err) {
662
+ stamped(`Could not run the check: ${err.message}`);
663
+ return 1;
664
+ }
665
+ };
666
+ export const controlCommands = { status, start, stop, restart, pause, resume, run, cancel, retry, runs, show, logs, clean, tick: tickCmd };
667
+ function engineLogin(engine) {
668
+ const [bin, args] = engine === 'codex' ? ['codex', ['login', 'status']] : ['claude', ['auth', 'status']];
669
+ const r = spawnSync(bin, args, { encoding: 'utf8' });
670
+ const name = 'engine login';
671
+ if (r.error)
672
+ return { name, status: 'fail', detail: `${bin} not installed` };
673
+ let ok = r.status === 0;
674
+ if (engine !== 'codex') {
675
+ try {
676
+ ok = JSON.parse(r.stdout).loggedIn === true;
677
+ }
678
+ catch {
679
+ ok = false;
680
+ }
681
+ }
682
+ return ok
683
+ ? { name, status: 'ok', detail: `${bin} logged in` }
684
+ : { name, status: 'fail', detail: `${bin} not logged in`, fix: engine === 'codex' ? 'codex login' : 'claude, then /login' };
685
+ }
686
+ export async function controlChecks(ctx) {
687
+ const checks = [];
688
+ const state = runnerState(ctx);
689
+ checks.push(state === 'running'
690
+ ? { name: 'agent runner', status: 'ok', detail: 'polling Linear every minute' }
691
+ : state === 'paused'
692
+ ? { name: 'agent runner', status: 'warn', detail: 'paused', fix: 'vsf agents resume' }
693
+ : { name: 'agent runner', status: 'warn', detail: 'not running', fix: 'vsf agents start' });
694
+ const plist = plistPath(ctx.home);
695
+ if (existsSync(plist)) {
696
+ const missing = parsePlistArgs(readFileSync(plist, 'utf8')).filter((a) => a.startsWith('/') && !existsSync(a));
697
+ checks.push(missing.length
698
+ ? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
699
+ : { name: 'runner program', status: 'ok', detail: 'found' });
700
+ }
701
+ const c = readCurrent(ctx.configDir);
702
+ if (c && !ours(c.pid))
703
+ checks.push({ name: 'stale lock', status: 'warn', detail: 'a stale lock from a run that died', fix: 'vsf agents cancel' });
704
+ if (existsSync(logPath(ctx.configDir))) {
705
+ const { count, last } = scanRunnerErrors(readLog(ctx.configDir), Date.now());
706
+ checks.push(count
707
+ ? { name: 'runner errors', status: 'warn', detail: `${plural(count, 'error')} in the last hour, last: ${last.slice(0, 120)}`, fix: 'vsf agents logs' }
708
+ : { name: 'runner errors', status: 'ok', detail: 'no errors in the last hour' });
709
+ }
710
+ checks.push(engineLogin(readAgentsConfig(ctx.configDir).engine));
711
+ return checks;
712
+ }