@voidlabs.gg/voidshift-agents 0.5.1 → 0.7.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
@@ -74,9 +74,9 @@ or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan an
74
74
  one final comment, nothing in between.
75
75
 
76
76
  ```sh
77
- vsf agents status # runner, the card in progress, parked cards
77
+ vsf agents status # runner, the card each workspace is on, parked cards
78
78
  vsf agents run ENG-123 [--from qa] # run one card now, in this terminal
79
- vsf agents start | stop | restart # the background runner (stop cancels the card)
79
+ vsf agents start | stop | restart # the background runner (stop cancels its cards)
80
80
  vsf agents pause | resume # pick up no new cards for a while
81
81
  vsf agents cancel | retry ENG-123 # stop a card, or queue a parked one again
82
82
  vsf agents runs | show ENG-123 # what happened, step by step
@@ -84,11 +84,25 @@ vsf agents logs [ENG-123] [-f] # the runner log, or a card's step logs
84
84
  vsf agents workflows # workflows in the order they match
85
85
  vsf agents workflow add|edit|show|rename|move|rm
86
86
  vsf agents projects | project add|edit|show|rename|rm
87
+ vsf agents workspace add|list|rm # folders of repos, each with its own queue
87
88
  vsf agents homes | link DIR # where the agents are installed
88
89
  vsf agents clean # old worktrees and run logs
89
90
  vsf agents validate [DIR] # check the config and agent folders
90
91
  ```
91
92
 
93
+ Each workspace has one queue: cards in different workspaces run side by side,
94
+ two cards in one workspace one after the other. Projects are in the `default`
95
+ workspace until you move them:
96
+
97
+ ```sh
98
+ vsf agents workspace add solvia --root ~/work/solvia # steps carry ~/work/solvia/CONTEXT.md
99
+ vsf agents project edit erp --workspace solvia --match DS06
100
+ ```
101
+
102
+ `--match` names a tracker project (or its first word, so `DS06` matches "DS06
103
+ Atleurope ERP"). A card of that project runs in the matching project, in any
104
+ workspace, before the workflow's own project is considered.
105
+
92
106
  A workflow can also be a folder you write by hand,
93
107
  `~/.config/voidshift/agents/defs/NAME/agent.json`, with its prompt files next to
94
108
  it. Steps are the built-in ids above or your own:
package/dist/config.d.ts CHANGED
@@ -52,7 +52,16 @@ export declare function describeTrigger(t: Trigger): string;
52
52
  export interface Project {
53
53
  path: string;
54
54
  base?: string;
55
+ workspace?: string;
56
+ match?: string[];
55
57
  }
58
+ export interface Workspace {
59
+ root: string;
60
+ context?: string;
61
+ }
62
+ export declare const DEFAULT_WORKSPACE = "default";
63
+ export declare const WORKSPACE_NAME: RegExp;
64
+ export declare const workspaceOf: (project: Pick<Project, "workspace"> | undefined) => string;
56
65
  export interface WriteBack {
57
66
  labels?: boolean;
58
67
  state?: string | null;
@@ -72,6 +81,7 @@ export interface AgentsConfig {
72
81
  review?: string;
73
82
  };
74
83
  projects: Record<string, Project>;
84
+ workspaces: Record<string, Workspace>;
75
85
  workflows: Record<string, Workflow>;
76
86
  reviewState: string;
77
87
  timeoutMinutes: number;
@@ -104,6 +114,15 @@ export declare function agentFolders(configDir: string): string[];
104
114
  export declare function readAgentFolder(dir: string): Workflow;
105
115
  export declare function readAgentsConfig(configDir: string): AgentsConfig;
106
116
  export declare function writeAgentsConfig(configDir: string, config: AgentsConfig): void;
117
+ export declare const workspacesInUse: (config: Pick<AgentsConfig, "projects" | "workspaces">) => string[];
118
+ export declare function matchesProject(entry: string, projectName: string): boolean;
119
+ export declare function resolveProject(config: Pick<AgentsConfig, 'projects'>, workflow: Pick<Workflow, 'project'>, card: {
120
+ projectName?: string;
121
+ }): {
122
+ name: string;
123
+ project: Project;
124
+ } | null;
125
+ export declare function workspaceContext(config: Pick<AgentsConfig, 'workspaces'>, name: string): string;
107
126
  export interface ResolvedStep {
108
127
  id: string;
109
128
  label: string;
package/dist/config.js CHANGED
@@ -43,6 +43,9 @@ export function describeTrigger(t) {
43
43
  return `cron ${t.schedule}`;
44
44
  return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
45
45
  }
46
+ export const DEFAULT_WORKSPACE = 'default';
47
+ export const WORKSPACE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
48
+ export const workspaceOf = (project) => project?.workspace || DEFAULT_WORKSPACE;
46
49
  // Step lists to start a workflow from.
47
50
  export const TEMPLATES = {
48
51
  build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
@@ -53,6 +56,7 @@ export const DEFAULTS = {
53
56
  engine: 'claude',
54
57
  models: { dev: 'opus', review: 'sonnet' },
55
58
  projects: {},
59
+ workspaces: {},
56
60
  workflows: {},
57
61
  reviewState: 'In Review',
58
62
  timeoutMinutes: 45,
@@ -157,6 +161,45 @@ export function writeAgentsConfig(configDir, config) {
157
161
  const workflows = Object.fromEntries(Object.entries(config.workflows).filter(([name]) => !folders?.[name]));
158
162
  writeFileSync(configPath(configDir), JSON.stringify({ ...rest, workflows }, null, 2) + '\n');
159
163
  }
164
+ // Every workspace a config uses: the named ones, then any a project names,
165
+ // the default one included.
166
+ export const workspacesInUse = (config) => [
167
+ ...new Set([...Object.keys(config.workspaces ?? {}), ...Object.values(config.projects).map(workspaceOf)]),
168
+ ];
169
+ // Whether a match entry names a tracker project: the whole name, or its first
170
+ // word, so "DS06" matches "DS06 Atleurope ERP". Case does not matter.
171
+ export function matchesProject(entry, projectName) {
172
+ const want = entry.trim().toLowerCase();
173
+ const name = projectName.trim().toLowerCase();
174
+ return !!want && (want === name || want === name.split(/\s+/)[0]);
175
+ }
176
+ // The project a card runs in. Explicit matches on the card's tracker project
177
+ // come first, in every workspace, and only then the workflow's own project.
178
+ // A scheduled run has no tracker project, so it always takes the workflow's.
179
+ export function resolveProject(config, workflow, card) {
180
+ if (card.projectName) {
181
+ for (const [name, project] of Object.entries(config.projects)) {
182
+ if (project.match?.some((m) => matchesProject(m, card.projectName)))
183
+ return { name, project };
184
+ }
185
+ }
186
+ const project = config.projects[workflow.project];
187
+ return project ? { name: workflow.project, project } : null;
188
+ }
189
+ // The workspace's context text for the step prompts: its context file, or
190
+ // CONTEXT.md in its root. Empty when there is none.
191
+ export function workspaceContext(config, name) {
192
+ const ws = config.workspaces?.[name];
193
+ if (!ws)
194
+ return '';
195
+ const file = ws.context ? resolve(ws.root, ws.context) : join(ws.root, 'CONTEXT.md');
196
+ try {
197
+ return readFileSync(file, 'utf8').trim();
198
+ }
199
+ catch {
200
+ return '';
201
+ }
202
+ }
160
203
  const packDir = fileURLToPath(new URL('../pack/', import.meta.url));
161
204
  const packSkill = (name) => join(packDir, name, 'SKILL.md');
162
205
  // The file a custom step's prompt comes from: its prompt file, a pack skill,
package/dist/control.d.ts CHANGED
@@ -10,8 +10,8 @@ export declare function stampDate(stamp: string): Date;
10
10
  export declare function countRounds(steps: {
11
11
  result?: string;
12
12
  }[]): number;
13
- export declare const isInterrupted: (e: RunEntry, live: Current | null) => boolean;
14
- export declare function formatRunLine(e: RunEntry, now?: number, live?: Current | null): string;
13
+ export declare const isInterrupted: (e: RunEntry, live: Current[]) => boolean;
14
+ export declare function formatRunLine(e: RunEntry, now?: number, live?: Current[]): string;
15
15
  export declare function scanRunnerErrors(text: string, now: number): {
16
16
  count: number;
17
17
  last: string;
@@ -28,8 +28,13 @@ export declare function trackerWarnings(kinds: Kind[], trackers: Trackers): Prom
28
28
  kind: Kind;
29
29
  message: string;
30
30
  }[]>;
31
+ export declare function changePaused(ctx: Pick<ToolContext, 'configDir' | 'log'>, paused: boolean, reason: string, fetcher?: typeof fetch, platform?: NodeJS.Platform): Promise<void>;
31
32
  export declare function workingLine(current: (Current & {
32
33
  elapsedMinutes: number;
33
34
  }) | null): string;
35
+ export declare function workspaceLines(workspaces: string[], running: (Current & {
36
+ elapsedMinutes: number;
37
+ })[]): string[];
38
+ export declare function pickCancel(locks: Current[], dispatcher: Current | null, card?: string): Current | string;
34
39
  export declare const controlCommands: Record<string, ToolCommand>;
35
40
  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 } from "./config.js";
6
- import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
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";
7
7
  import { runnerService } from "./service.js";
8
8
  import { TRACKERS, trackersFor } from "./tracker.js";
9
9
  import { parseFrom, resolveName, stepNames } from "./names.js";
10
10
  import { isStatusLabel } from "./workflow.js";
11
- import { cancelCard, tick } from "./runner.js";
11
+ import { cancelCard, dispatch, tick } from "./runner.js";
12
+ import { notifyRun, notifyRunner, sendSummary } from "./notify.js";
12
13
  const HOUR = 3_600_000;
13
14
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
14
15
  const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
@@ -78,9 +79,9 @@ export function countRounds(steps) {
78
79
  }
79
80
  return rounds;
80
81
  }
81
- // A run that says 'running' but is not the live card was cut off.
82
- export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !(live && (live.runDir ? live.runDir === e.dir : live.card === e.card));
83
- export function formatRunLine(e, now = Date.now(), live = null) {
82
+ // A run that says 'running' but is none of the live runs was cut off.
83
+ export const isInterrupted = (e, live) => e.record?.outcome === 'running' && !live.some((l) => (l.runDir ? l.runDir === e.dir : l.card === e.card));
84
+ export function formatRunLine(e, now = Date.now(), live = []) {
84
85
  const r = e.record;
85
86
  if (!r)
86
87
  return `${e.card} ${when(stampDate(e.stamp))} older run, logs only (${plural(e.logs.length, 'log')})`;
@@ -121,14 +122,14 @@ const readLog = (configDir) => {
121
122
  }
122
123
  };
123
124
  const USAGE = {
124
- status: 'status [--json] Runner, current card, parked cards and workflows',
125
+ status: 'status [--json] Runner, the card each workspace is on, parked cards and workflows',
125
126
  start: 'start Start the background runner',
126
- stop: 'stop Stop the runner and cancel the card it is working on',
127
+ stop: 'stop Stop the runner and cancel the cards it is working on',
127
128
  restart: 'restart Stop and start the runner',
128
129
  pause: 'pause Pick up no new cards; a card in progress carries on',
129
130
  resume: 'resume Pick up new cards again',
130
131
  run: 'run [CARD] [--workflow W] [--from STEP] Run now instead of waiting for the next minute',
131
- cancel: 'cancel [CARD] Stop the card being worked on and clear its labels',
132
+ cancel: 'cancel [CARD] Stop a card being worked on and clear its labels',
132
133
  retry: 'retry CARD [--workflow W] Queue a parked or cancelled card again',
133
134
  runs: 'runs [CARD] [--limit N] [--json] Past runs, newest first',
134
135
  show: 'show CARD [--json] Steps, outcome and labels of the latest run',
@@ -215,7 +216,7 @@ export async function cancelCurrent(ctx, c, deps) {
215
216
  }
216
217
  else
217
218
  ctx.log('Cleared a stale lock.');
218
- clearCurrent(ctx.configDir);
219
+ clearLock(ctx.configDir, c);
219
220
  }
220
221
  const realDeps = (ctx) => ({
221
222
  ours,
@@ -246,6 +247,13 @@ export async function trackerWarnings(kinds, trackers) {
246
247
  }
247
248
  return out;
248
249
  }
250
+ // Pauses or resumes the runner, and pings Discord only when that changed something.
251
+ export async function changePaused(ctx, paused, reason, fetcher = fetch, platform = process.platform) {
252
+ const was = isPaused(ctx.configDir);
253
+ setPaused(ctx.configDir, paused);
254
+ if (was !== paused)
255
+ await notifyRunner(ctx.configDir, paused, reason, ctx.log, fetcher, platform);
256
+ }
249
257
  async function startCmd(ctx) {
250
258
  if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
251
259
  ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
@@ -254,7 +262,7 @@ async function startCmd(ctx) {
254
262
  for (const w of await trackerWarnings(trackersInUse(readAgentsConfig(ctx.configDir)), trackersFor(ctx.configDir))) {
255
263
  ctx.log(`Warning: ${w.message.replace(/\.?$/, '.')} Workflows on ${TRACKERS[w.kind].name} are skipped until then.`);
256
264
  }
257
- setPaused(ctx.configDir, false);
265
+ await changePaused(ctx, false, 'by vsf agents start');
258
266
  if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
259
267
  ctx.log('Could not start the runner. Check vsf agents logs.');
260
268
  return 1;
@@ -263,7 +271,9 @@ async function startCmd(ctx) {
263
271
  }
264
272
  async function stopCmd(ctx) {
265
273
  runnerService().stop(ctx.home);
266
- // Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
274
+ // Stopping the runner may have stopped its runs; finish or tidy up whatever the locks still name.
275
+ for (const c of workspaceLocks(ctx.configDir))
276
+ await cancelCurrent(ctx, c, realDeps(ctx));
267
277
  const c = readCurrent(ctx.configDir);
268
278
  if (c)
269
279
  await cancelCurrent(ctx, c, realDeps(ctx));
@@ -278,6 +288,12 @@ export function workingLine(current) {
278
288
  return ' Checking now.';
279
289
  return ` Working on ${current.card}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round ? `round ${current.round}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`;
280
290
  }
291
+ const elapsed = (c) => (c.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(c.startedAt)) / 60_000)) : 0);
292
+ // One line per workspace: the card it is on, or idle.
293
+ export function workspaceLines(workspaces, running) {
294
+ const names = [...new Set([...workspaces, ...running.map((r) => workspaceOf(r))])];
295
+ return names.map((ws) => ` ${ws}:${workingLine(running.find((r) => workspaceOf(r) === ws) ?? null) || ' idle.'}`);
296
+ }
281
297
  const status = guard(async (args, ctx) => {
282
298
  const p = parse('status', args, { json: { type: 'boolean' } }, ctx);
283
299
  if (typeof p === 'number')
@@ -286,8 +302,11 @@ const status = guard(async (args, ctx) => {
286
302
  const runner = runnerState(ctx);
287
303
  const text = readLog(ctx.configDir);
288
304
  const lastTick = lastTickOf(text);
289
- const live = liveCurrent(ctx.configDir);
290
- const current = live ? { ...live, checking: !live.card, elapsedMinutes: live.startedAt ? Math.max(0, Math.round((Date.now() - Date.parse(live.startedAt)) / 60_000)) : 0 } : null;
305
+ const live = liveRuns(ctx.configDir);
306
+ const running = live.map((c) => ({ ...c, workspace: workspaceOf(c), elapsedMinutes: elapsed(c) }));
307
+ const dispatcher = liveCurrent(ctx.configDir);
308
+ // The first run, or the dispatcher checking for work, for dashboards that know one run only.
309
+ const current = running.length ? { ...running[0], checking: false } : dispatcher ? { ...dispatcher, checking: true, elapsedMinutes: elapsed(dispatcher) } : null;
291
310
  const seen = new Set();
292
311
  const parked = [];
293
312
  const interrupted = [];
@@ -303,16 +322,22 @@ const status = guard(async (args, ctx) => {
303
322
  const workflows = Object.keys(config.workflows).length;
304
323
  const projects = Object.keys(config.projects).length;
305
324
  if (p.values.json) {
306
- ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, parked, interrupted, workflows, projects }, null, 2));
325
+ ctx.log(JSON.stringify({ engine: config.engine, runner, lastTick, current, running, parked, interrupted, workflows, projects }, null, 2));
307
326
  return 0;
308
327
  }
309
- const working = workingLine(current);
328
+ // One workspace keeps the single line; several get a line each.
329
+ const workspaces = workspacesInUse(config);
330
+ const perWorkspace = runner !== 'stopped' && (workspaces.length > 1 || running.some((r) => !workspaces.includes(r.workspace)));
331
+ const working = perWorkspace ? '' : workingLine(current);
310
332
  if (runner === 'running')
311
333
  ctx.log(`Runner: running.${working}`);
312
334
  else if (runner === 'paused')
313
335
  ctx.log(`Runner: paused, picks up no new cards. vsf agents resume to continue.${working}`);
314
336
  else
315
337
  ctx.log('Runner: stopped. vsf agents start to begin.');
338
+ if (perWorkspace)
339
+ for (const line of workspaceLines(workspaces, running))
340
+ ctx.log(line);
316
341
  if (lastTick)
317
342
  ctx.log(`Last check: ${clock(new Date(lastTick.at))}, ${lastTick.line.replace(/\.$/, '') || 'no details'}.`);
318
343
  for (const card of interrupted)
@@ -349,7 +374,7 @@ const pause = guard(async (args, ctx) => {
349
374
  const p = parse('pause', args, {}, ctx);
350
375
  if (typeof p === 'number')
351
376
  return p;
352
- setPaused(ctx.configDir, true);
377
+ await changePaused(ctx, true, 'by vsf agents pause');
353
378
  ctx.log('Paused. The runner picks up no new cards; a card in progress carries on. vsf agents resume to continue.');
354
379
  return 0;
355
380
  });
@@ -357,7 +382,7 @@ const resume = guard(async (args, ctx) => {
357
382
  const p = parse('resume', args, {}, ctx);
358
383
  if (typeof p === 'number')
359
384
  return p;
360
- setPaused(ctx.configDir, false);
385
+ await changePaused(ctx, false, 'by vsf agents resume');
361
386
  ctx.log('Resumed.');
362
387
  return 0;
363
388
  });
@@ -399,6 +424,20 @@ const run = guard(async (args, ctx) => {
399
424
  }
400
425
  return code;
401
426
  });
427
+ // The lock a cancel ends, or why there is none. Every run's lock counts, stale
428
+ // ones included, so a dead run can be tidied up; with none, the dispatcher's
429
+ // own lock, a check in progress. Without a card it must be the only one.
430
+ export function pickCancel(locks, dispatcher, card) {
431
+ const all = locks.length ? locks : dispatcher ? [dispatcher] : [];
432
+ if (!all.length)
433
+ return card ? `${card} is not running.` : 'Nothing is running.';
434
+ const name = (l) => `${l.card ?? 'a check'}${l.workspace ? ` in ${l.workspace}` : ''}`;
435
+ if (card)
436
+ return all.find((l) => l.card === card) ?? `${card} is not running. Running: ${all.map(name).join(', ')}.`;
437
+ if (all.length > 1)
438
+ return `More than one card is running: ${all.map(name).join(', ')}. Name one: vsf agents cancel CARD.`;
439
+ return all[0];
440
+ }
402
441
  const cancel = guard(async (args, ctx) => {
403
442
  const p = parse('cancel', args, {}, ctx);
404
443
  if (typeof p === 'number')
@@ -406,16 +445,12 @@ const cancel = guard(async (args, ctx) => {
406
445
  const card = p.positionals[0]?.toUpperCase();
407
446
  if (p.positionals.length > 1 || (card && !CARD_ID.test(card)))
408
447
  return usageError('cancel', ctx, 'Give one card id like ENG-12.');
409
- const c = readCurrent(ctx.configDir);
410
- if (!c) {
411
- ctx.log(card ? `${card} is not running.` : 'Nothing is running.');
412
- return card ? 1 : 0;
413
- }
414
- if (card && c.card !== card) {
415
- ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
416
- return 1;
448
+ const found = pickCancel(workspaceLocks(ctx.configDir), readCurrent(ctx.configDir), card);
449
+ if (typeof found === 'string') {
450
+ ctx.log(found);
451
+ return found.startsWith('More than one') ? 2 : card ? 1 : 0;
417
452
  }
418
- await cancelCurrent(ctx, c, realDeps(ctx));
453
+ await cancelCurrent(ctx, found, realDeps(ctx));
419
454
  return 0;
420
455
  });
421
456
  const retry = guard(async (args, ctx) => {
@@ -425,7 +460,7 @@ const retry = guard(async (args, ctx) => {
425
460
  const id = p.positionals[0]?.toUpperCase();
426
461
  if (p.positionals.length !== 1 || !CARD_ID.test(id))
427
462
  return usageError('retry', ctx, 'Give one card id like ENG-12.');
428
- if (liveCurrent(ctx.configDir)?.card === id) {
463
+ if (liveRuns(ctx.configDir).some((c) => c.card === id)) {
429
464
  ctx.log(`${id} is running now. vsf agents cancel ${id} first.`);
430
465
  return 1;
431
466
  }
@@ -495,7 +530,7 @@ const runs = guard(async (args, ctx) => {
495
530
  }
496
531
  if (!entries.length)
497
532
  ctx.log('No runs yet.');
498
- const live = liveCurrent(ctx.configDir);
533
+ const live = liveRuns(ctx.configDir);
499
534
  for (const e of entries)
500
535
  ctx.log(formatRunLine(e, Date.now(), live));
501
536
  return 0;
@@ -529,7 +564,7 @@ const show = guard(async (args, ctx) => {
529
564
  return 0;
530
565
  }
531
566
  ctx.log(`${latest.card} ${latest.title}`);
532
- ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveCurrent(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
567
+ ctx.log(`Workflow ${latest.workflow}, ${isInterrupted(all[0], liveRuns(ctx.configDir)) ? 'interrupted' : latest.outcome}${latest.prUrl ? `, PR ${latest.prUrl}` : ''}`);
533
568
  if (labels)
534
569
  ctx.log(`Labels: ${labels.join(', ') || 'none'}`);
535
570
  for (const s of latest.steps) {
@@ -645,10 +680,10 @@ const clean = guard(async (args, ctx) => {
645
680
  if (ms === null)
646
681
  return usageError('clean', ctx, `${age} is not an age. Use a number of days or hours, like 14d or 36h.`);
647
682
  const cutoff = Date.now() - ms;
648
- const live = liveCurrent(ctx.configDir);
683
+ const live = new Set(liveRuns(ctx.configDir).map((c) => c.card));
649
684
  const wtRoot = join(agentsDir(ctx.configDir), 'worktrees');
650
685
  const worktrees = (existsSync(wtRoot) ? readdirSync(wtRoot) : [])
651
- .filter((card) => card !== live?.card)
686
+ .filter((card) => !live.has(card))
652
687
  .filter((card) => {
653
688
  const record = listRuns(ctx.configDir, card)[0]?.record;
654
689
  const ended = record?.endedAt ? Date.parse(record.endedAt) : mtime(join(wtRoot, card));
@@ -656,7 +691,7 @@ const clean = guard(async (args, ctx) => {
656
691
  })
657
692
  .map((card) => join(wtRoot, card));
658
693
  const old = listRuns(ctx.configDir).filter((e) => {
659
- if (e.record?.outcome === 'running' && e.card === live?.card)
694
+ if (e.record?.outcome === 'running' && live.has(e.card))
660
695
  return false;
661
696
  return (e.record?.endedAt ? Date.parse(e.record.endedAt) : mtime(e.dir)) < cutoff;
662
697
  });
@@ -686,12 +721,21 @@ const clean = guard(async (args, ctx) => {
686
721
  ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
687
722
  return 0;
688
723
  });
689
- // Internal: the runner (launchd or systemd) runs this every minute.
690
- const tickCmd = async (_args, ctx) => {
724
+ // Internal: the runner (launchd or systemd) runs this every minute, and it
725
+ // starts itself with --card or --schedule for each run, one per workspace.
726
+ const tickCmd = async (args, ctx) => {
691
727
  const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
692
728
  try {
729
+ const { values } = parseArgs({ args, options: { card: { type: 'string' }, workflow: { type: 'string' }, schedule: { type: 'string' } }, strict: true });
693
730
  const config = readAgentsConfig(ctx.configDir);
694
- return await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
731
+ const rc = {
732
+ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand,
733
+ notify: (e) => notifyRun(ctx.configDir, e, stamped),
734
+ summary: (paused) => sendSummary(ctx.configDir, paused, stamped),
735
+ };
736
+ if (values.card || values.workflow)
737
+ return await tick(rc, values.card?.toUpperCase(), { workflow: values.workflow, schedule: values.schedule, child: true });
738
+ return await dispatch(rc, { reload: () => readAgentsConfig(ctx.configDir) });
695
739
  }
696
740
  catch (err) {
697
741
  stamped(`Could not run the check: ${err.message}`);
@@ -734,8 +778,8 @@ export async function controlChecks(ctx) {
734
778
  ? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
735
779
  : { name: 'runner program', status: 'ok', detail: 'found' });
736
780
  }
737
- const c = readCurrent(ctx.configDir);
738
- if (c && !ours(c.pid))
781
+ const locks = [readCurrent(ctx.configDir), ...workspaceLocks(ctx.configDir)];
782
+ if (locks.some((c) => c && !ours(c.pid)))
739
783
  checks.push({ name: 'stale lock', status: 'warn', detail: 'a stale lock from a run that died', fix: 'vsf agents cancel' });
740
784
  if (existsSync(logPath(ctx.configDir))) {
741
785
  const { count, last } = scanRunnerErrors(readLog(ctx.configDir), Date.now());