@voidlabs.gg/voidshift-agents 0.0.0-stage → 0.4.1

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.
@@ -0,0 +1,22 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export type Engine = 'claude' | 'codex';
3
+ export interface RunOptions {
4
+ engine: Engine;
5
+ model?: string;
6
+ cwd: string;
7
+ prompt: string;
8
+ write: boolean;
9
+ logFile: string;
10
+ timeoutMs?: number;
11
+ vault?: string;
12
+ }
13
+ export declare function engineArgs(o: RunOptions, outFile: string): {
14
+ cmd: string;
15
+ args: string[];
16
+ };
17
+ export declare function track(child: ChildProcess): void;
18
+ export declare function stopAgents(): Promise<void>;
19
+ export declare function runAgent(o: RunOptions): Promise<{
20
+ ok: boolean;
21
+ output: string;
22
+ }>;
package/dist/engine.js ADDED
@@ -0,0 +1,95 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // Tools a write step may use under `claude -p`. Anything else is denied,
6
+ // since nobody is there to approve it.
7
+ const CLAUDE_WRITE = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
8
+ const CLAUDE_READ = ['Read', 'Glob', 'Grep', 'Bash(git diff:*)', 'Bash(git log:*)', 'Bash(git show:*)', 'Bash(npm test:*)', 'Bash(npm run:*)', 'Bash(pnpm:*)', 'Bash(yarn:*)', 'Bash(npx:*)', 'Bash(node:*)', 'Bash(ls:*)', 'Bash(rg:*)'];
9
+ export function engineArgs(o, outFile) {
10
+ if (o.engine === 'claude') {
11
+ // Edit rules cover every file-writing tool; a Write(path) rule is ignored.
12
+ const vaultTools = o.vault ? [`Edit(/${o.vault}/**)`] : [];
13
+ const args = ['-p', '--output-format', 'text', '--allowedTools', [...(o.write ? CLAUDE_WRITE : CLAUDE_READ), ...vaultTools].join(',')];
14
+ if (o.vault)
15
+ args.push('--add-dir', o.vault);
16
+ if (o.write)
17
+ args.push('--permission-mode', 'acceptEdits');
18
+ if (o.model)
19
+ args.push('--model', o.model);
20
+ return { cmd: 'claude', args };
21
+ }
22
+ const args = ['exec', '-C', o.cwd, '-s', o.write ? 'workspace-write' : 'read-only', '-o', outFile];
23
+ // A read-only sandbox cannot write anywhere, so only write steps get the vault.
24
+ if (o.vault && o.write)
25
+ args.push('--add-dir', o.vault);
26
+ if (o.model)
27
+ args.push('-m', o.model);
28
+ args.push('-');
29
+ return { cmd: 'codex', args };
30
+ }
31
+ // Agent processes running now, so a cancel can stop them.
32
+ const children = new Set();
33
+ // Registers a child process so stopAgents() can stop it.
34
+ export function track(child) {
35
+ children.add(child);
36
+ child.on('close', () => children.delete(child));
37
+ }
38
+ // Asks every running agent to stop and waits up to 2 seconds for them to go,
39
+ // then kills the rest. The caller exits right after, so nothing may linger.
40
+ export async function stopAgents() {
41
+ for (const c of children)
42
+ c.kill('SIGTERM');
43
+ const deadline = Date.now() + 2000;
44
+ while (children.size && Date.now() < deadline)
45
+ await new Promise((r) => setTimeout(r, 50));
46
+ for (const c of children)
47
+ if (c.exitCode === null && c.signalCode === null)
48
+ c.kill('SIGKILL');
49
+ }
50
+ // Runs one agent step with the prompt on stdin. Resolves with the agent's
51
+ // final text; everything it prints also goes to the log file.
52
+ export function runAgent(o) {
53
+ const dir = mkdtempSync(join(tmpdir(), 'voidshift-run-'));
54
+ const outFile = join(dir, 'last.txt');
55
+ const { cmd, args } = engineArgs(o, outFile);
56
+ const log = createWriteStream(o.logFile, { flags: 'a' });
57
+ log.write(`$ ${cmd} ${args.join(' ')}\n\n`);
58
+ return new Promise((resolve) => {
59
+ // The runner recalls memory for the card itself; the recall hook would
60
+ // match on the whole prompt, skill text included.
61
+ const env = { ...process.env, VOIDSHIFT_RECALL: 'off' };
62
+ const child = spawn(cmd, args, { cwd: o.cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
63
+ track(child);
64
+ let stdout = '';
65
+ child.stdout.on('data', (d) => {
66
+ stdout += d;
67
+ log.write(d);
68
+ });
69
+ child.stderr.on('data', (d) => log.write(d));
70
+ const timer = setTimeout(() => child.kill('SIGTERM'), o.timeoutMs ?? 45 * 60_000);
71
+ child.on('error', (err) => {
72
+ clearTimeout(timer);
73
+ children.delete(child);
74
+ log.end(`\n${err.message}\n`);
75
+ resolve({ ok: false, output: err.message });
76
+ });
77
+ child.on('close', (code) => {
78
+ clearTimeout(timer);
79
+ children.delete(child);
80
+ log.end(`\nexit ${code}\n`);
81
+ let output = stdout;
82
+ if (o.engine === 'codex') {
83
+ try {
84
+ output = readFileSync(outFile, 'utf8');
85
+ }
86
+ catch {
87
+ // fall back to stdout
88
+ }
89
+ }
90
+ rmSync(dir, { recursive: true, force: true });
91
+ resolve({ ok: code === 0, output });
92
+ });
93
+ child.stdin.end(o.prompt);
94
+ });
95
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export interface Out {
2
+ ok: boolean;
3
+ code: number | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ export declare function sh(cmd: string, args: string[], cwd: string, timeoutMs?: number): Out;
8
+ export declare function defaultBranch(repo: string): string;
9
+ export declare function ensureWorktree(repo: string, path: string, branch: string, base: string): void;
10
+ export declare function commitsAhead(worktree: string, base: string): number;
11
+ export declare function headSha(worktree: string): string;
12
+ export declare function push(worktree: string, branch: string): void;
13
+ export declare function ensureDraftPr(worktree: string, branch: string, base: string, title: string, body: string): string;
14
+ export declare function markReady(worktree: string, branch: string): boolean;
15
+ export interface CheckSummary {
16
+ failed: {
17
+ name: string;
18
+ link: string;
19
+ }[];
20
+ pending: string[];
21
+ passed: number;
22
+ }
23
+ export declare function summarizeChecks(text: string): CheckSummary;
24
+ export declare function ciReport(s: CheckSummary): string;
25
+ export declare function waitForPrHead(worktree: string, branch: string, sha: string, timeoutMs?: number): Promise<boolean>;
26
+ export declare function waitForChecks(worktree: string, branch: string, timeoutMs?: number, settleMs?: number): Promise<{
27
+ verdict: 'SHIP' | 'FIX' | 'SKIP';
28
+ report: string;
29
+ }>;
30
+ export declare function resetWorktree(worktree: string): void;
package/dist/git.js ADDED
@@ -0,0 +1,136 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { track } from "./engine.js";
4
+ export function sh(cmd, args, cwd, timeoutMs = 120_000) {
5
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
6
+ return { ok: r.status === 0, code: r.status, stdout: (r.stdout ?? '').trim(), stderr: (r.stderr ?? '').trim() };
7
+ }
8
+ function must(out, what) {
9
+ if (!out.ok)
10
+ throw new Error(`${what} failed: ${out.stderr || out.stdout}`);
11
+ return out.stdout;
12
+ }
13
+ export function defaultBranch(repo) {
14
+ const ref = sh('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repo);
15
+ if (ref.ok)
16
+ return ref.stdout.replace(/^origin\//, '');
17
+ for (const b of ['main', 'master'])
18
+ if (sh('git', ['rev-parse', '--verify', `origin/${b}`], repo).ok)
19
+ return b;
20
+ throw new Error(`cannot tell the default branch of ${repo}`);
21
+ }
22
+ // A worktree per card on the card's branch, so the developer's checkout is
23
+ // never touched.
24
+ export function ensureWorktree(repo, path, branch, base) {
25
+ must(sh('git', ['fetch', 'origin'], repo), 'git fetch');
26
+ if (existsSync(path)) {
27
+ sh('git', ['pull', '--ff-only', 'origin', branch], path);
28
+ return;
29
+ }
30
+ const remote = sh('git', ['rev-parse', '--verify', `origin/${branch}`], repo).ok;
31
+ const local = sh('git', ['rev-parse', '--verify', branch], repo).ok;
32
+ const args = remote
33
+ ? ['worktree', 'add', '-B', branch, path, `origin/${branch}`]
34
+ : local
35
+ ? ['worktree', 'add', path, branch]
36
+ : ['worktree', 'add', '-b', branch, path, `origin/${base}`];
37
+ must(sh('git', args, repo), 'git worktree add');
38
+ }
39
+ export function commitsAhead(worktree, base) {
40
+ const out = sh('git', ['rev-list', '--count', `origin/${base}..HEAD`], worktree);
41
+ return out.ok ? Number(out.stdout) : 0;
42
+ }
43
+ export function headSha(worktree) {
44
+ return sh('git', ['rev-parse', 'HEAD'], worktree).stdout;
45
+ }
46
+ export function push(worktree, branch) {
47
+ must(sh('git', ['push', '-u', 'origin', branch], worktree), 'git push');
48
+ }
49
+ // The branch's open pull request. A closed one is reopened, since a closed PR
50
+ // never runs CI on new commits; a merged one gets a fresh PR.
51
+ export function ensureDraftPr(worktree, branch, base, title, body) {
52
+ const view = sh('gh', ['pr', 'view', branch, '--json', 'url,state', '-q', '.state + " " + .url'], worktree);
53
+ const [state, url] = view.ok ? view.stdout.split(' ') : [];
54
+ if (state === 'OPEN' && url)
55
+ return url;
56
+ if (state === 'CLOSED' && url && sh('gh', ['pr', 'reopen', branch], worktree).ok)
57
+ return url;
58
+ return must(sh('gh', ['pr', 'create', '--draft', '--base', base, '--head', branch, '--title', title, '--body', body], worktree), 'gh pr create');
59
+ }
60
+ export function markReady(worktree, branch) {
61
+ return sh('gh', ['pr', 'ready', branch], worktree).ok;
62
+ }
63
+ // Parses plain `gh pr checks` output: name, state, duration, link per line,
64
+ // tab separated. Works on old gh versions that lack --json.
65
+ export function summarizeChecks(text) {
66
+ const out = { failed: [], pending: [], passed: 0 };
67
+ for (const line of text.split('\n')) {
68
+ const [name, state, , link] = line.split('\t');
69
+ if (!name || !state)
70
+ continue;
71
+ if (state === 'pass' || state === 'skipping')
72
+ out.passed++;
73
+ else if (state === 'pending')
74
+ out.pending.push(name.trim());
75
+ else
76
+ out.failed.push({ name: name.trim(), link: (link ?? '').trim() });
77
+ }
78
+ return out;
79
+ }
80
+ // One short line per failed check, for the card and for Dev.
81
+ export function ciReport(s) {
82
+ const lines = s.failed.map((f) => `- ${f.name}${f.link ? `: ${f.link}` : ''}`);
83
+ const pending = s.pending.length ? `\nStill running: ${s.pending.join(', ')}.` : '';
84
+ return `CI failed:\n${lines.join('\n')}${pending}`;
85
+ }
86
+ // Waits for CI on the PR, then reads the final state once. Repos without CI
87
+ // pass through as SKIP.
88
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
89
+ // True once GitHub shows `sha` as the head of the branch's pull request, so the
90
+ // checks read next belong to that commit and not to an older push.
91
+ export async function waitForPrHead(worktree, branch, sha, timeoutMs = 3 * 60_000) {
92
+ const until = Date.now() + timeoutMs;
93
+ for (;;) {
94
+ const head = sh('gh', ['pr', 'view', branch, '--json', 'headRefOid', '-q', '.headRefOid'], worktree);
95
+ if (head.ok && head.stdout === sha)
96
+ return true;
97
+ if (Date.now() > until)
98
+ return false;
99
+ await sleep(10_000);
100
+ }
101
+ }
102
+ // CI for the pull request's current head. Checks take a moment to register
103
+ // after a push, so "no checks" only counts once they have had time to show up.
104
+ export async function waitForChecks(worktree, branch, timeoutMs = 30 * 60_000, settleMs = 2 * 60_000) {
105
+ const settled = Date.now() + settleMs;
106
+ const noChecks = () => {
107
+ const out = sh('gh', ['pr', 'checks', branch], worktree);
108
+ return /no checks reported/i.test(`${out.stdout}\n${out.stderr}`);
109
+ };
110
+ while (Date.now() < settled && noChecks())
111
+ await sleep(15_000);
112
+ return new Promise((resolve) => {
113
+ const child = spawn('gh', ['pr', 'checks', branch, '--watch', '--interval', '30'], { cwd: worktree, stdio: ['ignore', 'pipe', 'pipe'] });
114
+ track(child);
115
+ let text = '';
116
+ child.stdout.on('data', (d) => (text += d));
117
+ child.stderr.on('data', (d) => (text += d));
118
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
119
+ child.on('close', () => {
120
+ clearTimeout(timer);
121
+ if (/no checks reported/i.test(text))
122
+ return resolve({ verdict: 'SKIP', report: 'No CI checks on this repo.' });
123
+ const summary = summarizeChecks(sh('gh', ['pr', 'checks', branch], worktree).stdout);
124
+ if (!summary.failed.length && !summary.pending.length)
125
+ return resolve({ verdict: 'SHIP', report: `CI passed (${summary.passed} checks).` });
126
+ if (!summary.failed.length)
127
+ return resolve({ verdict: 'FIX', report: `CI did not finish in time. Still running: ${summary.pending.join(', ')}.` });
128
+ resolve({ verdict: 'FIX', report: ciReport(summary) });
129
+ });
130
+ });
131
+ }
132
+ // QA runs with write access so it can start the app; nothing it changes stays.
133
+ export function resetWorktree(worktree) {
134
+ sh('git', ['reset', '--hard', 'HEAD'], worktree);
135
+ sh('git', ['clean', '-fd'], worktree);
136
+ }
@@ -0,0 +1,3 @@
1
+ import type { Tool } from './types.ts';
2
+ declare const agents: Tool;
3
+ export default agents;