@voidlabs.gg/voidshift-agents 0.0.0-stage → 0.4.1

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/dist/runner.js ADDED
@@ -0,0 +1,432 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { LABELS, PLAN_HEADING, buildPrompt, findingsFor, fixTarget, isStatusLabel, latestPlan, parseDevResult, parseVerdict, planBody, qaPassed, stageLabels, verdictBlock, } from "./workflow.js";
5
+ import { STEPS, agentsDir, hasWork, isProducer } from "./config.js";
6
+ import { runAgent, stopAgents } from "./engine.js";
7
+ import { commitsAhead, defaultBranch, ensureDraftPr, ensureWorktree, headSha, markReady, push, resetWorktree, waitForChecks, waitForPrHead } from "./git.js";
8
+ import { recall, vaultPath } from "./memory.js";
9
+ import { bootTime, clearCurrent, isPaused, ours, readCurrent, readPending, writeCurrent, writePending, writeRun } from "./state.js";
10
+ const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
11
+ const skill = (name) => readFileSync(join(packDir, name, 'SKILL.md'), 'utf8');
12
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
13
+ class Park extends Error {
14
+ }
15
+ function withLabels(card, drop, add) {
16
+ return [...new Set([...card.labels.filter((l) => !drop(l)), ...add])];
17
+ }
18
+ const UNATTENDED = `# Running unattended
19
+
20
+ You are one step in a workflow. Nobody can answer questions and the
21
+ Linear connection is not available: the card and any plan are included above.
22
+ Do not write plan files; return your output as your final message. Make the
23
+ call the code supports and state it.`;
24
+ // Agents keep the memory vault the way you do: durable learnings become notes.
25
+ const memoryNote = (vault) => `# Memory
26
+
27
+ The memory vault is \`${vault}\`. If you learn something durable that a later
28
+ run should know (how to run the tests, something broken on the base branch, a
29
+ trap in this code), save it there as a note: follow \`${vault}/CONVENTIONS.md\`
30
+ and add the note's line to \`${vault}/INDEX.md\`. Update a note in place
31
+ rather than adding a second one. Skip it when nothing durable came up.`;
32
+ // First three non-empty lines of what an agent reported, for run.json.
33
+ export function summarize(output) {
34
+ return output.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 3).join(' ').slice(0, 300);
35
+ }
36
+ // The run in progress, so a cancel signal can mark it.
37
+ let active = null;
38
+ const isVerdict = (s) => STEPS[s].output === 'verdict';
39
+ // One card through a trigger's workflow. Steps run in order. A failed check (or a
40
+ // run of consecutive checks) sends its findings back to the nearest earlier
41
+ // step that produces something, Plan or Dev, and the workflow continues from
42
+ // there, until everything passes or the loop cap parks the card.
43
+ export async function runCard(card, trigger, workflowName, workflow, project, rc, opts = {}) {
44
+ const { linear, config, log } = rc;
45
+ const runDir = join(agentsDir(rc.configDir), 'runs', card.identifier, stamp());
46
+ mkdirSync(runDir, { recursive: true });
47
+ const say = (line) => log(`${card.identifier} ${line}`);
48
+ const steps = workflow.steps;
49
+ const startedAt = new Date().toISOString();
50
+ const record = { card: card.identifier, title: card.title, url: card.url, workflow: workflowName, trigger: trigger.label, steps: [], outcome: 'running', startedAt };
51
+ active = { record, runDir };
52
+ const save = () => writeRun(runDir, record);
53
+ save();
54
+ let loops = 0;
55
+ // The lock tells status and cancel what is running.
56
+ const mark = (step) => writeCurrent(rc.configDir, { pid: process.pid, card: card.identifier, title: card.title, workflow: workflowName, step, round: loops, startedAt, runDir });
57
+ // Written before the first Linear call, so a cancel always knows the card.
58
+ mark('Starting');
59
+ const begin = (step) => {
60
+ const rec = { step, label: STEPS[step].label, startedAt: new Date().toISOString() };
61
+ record.steps.push(rec);
62
+ mark(rec.label);
63
+ save();
64
+ return rec;
65
+ };
66
+ const end = (rec, result, output, logFile) => {
67
+ rec.endedAt = new Date().toISOString();
68
+ rec.result = result;
69
+ if (output !== undefined)
70
+ rec.summary = summarize(output);
71
+ if (logFile)
72
+ rec.log = basename(logFile);
73
+ save();
74
+ };
75
+ const done = (outcome, reason) => {
76
+ record.outcome = outcome;
77
+ if (reason)
78
+ record.reason = reason;
79
+ record.endedAt = new Date().toISOString();
80
+ save();
81
+ return outcome;
82
+ };
83
+ // Shown on the card as the version label and in the lock as the round.
84
+ const setRound = (n) => {
85
+ loops = n;
86
+ };
87
+ const stage = (name, round) => linear.setLabels(card, stageLabels(card.labels, trigger.label, name, round));
88
+ // Linear hears from the runner twice: the plan once it has passed review,
89
+ // and one final comment. Findings stay here until the final one.
90
+ let lastFindings = '';
91
+ let planToPost = null;
92
+ const postPlan = async () => {
93
+ if (planToPost === null)
94
+ return;
95
+ await linear.comment(card, `${PLAN_HEADING}\n\n${planToPost}`);
96
+ planToPost = null;
97
+ };
98
+ async function park(reason) {
99
+ say(`parked: ${reason}`);
100
+ const comment = `**Parked:** ${reason}${lastFindings ? `\n\n${lastFindings}` : ''}\n\nFix the cause, then add \`${trigger.label}\` again to retry.`;
101
+ const add = [LABELS.parked, ...(loops ? [LABELS.version(loops)] : [])];
102
+ let commented = false;
103
+ try {
104
+ await postPlan();
105
+ await linear.comment(card, comment);
106
+ commented = true;
107
+ await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.parked, loops || undefined));
108
+ }
109
+ catch (err) {
110
+ say(`could not reach Linear to park it (${err.message}). Will retry next minute.`);
111
+ // An empty comment is skipped on retry, so it is not posted twice.
112
+ writePending(rc.configDir, [...readPending(rc.configDir), { card: card.identifier, comment: commented ? '' : comment, drop: [trigger.label], add, dropStatus: true }]);
113
+ }
114
+ return done('parked', reason);
115
+ }
116
+ try {
117
+ if (!existsSync(join(project.path, '.git')))
118
+ throw new Park(`The project folder ${project.path} is not a git repository on this Mac.`);
119
+ const from = opts.from ? steps.indexOf(opts.from) : 0;
120
+ if (from < 0)
121
+ throw new Park(`Workflow ${workflowName} has no ${STEPS[opts.from].label} step.`);
122
+ let plan = latestPlan(await linear.comments(card.identifier));
123
+ const firstPlan = steps.indexOf('vs-plan', from);
124
+ const needsPlan = steps.findIndex((s, n) => n >= from && (s === 'vs-dev' || s === 'vs-plan-review'));
125
+ if (!plan && needsPlan >= 0 && (firstPlan < 0 || firstPlan > needsPlan)) {
126
+ throw new Park('No plan comment (starting with "## Plan") was found. Publish a plan first, or start the workflow with Plan.');
127
+ }
128
+ await stage(LABELS.stage[steps[from]]);
129
+ const base = project.base || defaultBranch(project.path);
130
+ const vault = vaultPath(rc.configDir);
131
+ const known = [recall(rc.selfCommand, rc.configDir, `${workflow.project} ${card.title}\n${card.description ?? ''}`), vault ? memoryNote(vault) : ''].filter(Boolean).join('\n\n');
132
+ const worktree = join(agentsDir(rc.configDir), 'worktrees', card.identifier);
133
+ ensureWorktree(project.path, worktree, card.branchName, base);
134
+ say(`working in ${worktree} on ${card.branchName}`);
135
+ const findings = new Map();
136
+ const runs = { plan: 0, dev: 0 };
137
+ let prUrl = '';
138
+ let logs = 0;
139
+ const planText = () => plan ?? '(No plan was posted on this card. Check the work against the card itself.)';
140
+ const timeoutMs = config.timeoutMinutes * 60_000;
141
+ const logFile = (name) => join(runDir, `${String(logs++).padStart(2, '0')}-${name}.log`);
142
+ async function check(step, round) {
143
+ const label = STEPS[step].label;
144
+ const rec = begin(step);
145
+ let ciNote = '';
146
+ if (step === 'vs-qa') {
147
+ if (commitsAhead(worktree, base) === 0) {
148
+ end(rec, 'SKIP', 'No changes on the branch yet.');
149
+ return { step: label, verdict: 'SKIP', report: 'No changes on the branch yet.' };
150
+ }
151
+ // CI first, without an agent: a red build goes straight back. Only
152
+ // checks for the commit just pushed count, never an older push's.
153
+ if (prUrl) {
154
+ const sha = headSha(worktree);
155
+ if (!(await waitForPrHead(worktree, card.branchName, sha))) {
156
+ throw new Park(`GitHub never showed commit ${sha.slice(0, 9)} on the pull request, so its CI could not be read. PR: ${prUrl}`);
157
+ }
158
+ const ci = await waitForChecks(worktree, card.branchName);
159
+ if (ci.verdict === 'FIX') {
160
+ end(rec, 'FIX', ci.report);
161
+ return { step: label, verdict: 'FIX', report: ci.report };
162
+ }
163
+ ciNote = ci.verdict === 'SHIP' ? '# CI\n\nCI passed on this branch.' : '# CI\n\nThis repo has no CI checks.';
164
+ }
165
+ }
166
+ const log = logFile(step);
167
+ const r = await runAgent({
168
+ engine: config.engine, model: config.models.review, cwd: worktree, vault, write: step === 'vs-qa', logFile: log, timeoutMs,
169
+ prompt: buildPrompt({ skill: skill(step), card, plan: planText(), base, round, extra: [known, ciNote, UNATTENDED].filter(Boolean).join('\n\n') }),
170
+ });
171
+ if (step === 'vs-qa')
172
+ resetWorktree(worktree);
173
+ const verdict = parseVerdict(r.output);
174
+ end(rec, verdict, r.output, log);
175
+ return { step: label, verdict, report: r.output };
176
+ }
177
+ let i = from;
178
+ while (i < steps.length) {
179
+ const step = steps[i];
180
+ say(`${STEPS[step].label}`);
181
+ if (isVerdict(step)) {
182
+ const [start, end] = verdictBlock(steps, i, isVerdict);
183
+ const target = fixTarget(steps, start, isProducer);
184
+ const producer = target >= 0 ? steps[target] : undefined;
185
+ // The round being checked: how many times its producer has run.
186
+ const round = Math.max(producer === 'vs-plan' ? runs.plan : runs.dev, 1);
187
+ setRound(round);
188
+ const results = [];
189
+ for (const s of steps.slice(start, end)) {
190
+ await stage(LABELS.stage[s], round);
191
+ results.push(await check(s, round));
192
+ }
193
+ if (qaPassed(results)) {
194
+ i = end;
195
+ continue;
196
+ }
197
+ const notes = findingsFor(results);
198
+ lastFindings = `**Latest findings (round ${round}).**\n\n${notes}`;
199
+ if (target < 0) {
200
+ await postPlan();
201
+ await linear.comment(card, `**Changes needed.**\n\n${notes}`);
202
+ await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.changes, round));
203
+ say('changes requested');
204
+ return done('changes');
205
+ }
206
+ // Rounds caps how many times Plan or Dev runs, counting the first.
207
+ if (round >= workflow.maxRounds) {
208
+ throw new Park(`Checks still failed after ${round} ${producer === 'vs-plan' ? 'plan' : 'dev'} rounds.${prUrl ? ` PR: ${prUrl}` : ''}`);
209
+ }
210
+ findings.set(target, notes);
211
+ i = target;
212
+ continue;
213
+ }
214
+ if (step === 'vs-plan') {
215
+ setRound(runs.plan + 1);
216
+ await stage(LABELS.stage[step], runs.plan + 1);
217
+ const rec = begin(step);
218
+ const log = logFile('plan');
219
+ const r = await runAgent({
220
+ engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: false, logFile: log, timeoutMs,
221
+ prompt: buildPrompt({ skill: skill('vs-plan'), card, plan: plan ?? '(none yet, you are writing it)', base, round: runs.plan + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
222
+ });
223
+ const result = parseDevResult(r.output);
224
+ end(rec, result.status === 'blocked' ? 'blocked' : 'done', r.output, log);
225
+ if (result.status === 'blocked')
226
+ throw new Park(`The plan agent stopped: ${result.reason}`);
227
+ plan = planBody(r.output);
228
+ planToPost = plan;
229
+ runs.plan++;
230
+ }
231
+ else if (step === 'vs-publish') {
232
+ await postPlan();
233
+ const rec = begin(step);
234
+ push(worktree, card.branchName);
235
+ end(rec, 'pushed');
236
+ }
237
+ else if (step === 'vs-dev') {
238
+ await postPlan();
239
+ setRound(runs.dev + 1);
240
+ await stage(LABELS.stage[step], runs.dev + 1);
241
+ const rec = begin(step);
242
+ const log = logFile('dev');
243
+ const before = headSha(worktree);
244
+ const dev = await runAgent({
245
+ engine: config.engine, model: config.models.dev, cwd: worktree, vault, write: true, logFile: log, timeoutMs,
246
+ prompt: buildPrompt({ skill: skill('vs-dev'), card, plan: planText(), base, round: runs.dev + 1, findings: findings.get(i), extra: [known, UNATTENDED].filter(Boolean).join('\n\n') }),
247
+ });
248
+ const result = parseDevResult(dev.output);
249
+ end(rec, result.status === 'blocked' ? 'blocked' : 'done', dev.output, log);
250
+ if (result.status === 'blocked')
251
+ throw new Park(`The dev agent stopped: ${result.reason}`);
252
+ if (commitsAhead(worktree, base) === 0)
253
+ throw new Park('The dev agent made no commits.');
254
+ // A round sent back with findings must change something, or QA would
255
+ // just check the same commit again.
256
+ if (runs.dev > 0 && headSha(worktree) === before)
257
+ throw new Park('The dev agent made no new commits this round.');
258
+ push(worktree, card.branchName);
259
+ prUrl = ensureDraftPr(worktree, card.branchName, base, `${card.identifier}: ${card.title}`, card.url);
260
+ record.prUrl = prUrl;
261
+ save();
262
+ runs.dev++;
263
+ }
264
+ findings.delete(i);
265
+ i++;
266
+ }
267
+ await postPlan();
268
+ let moved = false;
269
+ if (hasWork(workflow)) {
270
+ markReady(worktree, card.branchName);
271
+ moved = await linear.moveTo(card, config.reviewState);
272
+ }
273
+ await linear.setLabels(card, stageLabels(card.labels, trigger.label, LABELS.verified, loops || undefined));
274
+ await linear.comment(card, `**All checks passed.**${prUrl ? ` PR ready: ${prUrl}` : ''}${hasWork(workflow) && !moved ? `\n\n(No "${config.reviewState}" state on this team, so the card was not moved.)` : ''}`);
275
+ say('verified');
276
+ return done('verified');
277
+ }
278
+ catch (err) {
279
+ if (err instanceof Park)
280
+ return park(err.message);
281
+ return park(`Something went wrong: ${err.message}`);
282
+ }
283
+ finally {
284
+ active = null;
285
+ }
286
+ }
287
+ // The first card waiting on any workflow's triggers, in workflow order.
288
+ // With a workflow name, only that workflow is considered.
289
+ export async function nextJob(rc, only, workflowName) {
290
+ const pinned = only ? await rc.linear.card(only) : null;
291
+ for (const [name, workflow] of Object.entries(rc.config.workflows)) {
292
+ if (workflowName && name !== workflowName)
293
+ continue;
294
+ for (const trigger of workflow.triggers) {
295
+ const cards = pinned ? [pinned] : await rc.linear.myCardsWithLabel(trigger.label);
296
+ // A pinned card may have lost its label (a parked card re-run by hand), so match by team.
297
+ const card = cards.find((c) => c.teamKey === trigger.team && (pinned || c.labels.includes(trigger.label)));
298
+ if (card)
299
+ return { card, trigger, workflow: name };
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+ // Retries Linear writes that failed while parking. Never throws.
305
+ async function flushPending(rc) {
306
+ const left = [];
307
+ for (const p of readPending(rc.configDir)) {
308
+ try {
309
+ const card = await rc.linear.card(p.card);
310
+ if (p.comment) {
311
+ await rc.linear.comment(card, p.comment);
312
+ p.comment = '';
313
+ }
314
+ await rc.linear.setLabels(card, withLabels(card, (l) => p.drop.includes(l) || (!!p.dropStatus && isStatusLabel(l)), p.add));
315
+ }
316
+ catch {
317
+ left.push(p);
318
+ }
319
+ }
320
+ try {
321
+ writePending(rc.configDir, left);
322
+ }
323
+ catch {
324
+ // nothing more to do
325
+ }
326
+ }
327
+ // Takes the working label off a cancelled card and says so. When Linear
328
+ // cannot be reached the same change is queued for the next tick.
329
+ export async function cancelCard(linear, configDir, cardId, label) {
330
+ const comment = `**Cancelled.** Add \`${label}\` again to restart.`;
331
+ try {
332
+ const card = await linear.card(cardId);
333
+ await linear.setLabels(card, withLabels(card, isStatusLabel, []));
334
+ await linear.comment(card, comment);
335
+ return true;
336
+ }
337
+ catch {
338
+ queueCancel(configDir, cardId, comment);
339
+ return false;
340
+ }
341
+ }
342
+ function queueCancel(configDir, cardId, comment) {
343
+ const pending = readPending(configDir);
344
+ if (pending.some((p) => p.card === cardId && p.comment === comment))
345
+ return;
346
+ try {
347
+ writePending(configDir, [...pending, { card: cardId, comment, drop: [], add: [], dropStatus: true }]);
348
+ }
349
+ catch {
350
+ // nothing more to do
351
+ }
352
+ }
353
+ // One pass for the background runner. A lock keeps two passes from working at once.
354
+ export async function tick(rc, only, opts = {}) {
355
+ const held = readCurrent(rc.configDir);
356
+ const booted = bootTime();
357
+ // A lock from before the last boot cannot be a live run, whatever its pid is now.
358
+ const fromBeforeBoot = !!(held?.startedAt && booted && new Date(held.startedAt) < booted);
359
+ if (held && held.pid !== process.pid && !fromBeforeBoot && ours(held.pid)) {
360
+ if (only)
361
+ rc.log(`The runner is busy with ${held.card ?? 'a check'}. Try again when it finishes.`);
362
+ return only ? 1 : 0;
363
+ }
364
+ writeCurrent(rc.configDir, { pid: process.pid, startedAt: new Date().toISOString() });
365
+ // A stop (Ctrl+C, launchd, shutdown) ends the agents, marks the run, and
366
+ // takes the working label off the card so it is never left stuck.
367
+ const onSignal = async () => {
368
+ await stopAgents();
369
+ const running = active && active.record.outcome === 'running' ? active : null;
370
+ if (active) {
371
+ active.record.outcome = 'cancelled';
372
+ active.record.endedAt = new Date().toISOString();
373
+ try {
374
+ writeRun(active.runDir, active.record);
375
+ }
376
+ catch {
377
+ // the run folder is gone
378
+ }
379
+ }
380
+ if (running) {
381
+ const { card, trigger } = running.record;
382
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5000).unref());
383
+ const finished = await Promise.race([cancelCard(rc.linear, rc.configDir, card, trigger), timeout]);
384
+ // Did not finish in time: leave the work for the next tick.
385
+ if (!finished)
386
+ queueCancel(rc.configDir, card, `**Cancelled.** Add \`${trigger}\` again to restart.`);
387
+ }
388
+ clearCurrent(rc.configDir);
389
+ process.exit(143);
390
+ };
391
+ process.once('SIGTERM', onSignal);
392
+ process.once('SIGINT', onSignal);
393
+ try {
394
+ await flushPending(rc);
395
+ if (!only && isPaused(rc.configDir))
396
+ return 0;
397
+ let job;
398
+ try {
399
+ job = await nextJob(rc, only, opts.workflow);
400
+ }
401
+ catch (err) {
402
+ rc.log(`Could not check Linear (${err.message}). Trying again next minute.`);
403
+ return 1;
404
+ }
405
+ if (!job) {
406
+ if (only)
407
+ rc.log(`${only} starts no workflow: it needs a workflow's team and label.`);
408
+ return only ? 1 : 0;
409
+ }
410
+ const workflow = rc.config.workflows[job.workflow];
411
+ const project = rc.config.projects[workflow.project];
412
+ if (!project) {
413
+ rc.log(`Workflow ${job.workflow} points at a missing project ${workflow.project}. Run vsf agents workflow edit to fix it.`);
414
+ return 1;
415
+ }
416
+ if (opts.from && !workflow.steps.includes(opts.from)) {
417
+ rc.log(`Workflow ${job.workflow} has no ${STEPS[opts.from].label} step.`);
418
+ return 2;
419
+ }
420
+ const outcome = await runCard(job.card, job.trigger, job.workflow, workflow, project, rc, { from: opts.from });
421
+ return outcome === 'verified' ? 0 : 1;
422
+ }
423
+ catch (err) {
424
+ rc.log(`Error: ${err.message}`);
425
+ return 1;
426
+ }
427
+ finally {
428
+ process.off('SIGTERM', onSignal);
429
+ process.off('SIGINT', onSignal);
430
+ clearCurrent(rc.configDir);
431
+ }
432
+ }
@@ -0,0 +1,63 @@
1
+ import { type StepId } from './config.ts';
2
+ export interface Current {
3
+ pid: number;
4
+ card?: string;
5
+ title?: string;
6
+ workflow?: string;
7
+ step?: string;
8
+ round?: number;
9
+ startedAt?: string;
10
+ runDir?: string;
11
+ }
12
+ export declare const lockPath: (configDir: string) => string;
13
+ export declare function readCurrent(configDir: string): Current | null;
14
+ export declare function writeCurrent(configDir: string, current: Current): void;
15
+ export declare const clearCurrent: (configDir: string) => void;
16
+ export declare function alive(pid: number): boolean;
17
+ export declare function ours(pid: number): boolean;
18
+ export declare function bootTime(): Date | null;
19
+ export declare function liveCurrent(configDir: string): Current | null;
20
+ export declare const isPaused: (configDir: string) => boolean;
21
+ export declare function setPaused(configDir: string, paused: boolean): void;
22
+ export interface Pending {
23
+ card: string;
24
+ comment: string;
25
+ drop: string[];
26
+ add: string[];
27
+ dropStatus?: boolean;
28
+ }
29
+ export declare function readPending(configDir: string): Pending[];
30
+ export declare function writePending(configDir: string, pending: Pending[]): void;
31
+ export type Outcome = 'running' | 'verified' | 'changes' | 'parked' | 'cancelled';
32
+ export interface StepRecord {
33
+ step: StepId;
34
+ label: string;
35
+ startedAt: string;
36
+ endedAt?: string;
37
+ result?: string;
38
+ summary?: string;
39
+ log?: string;
40
+ }
41
+ export interface RunRecord {
42
+ card: string;
43
+ title: string;
44
+ url?: string;
45
+ workflow: string;
46
+ trigger: string;
47
+ steps: StepRecord[];
48
+ outcome: Outcome;
49
+ reason?: string;
50
+ prUrl?: string;
51
+ startedAt: string;
52
+ endedAt?: string;
53
+ }
54
+ export declare const runsDir: (configDir: string) => string;
55
+ export declare function writeRun(runDir: string, run: RunRecord): void;
56
+ export interface RunEntry {
57
+ card: string;
58
+ stamp: string;
59
+ dir: string;
60
+ record?: RunRecord;
61
+ logs: string[];
62
+ }
63
+ export declare function listRuns(configDir: string, card?: string): RunEntry[];
package/dist/state.js ADDED
@@ -0,0 +1,107 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { agentsDir } from "./config.js";
5
+ export const lockPath = (configDir) => join(agentsDir(configDir), 'runner.lock');
6
+ export function readCurrent(configDir) {
7
+ const path = lockPath(configDir);
8
+ if (!existsSync(path))
9
+ return null;
10
+ const text = readFileSync(path, 'utf8').trim();
11
+ try {
12
+ const parsed = JSON.parse(text);
13
+ return typeof parsed === 'number' ? { pid: parsed } : parsed;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function writeCurrent(configDir, current) {
20
+ mkdirSync(agentsDir(configDir), { recursive: true });
21
+ writeFileSync(lockPath(configDir), JSON.stringify(current) + '\n');
22
+ }
23
+ export const clearCurrent = (configDir) => rmSync(lockPath(configDir), { force: true });
24
+ export function alive(pid) {
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ // True only when the pid is alive and is our runner ("agents tick" or
34
+ // "agents run"). After a reboot the old pid can belong to any program.
35
+ export function ours(pid) {
36
+ if (!alive(pid))
37
+ return false;
38
+ const r = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
39
+ return / agents (tick|run)\b/.test(r.stdout ?? '');
40
+ }
41
+ // When the machine last booted, or null when it cannot be read.
42
+ export function bootTime() {
43
+ const r = spawnSync('sysctl', ['-n', 'kern.boottime'], { encoding: 'utf8' });
44
+ const sec = /sec = (\d+)/.exec(r.stdout ?? '')?.[1];
45
+ return sec ? new Date(Number(sec) * 1000) : null;
46
+ }
47
+ // The live run, or null when nothing runs or the lock is stale.
48
+ export function liveCurrent(configDir) {
49
+ const c = readCurrent(configDir);
50
+ return c && ours(c.pid) ? c : null;
51
+ }
52
+ // Paused: the runner stays loaded but picks up no new cards.
53
+ const pausePath = (configDir) => join(agentsDir(configDir), 'paused');
54
+ export const isPaused = (configDir) => existsSync(pausePath(configDir));
55
+ export function setPaused(configDir, paused) {
56
+ mkdirSync(agentsDir(configDir), { recursive: true });
57
+ if (paused)
58
+ writeFileSync(pausePath(configDir), new Date().toISOString() + '\n');
59
+ else
60
+ rmSync(pausePath(configDir), { force: true });
61
+ }
62
+ const pendingPath = (configDir) => join(agentsDir(configDir), 'pending.json');
63
+ export function readPending(configDir) {
64
+ try {
65
+ return JSON.parse(readFileSync(pendingPath(configDir), 'utf8'));
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ }
71
+ export function writePending(configDir, pending) {
72
+ mkdirSync(agentsDir(configDir), { recursive: true });
73
+ if (pending.length)
74
+ writeFileSync(pendingPath(configDir), JSON.stringify(pending, null, 2) + '\n');
75
+ else
76
+ rmSync(pendingPath(configDir), { force: true });
77
+ }
78
+ export const runsDir = (configDir) => join(agentsDir(configDir), 'runs');
79
+ export function writeRun(runDir, run) {
80
+ writeFileSync(join(runDir, 'run.json'), JSON.stringify(run, null, 2) + '\n');
81
+ }
82
+ // Every saved run, newest first. Pass a card to limit it to that card.
83
+ export function listRuns(configDir, card) {
84
+ const root = runsDir(configDir);
85
+ // Skip stray files such as .DS_Store at both levels.
86
+ const dirs = (path) => readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
87
+ const cards = card ? [card] : existsSync(root) ? dirs(root) : [];
88
+ const out = [];
89
+ for (const c of cards) {
90
+ const dir = join(root, c);
91
+ if (!existsSync(dir))
92
+ continue;
93
+ for (const stamp of dirs(dir)) {
94
+ const runDir = join(dir, stamp);
95
+ let record;
96
+ try {
97
+ record = JSON.parse(readFileSync(join(runDir, 'run.json'), 'utf8'));
98
+ }
99
+ catch {
100
+ // an older run, logs only
101
+ }
102
+ const logs = readdirSync(runDir).filter((f) => f.endsWith('.log')).sort();
103
+ out.push({ card: c, stamp, dir: runDir, record, logs });
104
+ }
105
+ }
106
+ return out.sort((a, b) => (a.stamp < b.stamp ? 1 : -1));
107
+ }
@@ -0,0 +1,51 @@
1
+ export type CheckStatus = 'ok' | 'warn' | 'fail';
2
+ export interface Check {
3
+ name: string;
4
+ status: CheckStatus;
5
+ detail: string;
6
+ fix?: string;
7
+ }
8
+ export interface Choice<T extends string> {
9
+ value: T;
10
+ label: string;
11
+ hint?: string;
12
+ }
13
+ export interface Ui {
14
+ info(line: string): void;
15
+ warn(line: string): void;
16
+ text(message: string, o?: {
17
+ initial?: string;
18
+ placeholder?: string;
19
+ validate?: (v: string) => string | undefined;
20
+ }): Promise<string>;
21
+ secret(message: string, validate?: (v: string) => string | undefined): Promise<string>;
22
+ confirm(message: string, initial?: boolean): Promise<boolean>;
23
+ select<T extends string>(message: string, choices: Choice<T>[], initial?: T): Promise<T>;
24
+ multiselect<T extends string>(message: string, choices: Choice<T>[], initial?: T[]): Promise<T[]>;
25
+ search<T extends string>(message: string, choices: Choice<T>[], placeholder?: string, initial?: T): Promise<T>;
26
+ path(message: string, o?: {
27
+ initial?: string;
28
+ validate?: (v: string) => string | undefined;
29
+ }): Promise<string>;
30
+ }
31
+ export interface ToolContext {
32
+ defaults: boolean;
33
+ home: string;
34
+ configDir: string;
35
+ selfCommand: string[];
36
+ log: (line: string) => void;
37
+ ui: Ui;
38
+ }
39
+ export type ToolCommand = (args: string[], ctx: ToolContext) => Promise<number>;
40
+ export interface Tool {
41
+ name: string;
42
+ summary: string;
43
+ install(ctx: ToolContext): Promise<void>;
44
+ uninstall(ctx: ToolContext): Promise<void>;
45
+ checks(ctx: ToolContext): Promise<Check[]>;
46
+ commands?: Record<string, ToolCommand>;
47
+ config?: {
48
+ read(configDir: string): Record<string, unknown>;
49
+ write(configDir: string, value: Record<string, unknown>): string[];
50
+ };
51
+ }
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // Structural copy of the contract in packages/cli/src/tool.ts, so the tool
2
+ // builds without depending on the CLI package.
3
+ export {};