@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 +31 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +11 -0
- package/dist/control.d.ts +19 -0
- package/dist/control.js +87 -19
- package/dist/engine.d.ts +22 -0
- package/dist/engine.js +144 -7
- package/dist/git.d.ts +1 -0
- package/dist/git.js +5 -0
- package/dist/manage.d.ts +2 -2
- package/dist/manage.js +21 -5
- package/dist/notify.js +3 -3
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +215 -40
- package/dist/service.js +1 -1
- package/dist/state.d.ts +40 -1
- package/dist/state.js +116 -0
- package/dist/systemd.d.ts +2 -0
- package/dist/systemd.js +14 -0
- package/dist/usage.d.ts +18 -0
- package/dist/usage.js +102 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/usage.d.ts
ADDED
|
@@ -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