@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 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(' → '), isInterrupted(e, live) ? 'interrupted' : r.outcome];
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
- for (const e of listRuns(ctx.configDir)) {
314
- if (e.record?.outcome === 'running' && isInterrupted(e, live))
315
- interrupted.push(e.card);
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
- ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, running, parked, interrupted, workflows, projects }, null, 2));
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 card of interrupted)
344
- ctx.log(`Interrupted: ${card}. vsf agents retry ${card} to run it again.`);
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
- const entries = listRuns(ctx.configDir, card).slice(0, limit);
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}, ${isInterrupted(all[0], liveRuns(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
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
- ctx.log(` ${s.label.padEnd(12)} ${(s.result ?? 'running').toLowerCase().padEnd(8)} ${took.padEnd(6)} ${oneLine(s.summary ?? '')}`.trimEnd());
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', 'text', '--allowedTools', [...(o.write ? CLAUDE_WRITE : CLAUDE_READ), ...vaultTools].join(',')];
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; everything it prints also goes to the log file.
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
- child.stderr.on('data', (d) => log.write(d));
72
- const timer = setTimeout(() => child.kill('SIGTERM'), o.timeoutMs ?? 45 * 60_000);
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
- resolve({ ok: code === 0, output });
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'>, dir?: string): string[];
3
- export declare function validateWorkflow(config: Pick<AgentsConfig, 'enabled' | 'projects'>, workflow: Workflow, dir?: string): string[];
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;