@voidlabs.gg/voidshift-agents 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +11 -0
- package/dist/control.d.ts +19 -0
- package/dist/control.js +87 -19
- package/dist/engine.d.ts +22 -0
- package/dist/engine.js +144 -7
- package/dist/git.d.ts +1 -0
- package/dist/git.js +5 -0
- package/dist/manage.d.ts +2 -2
- package/dist/manage.js +21 -5
- package/dist/notify.js +3 -3
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +215 -40
- package/dist/service.js +1 -1
- package/dist/state.d.ts +40 -1
- package/dist/state.js +116 -0
- package/dist/systemd.d.ts +2 -0
- package/dist/systemd.js +14 -0
- package/dist/usage.d.ts +18 -0
- package/dist/usage.js +102 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,6 +103,15 @@ vsf agents project edit erp --workspace solvia --match DS06
|
|
|
103
103
|
Atleurope ERP"). A card of that project runs in the matching project, in any
|
|
104
104
|
workspace, before the workflow's own project is considered.
|
|
105
105
|
|
|
106
|
+
Two settings keep runs inside your Claude subscription. `vsf config set
|
|
107
|
+
agents.maxRuns 2` caps how many cards run at once over every workspace (0, the
|
|
108
|
+
default, is no cap). Every 5 minutes the runner reads your 5 hour window with
|
|
109
|
+
Claude Code's login, read only, and once it reaches `agents.pauseAt` percent
|
|
110
|
+
(90 by default, 0 turns it off) no new run starts until the window resets;
|
|
111
|
+
running cards finish. `vsf agents status` shows the window, and each run's
|
|
112
|
+
`run.json` keeps the tokens and estimated cost of every step. Naming a card,
|
|
113
|
+
`vsf agents run CARD`, starts it anyway.
|
|
114
|
+
|
|
106
115
|
A workflow can also be a folder you write by hand,
|
|
107
116
|
`~/.config/voidshift/agents/defs/NAME/agent.json`, with its prompt files next to
|
|
108
117
|
it. Steps are the built-in ids above or your own:
|
|
@@ -127,6 +136,20 @@ verified card there (`null` never moves it), `labels: false` only takes the
|
|
|
127
136
|
trigger label off, and `comment: false` skips the closing comment. Edit the file,
|
|
128
137
|
then `vsf agents validate`.
|
|
129
138
|
|
|
139
|
+
A step can run a shell command instead of an agent: `"run": "node sweep.mjs"`
|
|
140
|
+
with `output: "none"`. Exit 0 is done, anything else fails the run, and the last
|
|
141
|
+
line it prints is the step's summary. A run made only of command steps works in
|
|
142
|
+
the project folder, with no worktree or branch. A step with `"handoff": "WORKFLOW"`
|
|
143
|
+
hands cards on: every `HANDOFF: ENG-12` line in its output queues that card for
|
|
144
|
+
the named workflow, which runs it as a run of its own, label or not. The card
|
|
145
|
+
leaves the queue once its run has started.
|
|
146
|
+
|
|
147
|
+
Scheduled runs are jobs: `vsf agents status` shows one line per job, the Runs
|
|
148
|
+
list keeps to card runs (`vsf agents runs --jobs` lists the scheduled ones), and
|
|
149
|
+
a scheduled run that opened no pull request removes its worktree and branch. A
|
|
150
|
+
run whose process ends without an outcome is closed as failed on the runner's
|
|
151
|
+
next pass.
|
|
152
|
+
|
|
130
153
|
### memory
|
|
131
154
|
|
|
132
155
|
A folder of markdown notes, one topic per note, with an `INDEX.md` holding one
|
|
@@ -182,6 +205,14 @@ DCO. Only the new commits of that step are rewritten, never pushed ones.
|
|
|
182
205
|
- **Your config** lives in `~/.config/voidshift`.
|
|
183
206
|
- **Linux.** The runner is a systemd user timer. Run
|
|
184
207
|
`sudo loginctl enable-linger $USER` so it keeps going after you log out.
|
|
208
|
+
Agents there run with permissions skipped (`--dangerously-skip-permissions`
|
|
209
|
+
for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex), so no
|
|
210
|
+
step ever waits on a prompt and the runner's user account is the boundary.
|
|
211
|
+
Run it as a dedicated user with no sudo that holds only the keys it needs,
|
|
212
|
+
never as root, and enable lingering for it from an admin account
|
|
213
|
+
(`sudo loginctl enable-linger <user>`). `vsf doctor` warns when the runner
|
|
214
|
+
user has sudo. A step that fails shows its error in the run log, on the card
|
|
215
|
+
and in Discord.
|
|
185
216
|
|
|
186
217
|
```sh
|
|
187
218
|
vsf doctor # check everything is set up, and whether an update is out
|
package/dist/config.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface StepDef {
|
|
|
14
14
|
label?: string;
|
|
15
15
|
skill?: string;
|
|
16
16
|
prompt?: string;
|
|
17
|
+
run?: string;
|
|
18
|
+
handoff?: string;
|
|
17
19
|
output: Output;
|
|
18
20
|
engine?: Engine;
|
|
19
21
|
model?: string;
|
|
@@ -94,6 +96,8 @@ export interface AgentsConfig {
|
|
|
94
96
|
signOff?: boolean;
|
|
95
97
|
dropAiTrailers?: boolean;
|
|
96
98
|
};
|
|
99
|
+
maxRuns?: number;
|
|
100
|
+
pauseAt?: number;
|
|
97
101
|
folders?: Record<string, string>;
|
|
98
102
|
}
|
|
99
103
|
export declare const TEMPLATES: Record<string, {
|
|
@@ -133,6 +137,8 @@ export interface ResolvedStep {
|
|
|
133
137
|
write: boolean;
|
|
134
138
|
prompt: () => string;
|
|
135
139
|
builtin?: StepId;
|
|
140
|
+
command?: string;
|
|
141
|
+
handoff?: string;
|
|
136
142
|
}
|
|
137
143
|
export declare function stepFile(step: StepDef, dir?: string): string | null;
|
|
138
144
|
export declare function resolveStep(step: Step, config: Pick<AgentsConfig, 'engine' | 'models' | 'timeoutMinutes'>, dir?: string): ResolvedStep;
|
package/dist/config.js
CHANGED
|
@@ -62,6 +62,8 @@ export const DEFAULTS = {
|
|
|
62
62
|
timeoutMinutes: 45,
|
|
63
63
|
homes: [],
|
|
64
64
|
enabled: [],
|
|
65
|
+
maxRuns: 0,
|
|
66
|
+
pauseAt: 90,
|
|
65
67
|
};
|
|
66
68
|
export const agentsDir = (configDir) => join(configDir, 'agents');
|
|
67
69
|
const configPath = (configDir) => join(agentsDir(configDir), 'config.json');
|
|
@@ -221,6 +223,14 @@ export function resolveStep(step, config, dir) {
|
|
|
221
223
|
timeoutMs: config.timeoutMinutes * 60_000, write: s.write, prompt: () => readFileSync(packSkill(step), 'utf8'), builtin: step,
|
|
222
224
|
};
|
|
223
225
|
}
|
|
226
|
+
// A command step runs a shell command, so it has no prompt.
|
|
227
|
+
if (step.run) {
|
|
228
|
+
return {
|
|
229
|
+
id: step.id, label: step.label ?? step.id, output: step.output, engine: step.engine ?? config.engine,
|
|
230
|
+
model: step.model ?? config.models.dev, timeoutMs: (step.timeoutMinutes ?? config.timeoutMinutes) * 60_000,
|
|
231
|
+
write: step.write ?? false, command: step.run, prompt: () => '', handoff: step.handoff,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
224
234
|
return {
|
|
225
235
|
id: step.id,
|
|
226
236
|
label: step.label ?? step.id,
|
|
@@ -235,5 +245,6 @@ export function resolveStep(step, config, dir) {
|
|
|
235
245
|
throw new Error(`Step ${step.id} has no prompt or skill.`);
|
|
236
246
|
return readFileSync(file, 'utf8');
|
|
237
247
|
},
|
|
248
|
+
handoff: step.handoff,
|
|
238
249
|
};
|
|
239
250
|
}
|
package/dist/control.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare function countRounds(steps: {
|
|
|
11
11
|
result?: string;
|
|
12
12
|
}[]): number;
|
|
13
13
|
export declare const isInterrupted: (e: RunEntry, live: Current[]) => boolean;
|
|
14
|
+
export declare const shownOutcome: (e: RunEntry, live: Current[]) => string;
|
|
14
15
|
export declare function formatRunLine(e: RunEntry, now?: number, live?: Current[]): string;
|
|
15
16
|
export declare function scanRunnerErrors(text: string, now: number): {
|
|
16
17
|
count: number;
|
|
@@ -35,6 +36,24 @@ export declare function workingLine(current: (Current & {
|
|
|
35
36
|
export declare function workspaceLines(workspaces: string[], running: (Current & {
|
|
36
37
|
elapsedMinutes: number;
|
|
37
38
|
})[]): string[];
|
|
39
|
+
interface JobSummary {
|
|
40
|
+
workflow: string;
|
|
41
|
+
schedule: string;
|
|
42
|
+
task: string;
|
|
43
|
+
last: {
|
|
44
|
+
at: string;
|
|
45
|
+
outcome: string;
|
|
46
|
+
reason?: string;
|
|
47
|
+
summary?: string;
|
|
48
|
+
} | null;
|
|
49
|
+
lastOkAt: string | null;
|
|
50
|
+
today: {
|
|
51
|
+
runs: number;
|
|
52
|
+
failed: number;
|
|
53
|
+
handed: number;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export declare function jobSummaries(config: Pick<AgentsConfig, 'workflows'>, all: RunEntry[], live: Current[], now?: number): JobSummary[];
|
|
38
57
|
export declare function pickCancel(locks: Current[], dispatcher: Current | null, card?: string): Current | string;
|
|
39
58
|
export declare const controlCommands: Record<string, ToolCommand>;
|
|
40
59
|
export declare function controlChecks(ctx: ToolContext): Promise<Check[]>;
|
package/dist/control.js
CHANGED
|
@@ -2,13 +2,14 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { agentsDir, isCardTrigger, readAgentsConfig, workspaceOf, workspacesInUse } from "./config.js";
|
|
6
|
-
import { isPaused, listRuns, ours, clearLock, liveCurrent, liveRuns, readCurrent, setPaused, workspaceLocks, writeRun } from "./state.js";
|
|
5
|
+
import { DEFAULTS, agentsDir, isCardTrigger, readAgentsConfig, workspaceOf, workspacesInUse } from "./config.js";
|
|
6
|
+
import { STOPPED_REASON, isPaused, lastSeen, listRuns, ours, clearLock, liveCurrent, liveRuns, readCurrent, readWindow, setPaused, usageText, workspaceLocks, writeRun } from "./state.js";
|
|
7
|
+
import { windowHold, windowLine } from "./usage.js";
|
|
7
8
|
import { runnerService } from "./service.js";
|
|
8
9
|
import { TRACKERS, trackersFor } from "./tracker.js";
|
|
9
10
|
import { parseFrom, resolveName, stepNames } from "./names.js";
|
|
10
11
|
import { isStatusLabel } from "./workflow.js";
|
|
11
|
-
import { cancelCard, dispatch, tick } from "./runner.js";
|
|
12
|
+
import { cancelCard, dispatch, gateReason, tick } from "./runner.js";
|
|
12
13
|
import { notifyRun, notifyRunner, sendSummary } from "./notify.js";
|
|
13
14
|
const HOUR = 3_600_000;
|
|
14
15
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
@@ -81,13 +82,15 @@ export function countRounds(steps) {
|
|
|
81
82
|
}
|
|
82
83
|
// A run that says 'running' but is none of the live runs was cut off.
|
|
83
84
|
export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !live.some((l) => (l.runDir ? l.runDir === e.dir : l.card === e.card));
|
|
85
|
+
// The record's outcome, except a run cut off without a live process, which failed.
|
|
86
|
+
export const shownOutcome = (e, live) => (isInterrupted(e, live) ? 'failed' : e.record.outcome);
|
|
84
87
|
export function formatRunLine(e, now = Date.now(), live = []) {
|
|
85
88
|
const r = e.record;
|
|
86
89
|
if (!r)
|
|
87
90
|
return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
|
|
88
91
|
const rounds = countRounds(r.steps);
|
|
89
|
-
const end = r.endedAt ? Date.parse(r.endedAt) : now;
|
|
90
|
-
const parts = [e.card, when(new Date(r.startedAt)), r.workflow, r.steps.map((s) => s.label).join(' → '),
|
|
92
|
+
const end = r.endedAt ? Date.parse(r.endedAt) : isInterrupted(e, live) ? Date.parse(lastSeen(r)) : now;
|
|
93
|
+
const parts = [e.card, when(new Date(r.startedAt)), r.workflow, r.steps.map((s) => s.label).join(' → '), shownOutcome(e, live)];
|
|
91
94
|
if (rounds)
|
|
92
95
|
parts.push(plural(rounds, 'round'));
|
|
93
96
|
parts.push(formatDuration(end - Date.parse(r.startedAt)));
|
|
@@ -122,16 +125,16 @@ const readLog = (configDir) => {
|
|
|
122
125
|
}
|
|
123
126
|
};
|
|
124
127
|
const USAGE = {
|
|
125
|
-
status: 'status [--json] Runner, the card each workspace is on, parked cards and workflows',
|
|
128
|
+
status: 'status [--json] Runner, the card each workspace is on, failed and parked cards, scheduled jobs and workflows',
|
|
126
129
|
start: 'start Start the background runner',
|
|
127
130
|
stop: 'stop Stop the runner and cancel the cards it is working on',
|
|
128
131
|
restart: 'restart Stop and start the runner',
|
|
129
132
|
pause: 'pause Pick up no new cards; a card in progress carries on',
|
|
130
133
|
resume: 'resume Pick up new cards again',
|
|
131
|
-
run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute',
|
|
134
|
+
run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute; a named card skips the pause, the cap and the window hold',
|
|
132
135
|
cancel: 'cancel [CARD] Stop a card being worked on and clear its labels',
|
|
133
136
|
retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
|
|
134
|
-
runs: 'runs [CARD] [--limit N] [--json] Past runs, newest first',
|
|
137
|
+
runs: 'runs [CARD] [--limit N] [--jobs] [--json] Past runs, newest first; --jobs lists scheduled runs only',
|
|
135
138
|
show: 'show CARD [--json] Steps, outcome and labels of the latest run',
|
|
136
139
|
logs: 'logs [CARD] [--step N] [-f] [--lines N] Runner log, or a card\'s step logs',
|
|
137
140
|
clean: 'clean [--older 14d] [--yes] Remove old worktrees and run folders',
|
|
@@ -294,6 +297,43 @@ export function workspaceLines(workspaces, running) {
|
|
|
294
297
|
const names = [...new Set([...workspaces, ...running.map((r) => workspaceOf(r))])];
|
|
295
298
|
return names.map((ws) => ` ${ws}:${workingLine(running.find((r) => workspaceOf(r) === ws) ?? null) || ' idle.'}`);
|
|
296
299
|
}
|
|
300
|
+
// One entry per cron trigger: its newest run, last success and today's counts.
|
|
301
|
+
export function jobSummaries(config, all, live, now = Date.now()) {
|
|
302
|
+
const day = new Date(now).toDateString();
|
|
303
|
+
const jobs = [];
|
|
304
|
+
for (const [workflow, w] of Object.entries(config.workflows)) {
|
|
305
|
+
for (const t of w.triggers) {
|
|
306
|
+
if (t.source !== 'cron')
|
|
307
|
+
continue;
|
|
308
|
+
const mine = all.filter((e) => e.record?.source === 'cron' && e.record.workflow === workflow && e.record.trigger === t.schedule);
|
|
309
|
+
const newest = mine[0];
|
|
310
|
+
const r = newest?.record;
|
|
311
|
+
const ok = mine.find((e) => e.record.outcome === 'done' || e.record.outcome === 'verified');
|
|
312
|
+
const todays = mine.filter((e) => new Date(e.record.startedAt).toDateString() === day);
|
|
313
|
+
jobs.push({
|
|
314
|
+
workflow,
|
|
315
|
+
schedule: t.schedule,
|
|
316
|
+
task: t.task,
|
|
317
|
+
last: r ? { at: r.endedAt ?? r.startedAt, outcome: shownOutcome(newest, live), reason: r.reason, summary: r.steps.at(-1)?.summary } : null,
|
|
318
|
+
lastOkAt: ok?.record.startedAt ?? null,
|
|
319
|
+
today: {
|
|
320
|
+
runs: todays.length,
|
|
321
|
+
failed: todays.filter((e) => ['failed', 'parked'].includes(shownOutcome(e, live))).length,
|
|
322
|
+
handed: todays.reduce((n, e) => n + e.record.steps.reduce((m, s) => m + (s.handoffs?.length ?? 0), 0), 0),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return jobs;
|
|
328
|
+
}
|
|
329
|
+
// The status line for one job.
|
|
330
|
+
function jobLine(j) {
|
|
331
|
+
const bad = j.last?.outcome === 'failed' || j.last?.outcome === 'parked';
|
|
332
|
+
const head = bad
|
|
333
|
+
? `last failed ${clock(new Date(j.last.at))}${j.last.reason ? ` (${j.last.reason.replace(/\.$/, '')})` : ''}`
|
|
334
|
+
: `last ok ${j.lastOkAt ? clock(new Date(j.lastOkAt)) : 'never'}`;
|
|
335
|
+
return `Job ${j.workflow} (cron ${j.schedule}): ${head}, ${plural(j.today.runs, 'run')} today, ${j.today.failed} failed, ${j.today.handed} handed.`;
|
|
336
|
+
}
|
|
297
337
|
const status = guard(async (args, ctx) => {
|
|
298
338
|
const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
|
|
299
339
|
if (typeof p === 'number')
|
|
@@ -309,20 +349,29 @@ const status = guard(async (args, ctx) => {
|
|
|
309
349
|
const current = running.length ? { ...running[0], checking: false } : dispatcher ? { ...dispatcher, checking: true, elapsedMinutes: elapsed(dispatcher) } : null;
|
|
310
350
|
const seen = new Set();
|
|
311
351
|
const parked = [];
|
|
352
|
+
const failed = [];
|
|
312
353
|
const interrupted = [];
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (seen.has(e.card))
|
|
354
|
+
const all = listRuns(ctx.configDir);
|
|
355
|
+
for (const e of all) {
|
|
356
|
+
if (e.record?.source === 'cron' || seen.has(e.card))
|
|
317
357
|
continue;
|
|
318
358
|
seen.add(e.card);
|
|
319
359
|
if (e.record?.outcome === 'parked')
|
|
320
360
|
parked.push({ card: e.card, reason: e.record.reason });
|
|
361
|
+
else if (e.record?.outcome === 'failed')
|
|
362
|
+
failed.push({ card: e.card, reason: e.record.reason });
|
|
363
|
+
else if (isInterrupted(e, live))
|
|
364
|
+
failed.push({ card: e.card, reason: e.record?.reason ?? STOPPED_REASON });
|
|
321
365
|
}
|
|
366
|
+
const jobs = jobSummaries(config, all, live);
|
|
322
367
|
const workflows = Object.keys(config.workflows).length;
|
|
323
368
|
const projects = Object.keys(config.projects).length;
|
|
369
|
+
const pauseAt = config.pauseAt ?? DEFAULTS.pauseAt;
|
|
370
|
+
const maxRuns = config.maxRuns ?? 0;
|
|
371
|
+
const w = readWindow(ctx.configDir);
|
|
324
372
|
if (p.values.json) {
|
|
325
|
-
|
|
373
|
+
const window = w && { ...w, heldUntil: windowHold(w, pauseAt) };
|
|
374
|
+
ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, running, parked, failed, interrupted, jobs, workflows, projects, window, maxRuns }, null, 2));
|
|
326
375
|
return 0;
|
|
327
376
|
}
|
|
328
377
|
// One workspace keeps the single line; several get a line each.
|
|
@@ -338,12 +387,19 @@ const status = guard(async (args, ctx) => {
|
|
|
338
387
|
if (perWorkspace)
|
|
339
388
|
for (const line of workspaceLines(workspaces, running))
|
|
340
389
|
ctx.log(line);
|
|
390
|
+
const windowText = windowLine(w, pauseAt);
|
|
391
|
+
if (windowText)
|
|
392
|
+
ctx.log(windowText);
|
|
393
|
+
if (maxRuns)
|
|
394
|
+
ctx.log(`Cap: at most ${plural(maxRuns, 'run')} at once.`);
|
|
341
395
|
if (lastTick)
|
|
342
396
|
ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
|
|
343
|
-
for (const
|
|
344
|
-
ctx.log(`
|
|
397
|
+
for (const k of failed)
|
|
398
|
+
ctx.log(`Failed: ${k.card}${k.reason ? ` (${k.reason.replace(/\.$/, '')})` : ''}. vsf agents retry ${k.card} after fixing the cause.`);
|
|
345
399
|
for (const k of parked)
|
|
346
400
|
ctx.log(`Parked: ${k.card}${k.reason ? ` (${k.reason.replace(/\.$/, '')})` : ''}. vsf agents retry ${k.card} after fixing the cause.`);
|
|
401
|
+
for (const j of jobs)
|
|
402
|
+
ctx.log(jobLine(j));
|
|
347
403
|
ctx.log(`${plural(workflows, 'workflow')}, ${plural(projects, 'project')}. vsf agents workflows lists them.`);
|
|
348
404
|
return 0;
|
|
349
405
|
});
|
|
@@ -419,6 +475,8 @@ const run = guard(async (args, ctx) => {
|
|
|
419
475
|
if (!card && code === 0) {
|
|
420
476
|
if (isPaused(ctx.configDir))
|
|
421
477
|
ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
|
|
478
|
+
else if (gateReason({ configDir: ctx.configDir, config }))
|
|
479
|
+
ctx.log('Name a card to run it anyway.');
|
|
422
480
|
else
|
|
423
481
|
ctx.log('Nothing to do, or the card was verified.');
|
|
424
482
|
}
|
|
@@ -464,6 +522,11 @@ const retry = guard(async (args, ctx) => {
|
|
|
464
522
|
ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
|
|
465
523
|
return 1;
|
|
466
524
|
}
|
|
525
|
+
const latest = listRuns(ctx.configDir, id)[0]?.record;
|
|
526
|
+
if (latest?.source === 'cron') {
|
|
527
|
+
ctx.log(`${id} is a scheduled run. vsf agents run --workflow ${latest.workflow} fires it now.`);
|
|
528
|
+
return 1;
|
|
529
|
+
}
|
|
467
530
|
const config = readAgentsConfig(ctx.configDir);
|
|
468
531
|
const names = Object.keys(config.workflows);
|
|
469
532
|
let found;
|
|
@@ -514,7 +577,7 @@ const retry = guard(async (args, ctx) => {
|
|
|
514
577
|
return 0;
|
|
515
578
|
});
|
|
516
579
|
const runs = guard(async (args, ctx) => {
|
|
517
|
-
const p = parse('runs', args, { limit: { type: 'string' }, json: { type: 'boolean' } }, ctx);
|
|
580
|
+
const p = parse('runs', args, { limit: { type: 'string' }, jobs: { type: 'boolean' }, json: { type: 'boolean' } }, ctx);
|
|
518
581
|
if (typeof p === 'number')
|
|
519
582
|
return p;
|
|
520
583
|
const card = p.positionals[0]?.toUpperCase();
|
|
@@ -523,7 +586,8 @@ const runs = guard(async (args, ctx) => {
|
|
|
523
586
|
const limit = p.values.limit === undefined ? 20 : Number(p.values.limit);
|
|
524
587
|
if (!Number.isInteger(limit) || limit < 1)
|
|
525
588
|
return usageError('runs', ctx, '--limit needs a whole number above 0.');
|
|
526
|
-
|
|
589
|
+
// A named card lists all its runs; otherwise card runs, or with --jobs scheduled ones.
|
|
590
|
+
const entries = listRuns(ctx.configDir, card).filter((e) => !!card || (e.record?.source === 'cron') === !!p.values.jobs).slice(0, limit);
|
|
527
591
|
if (p.values.json) {
|
|
528
592
|
ctx.log(JSON.stringify(entries.map((e) => ({ card: e.card, stamp: e.stamp, dir: e.dir, logs: e.logs, record: e.record })), null, 2));
|
|
529
593
|
return 0;
|
|
@@ -564,13 +628,17 @@ const show = guard(async (args, ctx) => {
|
|
|
564
628
|
return 0;
|
|
565
629
|
}
|
|
566
630
|
ctx.log(`${latest.card} ${latest.title}`);
|
|
567
|
-
ctx.log(`Workflow ${latest.workflow}, ${
|
|
631
|
+
ctx.log(`Workflow ${latest.workflow}, ${shownOutcome(all[0], liveRuns(ctx.configDir))}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
|
|
568
632
|
if (labels)
|
|
569
633
|
ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
|
|
570
634
|
for (const s of latest.steps) {
|
|
571
635
|
const took = s.endedAt ? formatDuration(Date.parse(s.endedAt) - Date.parse(s.startedAt)) : 'running';
|
|
572
|
-
|
|
636
|
+
const used = usageText(s.usage);
|
|
637
|
+
ctx.log(` ${s.label.padEnd(12)} ${(s.result ?? 'running').toLowerCase().padEnd(8)} ${took.padEnd(6)} ${used ? `${used} ` : ''}${oneLine(s.summary ?? '')}`.trimEnd());
|
|
573
638
|
}
|
|
639
|
+
const total = usageText(latest.usage);
|
|
640
|
+
if (total || latest.usage?.models?.length)
|
|
641
|
+
ctx.log(`Usage: ${[total, latest.usage?.models?.join(', ')].filter(Boolean).join(', ')}`);
|
|
574
642
|
if (latest.outcome === 'parked' && latest.reason)
|
|
575
643
|
ctx.log(`Parked: ${oneLine(latest.reason, 300)}`);
|
|
576
644
|
if (all.length > 1)
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { type Usage } from './state.ts';
|
|
2
3
|
export type Engine = 'claude' | 'codex';
|
|
3
4
|
export interface RunOptions {
|
|
4
5
|
engine: Engine;
|
|
@@ -10,6 +11,7 @@ export interface RunOptions {
|
|
|
10
11
|
timeoutMs?: number;
|
|
11
12
|
vault?: string;
|
|
12
13
|
settingsFile?: string;
|
|
14
|
+
platform?: NodeJS.Platform;
|
|
13
15
|
}
|
|
14
16
|
export declare function engineArgs(o: RunOptions, outFile: string): {
|
|
15
17
|
cmd: string;
|
|
@@ -17,7 +19,27 @@ export declare function engineArgs(o: RunOptions, outFile: string): {
|
|
|
17
19
|
};
|
|
18
20
|
export declare function track(child: ChildProcess): void;
|
|
19
21
|
export declare function stopAgents(): Promise<void>;
|
|
22
|
+
export declare function errorTail(stderr: string): string;
|
|
23
|
+
export interface ClaudeResult {
|
|
24
|
+
text: string;
|
|
25
|
+
isError: boolean;
|
|
26
|
+
usage: Usage;
|
|
27
|
+
}
|
|
28
|
+
export declare function parseClaudeJson(stdout: string): ClaudeResult | null;
|
|
20
29
|
export declare function runAgent(o: RunOptions): Promise<{
|
|
21
30
|
ok: boolean;
|
|
22
31
|
output: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
usage?: Usage;
|
|
34
|
+
}>;
|
|
35
|
+
export declare function runCommand(o: {
|
|
36
|
+
command: string;
|
|
37
|
+
cwd: string;
|
|
38
|
+
logFile: string;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
env?: Record<string, string>;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
ok: boolean;
|
|
43
|
+
output: string;
|
|
44
|
+
error?: string;
|
|
23
45
|
}>;
|
package/dist/engine.js
CHANGED
|
@@ -2,15 +2,29 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { usageText } from "./state.js";
|
|
5
6
|
// Tools a write step may use under `claude -p`. Anything else is denied,
|
|
6
7
|
// since nobody is there to approve it.
|
|
7
8
|
const CLAUDE_WRITE = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
|
|
8
9
|
const CLAUDE_READ = ['Read', 'Glob', 'Grep', 'Bash(git diff:*)', 'Bash(git log:*)', 'Bash(git show:*)', 'Bash(npm test:*)', 'Bash(npm run:*)', 'Bash(pnpm:*)', 'Bash(yarn:*)', 'Bash(npx:*)', 'Bash(node:*)', 'Bash(ls:*)', 'Bash(rg:*)'];
|
|
9
10
|
export function engineArgs(o, outFile) {
|
|
11
|
+
// Nobody is there to approve a prompt, and an allowlist that misses a tool
|
|
12
|
+
// breaks the run, so on Linux every step runs with permissions skipped.
|
|
13
|
+
const skip = (o.platform ?? process.platform) === 'linux';
|
|
14
|
+
if (o.engine === 'claude' && skip) {
|
|
15
|
+
const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
|
|
16
|
+
if (o.vault)
|
|
17
|
+
args.push('--add-dir', o.vault);
|
|
18
|
+
if (o.model)
|
|
19
|
+
args.push('--model', o.model);
|
|
20
|
+
if (o.settingsFile)
|
|
21
|
+
args.push('--settings', o.settingsFile);
|
|
22
|
+
return { cmd: 'claude', args };
|
|
23
|
+
}
|
|
10
24
|
if (o.engine === 'claude') {
|
|
11
25
|
// Edit rules cover every file-writing tool; a Write(path) rule is ignored.
|
|
12
26
|
const vaultTools = o.vault ? [`Edit(/${o.vault}/**)`] : [];
|
|
13
|
-
const args = ['-p', '--output-format', '
|
|
27
|
+
const args = ['-p', '--output-format', 'json', '--allowedTools', [...(o.write ? CLAUDE_WRITE : CLAUDE_READ), ...vaultTools].join(',')];
|
|
14
28
|
if (o.vault)
|
|
15
29
|
args.push('--add-dir', o.vault);
|
|
16
30
|
if (o.write)
|
|
@@ -21,6 +35,13 @@ export function engineArgs(o, outFile) {
|
|
|
21
35
|
args.push('--settings', o.settingsFile);
|
|
22
36
|
return { cmd: 'claude', args };
|
|
23
37
|
}
|
|
38
|
+
if (skip) {
|
|
39
|
+
const args = ['exec', '-C', o.cwd, '--dangerously-bypass-approvals-and-sandbox', '-o', outFile];
|
|
40
|
+
if (o.model)
|
|
41
|
+
args.push('-m', o.model);
|
|
42
|
+
args.push('-');
|
|
43
|
+
return { cmd: 'codex', args };
|
|
44
|
+
}
|
|
24
45
|
const args = ['exec', '-C', o.cwd, '-s', o.write ? 'workspace-write' : 'read-only', '-o', outFile];
|
|
25
46
|
// A read-only sandbox cannot write anywhere, so only write steps get the vault.
|
|
26
47
|
if (o.vault && o.write)
|
|
@@ -49,8 +70,52 @@ export async function stopAgents() {
|
|
|
49
70
|
if (c.exitCode === null && c.signalCode === null)
|
|
50
71
|
c.kill('SIGKILL');
|
|
51
72
|
}
|
|
73
|
+
// Why a failed agent process failed: the last lines it wrote to stderr.
|
|
74
|
+
export function errorTail(stderr) {
|
|
75
|
+
const lines = stderr.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
76
|
+
let out = '';
|
|
77
|
+
for (const line of lines.reverse()) {
|
|
78
|
+
const next = out ? `${line} ${out}` : line;
|
|
79
|
+
if (next.length > 300)
|
|
80
|
+
break;
|
|
81
|
+
out = next;
|
|
82
|
+
}
|
|
83
|
+
return out || lines[0]?.slice(-300) || '';
|
|
84
|
+
}
|
|
85
|
+
// Claude's JSON answer: its final text, and the tokens and cost it spent.
|
|
86
|
+
// Null when the output is not that JSON, so it is taken as plain text.
|
|
87
|
+
export function parseClaudeJson(stdout) {
|
|
88
|
+
let j;
|
|
89
|
+
try {
|
|
90
|
+
j = JSON.parse(stdout);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (!j || typeof j !== 'object' || typeof j.result !== 'string')
|
|
96
|
+
return null;
|
|
97
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
98
|
+
const usage = {};
|
|
99
|
+
const models = j.modelUsage && typeof j.modelUsage === 'object' ? Object.entries(j.modelUsage) : [];
|
|
100
|
+
if (models.length) {
|
|
101
|
+
// Every model the step used, side models included.
|
|
102
|
+
usage.input = models.reduce((t, [, m]) => t + num(m?.inputTokens), 0);
|
|
103
|
+
usage.output = models.reduce((t, [, m]) => t + num(m?.outputTokens), 0);
|
|
104
|
+
usage.cached = models.reduce((t, [, m]) => t + num(m?.cacheReadInputTokens) + num(m?.cacheCreationInputTokens), 0);
|
|
105
|
+
usage.model = models.reduce((best, cur) => (num(cur[1]?.costUSD) > num(best[1]?.costUSD) ? cur : best))[0];
|
|
106
|
+
}
|
|
107
|
+
else if (j.usage && typeof j.usage === 'object') {
|
|
108
|
+
usage.input = num(j.usage.input_tokens);
|
|
109
|
+
usage.output = num(j.usage.output_tokens);
|
|
110
|
+
usage.cached = num(j.usage.cache_read_input_tokens) + num(j.usage.cache_creation_input_tokens);
|
|
111
|
+
}
|
|
112
|
+
if (typeof j.total_cost_usd === 'number')
|
|
113
|
+
usage.costUsd = j.total_cost_usd;
|
|
114
|
+
return { text: j.result, isError: j.is_error === true, usage };
|
|
115
|
+
}
|
|
52
116
|
// Runs one agent step with the prompt on stdin. Resolves with the agent's
|
|
53
|
-
// final text;
|
|
117
|
+
// final text; what it prints also goes to the log file, Claude's answer as
|
|
118
|
+
// its text and a usage line. A process that fails also says why in `error`.
|
|
54
119
|
export function runAgent(o) {
|
|
55
120
|
const dir = mkdtempSync(join(tmpdir(), 'voidshift-run-'));
|
|
56
121
|
const outFile = join(dir, 'last.txt');
|
|
@@ -63,24 +128,52 @@ export function runAgent(o) {
|
|
|
63
128
|
const env = { ...process.env, VOIDSHIFT_RECALL: 'off' };
|
|
64
129
|
const child = spawn(cmd, args, { cwd: o.cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
65
130
|
track(child);
|
|
131
|
+
// Claude's JSON comes at exit, so it is held and logged as text then.
|
|
66
132
|
let stdout = '';
|
|
67
133
|
child.stdout.on('data', (d) => {
|
|
68
134
|
stdout += d;
|
|
135
|
+
if (o.engine !== 'claude')
|
|
136
|
+
log.write(d);
|
|
137
|
+
});
|
|
138
|
+
// Only the tail is kept; the log has all of it.
|
|
139
|
+
let stderr = '';
|
|
140
|
+
child.stderr.on('data', (d) => {
|
|
141
|
+
stderr = (stderr + d).split('\n').slice(-20).join('\n');
|
|
69
142
|
log.write(d);
|
|
70
143
|
});
|
|
71
|
-
|
|
72
|
-
|
|
144
|
+
const timeoutMs = o.timeoutMs ?? 45 * 60_000;
|
|
145
|
+
let timedOut = false;
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
timedOut = true;
|
|
148
|
+
child.kill('SIGTERM');
|
|
149
|
+
}, timeoutMs);
|
|
73
150
|
child.on('error', (err) => {
|
|
74
151
|
clearTimeout(timer);
|
|
75
152
|
children.delete(child);
|
|
76
153
|
log.end(`\n${err.message}\n`);
|
|
77
|
-
resolve({ ok: false, output: err.message });
|
|
154
|
+
resolve({ ok: false, output: err.message, error: err.message });
|
|
78
155
|
});
|
|
79
156
|
child.on('close', (code) => {
|
|
80
157
|
clearTimeout(timer);
|
|
81
158
|
children.delete(child);
|
|
82
|
-
log.end(`\nexit ${code}\n`);
|
|
83
159
|
let output = stdout;
|
|
160
|
+
let usage;
|
|
161
|
+
let failure = '';
|
|
162
|
+
if (o.engine === 'claude') {
|
|
163
|
+
const parsed = parseClaudeJson(stdout);
|
|
164
|
+
if (parsed) {
|
|
165
|
+
output = parsed.text;
|
|
166
|
+
if (Object.keys(parsed.usage).length)
|
|
167
|
+
usage = parsed.usage;
|
|
168
|
+
if (parsed.isError)
|
|
169
|
+
failure = parsed.text;
|
|
170
|
+
log.write(`${output}\n\nusage: ${[usageText(usage), usage?.model].filter(Boolean).join(', ') || 'none reported'}\n`);
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
log.write(stdout);
|
|
174
|
+
}
|
|
175
|
+
else if (o.model)
|
|
176
|
+
usage = { model: o.model };
|
|
84
177
|
if (o.engine === 'codex') {
|
|
85
178
|
try {
|
|
86
179
|
output = readFileSync(outFile, 'utf8');
|
|
@@ -90,8 +183,52 @@ export function runAgent(o) {
|
|
|
90
183
|
}
|
|
91
184
|
}
|
|
92
185
|
rmSync(dir, { recursive: true, force: true });
|
|
93
|
-
|
|
186
|
+
const minutes = Math.round(timeoutMs / 60_000);
|
|
187
|
+
const error = code === 0 ? undefined : timedOut ? `timed out after ${minutes} minute${minutes === 1 ? '' : 's'}` : errorTail(stderr) || errorTail(failure) || `exit ${code}`;
|
|
188
|
+
// Settled once the log is written, so the step's log is whole when read.
|
|
189
|
+
log.end(`\nexit ${code}\n`, () => resolve({ ok: code === 0, output, ...(error ? { error } : {}), ...(usage ? { usage } : {}) }));
|
|
94
190
|
});
|
|
95
191
|
child.stdin.end(o.prompt);
|
|
96
192
|
});
|
|
97
193
|
}
|
|
194
|
+
// Runs one command step in a shell in place of an agent. Resolves with what it
|
|
195
|
+
// printed on stdout, which is also logged along with stderr; a command that
|
|
196
|
+
// fails also says why in `error`.
|
|
197
|
+
export function runCommand(o) {
|
|
198
|
+
const log = createWriteStream(o.logFile, { flags: 'a' });
|
|
199
|
+
log.write(`$ ${o.command}\n\n`);
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
const child = spawn('sh', ['-c', o.command], { cwd: o.cwd, env: { ...process.env, ...o.env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
202
|
+
track(child);
|
|
203
|
+
let stdout = '';
|
|
204
|
+
child.stdout.on('data', (d) => {
|
|
205
|
+
stdout += d;
|
|
206
|
+
log.write(d);
|
|
207
|
+
});
|
|
208
|
+
// Only the tail is kept; the log has all of it.
|
|
209
|
+
let stderr = '';
|
|
210
|
+
child.stderr.on('data', (d) => {
|
|
211
|
+
stderr = (stderr + d).split('\n').slice(-20).join('\n');
|
|
212
|
+
log.write(d);
|
|
213
|
+
});
|
|
214
|
+
const timeoutMs = o.timeoutMs ?? 45 * 60_000;
|
|
215
|
+
let timedOut = false;
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
timedOut = true;
|
|
218
|
+
child.kill('SIGTERM');
|
|
219
|
+
}, timeoutMs);
|
|
220
|
+
child.on('error', (err) => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
children.delete(child);
|
|
223
|
+
log.end(`\n${err.message}\n`);
|
|
224
|
+
resolve({ ok: false, output: err.message, error: err.message });
|
|
225
|
+
});
|
|
226
|
+
child.on('close', (code) => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
children.delete(child);
|
|
229
|
+
const minutes = Math.round(timeoutMs / 60_000);
|
|
230
|
+
const error = code === 0 ? undefined : timedOut ? `timed out after ${minutes} minute${minutes === 1 ? '' : 's'}` : errorTail(stderr) || `exit ${code}`;
|
|
231
|
+
log.end(`\nexit ${code}\n`, () => resolve({ ok: code === 0, output: stdout, ...(error ? { error } : {}) }));
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
package/dist/git.d.ts
CHANGED
|
@@ -34,3 +34,4 @@ export interface CommitPolicy {
|
|
|
34
34
|
}
|
|
35
35
|
export declare function applyPolicy(message: string, policy: CommitPolicy, signer: string | null, cwd: string): string;
|
|
36
36
|
export declare function enforceCommitPolicy(worktree: string, from: string, policy: CommitPolicy): number;
|
|
37
|
+
export declare function removeWorktree(repo: string, worktree: string, branch: string): void;
|
package/dist/git.js
CHANGED
|
@@ -194,3 +194,8 @@ export function enforceCommitPolicy(worktree, from, policy) {
|
|
|
194
194
|
must(sh('git', ['reset', '-q', '--soft', head], worktree), 'git reset');
|
|
195
195
|
return changed;
|
|
196
196
|
}
|
|
197
|
+
// Removes a worktree and its branch. Errors are ignored: either may be gone already.
|
|
198
|
+
export function removeWorktree(repo, worktree, branch) {
|
|
199
|
+
sh('git', ['worktree', 'remove', '--force', worktree], repo);
|
|
200
|
+
sh('git', ['branch', '-D', branch], repo);
|
|
201
|
+
}
|
package/dist/manage.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AgentsConfig, type Step, type Trigger, type Workflow } from './config.ts';
|
|
2
|
-
export declare function validateStep(step: Step, config: Pick<AgentsConfig, 'enabled'
|
|
3
|
-
export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'
|
|
2
|
+
export declare function validateStep(step: Step, config: Pick<AgentsConfig, 'enabled'> & Partial<Pick<AgentsConfig, 'workflows'>>, dir?: string): string[];
|
|
3
|
+
export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'> & Partial<Pick<AgentsConfig, 'workflows'>>, workflow: Workflow, dir?: string): string[];
|
|
4
4
|
export declare function validateAgentFolder(dir: string, config: Pick<AgentsConfig, 'enabled' | 'projects' | 'workflows' | 'folders'>): string[];
|
|
5
5
|
export declare const TRIGGER_FORMS = "TEAM:LABEL (Linear), voidtrack:TEAM:LABEL, or cron:SCHEDULE:TASK";
|
|
6
6
|
export declare function parseTrigger(input: string): Trigger | null;
|