@voidlabs.gg/voidshift-agents 0.6.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;
@@ -28,12 +29,31 @@ export declare function trackerWarnings(kinds: Kind[], trackers: Trackers): Prom
28
29
  kind: Kind;
29
30
  message: string;
30
31
  }[]>;
32
+ export declare function changePaused(ctx: Pick<ToolContext, 'configDir' | 'log'>, paused: boolean, reason: string, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
31
33
  export declare function workingLine(current: (Current & {
32
34
  elapsedMinutes: number;
33
35
  }) | null): string;
34
36
  export declare function workspaceLines(workspaces: string[], running: (Current & {
35
37
  elapsedMinutes: number;
36
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[];
37
57
  export declare function pickCancel(locks: Current[], dispatcher: Current | null, card?: string): Current | string;
38
58
  export declare const controlCommands: Record<string, ToolCommand>;
39
59
  export declare function controlChecks(ctx: ToolContext): Promise<Check[]>;
package/dist/control.js CHANGED
@@ -2,13 +2,15 @@ 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";
13
+ import { notifyRun, notifyRunner, sendSummary } from "./notify.js";
12
14
  const HOUR = 3_600_000;
13
15
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
14
16
  const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
@@ -80,13 +82,15 @@ export function countRounds(steps) {
80
82
  }
81
83
  // A run that says 'running' but is none of the live runs was cut off.
82
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);
83
87
  export function formatRunLine(e, now = Date.now(), live = []) {
84
88
  const r = e.record;
85
89
  if (!r)
86
90
  return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
87
91
  const rounds = countRounds(r.steps);
88
- const end = r.endedAt ? Date.parse(r.endedAt) : now;
89
- 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)];
90
94
  if (rounds)
91
95
  parts.push(plural(rounds, 'round'));
92
96
  parts.push(formatDuration(end - Date.parse(r.startedAt)));
@@ -121,16 +125,16 @@ const readLog = (configDir) => {
121
125
  }
122
126
  };
123
127
  const USAGE = {
124
- 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',
125
129
  start: 'start Start the background runner',
126
130
  stop: 'stop Stop the runner and cancel the cards it is working on',
127
131
  restart: 'restart Stop and start the runner',
128
132
  pause: 'pause Pick up no new cards; a card in progress carries on',
129
133
  resume: 'resume Pick up new cards again',
130
- 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',
131
135
  cancel: 'cancel [CARD] Stop a card being worked on and clear its labels',
132
136
  retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
133
- 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',
134
138
  show: 'show CARD [--json] Steps, outcome and labels of the latest run',
135
139
  logs: 'logs [CARD] [--step N] [-f] [--lines N] Runner log, or a card\'s step logs',
136
140
  clean: 'clean [--older 14d] [--yes] Remove old worktrees and run folders',
@@ -246,6 +250,13 @@ export async function trackerWarnings(kinds, trackers) {
246
250
  }
247
251
  return out;
248
252
  }
253
+ // Pauses or resumes the runner, and pings Discord only when that changed something.
254
+ export async function changePaused(ctx, paused, reason, fetcher = fetch, platform = process.platform) {
255
+ const was = isPaused(ctx.configDir);
256
+ setPaused(ctx.configDir, paused);
257
+ if (was !== paused)
258
+ await notifyRunner(ctx.configDir, paused, reason, ctx.log, fetcher, platform);
259
+ }
249
260
  async function startCmd(ctx) {
250
261
  if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
251
262
  ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
@@ -254,7 +265,7 @@ async function startCmd(ctx) {
254
265
  for (const w of await trackerWarnings(trackersInUse(readAgentsConfig(ctx.configDir)), trackersFor(ctx.configDir))) {
255
266
  ctx.log(`Warning: ${w.message.replace(/\.?$/, '.')} Workflows on ${TRACKERS[w.kind].name} are skipped until then.`);
256
267
  }
257
- setPaused(ctx.configDir, false);
268
+ await changePaused(ctx, false, 'by vsf agents start');
258
269
  if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
259
270
  ctx.log('Could not start the runner. Check vsf agents logs.');
260
271
  return 1;
@@ -286,6 +297,43 @@ export function workspaceLines(workspaces, running) {
286
297
  const names = [...new Set([...workspaces, ...running.map((r) => workspaceOf(r))])];
287
298
  return names.map((ws) => ` ${ws}:${workingLine(running.find((r) => workspaceOf(r) === ws) ?? null) || ' idle.'}`);
288
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
+ }
289
337
  const status = guard(async (args, ctx) => {
290
338
  const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
291
339
  if (typeof p === 'number')
@@ -301,20 +349,29 @@ const status = guard(async (args, ctx) => {
301
349
  const current = running.length ? { ...running[0], checking: false } : dispatcher ? { ...dispatcher, checking: true, elapsedMinutes: elapsed(dispatcher) } : null;
302
350
  const seen = new Set();
303
351
  const parked = [];
352
+ const failed = [];
304
353
  const interrupted = [];
305
- for (const e of listRuns(ctx.configDir)) {
306
- if (e.record?.outcome === 'running' && isInterrupted(e, live))
307
- interrupted.push(e.card);
308
- 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))
309
357
  continue;
310
358
  seen.add(e.card);
311
359
  if (e.record?.outcome === 'parked')
312
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 });
313
365
  }
366
+ const jobs = jobSummaries(config, all, live);
314
367
  const workflows = Object.keys(config.workflows).length;
315
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);
316
372
  if (p.values.json) {
317
- 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));
318
375
  return 0;
319
376
  }
320
377
  // One workspace keeps the single line; several get a line each.
@@ -330,12 +387,19 @@ const status = guard(async (args, ctx) => {
330
387
  if (perWorkspace)
331
388
  for (const line of workspaceLines(workspaces, running))
332
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.`);
333
395
  if (lastTick)
334
396
  ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
335
- for (const card of interrupted)
336
- 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.`);
337
399
  for (const k of parked)
338
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));
339
403
  ctx.log(`${plural(workflows, 'workflow')}, ${plural(projects, 'project')}. vsf agents workflows lists them.`);
340
404
  return 0;
341
405
  });
@@ -366,7 +430,7 @@ const pause = guard(async (args, ctx) => {
366
430
  const p = parse('pause', args, {}, ctx);
367
431
  if (typeof p === 'number')
368
432
  return p;
369
- setPaused(ctx.configDir, true);
433
+ await changePaused(ctx, true, 'by vsf agents pause');
370
434
  ctx.log('Paused. The runner picks up no new cards; a card in progress carries on. vsf agents resume to continue.');
371
435
  return 0;
372
436
  });
@@ -374,7 +438,7 @@ const resume = guard(async (args, ctx) => {
374
438
  const p = parse('resume', args, {}, ctx);
375
439
  if (typeof p === 'number')
376
440
  return p;
377
- setPaused(ctx.configDir, false);
441
+ await changePaused(ctx, false, 'by vsf agents resume');
378
442
  ctx.log('Resumed.');
379
443
  return 0;
380
444
  });
@@ -411,6 +475,8 @@ const run = guard(async (args, ctx) => {
411
475
  if (!card && code === 0) {
412
476
  if (isPaused(ctx.configDir))
413
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.');
414
480
  else
415
481
  ctx.log('Nothing to do, or the card was verified.');
416
482
  }
@@ -456,6 +522,11 @@ const retry = guard(async (args, ctx) => {
456
522
  ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
457
523
  return 1;
458
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
+ }
459
530
  const config = readAgentsConfig(ctx.configDir);
460
531
  const names = Object.keys(config.workflows);
461
532
  let found;
@@ -506,7 +577,7 @@ const retry = guard(async (args, ctx) => {
506
577
  return 0;
507
578
  });
508
579
  const runs = guard(async (args, ctx) => {
509
- 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);
510
581
  if (typeof p === 'number')
511
582
  return p;
512
583
  const card = p.positionals[0]?.toUpperCase();
@@ -515,7 +586,8 @@ const runs = guard(async (args, ctx) => {
515
586
  const limit = p.values.limit === undefined ? 20 : Number(p.values.limit);
516
587
  if (!Number.isInteger(limit) || limit < 1)
517
588
  return usageError('runs', ctx, '--limit needs a whole number above 0.');
518
- 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);
519
591
  if (p.values.json) {
520
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));
521
593
  return 0;
@@ -556,13 +628,17 @@ const show = guard(async (args, ctx) => {
556
628
  return 0;
557
629
  }
558
630
  ctx.log(`${latest.card} ${latest.title}`);
559
- 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}` : ''}`);
560
632
  if (labels)
561
633
  ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
562
634
  for (const s of latest.steps) {
563
635
  const took = s.endedAt ? formatDuration(Date.parse(s.endedAt) - Date.parse(s.startedAt)) : 'running';
564
- 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());
565
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(', ')}`);
566
642
  if (latest.outcome === 'parked' && latest.reason)
567
643
  ctx.log(`Parked: ${oneLine(latest.reason, 300)}`);
568
644
  if (all.length > 1)
@@ -720,7 +796,11 @@ const tickCmd = async (args, ctx) => {
720
796
  try {
721
797
  const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
722
798
  const config = readAgentsConfig(ctx.configDir);
723
- const rc = { configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand };
799
+ const rc = {
800
+ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand,
801
+ notify: (e) => notifyRun(ctx.configDir, e, stamped),
802
+ summary: (paused) => sendSummary(ctx.configDir, paused, stamped),
803
+ };
724
804
  if (values.card || values.workflow)
725
805
  return await tick(rc, values.card?.toUpperCase(), { workflow: values.workflow, schedule: values.schedule, child: true });
726
806
  return await dispatch(rc, { reload: () => readAgentsConfig(ctx.configDir) });
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
  }>;