@galda/cli 0.10.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.
Files changed (34) hide show
  1. package/CLAUDE.md +44 -0
  2. package/README.md +83 -0
  3. package/app/fonts.css +8 -0
  4. package/app/index.html +7638 -0
  5. package/app/theme.css +126 -0
  6. package/app/wp/w1.jpg +0 -0
  7. package/app/wp/w2.jpg +0 -0
  8. package/bin/manager-for-ai.mjs +76 -0
  9. package/engine/lib.mjs +2378 -0
  10. package/engine/manager.mjs +115 -0
  11. package/engine/mcp.mjs +123 -0
  12. package/engine/pr.mjs +144 -0
  13. package/engine/relay-client.mjs +82 -0
  14. package/engine/server.mjs +3315 -0
  15. package/engine/verify.mjs +158 -0
  16. package/examples/task-001/runs/2026-07-03T06-23-57-441Z/attempt-1-proof.png +0 -0
  17. package/examples/task-001/runs/2026-07-03T06-23-57-441Z/report.json +20 -0
  18. package/examples/task-001/runs/2026-07-03T06-29-54-517Z/attempt-1-proof.png +0 -0
  19. package/examples/task-001/runs/2026-07-03T06-29-54-517Z/report.json +20 -0
  20. package/examples/task-001/runs/2026-07-03T06-40-23-231Z/attempt-1-proof.png +0 -0
  21. package/examples/task-001/runs/2026-07-03T06-40-23-231Z/report.json +20 -0
  22. package/examples/task-001/runs/2026-07-03T07-34-58-109Z/attempt-1-proof.png +0 -0
  23. package/examples/task-001/runs/2026-07-03T07-34-58-109Z/report.json +21 -0
  24. package/examples/task-001/runs/2026-07-03T07-53-44-639Z/attempt-1-proof.png +0 -0
  25. package/examples/task-001/runs/2026-07-03T07-53-44-639Z/report.json +21 -0
  26. package/examples/task-001/runs/2026-07-03T08-02-12-117Z/attempt-1-proof.png +0 -0
  27. package/examples/task-001/runs/2026-07-03T08-02-12-117Z/report.json +21 -0
  28. package/examples/task-001/task.json +11 -0
  29. package/examples/task-001/verify.mjs +16 -0
  30. package/examples/toast-app/app.js +23 -0
  31. package/examples/toast-app/index.html +28 -0
  32. package/examples/toast-app/test/guard.test.mjs +83 -0
  33. package/examples/toast-app/test/style.test.mjs +19 -0
  34. package/package.json +52 -0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // Manager for AI — minimal verify-proof loop (dogfood v0)
3
+ //
4
+ // node engine/manager.mjs examples/task-001
5
+ //
6
+ // Loop: hand the task to a Claude Code worker (flat-rate, `claude -p`)
7
+ // → Manager independently verifies against the explicit pass condition
8
+ // (headless Chrome; the worker's self-report is never trusted)
9
+ // → on fail, retry with the failure evidence, up to maxAttempts
10
+ // → on pass, emit proof (video + screenshot) — only then is the task "done".
11
+
12
+ import { spawnSync } from 'node:child_process';
13
+ import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
14
+ import { resolve, dirname, join } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { createProofPR } from './pr.mjs';
17
+ import { runVerification } from './verify.mjs';
18
+
19
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+ const WORKER_TIMEOUT_MS = 8 * 60 * 1000;
21
+
22
+ const taskDir = resolve(process.argv[2] ?? '');
23
+ if (!taskDir) { console.error('usage: node engine/manager.mjs <taskDir>'); process.exit(2); }
24
+
25
+ const task = JSON.parse(readFileSync(join(taskDir, 'task.json'), 'utf8'));
26
+ const appDir = resolve(ROOT, task.app);
27
+ const entryUrl = pathToFileURL(join(appDir, task.entry)).href;
28
+ const { default: verify } = await import(pathToFileURL(join(taskDir, task.verify)).href);
29
+
30
+ const runDir = join(taskDir, 'runs', new Date().toISOString().replace(/[:.]/g, '-'));
31
+ mkdirSync(runDir, { recursive: true });
32
+
33
+ const log = (m) => console.log(`[manager] ${m}`);
34
+
35
+ function workerPrompt(attempt, lastFailure) {
36
+ return [
37
+ `You are a worker inside an autonomous verify loop ("Manager for AI"). Attempt ${attempt}/${task.maxAttempts}.`,
38
+ ``,
39
+ `TASK: ${task.prompt}`,
40
+ ``,
41
+ `EXPLICIT PASS CONDITION (checked externally by a headless browser after you finish — NOT by you): ${task.passCondition}`,
42
+ lastFailure ? `\nPREVIOUS ATTEMPT FAILED VERIFICATION: ${lastFailure}\nThe code on disk includes your previous changes. Diagnose why verification failed and fix it.` : '',
43
+ ``,
44
+ `RULES: Edit only files inside this directory. Keep the change minimal. Do not claim completion — the external verifier decides. When you believe the fix is in place, just stop.`,
45
+ ].join('\n');
46
+ }
47
+
48
+ function runWorker(attempt, lastFailure) {
49
+ const prompt = workerPrompt(attempt, lastFailure);
50
+ log(`attempt ${attempt}: handing task to Claude Code worker…`);
51
+ const t0 = Date.now();
52
+ const res = spawnSync('claude', [
53
+ '-p', prompt,
54
+ '--model', 'sonnet',
55
+ '--permission-mode', 'acceptEdits',
56
+ '--allowedTools', 'Edit,Write,Read,Glob,Grep',
57
+ ], { cwd: appDir, encoding: 'utf8', timeout: WORKER_TIMEOUT_MS });
58
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
59
+ const out = (res.stdout ?? '') + (res.stderr ? `\n[stderr]\n${res.stderr}` : '');
60
+ writeFileSync(join(runDir, `attempt-${attempt}-worker.log`), out);
61
+ if (res.error) throw res.error;
62
+ log(`attempt ${attempt}: worker finished in ${secs}s (exit ${res.status})`);
63
+ return { secs, exit: res.status };
64
+ }
65
+
66
+ async function verifyAttempt(attempt) {
67
+ log(`attempt ${attempt}: verifying against pass condition (headless)…`);
68
+ return runVerification({ entryUrl, verify, outBase: join(runDir, `attempt-${attempt}`) });
69
+ }
70
+
71
+ const attempts = [];
72
+ let final = null;
73
+ for (let attempt = 1; attempt <= task.maxAttempts; attempt++) {
74
+ const worker = runWorker(attempt, attempts.at(-1)?.detail);
75
+ const v = await verifyAttempt(attempt);
76
+ attempts.push({ attempt, workerSecs: worker.secs, ...v });
77
+ log(`attempt ${attempt}: ${v.pass ? 'PASS' : 'FAIL'} — ${v.detail}`);
78
+ if (v.pass) { final = attempts.at(-1); break; }
79
+ }
80
+
81
+ const report = {
82
+ task: { id: task.id, title: task.title, passCondition: task.passCondition },
83
+ status: final ? 'done' : 'failed',
84
+ attempts: attempts.map(({ attempt, workerSecs, pass, detail, shotPath, videoPath }) =>
85
+ ({ attempt, workerSecs, pass, detail, proofScreenshot: shotPath, proofVideo: videoPath })),
86
+ runDir,
87
+ finishedAt: new Date().toISOString(),
88
+ };
89
+ writeFileSync(join(runDir, 'report.json'), JSON.stringify(report, null, 2));
90
+
91
+ if (final) {
92
+ log(`✅ DONE with proof — ${final.detail}`);
93
+ log(`proof: ${final.shotPath}${final.videoPath ? ` + ${final.videoPath}` : ''}`);
94
+ if (task.pr) {
95
+ try {
96
+ const { url } = createProofPR({
97
+ repoDir: appDir,
98
+ taskId: task.id,
99
+ title: task.title,
100
+ passCondition: task.passCondition,
101
+ attempts: report.attempts,
102
+ finalProof: final,
103
+ });
104
+ report.pr = url;
105
+ writeFileSync(join(runDir, 'report.json'), JSON.stringify(report, null, 2));
106
+ log(`PR opened: ${url}`);
107
+ } catch (e) {
108
+ log(`PR creation skipped (proof still saved locally): ${e.message}`);
109
+ }
110
+ }
111
+ } else {
112
+ log(`🟠 NOT done after ${task.maxAttempts} attempts — needs a human decision (never claimed "done" without proof).`);
113
+ }
114
+ log(`report: ${join(runDir, 'report.json')}`);
115
+ process.exit(final ? 0 : 1);
package/engine/mcp.mjs ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ // Manager for AI — MCP server (stdio, zero dependencies)
3
+ //
4
+ // Bridges any MCP client (Claude Code, Claude Desktop, …) to the local
5
+ // Manager server. Register once:
6
+ // claude mcp add -s user manager-for-ai \
7
+ // -e MANAGER_BASE=http://localhost:4400 -e MANAGER_KEY=<key> \
8
+ // -- node /path/to/galda2/engine/mcp.mjs
9
+ //
10
+ // Then just talk: "create a goal in galda2: …", "what's the status?",
11
+ // "reply to goal 12: …".
12
+
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { resolve, dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createInterface } from 'node:readline';
17
+
18
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
+ const DATA_DIR = process.env.MANAGER_HOME
20
+ ?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(process.env.HOME ?? '', '.manager-for-ai'));
21
+ const BASE = process.env.MANAGER_BASE ?? 'http://localhost:4400';
22
+ const KEY = process.env.MANAGER_KEY
23
+ ?? (existsSync(join(DATA_DIR, 'secret.key')) ? readFileSync(join(DATA_DIR, 'secret.key'), 'utf8').trim() : '');
24
+
25
+ async function api(path, opts = {}) {
26
+ const sep = path.includes('?') ? '&' : '?';
27
+ const r = await fetch(`${BASE}${path}${sep}key=${KEY}`, opts);
28
+ if (!r.ok) throw new Error(`Manager server ${r.status}: ${(await r.text()).slice(0, 200)}`);
29
+ return r.json();
30
+ }
31
+
32
+ const fmtGoal = (g, tasks) => {
33
+ const gt = tasks.filter((t) => t.goalId === g.id);
34
+ const lines = gt.map((t) => ` - [${t.status}] ${t.num} ${t.title}${t.secs ? ` (${t.secs}s)` : ''}${t.proof ? ` proof:${t.proof.pass ? 'PASS' : 'FAIL'}` : ''}`);
35
+ return [`goal ${g.id} [${g.status}] (${g.projectId}) ${g.text.replace(/\s+/g, ' ').slice(0, 120)}`,
36
+ g.pr ? ` PR: ${g.pr}` : '', ...lines].filter(Boolean).join('\n');
37
+ };
38
+
39
+ const TOOLS = [
40
+ {
41
+ name: 'manager_create_goal',
42
+ description: 'Hand a goal to Manager for AI. It is decomposed into tasks, implemented by Claude Code workers, tested, optionally verified against a pass condition (with video proof), and optionally turned into a pull request. Write the goal in natural language; include explicit pass conditions ("clicking X shows Y") when you want independent verification.',
43
+ inputSchema: { type: 'object', properties: {
44
+ project: { type: 'string', description: 'project id (e.g. galda1, galda2)' },
45
+ text: { type: 'string', description: 'the goal, natural language' },
46
+ deliverable: { type: 'string', enum: ['auto', 'pr', 'none'], description: 'pull request or not (default auto-detect)' },
47
+ model: { type: 'string', enum: ['sonnet', 'opus', 'haiku'], description: 'worker model (default sonnet)' },
48
+ }, required: ['project', 'text'] },
49
+ run: async (a) => {
50
+ const g = await api('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({ projectId: a.project, text: a.text, pr: a.deliverable, model: a.model, source: 'mcp' }) });
52
+ return `Accepted: goal ${g.id} [${g.status}] in ${g.projectId}. It runs in the background; check with manager_status.`;
53
+ },
54
+ },
55
+ {
56
+ name: 'manager_request_review',
57
+ description: 'Put an item in the Manager Review lane for a human to look at / approve — WITHOUT running any worker. Use for "please confirm / decide / eyeball this" that needs human judgment, not implementation (a design call, a go/no-go, a thing you already did and want signed off). It appears in the Review column; the human taps Approve (files it to Done) or Dismiss (drops it). No Claude worker is spawned, no PR is opened. Prefer this over manager_create_goal whenever you only need a decision, not work.',
58
+ inputSchema: { type: 'object', properties: {
59
+ project: { type: 'string', description: 'project id (e.g. galda1, galda2)' },
60
+ text: { type: 'string', description: 'the one-line ask / what to decide (shown as the requirement)' },
61
+ note: { type: 'string', description: 'optional detail / context / what you did, for the reviewer to read' },
62
+ }, required: ['project', 'text'] },
63
+ run: async (a) => {
64
+ const g = await api('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify({ projectId: a.project, text: a.text, note: a.note, review: true, source: 'mcp' }) });
66
+ return `Filed for review: goal ${g.id} [${g.status}] in ${g.projectId}. No worker started — waiting on a human Approve/Dismiss.`;
67
+ },
68
+ },
69
+ {
70
+ name: 'manager_status',
71
+ description: 'List goals and their tasks (status, proof, PR links). Optionally filter by project id.',
72
+ inputSchema: { type: 'object', properties: { project: { type: 'string' } } },
73
+ run: async (a) => {
74
+ const s = await api('/api/state');
75
+ const goals = s.goals.filter((g) => !a.project || g.projectId === a.project).slice(-12);
76
+ return goals.length ? goals.map((g) => fmtGoal(g, s.tasks)).join('\n\n') : 'No goals yet.';
77
+ },
78
+ },
79
+ {
80
+ name: 'manager_reply',
81
+ description: 'Reply in a goal thread. The reply resumes the worker session for that goal, so context carries over.',
82
+ inputSchema: { type: 'object', properties: {
83
+ goalId: { type: 'number' }, text: { type: 'string' },
84
+ }, required: ['goalId', 'text'] },
85
+ run: async (a) => {
86
+ const t = await api(`/api/goals/${a.goalId}/reply`, { method: 'POST', headers: { 'content-type': 'application/json' },
87
+ body: JSON.stringify({ text: a.text }) });
88
+ return `Reply queued as task ${t.id} on goal ${a.goalId}.`;
89
+ },
90
+ },
91
+ ];
92
+
93
+ function reply(id, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); }
94
+ function replyErr(id, message) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message } }) + '\n'); }
95
+
96
+ const rl = createInterface({ input: process.stdin });
97
+ rl.on('line', async (line) => {
98
+ let msg;
99
+ try { msg = JSON.parse(line); } catch { return; }
100
+ const { id, method, params } = msg;
101
+ try {
102
+ if (method === 'initialize') {
103
+ reply(id, {
104
+ protocolVersion: params?.protocolVersion ?? '2024-11-05',
105
+ capabilities: { tools: {} },
106
+ serverInfo: { name: 'manager-for-ai', version: '0.4.0' },
107
+ });
108
+ } else if (method === 'tools/list') {
109
+ reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
110
+ } else if (method === 'tools/call') {
111
+ const tool = TOOLS.find((t) => t.name === params?.name);
112
+ if (!tool) return replyErr(id, `unknown tool: ${params?.name}`);
113
+ const text = await tool.run(params?.arguments ?? {});
114
+ reply(id, { content: [{ type: 'text', text }] });
115
+ } else if (method === 'ping') {
116
+ reply(id, {});
117
+ } else if (id !== undefined) {
118
+ replyErr(id, `unsupported method: ${method}`);
119
+ }
120
+ } catch (e) {
121
+ if (id !== undefined) replyErr(id, String(e.message ?? e).slice(0, 300));
122
+ }
123
+ });
package/engine/pr.mjs ADDED
@@ -0,0 +1,144 @@
1
+ // Manager for AI — proof-carrying PR creation (worktree-based)
2
+ //
3
+ // NEVER touches the user's working tree or current branch: all commits happen
4
+ // in a temporary `git worktree` created from HEAD, which is removed afterwards.
5
+ // Only the files the Manager hands over are copied in and committed.
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import { buildProofBody, hasGitChanges } from './lib.mjs';
9
+ import { copyFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
10
+ import { basename, dirname, join, relative } from 'node:path';
11
+
12
+ function sh(cmd, args, opts = {}) {
13
+ const res = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
14
+ if (res.status !== 0) {
15
+ throw new Error(`${cmd} ${args.join(' ')} failed: ${(res.stderr || res.stdout || '').trim().slice(0, 400)}`);
16
+ }
17
+ return res.stdout.trim();
18
+ }
19
+
20
+ const FOOTER = [
21
+ '',
22
+ 'Generated with [Claude Code](https://claude.com/claude-code)',
23
+ '',
24
+ 'https://claude.ai/code/session_01AAByzSBZo4CwwrUxae7iw8',
25
+ ].join('\n');
26
+
27
+ const TRAILERS = [
28
+ '',
29
+ 'Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>',
30
+ 'Claude-Session: https://claude.ai/code/session_01AAByzSBZo4CwwrUxae7iw8',
31
+ ].join('\n');
32
+
33
+ // Core: commit `files` (paths relative to repo root; may be dirs) plus
34
+ // `extraCopies` [{src, destRel}] on a fresh branch in a temp worktree,
35
+ // push, open a PR. Returns { branch, url }.
36
+ //
37
+ // Passing `existingBranch` + `existingPrUrl` (rework: the goal already has an
38
+ // open PR) switches this to update mode instead: the same branch is fetched
39
+ // and reused rather than branching a new one, and `gh pr create` is skipped
40
+ // entirely — pushing new commits to a branch that already has an open PR
41
+ // makes GitHub refresh that PR's diff on its own, so calling `gh pr create`
42
+ // again would either error (duplicate) or open a second PR for the goal.
43
+ export function openPR({ repoDir, slug, title, body, files = [], extraCopies = [], extraWrites = [], buildBody = null, existingBranch = null, existingPrUrl = null }) {
44
+ const repoRoot = sh('git', ['-C', repoDir, 'rev-parse', '--show-toplevel']);
45
+ const branch = existingBranch ?? `manager-ai/${slug}-${Date.now()}`;
46
+ const wt = join(repoRoot, '.manager-wt', branch.replace(/[\/]/g, '-'));
47
+ mkdirSync(dirname(wt), { recursive: true });
48
+ if (existingBranch) {
49
+ sh('git', ['-C', repoRoot, 'fetch', 'origin', existingBranch]);
50
+ sh('git', ['-C', repoRoot, 'worktree', 'add', wt, '-B', existingBranch, `origin/${existingBranch}`]);
51
+ } else {
52
+ sh('git', ['-C', repoRoot, 'worktree', 'add', wt, '-b', branch]);
53
+ }
54
+ try {
55
+ for (const f of files) {
56
+ const rel = f.replace(/\/+$/, '');
57
+ const src = join(repoRoot, rel);
58
+ if (!existsSync(src)) continue; // deleted files: skip in v0
59
+ const dst = join(wt, rel);
60
+ mkdirSync(dirname(dst), { recursive: true });
61
+ rmSync(dst, { recursive: true, force: true });
62
+ sh('cp', ['-R', src, dst]);
63
+ }
64
+ for (const { src, destRel } of extraCopies) {
65
+ const dst = join(wt, destRel);
66
+ mkdirSync(dirname(dst), { recursive: true });
67
+ copyFileSync(src, dst);
68
+ }
69
+ for (const { destRel, content } of extraWrites) {
70
+ const dst = join(wt, destRel);
71
+ mkdirSync(dirname(dst), { recursive: true });
72
+ writeFileSync(dst, content);
73
+ }
74
+ sh('git', ['-C', wt, 'add', '-A']);
75
+ // proof artifacts live under gitignored paths in the main tree — force-add them here
76
+ for (const rel of [...extraCopies.map((c) => c.destRel), ...extraWrites.map((w) => w.destRel)]) {
77
+ sh('git', ['-C', wt, 'add', '-f', rel]);
78
+ }
79
+ const status = sh('git', ['-C', wt, 'status', '--porcelain']);
80
+ // A rework round can land with nothing new to commit (e.g. a comment-only
81
+ // reply task) — skip commit/push rather than let `git commit` fail on an
82
+ // empty tree. Only valid in update mode: the initial-creation caller
83
+ // already checked files.length before calling openPR.
84
+ if (existingBranch && existingPrUrl && !hasGitChanges(status)) {
85
+ return { branch, url: existingPrUrl, pushed: false };
86
+ }
87
+ sh('git', ['-C', wt, 'commit', '-m', `${title}${TRAILERS}`]);
88
+ // Proof bundles (mp4/gif/png) make the push exceed git's default ~1MB
89
+ // http.postBuffer, so git falls back to chunked transfer-encoding — which
90
+ // GitHub's smart-HTTP endpoint rejects with "RPC failed; HTTP 400 … send-pack:
91
+ // unexpected disconnect while reading sideband packet", leaving the goal stuck
92
+ // in review with a prError and no PR. Raising the buffer above the push size
93
+ // forces a single Content-Length POST, which succeeds. (Set on the shared repo
94
+ // config so every branch push from here uses it.)
95
+ sh('git', ['-C', wt, 'config', 'http.postBuffer', '524288000']);
96
+ sh('git', ['-C', wt, 'push', '-u', 'origin', branch]);
97
+ if (existingBranch && existingPrUrl) return { branch, url: existingPrUrl, pushed: true };
98
+ const finalBody = buildBody ? buildBody(branch) : body;
99
+ const url = sh('gh', ['pr', 'create', '--title', title, '--body', `${finalBody}${FOOTER}`, '--head', branch], { cwd: wt });
100
+ return { branch, url: url.split('\n').pop(), pushed: true };
101
+ } finally {
102
+ try { sh('git', ['-C', repoRoot, 'worktree', 'remove', '--force', wt]); } catch { /* best effort */ }
103
+ }
104
+ }
105
+
106
+ // Verify-proof loop entry point (called by manager.mjs after PASS):
107
+ // commits the app change + proof screenshot/video, links them in the PR body.
108
+ export function createProofPR({ repoDir, taskId, title, passCondition, attempts, finalProof }) {
109
+ const repoRoot = sh('git', ['-C', repoDir, 'rev-parse', '--show-toplevel']);
110
+ const changed = sh('git', ['-C', repoRoot, 'status', '--porcelain'])
111
+ .split('\n').filter(Boolean).map((l) => l.slice(3))
112
+ // keep the PR reviewable: run artifacts and logs stay local, proof goes in via extraCopies
113
+ .filter((f) => !/\/runs\/|\.webm$|\.log$/.test(f));
114
+
115
+ const proofRel = join('.manager-proof', taskId);
116
+ const extraCopies = [{ src: finalProof.shotPath, destRel: join(proofRel, basename(finalProof.shotPath)) }];
117
+ if (finalProof.videoPath) {
118
+ extraCopies.push({ src: finalProof.videoPath, destRel: join(proofRel, basename(finalProof.videoPath)) });
119
+ }
120
+ if (finalProof.gifPath) {
121
+ extraCopies.push({ src: finalProof.gifPath, destRel: join(proofRel, basename(finalProof.gifPath)) });
122
+ }
123
+
124
+ const appRel = relative(repoRoot, repoDir);
125
+ const owner = sh('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { cwd: repoRoot });
126
+ const shotName = basename(finalProof.shotPath);
127
+ const videoName = finalProof.videoPath ? basename(finalProof.videoPath) : null;
128
+ const gifName = finalProof.gifPath ? basename(finalProof.gifPath) : null;
129
+ // PROOF.md content is branch-dependent only through relative links, so it
130
+ // can be written up front; the PR body is built once the branch is known.
131
+ return openPR({
132
+ repoDir, slug: taskId,
133
+ title: `Manager for AI: ${title}`,
134
+ body: 'placeholder', // replaced by buildBody once the branch name is known
135
+ files: changed,
136
+ extraCopies,
137
+ extraWrites: [{
138
+ destRel: join(proofRel, 'PROOF.md'),
139
+ content: buildProofBody({ owner, branch: 'x', proofRel, shotName, videoName, gifName, passCondition, attempts, appRel }).proofMd,
140
+ }],
141
+ buildBody: (branch) =>
142
+ buildProofBody({ owner, branch, proofRel, shotName, videoName, gifName, passCondition, attempts, appRel }).body,
143
+ });
144
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // Agent Manager — relay client (the outbound half of the shared entrance)
3
+ //
4
+ // Connects this machine's local engine to a relay over an OUTBOUND
5
+ // WebSocket, so the user needs no tunnel, no port forwarding, no ngrok.
6
+ // Requests arriving from the relay are replayed against the local server
7
+ // (with the local access key injected) and streamed back.
8
+ //
9
+ // RELAY_URL=wss://relay.example/agent RELAY_AGENT_TOKEN=… node engine/relay-client.mjs
10
+
11
+ import { readFileSync, existsSync } from 'node:fs';
12
+ import { resolve, dirname, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const DATA_DIR = process.env.MANAGER_HOME
18
+ ?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
19
+ const LOCAL = process.env.MANAGER_LOCAL ?? `http://localhost:${process.env.MANAGER_PORT ?? 4400}`;
20
+ const KEY = existsSync(join(DATA_DIR, 'secret.key')) ? readFileSync(join(DATA_DIR, 'secret.key'), 'utf8').trim() : '';
21
+ const RELAY = process.env.RELAY_URL ?? 'ws://localhost:5500/agent';
22
+ // The relay (P3.1) authenticates the agent by its signed LICENSE TOKEN — only
23
+ // the billing Worker can mint one for a verified email, so the relay binds this
24
+ // socket to that email's id (no shared token, unspoofable). Falls back to an
25
+ // explicit RELAY_AGENT_TOKEN, and to a dev email for local testing.
26
+ const LICENSE = existsSync(join(DATA_DIR, 'license.token')) ? readFileSync(join(DATA_DIR, 'license.token'), 'utf8').trim() : '';
27
+ const TOKEN = process.env.RELAY_AGENT_TOKEN ?? LICENSE;
28
+ const DEV_EMAIL = process.env.RELAY_DEV_EMAIL ?? '';
29
+
30
+ const live = new Map(); // reqId -> AbortController
31
+
32
+ function connect(retryMs = 1000) {
33
+ const auth = DEV_EMAIL ? `devEmail=${encodeURIComponent(DEV_EMAIL)}` : `token=${encodeURIComponent(TOKEN)}`;
34
+ const ws = new WebSocket(`${RELAY}?${auth}`);
35
+ ws.onopen = () => { retryMs = 1000; console.log(`[agent] connected to relay ${RELAY}`); };
36
+ ws.onmessage = async (ev) => {
37
+ let m; try { m = JSON.parse(ev.data); } catch { return; }
38
+ if (m.type === 'abort') { live.get(m.id)?.abort(); live.delete(m.id); return; }
39
+ const ctl = new AbortController();
40
+ live.set(m.id, ctl);
41
+ try {
42
+ // Authoritatively SET the access key on the forwarded request. Blindly
43
+ // appending (?|&)key= meant a client URL that ALREADY carried an (empty)
44
+ // key — e.g. a proof <img src="/proof/…?key="> rendered when the browser
45
+ // had no ?key= in its address bar (AKEY='') — became "?key=&key=<KEY>",
46
+ // and the server's searchParams.get('key') reads the FIRST (empty) value
47
+ // → 403, so every proof image/video silently broke through the relay.
48
+ // URL.searchParams.set replaces any existing key with exactly ours.
49
+ const fwd = new URL(m.url, LOCAL);
50
+ if (KEY) fwd.searchParams.set('key', KEY);
51
+ const r = await fetch(fwd, {
52
+ method: m.method,
53
+ headers: m.headers?.['content-type'] ? { 'content-type': m.headers['content-type'] } : undefined,
54
+ body: m.body ? Buffer.from(m.body, 'base64') : undefined,
55
+ signal: ctl.signal,
56
+ });
57
+ ws.send(JSON.stringify({ id: m.id, type: 'head', status: r.status,
58
+ headers: { 'content-type': r.headers.get('content-type') ?? 'application/octet-stream' } }));
59
+ for await (const chunk of r.body ?? []) {
60
+ ws.send(JSON.stringify({ id: m.id, type: 'chunk', data: Buffer.from(chunk).toString('base64') }));
61
+ }
62
+ ws.send(JSON.stringify({ id: m.id, type: 'end' }));
63
+ } catch (e) {
64
+ if (!ctl.signal.aborted) {
65
+ try {
66
+ ws.send(JSON.stringify({ id: m.id, type: 'head', status: 502, headers: { 'content-type': 'text/plain' } }));
67
+ ws.send(JSON.stringify({ id: m.id, type: 'chunk', data: Buffer.from(String(e.message ?? e)).toString('base64') }));
68
+ ws.send(JSON.stringify({ id: m.id, type: 'end' }));
69
+ } catch { /* ws already gone */ }
70
+ }
71
+ } finally {
72
+ live.delete(m.id);
73
+ }
74
+ };
75
+ ws.onclose = () => {
76
+ console.log(`[agent] relay connection lost — retrying in ${retryMs}ms`);
77
+ setTimeout(() => connect(Math.min(retryMs * 2, 15000)), retryMs);
78
+ };
79
+ ws.onerror = () => { /* onclose follows */ };
80
+ }
81
+
82
+ connect();