@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/dist/state.js CHANGED
@@ -113,6 +113,19 @@ export function setPaused(configDir, paused) {
113
113
  else
114
114
  rmSync(pausePath(configDir), { force: true });
115
115
  }
116
+ const windowPath = (configDir) => join(agentsDir(configDir), 'window.json');
117
+ export function readWindow(configDir) {
118
+ try {
119
+ return JSON.parse(readFileSync(windowPath(configDir), 'utf8'));
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ export function writeWindow(configDir, w) {
126
+ mkdirSync(agentsDir(configDir), { recursive: true });
127
+ writeFileSync(windowPath(configDir), JSON.stringify(w, null, 2) + '\n');
128
+ }
116
129
  const pendingPath = (configDir) => join(agentsDir(configDir), 'pending.json');
117
130
  export function readPending(configDir) {
118
131
  try {
@@ -129,6 +142,42 @@ export function writePending(configDir, pending) {
129
142
  else
130
143
  rmSync(pendingPath(configDir), { force: true });
131
144
  }
145
+ export function addUsage(total, step) {
146
+ const sum = (a, b) => (a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0));
147
+ const out = {};
148
+ const input = sum(total?.input, step.input);
149
+ const output = sum(total?.output, step.output);
150
+ const cached = sum(total?.cached, step.cached);
151
+ const costUsd = sum(total?.costUsd, step.costUsd);
152
+ if (input !== undefined)
153
+ out.input = input;
154
+ if (output !== undefined)
155
+ out.output = output;
156
+ if (cached !== undefined)
157
+ out.cached = cached;
158
+ if (costUsd !== undefined)
159
+ out.costUsd = costUsd;
160
+ const models = [...new Set([...(total?.models ?? []), ...(step.model ? [step.model] : [])])];
161
+ if (models.length)
162
+ out.models = models;
163
+ return out;
164
+ }
165
+ // "12.3k in, 4.1k out, 1.2M cached, $0.42 est.", or empty with nothing to show.
166
+ export function usageText(u) {
167
+ if (!u)
168
+ return '';
169
+ const n = (v) => (v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}k` : String(v));
170
+ const parts = [];
171
+ if (u.input !== undefined)
172
+ parts.push(`${n(u.input)} in`);
173
+ if (u.output !== undefined)
174
+ parts.push(`${n(u.output)} out`);
175
+ if (u.cached)
176
+ parts.push(`${n(u.cached)} cached`);
177
+ if (u.costUsd !== undefined)
178
+ parts.push(`$${u.costUsd.toFixed(2)} est.`);
179
+ return parts.join(', ');
180
+ }
132
181
  export const runsDir = (configDir) => join(agentsDir(configDir), 'runs');
133
182
  export function writeRun(runDir, run) {
134
183
  writeFileSync(join(runDir, 'run.json'), JSON.stringify(run, null, 2) + '\n');
@@ -174,3 +223,70 @@ export function writeCronState(configDir, state) {
174
223
  mkdirSync(agentsDir(configDir), { recursive: true });
175
224
  writeFileSync(cronPath(configDir), JSON.stringify(state, null, 2) + '\n');
176
225
  }
226
+ const handoffsPath = (configDir) => join(agentsDir(configDir), 'handoffs.json');
227
+ export function readHandoffs(configDir) {
228
+ try {
229
+ return JSON.parse(readFileSync(handoffsPath(configDir), 'utf8'));
230
+ }
231
+ catch {
232
+ return [];
233
+ }
234
+ }
235
+ export function writeHandoffs(configDir, handoffs) {
236
+ mkdirSync(agentsDir(configDir), { recursive: true });
237
+ if (handoffs.length)
238
+ writeFileSync(handoffsPath(configDir), JSON.stringify(handoffs, null, 2) + '\n');
239
+ else
240
+ rmSync(handoffsPath(configDir), { force: true });
241
+ }
242
+ // Queues cards for a workflow, once each: a card already waiting for it is skipped.
243
+ export function queueHandoffs(configDir, workflow, cards, from, now = new Date()) {
244
+ const queue = readHandoffs(configDir);
245
+ const added = [];
246
+ for (const card of cards) {
247
+ if (queue.some((h) => h.card === card && h.workflow === workflow))
248
+ continue;
249
+ const h = { card, workflow, from, at: now.toISOString() };
250
+ queue.push(h);
251
+ added.push(h);
252
+ }
253
+ if (added.length)
254
+ writeHandoffs(configDir, queue);
255
+ return added;
256
+ }
257
+ // Takes a card off the queue once its run for the workflow has started.
258
+ export function takeHandoff(configDir, card, workflow) {
259
+ const queue = readHandoffs(configDir);
260
+ const left = queue.filter((h) => !(h.card === card && h.workflow === workflow));
261
+ if (left.length !== queue.length)
262
+ writeHandoffs(configDir, left);
263
+ }
264
+ // When a run that ended without finishing last showed signs of life: its last
265
+ // step's end or start, else its start.
266
+ export function lastSeen(run) {
267
+ const last = run.steps.at(-1);
268
+ return last?.endedAt ?? last?.startedAt ?? run.startedAt;
269
+ }
270
+ export const STOPPED_REASON = 'The run stopped without finishing: its process ended before it could record an outcome.';
271
+ // Closes runs that say "running" while no live run holds them: their process
272
+ // is gone, so they failed. `live` is the run folders live runs hold, and
273
+ // `cards` the cards of live runs whose lock names no folder. Returns the cards closed.
274
+ export function closeStale(configDir, live) {
275
+ const closed = [];
276
+ for (const e of listRuns(configDir)) {
277
+ const r = e.record;
278
+ if (!r || r.outcome !== 'running')
279
+ continue;
280
+ if (live.some((l) => (l.runDir ? l.runDir === e.dir : l.card === e.card)))
281
+ continue;
282
+ const steps = r.steps.map((s) => (s.endedAt ? s : { ...s, endedAt: lastSeen(r), result: s.result ?? 'stopped' }));
283
+ try {
284
+ writeRun(e.dir, { ...r, steps, outcome: 'failed', reason: r.reason ?? STOPPED_REASON, endedAt: lastSeen({ ...r, steps }) });
285
+ closed.push(e.card);
286
+ }
287
+ catch {
288
+ // the folder is gone
289
+ }
290
+ }
291
+ return closed;
292
+ }
package/dist/systemd.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import type { Check } from './types.ts';
2
3
  export declare const UNIT = "voidshift-agents";
3
4
  export declare const servicePath: (home: string) => string;
@@ -12,3 +13,4 @@ export declare function startRunner(home: string, program: string[], env: Record
12
13
  export declare function stopRunner(home: string): void;
13
14
  export declare function programArgs(home: string): string[] | null;
14
15
  export declare function lingerCheck(): Check;
16
+ export declare function sudoCheck(run?: typeof spawnSync, uid?: number): Check;
package/dist/systemd.js CHANGED
@@ -83,3 +83,17 @@ export function lingerCheck() {
83
83
  ? { name: 'runner after logout', status: 'ok', detail: 'lingering is on, the runner keeps going after logout and reboot' }
84
84
  : { name: 'runner after logout', status: 'warn', detail: 'lingering is off, the runner stops when you log out', fix: `sudo loginctl enable-linger ${user}` };
85
85
  }
86
+ // Agents run with permissions skipped on Linux, so the runner's user account
87
+ // is the boundary: anything that user can do, an agent can too.
88
+ export function sudoCheck(run = spawnSync, uid = userInfo().uid) {
89
+ const name = 'runner user';
90
+ const fix = 'Run the runner as a dedicated user with no sudo, holding only the keys it needs.';
91
+ if (uid === 0)
92
+ return { name, status: 'fail', detail: 'runs as root; Claude refuses to skip permissions as root', fix };
93
+ const groups = (run('id', ['-Gn'], { encoding: 'utf8' }).stdout ?? '').toString().split(/\s+/);
94
+ const admin = groups.some((g) => ['sudo', 'wheel', 'admin'].includes(g));
95
+ if (admin || run('sudo', ['-n', 'true'], { encoding: 'utf8' }).status === 0) {
96
+ return { name, status: 'warn', detail: 'the runner user has sudo; anything an agent runs could too', fix };
97
+ }
98
+ return { name, status: 'ok', detail: 'no sudo, the account limits what agents can do' };
99
+ }
@@ -0,0 +1,18 @@
1
+ import { type Window } from './state.ts';
2
+ export declare const WINDOW_MS: number;
3
+ export interface TokenSource {
4
+ platform?: NodeJS.Platform;
5
+ claudeDir?: string;
6
+ now?: number;
7
+ }
8
+ export declare function readClaudeToken(o?: TokenSource): {
9
+ token: string;
10
+ } | {
11
+ error: string;
12
+ };
13
+ export declare function fetchWindow(token: string, fetcher?: typeof fetch): Promise<Pick<Window, 'utilization' | 'resetsAt'>>;
14
+ export declare function windowStale(w: Window | null, now?: Date): boolean;
15
+ export declare function refreshWindow(configDir: string, fetcher?: typeof fetch, token?: () => ReturnType<typeof readClaudeToken>, now?: Date): Promise<Window>;
16
+ export declare function windowHold(w: Window | null, pauseAt: number, now?: Date): string | null;
17
+ export declare const clock: (iso: string) => string;
18
+ export declare function windowLine(w: Window | null, pauseAt: number, now?: Date): string;
package/dist/usage.js ADDED
@@ -0,0 +1,102 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { readWindow, writeWindow } from "./state.js";
6
+ // The Claude subscription's 5 hour window. The runner reads it every few
7
+ // minutes with Claude Code's own login, read only: the token is never
8
+ // refreshed here, so an expired one just means no reading until Claude runs.
9
+ export const WINDOW_MS = 5 * 60_000;
10
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
11
+ // Claude Code's access token: the macOS Keychain first, then its credentials file.
12
+ export function readClaudeToken(o = {}) {
13
+ let text = '';
14
+ if ((o.platform ?? process.platform) === 'darwin') {
15
+ const r = spawnSync('/usr/bin/security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { encoding: 'utf8', timeout: 5000 });
16
+ if (r.status === 0)
17
+ text = r.stdout.trim();
18
+ }
19
+ if (!text) {
20
+ try {
21
+ text = readFileSync(join(o.claudeDir ?? (process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')), '.credentials.json'), 'utf8');
22
+ }
23
+ catch {
24
+ return { error: 'no Claude login found' };
25
+ }
26
+ }
27
+ let oauth;
28
+ try {
29
+ oauth = JSON.parse(text)?.claudeAiOauth;
30
+ }
31
+ catch {
32
+ return { error: 'the Claude credentials could not be read' };
33
+ }
34
+ if (typeof oauth?.accessToken !== 'string' || !oauth.accessToken)
35
+ return { error: 'no Claude login found' };
36
+ if (typeof oauth.expiresAt === 'number' && oauth.expiresAt <= (o.now ?? Date.now()))
37
+ return { error: 'the Claude login has expired; it renews when Claude next runs' };
38
+ return { token: oauth.accessToken };
39
+ }
40
+ export async function fetchWindow(token, fetcher = fetch) {
41
+ const res = await fetcher(USAGE_URL, { headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20' }, signal: AbortSignal.timeout(10_000) });
42
+ if (!res.ok)
43
+ throw new Error(`the usage endpoint answered ${res.status}`);
44
+ const five = (await res.json())?.five_hour;
45
+ // Percent used, 0 to 100, as Claude's /usage shows it. A value under 1 is
46
+ // under 1%, not a fraction: reading it as one would hold runs at a reset.
47
+ const u = typeof five?.utilization === 'number' ? five.utilization : NaN;
48
+ if (!Number.isFinite(u))
49
+ throw new Error('the answer had no 5 hour window');
50
+ const resets = typeof five?.resets_at === 'string' ? new Date(five.resets_at) : null;
51
+ return { utilization: u, ...(resets && !isNaN(resets.getTime()) ? { resetsAt: resets.toISOString() } : {}) };
52
+ }
53
+ // A reading is stale after 5 minutes, or once its window has reset.
54
+ export function windowStale(w, now = new Date()) {
55
+ if (!w)
56
+ return true;
57
+ if (now.getTime() - Date.parse(w.checkedAt) >= WINDOW_MS)
58
+ return true;
59
+ return !!w.resetsAt && Date.parse(w.resetsAt) <= now.getTime();
60
+ }
61
+ // Reads the window and saves it to window.json. Never throws: a failure is
62
+ // saved as the error, keeping the old reading while its window is still on.
63
+ export async function refreshWindow(configDir, fetcher = fetch, token = readClaudeToken, now = new Date()) {
64
+ const old = readWindow(configDir);
65
+ const keep = old?.utilization !== undefined && old.resetsAt && Date.parse(old.resetsAt) > now.getTime() ? { utilization: old.utilization, resetsAt: old.resetsAt } : {};
66
+ const checkedAt = now.toISOString();
67
+ let w;
68
+ try {
69
+ const t = token();
70
+ w = 'error' in t ? { checkedAt, ...keep, error: t.error } : { checkedAt, ...(await fetchWindow(t.token, fetcher)) };
71
+ }
72
+ catch (err) {
73
+ w = { checkedAt, ...keep, error: err.message };
74
+ }
75
+ writeWindow(configDir, w);
76
+ return w;
77
+ }
78
+ // When new runs may start again, or null when they may start now. Holds once
79
+ // the window is at `pauseAt` percent, until it resets; 0 never holds.
80
+ export function windowHold(w, pauseAt, now = new Date()) {
81
+ if (!w || pauseAt <= 0 || w.utilization === undefined || !w.resetsAt)
82
+ return null;
83
+ return w.utilization >= pauseAt && Date.parse(w.resetsAt) > now.getTime() ? w.resetsAt : null;
84
+ }
85
+ const pad2 = (n) => String(n).padStart(2, '0');
86
+ export const clock = (iso) => {
87
+ const d = new Date(iso);
88
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
89
+ };
90
+ // What status prints for the window, or empty with nothing read yet.
91
+ export function windowLine(w, pauseAt, now = new Date()) {
92
+ if (!w)
93
+ return '';
94
+ if (w.utilization === undefined)
95
+ return w.error ? `Window: could not read the 5 hour window (${w.error}).` : '';
96
+ const pct = Math.round(w.utilization);
97
+ const until = windowHold(w, pauseAt, now);
98
+ if (until)
99
+ return `Window: held at ${pct}% until ${clock(until)}, running cards finish.`;
100
+ const resets = w.resetsAt && Date.parse(w.resetsAt) > now.getTime() ? `, resets ${clock(w.resetsAt)}` : '';
101
+ return `Window: 5 hour at ${pct}%${resets}.`;
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidlabs.gg/voidshift-agents",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Agents for Claude Code and Codex that plan, build and QA Linear cards, chained and run in the background.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {