@voidlabs.gg/voidshift 0.4.6 → 0.4.7

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:
@@ -13,6 +13,7 @@ export interface AgentsCurrent {
13
13
  runDir?: string;
14
14
  checking?: boolean;
15
15
  elapsedMinutes?: number;
16
+ workspace?: string;
16
17
  }
17
18
  export interface AgentsStatus {
18
19
  engine?: string;
@@ -22,6 +23,7 @@ export interface AgentsStatus {
22
23
  line: string;
23
24
  } | null;
24
25
  current: AgentsCurrent | null;
26
+ running?: AgentsCurrent[];
25
27
  parked: {
26
28
  card: string;
27
29
  reason?: string;
@@ -30,6 +32,7 @@ export interface AgentsStatus {
30
32
  workflows: number;
31
33
  projects: number;
32
34
  }
35
+ export declare const liveRunsOf: (agents: AgentsStatus | null | undefined) => AgentsCurrent[];
33
36
  export interface StepRecord {
34
37
  step: string;
35
38
  label: string;
package/dist/tui/data.js CHANGED
@@ -22,6 +22,8 @@ import { CLI_PKG, cliVersion, isNewer } from "../version.js";
22
22
  const h = React.createElement;
23
23
  export const POLL_MS = 2000;
24
24
  export const CHECKS_MS = 30_000;
25
+ // The runs going on now, whichever shape the agents tool printed.
26
+ export const liveRunsOf = (agents) => agents?.running ?? (agents?.current?.card ? [agents.current] : []);
25
27
  export function emptyState(tools = []) {
26
28
  return {
27
29
  version: cliVersion(),
@@ -10,6 +10,7 @@ export declare function nextSection(sections: SectionId[], current: SectionId, d
10
10
  export declare const clampFocus: (sections: SectionId[], current: SectionId) => SectionId;
11
11
  export interface LiveCard {
12
12
  card: string;
13
+ workspace?: string;
13
14
  title: string;
14
15
  workflow: string;
15
16
  step: string;
@@ -18,7 +19,9 @@ export interface LiveCard {
18
19
  maxRounds: number | null;
19
20
  steps: StepProgress[];
20
21
  }
21
- export declare function liveCard(state: Pick<DataState, 'agents' | 'runs' | 'workflows' | 'steps' | 'agentsConfig'>, nowMs: number): LiveCard | null;
22
+ type LiveState = Pick<DataState, 'agents' | 'runs' | 'workflows' | 'steps' | 'agentsConfig'>;
23
+ export declare function liveCards(state: LiveState, nowMs: number): LiveCard[];
24
+ export declare const liveCard: (state: LiveState, nowMs: number) => LiveCard | null;
22
25
  export declare function runnerLines(agents: AgentsStatus, nowMs: number): {
23
26
  left: Line;
24
27
  right: Line;
@@ -49,3 +52,4 @@ export declare function homeHints(opts: {
49
52
  }): Hint[];
50
53
  export declare const homeScreen: Screen;
51
54
  export declare function Home(): React.ReactElement;
55
+ export {};
package/dist/tui/home.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // shaping of the store's data into lines (tested); the component below draws them.
4
4
  import React, { useEffect, useState } from 'react';
5
5
  import { Box, Text } from 'ink';
6
- import { useData, runsThisWeek, updateNotice } from "./data.js";
6
+ import { liveRunsOf, useData, runsThisWeek, updateNotice } from "./data.js";
7
7
  import { useHints, useKeys, useScreen } from "./context.js";
8
8
  import { clock, clockOrStamp, duration, minutesText, plural, secondsToNextCheck, tilde, truncate } from "./format.js";
9
9
  import { theme } from "./theme.js";
@@ -31,10 +31,18 @@ const titleCase = (id) => {
31
31
  const t = id.replace(/^vs-/, '').replace(/-/g, ' ');
32
32
  return t.charAt(0).toUpperCase() + t.slice(1);
33
33
  };
34
- // The card being worked on, with the progress of each step of its workflow.
35
- export function liveCard(state, nowMs) {
36
- const current = state.agents?.current;
37
- if (!current || !current.card)
34
+ // The cards being worked on, one per busy workspace, in workspace order.
35
+ export function liveCards(state, nowMs) {
36
+ return liveRunsOf(state.agents)
37
+ .map((c) => liveCardOf(c, state, nowMs))
38
+ .filter((c) => !!c)
39
+ .sort((a, b) => (a.workspace ?? '').localeCompare(b.workspace ?? ''));
40
+ }
41
+ // The first card being worked on.
42
+ export const liveCard = (state, nowMs) => liveCards(state, nowMs)[0] ?? null;
43
+ // A card being worked on, with the progress of each step of its workflow.
44
+ function liveCardOf(current, state, nowMs) {
45
+ if (!current.card)
38
46
  return null;
39
47
  const run = state.runs.find((r) => r.card === current.card && r.record?.outcome === 'running' && (!current.runDir || r.dir === current.runDir));
40
48
  const rec = run?.record;
@@ -67,11 +75,13 @@ export function liveCard(state, nowMs) {
67
75
  const startedAt = Date.parse(current.startedAt ?? rec?.startedAt ?? '');
68
76
  return {
69
77
  card: current.card,
78
+ workspace: current.workspace,
70
79
  title: current.title ?? rec?.title ?? '',
71
80
  workflow: current.workflow ?? rec?.workflow ?? '',
72
81
  step: current.step ?? '',
73
82
  minutes: current.elapsedMinutes ?? (Number.isNaN(startedAt) ? 0 : Math.max(0, Math.round((nowMs - startedAt) / 60_000))),
74
- round: (current.round ?? 0) + 1,
83
+ // The lock's round is 1-based like the card's label; 0 before the first.
84
+ round: current.round || 1,
75
85
  maxRounds: workflow?.maxRounds ?? null,
76
86
  steps,
77
87
  };
@@ -291,19 +301,19 @@ function AgentsPanel(props) {
291
301
  if (!agents)
292
302
  return h(Panel, { title: 'Agents', focused: props.focused }, toolState(state.errors.agents));
293
303
  const run = runnerLines(agents, props.now);
294
- const live = liveCard(state, props.now);
304
+ const lives = liveCards(state, props.now);
295
305
  const parked = parkedRows(agents);
296
306
  const rows = [h(SplitRow, { key: 'runner', left: run.left, right: run.right })];
297
- if (live) {
298
- rows.push(h(Text, { key: 'g1' }, ' '));
307
+ for (const live of lives) {
308
+ rows.push(h(Text, { key: `g1${live.card}` }, ' '));
299
309
  rows.push(h(SplitRow, {
300
- key: 'live',
310
+ key: `live${live.card}`,
301
311
  left: [seg('▶ ', theme.accent), seg(live.card, theme.accent, { bold: true }), seg(` ${truncate(live.title, 60)}`)],
302
- right: [seg(live.workflow, theme.dim)],
312
+ right: [seg(live.workspace && live.workspace !== 'default' ? `${live.workspace} · ${live.workflow}` : live.workflow, theme.dim)],
303
313
  }));
304
- rows.push(h(Box, { key: 'steps', flexDirection: 'row' }, h(Text, null, ' '), h(ProgressSteps, { steps: live.steps }), h(Text, { color: theme.dim }, ` running ${duration(live.minutes * 60_000)}${live.maxRounds ? ` · round ${live.round} of ${live.maxRounds}` : ` · round ${live.round}`}`)));
314
+ rows.push(h(Box, { key: `steps${live.card}`, flexDirection: 'row' }, h(Text, null, ' '), h(ProgressSteps, { steps: live.steps }), h(Text, { color: theme.dim }, ` running ${duration(live.minutes * 60_000)}${live.maxRounds ? ` · round ${live.round} of ${live.maxRounds}` : ` · round ${live.round}`}`)));
305
315
  }
306
- else if (agents.current) {
316
+ if (!lives.length && agents.current) {
307
317
  rows.push(h(Text, { key: 'g1' }, ' '));
308
318
  rows.push(h(Text, { key: 'checking', color: theme.dim }, ' Checking Linear now.'));
309
319
  }
@@ -8,7 +8,7 @@ import { Box, Text, useInput } from 'ink';
8
8
  import { needsTerminal } from "./actions.js";
9
9
  import { editText, Panel } from "./widgets.js";
10
10
  import { theme } from "./theme.js";
11
- import { useData } from "./data.js";
11
+ import { liveRunsOf, useData } from "./data.js";
12
12
  const h = React.createElement;
13
13
  // Splits "agents retry 'ENG 1'" into words. Quotes group words.
14
14
  export function splitCommand(text) {
@@ -113,9 +113,9 @@ export function dynamicEntries(state) {
113
113
  add(`agents retry ${p.card}`, 'queue the parked card again');
114
114
  for (const c of state.agents?.interrupted ?? [])
115
115
  add(`agents retry ${c}`, 'queue the interrupted card again');
116
- const live = state.agents?.current?.card;
117
- if (live)
118
- add(`agents cancel ${live}`, 'stop the card being worked on');
116
+ for (const live of liveRunsOf(state.agents))
117
+ if (live.card)
118
+ add(`agents cancel ${live.card}`, 'stop the card being worked on');
119
119
  const cards = new Set();
120
120
  for (const r of state.runs) {
121
121
  if (cards.size >= 6 && !cards.has(r.card))
@@ -9,7 +9,7 @@ import { useHints, useKeys, useScreen } from "../context.js";
9
9
  import { useData } from "../data.js";
10
10
  import { Lines, Panel, seg } from "../widgets.js";
11
11
  import { theme } from "../theme.js";
12
- import { cancelSpec, isLive, settledSteps, stepDuration, stepMark } from "./runs.js";
12
+ import { cancelSpec, liveRunFor, settledSteps, stepDuration, stepMark } from "./runs.js";
13
13
  const h = React.createElement;
14
14
  export const POLL_LOG_MS = 500;
15
15
  const MAX_LINES = 5000;
@@ -162,7 +162,8 @@ export function LogView(props) {
162
162
  const card = String(props.params.card ?? '');
163
163
  const dir = typeof props.params.dir === 'string' ? props.params.dir : null;
164
164
  const run = state.runs.find((r) => (dir ? r.dir === dir : r.card === card));
165
- const live = isLive(run, state);
165
+ const cur = liveRunFor(run, state);
166
+ const live = !!cur;
166
167
  const record = run?.record;
167
168
  const [picked, setPicked] = useState(null);
168
169
  const [scroll, setScroll] = useState({ top: null });
@@ -216,7 +217,7 @@ export function LogView(props) {
216
217
  if (!live)
217
218
  ctx.toast(`${card} is not running, so there is nothing to cancel.`, 'info');
218
219
  else
219
- void ctx.actions.run(cancelSpec(card, state.agents?.current?.step));
220
+ void ctx.actions.run(cancelSpec(card, cur?.step));
220
221
  }
221
222
  });
222
223
  if (!run) {
@@ -232,8 +233,8 @@ export function LogView(props) {
232
233
  if (!steps.length)
233
234
  left.push([seg('No steps yet', theme.dim)]);
234
235
  const workflow = state.workflows.find((w) => w.name === record?.workflow);
235
- const cur = state.agents?.current;
236
- const round = live && cur?.round !== undefined ? cur.round + 1 : Math.max(1, steps.filter((s) => s.step === steps[0]?.step).length);
236
+ // The lock's round is 1-based like the card's label; 0 before the first.
237
+ const round = cur?.round ? cur.round : Math.max(1, steps.filter((s) => s.step === steps[0]?.step).length);
237
238
  left.push([], [seg(`round ${round}${workflow?.maxRounds ? ` of ${workflow.maxRounds}` : ''}`, theme.dim)], []);
238
239
  run.logs.forEach((l) => {
239
240
  const on = l === file;
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { type Key } from 'ink';
3
3
  import { type ActionSpec, type Hint } from '../context.ts';
4
- import { type DataState, type RunItem, type StepRecord } from '../data.ts';
4
+ import { type AgentsCurrent, type DataState, type RunItem, type StepRecord } from '../data.ts';
5
5
  import { type Line } from '../widgets.ts';
6
6
  import type { Screen } from '../screens.ts';
7
7
  export declare const manualLogPath: (dir: string) => string;
@@ -13,7 +13,8 @@ export declare function stepsLabel(steps: StepRecord[]): string;
13
13
  export declare function roundsOf(steps: StepRecord[]): number;
14
14
  export declare function runMs(run: RunItem, nowMs: number): number | null;
15
15
  export declare function outcomeOf(run: RunItem, state: Pick<DataState, 'agents'>): string;
16
- export declare function isLive(run: RunItem | undefined, state: Pick<DataState, 'agents'>): boolean;
16
+ export declare function liveRunFor(run: RunItem | undefined, state: Pick<DataState, 'agents'>): AgentsCurrent | undefined;
17
+ export declare const isLive: (run: RunItem | undefined, state: Pick<DataState, "agents">) => boolean;
17
18
  export declare function startedText(run: RunItem): string;
18
19
  export declare function settledSteps(run: RunItem, state: Pick<DataState, 'agents'>): StepRecord[];
19
20
  export declare function stepMark(step: StepRecord): {
@@ -7,7 +7,7 @@ import { join } from 'node:path';
7
7
  import { configDir } from "../../config.js";
8
8
  import { runAction } from "../actions.js";
9
9
  import { useBackGuard, useEquivalent, useHints, useKeys, useScreen } from "../context.js";
10
- import { useData } from "../data.js";
10
+ import { liveRunsOf, useData } from "../data.js";
11
11
  import { duration, folderStamp, stamp, tilde, truncate } from "../format.js";
12
12
  import { Lines, Panel, Select, Table, TextInput, outcomeColour, seg } from "../widgets.js";
13
13
  import { theme } from "../theme.js";
@@ -48,22 +48,23 @@ export function runMs(run, nowMs) {
48
48
  const end = rec.endedAt ? Date.parse(rec.endedAt) : nowMs;
49
49
  return Math.max(0, end - start);
50
50
  }
51
- // The outcome shown for a run. A run that says "running" while the runner is
52
- // busy with something else was cut off.
51
+ // The outcome shown for a run. A run that says "running" while no live run
52
+ // is on its card was cut off.
53
53
  export function outcomeOf(run, state) {
54
54
  const outcome = run.record?.outcome;
55
55
  if (!outcome)
56
56
  return 'old run';
57
- if (outcome === 'running' && state.agents && state.agents.current?.card !== run.card)
57
+ if (outcome === 'running' && state.agents && !liveRunsOf(state.agents).some((c) => c.card === run.card))
58
58
  return 'interrupted';
59
59
  return outcome;
60
60
  }
61
- export function isLive(run, state) {
61
+ // The live run working on this run, if any.
62
+ export function liveRunFor(run, state) {
62
63
  if (!run || run.record?.outcome !== 'running')
63
- return false;
64
- const cur = state.agents?.current;
65
- return !!cur && cur.card === run.card && (!cur.runDir || cur.runDir === run.dir);
64
+ return undefined;
65
+ return liveRunsOf(state.agents).find((cur) => cur.card === run.card && (!cur.runDir || cur.runDir === run.dir));
66
66
  }
67
+ export const isLive = (run, state) => !!liveRunFor(run, state);
67
68
  export function startedText(run) {
68
69
  const t = run.record ? Date.parse(run.record.startedAt) : NaN;
69
70
  return Number.isNaN(t) ? folderStamp(run.stamp) : stamp(new Date(t));
@@ -235,7 +236,7 @@ export function Runs(props) {
235
236
  const isLatest = !!run && state.runs.find((r) => r.card === run.card)?.dir === run.dir;
236
237
  const labels = useLabels(isLatest && record ? run.card : undefined);
237
238
  const live = isLive(run, state);
238
- const liveStep = state.agents?.current?.step;
239
+ const liveStep = liveRunFor(run, state)?.step;
239
240
  const workflow = state.workflows.find((w) => w.name === record?.workflow);
240
241
  const stepIds = workflow?.steps ?? [...new Set((record?.steps ?? []).map((s) => s.step))];
241
242
  const labelOf = (id) => state.steps?.steps.find((s) => s.id === id)?.label ?? record?.steps.find((s) => s.step === id)?.label ?? id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "One CLI for the Void dev tools: keep your Mac awake, run agents in the background, shared skills and memory for Claude Code and Codex.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "scripts": {
42
42
  "build": "rm -rf dist && tsc -p tsconfig.json",
43
43
  "typecheck": "tsc -p tsconfig.json --noEmit",
44
- "test": "node --test test/*.test.ts",
44
+ "test": "node --test --test-force-exit --test-timeout=60000 test/*.test.ts",
45
45
  "dev": "node src/main.ts",
46
46
  "prepublishOnly": "rm -rf dist && tsc -p tsconfig.json"
47
47
  },