svamp-cli 0.2.223-canary.2 → 0.2.223

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 (39) hide show
  1. package/dist/{agentCommands-MGRUIvWB.mjs → agentCommands-Be_5WSKW.mjs} +5 -5
  2. package/dist/{auth-FvyDIAch.mjs → auth-CKjLWEcA.mjs} +1 -1
  3. package/dist/cli.mjs +61 -61
  4. package/dist/{commands-BuecUAk8.mjs → commands-B_XZmurW.mjs} +1 -1
  5. package/dist/{commands-Cmjb9Je1.mjs → commands-CK3ZbA0p.mjs} +6 -6
  6. package/dist/{commands-BiIkCdGB.mjs → commands-ClmIatXG.mjs} +1 -1
  7. package/dist/{commands-Dm3EQICq.mjs → commands-DGD5uSeq.mjs} +2 -2
  8. package/dist/{commands-_RXnsA1v.mjs → commands-DeBy9HEU.mjs} +1 -1
  9. package/dist/{commands-BhFr0kUL.mjs → commands-Dw2YesDl.mjs} +1 -1
  10. package/dist/{commands-nlra_-D0.mjs → commands-DzwKn57T.mjs} +2 -2
  11. package/dist/{fleet-Bi3KIev7.mjs → fleet-CPm4Bm3m.mjs} +1 -1
  12. package/dist/{frpc-BpQyeWvI.mjs → frpc-C2eRCDNy.mjs} +1 -1
  13. package/dist/{headlessCli-Be0bYOUB.mjs → headlessCli-Bt3ZIoTG.mjs} +2 -2
  14. package/dist/index.mjs +1 -1
  15. package/dist/package-BL4Hd940.mjs +63 -0
  16. package/dist/{rpc-WS0yDVqB.mjs → rpc-CekroPL2.mjs} +1 -1
  17. package/dist/{rpc-CRZWx5YY.mjs → rpc-CrjSXvID.mjs} +1 -1
  18. package/dist/{run-DVdqRhtp.mjs → run-BqvXq2yD.mjs} +10 -24
  19. package/dist/{run-8_mnLg6R.mjs → run-DUquoPkl.mjs} +1 -1
  20. package/dist/{scheduler-Dx-QrQfP.mjs → scheduler-agjaeQjl.mjs} +1 -1
  21. package/dist/{serveCommands-CI2P6buK.mjs → serveCommands-Bc4ZhEqi.mjs} +5 -5
  22. package/dist/{serveManager-B-keYGE9.mjs → serveManager-Ddqi1kbZ.mjs} +2 -2
  23. package/dist/{sideband-BdYb3ezu.mjs → sideband-lLT0D1PF.mjs} +1 -1
  24. package/package.json +3 -3
  25. package/bin/skills/loop/IMPLEMENTATION_PROGRESS.md +0 -49
  26. package/bin/skills/loop/SKILL.md +0 -100
  27. package/bin/skills/loop/bin/channel-core.mjs +0 -161
  28. package/bin/skills/loop/bin/channel-server.mjs +0 -151
  29. package/bin/skills/loop/bin/inject-loop.mjs +0 -90
  30. package/bin/skills/loop/bin/loop-init.mjs +0 -194
  31. package/bin/skills/loop/bin/loop-status.mjs +0 -51
  32. package/bin/skills/loop/bin/precompact.mjs +0 -30
  33. package/bin/skills/loop/bin/routine-core.mjs +0 -126
  34. package/bin/skills/loop/bin/state-fp.mjs +0 -111
  35. package/bin/skills/loop/bin/stop-gate.mjs +0 -349
  36. package/bin/skills/loop/test/test-channel-core.mjs +0 -86
  37. package/bin/skills/loop/test/test-loop-gate.mjs +0 -489
  38. package/bin/skills/loop/test/test-routine-core.mjs +0 -54
  39. package/dist/package-CwGmpH_-.mjs +0 -63
@@ -1,161 +0,0 @@
1
- #!/usr/bin/env node
2
- // channel-core.mjs — transport-agnostic core for Channels (external / agent-to-agent
3
- // inbound endpoints). Project-folder config + identity resolution + default XML
4
- // template + skill generation. Used by both the HTTP and Hypha transports.
5
- // See docs/channels-design.md.
6
- import { mkdirSync, writeFileSync, renameSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
7
- import { join } from 'node:path';
8
- import { randomBytes } from 'node:crypto';
9
- import { renderTemplate } from './routine-core.mjs';
10
-
11
- const genId = () => 'c_' + randomBytes(5).toString('hex');
12
- const genKey = () => 'ck_' + randomBytes(18).toString('base64url');
13
-
14
- // Default message template: XML carrying provenance so the agent always knows
15
- // who sent it, through which channel, and whether the identity is verified.
16
- export const DEFAULT_TEMPLATE =
17
- `<inbound-message from="\${sender.name}" sender-type="\${sender.kind}" verified="\${sender.verified}" channel="\${channel.name}" call-id="\${call.id}" at="\${now}">
18
- \${body.message}
19
- </inbound-message>`;
20
-
21
- // ---- store (project folder: <dir>/.svamp/channels/<id>.json) ------------
22
- export class ChannelStore {
23
- constructor(projectDir) { this.dir = join(projectDir, '.svamp', 'channels'); mkdirSync(this.dir, { recursive: true }); }
24
- _path(id) { return join(this.dir, `${id}.json`); }
25
- list() {
26
- return readdirSync(this.dir).filter((f) => f.endsWith('.json')).map((f) => {
27
- try { return JSON.parse(readFileSync(join(this.dir, f), 'utf8')); } catch { return null; }
28
- }).filter(Boolean);
29
- }
30
- get(id) { try { return JSON.parse(readFileSync(this._path(id), 'utf8')); } catch { return null; } }
31
- save(channel) {
32
- const c = { enabled: true, bind: 'active', template: DEFAULT_TEMPLATE, action: { kind: 'message' },
33
- identity: { mode: 'per-key', callers: [] }, last_calls: [], ...channel };
34
- if (!c.id) c.id = genId();
35
- const errs = validateChannel(c);
36
- if (errs.length) throw new Error('invalid channel: ' + errs.join('; '));
37
- const tmp = this._path(c.id) + '.tmp';
38
- writeFileSync(tmp, JSON.stringify(c, null, 2));
39
- renameSync(tmp, this._path(c.id));
40
- return c;
41
- }
42
- remove(id) { const p = this._path(id); if (existsSync(p)) { rmSync(p); return true; } return false; }
43
- addCaller(id, name, kind = 'agent') {
44
- const c = this.get(id); if (!c) return null;
45
- c.identity.callers = c.identity.callers || [];
46
- const caller = { name, kind, key: genKey() };
47
- c.identity.callers.push(caller);
48
- this.save(c);
49
- return caller;
50
- }
51
- recordCall(id, entry) {
52
- const c = this.get(id); if (!c) return null;
53
- c.last_calls = [{ at: new Date().toISOString(), ...entry }, ...(c.last_calls || [])].slice(0, 30);
54
- return this.save(c);
55
- }
56
- }
57
-
58
- export function validateChannel(c) {
59
- const errs = [];
60
- if (!c || typeof c !== 'object') return ['channel must be an object'];
61
- if (!c.name) errs.push('name required');
62
- const m = c.identity?.mode;
63
- if (!['per-key', 'caller-supplied', 'fixed'].includes(m)) errs.push('identity.mode must be per-key|caller-supplied|fixed');
64
- if (m === 'fixed' && !c.identity.fixed?.name) errs.push('identity.fixed.name required for fixed mode');
65
- if (!['message', 'loop'].includes(c.action?.kind)) errs.push('action.kind must be message|loop');
66
- if (c.bind !== 'active' && !(c.bind && c.bind.tag) && !(c.bind && c.bind.session)) errs.push('bind must be "active", {tag}, or {session}');
67
- // Security: a keyless/open channel + loop action = unauthenticated task injection.
68
- if (c.action?.kind === 'loop' && m === 'caller-supplied' && !c.identity?.shared_key)
69
- errs.push('a caller-supplied channel without a shared_key may not use a loop action (unauthenticated task injection)');
70
- // Defense-in-depth: single-line, no XML metachars on operator-set strings that
71
- // land in the trust tag and/or the served skill markdown (frontmatter breakout).
72
- const unsafe = /[<>"'&\r\n]/;
73
- if (unsafe.test(c.name || '')) errs.push('name must be single-line and not contain < > " \' &');
74
- if (c.description && unsafe.test(c.description)) errs.push('description must be single-line and not contain < > " \' &');
75
- if (c.skill?.name && unsafe.test(c.skill.name)) errs.push('skill.name must be single-line and not contain < > " \' &');
76
- if (c.skill?.description && unsafe.test(c.skill.description)) errs.push('skill.description must be single-line and not contain < > " \' &');
77
- if (m === 'fixed' && c.identity.fixed?.name && unsafe.test(c.identity.fixed.name)) errs.push('identity.fixed.name must not contain < > " \' & or newlines');
78
- for (const cl of (c.identity?.callers || [])) if (unsafe.test(cl.name || '')) errs.push(`caller name "${cl.name}" must not contain < > " ' & or newlines`);
79
- return errs;
80
- }
81
-
82
- // ---- identity resolution ------------------------------------------------
83
- // Returns { sender:{name,kind,verified} } or { error } (401/403-ish).
84
- export function resolveSender(channel, { key, from, hyphaUser, hyphaWorkspace, hyphaAnonymous } = {}) {
85
- const id = channel.identity || {};
86
- // Hypha-authenticated caller: honored ONLY when the channel EXPLICITLY opts in
87
- // via a non-empty hypha_allow (deny-by-default — never allow-all when unset, and
88
- // never override a configured per-key/fixed mode). ANONYMOUS Hypha callers are
89
- // NOT verified identities, so they never take this branch (even with '*').
90
- if (hyphaUser && !hyphaAnonymous && Array.isArray(id.hypha_allow) && id.hypha_allow.length) {
91
- if (id.hypha_allow.includes('*') || id.hypha_allow.includes(hyphaUser) || (hyphaWorkspace && id.hypha_allow.includes(hyphaWorkspace)))
92
- return { sender: { name: hyphaUser, kind: 'agent', verified: true } };
93
- return { error: 'caller not in hypha_allow' };
94
- }
95
- if (id.mode === 'fixed') return { sender: { ...id.fixed, verified: true } };
96
- if (id.mode === 'per-key') {
97
- const caller = (id.callers || []).find((c) => c.key && c.key === key);
98
- if (!caller) return { error: 'invalid or missing key' };
99
- return { sender: { name: caller.name, kind: caller.kind, verified: true } };
100
- }
101
- if (id.mode === 'caller-supplied') {
102
- if (id.shared_key && key !== id.shared_key) return { error: 'invalid key' };
103
- return { sender: { name: from || 'anonymous', kind: 'user', verified: false } };
104
- }
105
- return { error: 'unsupported identity mode' };
106
- }
107
-
108
- // ---- render the injected message ----------------------------------------
109
- // XML-escape EVERY interpolated value. The <inbound-message from=… verified=…>
110
- // tag is the agent's entire trust signal, and from/message are caller-controlled
111
- // — without escaping a caller could forge verified="true" or inject a second tag
112
- // (provenance forgery / tag breakout). Escaping neutralizes < > " ' &.
113
- const MAX_BODY = 16 * 1024;
114
- export function xmlEscape(s) {
115
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
116
- .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
117
- }
118
- // Strip control chars (incl. newlines) — for single-line ATTRIBUTE values, so an
119
- // attacker-controlled name can't smuggle extra lines into the trust header.
120
- const stripControl = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f]/g, ' ');
121
- export function renderMessage(channel, { sender = {}, body = {}, query = {}, callId, now }) {
122
- const obj = (v) => (typeof v === 'object' && v !== null ? JSON.stringify(v) : v);
123
- const escVal = (v) => xmlEscape(obj(v)); // element content (multi-line ok)
124
- const escAttr = (v) => xmlEscape(stripControl(obj(v))); // attribute position (single-line)
125
- const bodyEsc = {};
126
- for (const [k, v] of Object.entries(body)) bodyEsc[k] = k === 'message' ? escVal(String(v ?? '').slice(0, MAX_BODY)) : escAttr(v);
127
- const queryEsc = {};
128
- for (const [k, v] of Object.entries(query)) if (k !== 'key') queryEsc[k] = escAttr(v); // never echo the secret
129
- const ctx = {
130
- sender: { name: escAttr(sender.name), kind: escAttr(sender.kind), verified: sender.verified === true },
131
- body: bodyEsc, query: queryEsc,
132
- channel: { name: escAttr(channel.name), id: escAttr(channel.id) },
133
- call: { id: escAttr(callId) }, now: escAttr(now || new Date().toISOString()),
134
- };
135
- return renderTemplate(channel.template || DEFAULT_TEMPLATE, ctx);
136
- }
137
-
138
- // ---- skill (how external agents call this channel) ----------------------
139
- export function generateSkillBody(channel, urlBase) {
140
- const url = `${urlBase || 'https://<svamp-tunnel>'}/channel/${channel.id}`;
141
- return `---
142
- name: ${channel.skill?.name || channel.name}
143
- description: ${channel.skill?.description || channel.description || `Send a message to the "${channel.name}" channel.`}
144
- ---
145
- # ${channel.name}
146
- ${channel.description || ''}
147
-
148
- This is a self-contained guide for messaging another agent. Share this skill
149
- (or its URL, \`${url}/skill.md\`) with an agent and it will know how to reach me.
150
-
151
- **Hypha RPC (preferred, verified identity — no key needed):**
152
- \`get_service("<ws>/<machine>:channels").send({ channel: "${channel.id}", message: "..." })\`
153
-
154
- **HTTP:** \`POST ${url}\` with header \`Authorization: Bearer <your-key>\`
155
- Body: \`{ "message": "<your message>", "from": "<your name>" }\`
156
-
157
- Your identity is attached automatically; the receiving agent sees an
158
- \`<inbound-message from="...">\` tag. Keep messages self-contained.`;
159
- }
160
-
161
- export { genId, genKey };
@@ -1,151 +0,0 @@
1
- #!/usr/bin/env node
2
- // channel-server.mjs — serve a project's Channels over BOTH transports:
3
- // • Hypha RPC: registers a `channels` service (type svamp-channels) with
4
- // list/describe/send; caller identity = verified context.user.
5
- // • HTTP: POST /channel/<id> (Bearer key), GET /channel/<id>[/skill.md].
6
- // Delivery: renders the channel template and injects it into the bound session
7
- // via `svamp session send`. Config from the project folder (.svamp/channels/).
8
- //
9
- // Env: CHANNEL_PROJECT_DIR (default cwd), CHANNEL_SESSION (default bind target),
10
- // CHANNEL_HTTP_PORT (default 8733), CHANNEL_URL_BASE (for skill URLs),
11
- // HYPHA_SERVER_URL / HYPHA_TOKEN / HYPHA_WORKSPACE (from ~/.hypha/.env).
12
- import { execFileSync } from 'node:child_process';
13
- import { createServer } from 'node:http';
14
- import { resolve, dirname, join } from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
- import { randomBytes } from 'node:crypto';
17
- import { ChannelStore, resolveSender, renderMessage, generateSkillBody, validateChannel } from './channel-core.mjs';
18
- import { renderTemplate } from './routine-core.mjs';
19
-
20
- const LOOP_INIT = join(dirname(fileURLToPath(import.meta.url)), 'loop-init.mjs');
21
-
22
- const PROJECT = resolve(process.env.CHANNEL_PROJECT_DIR || process.cwd());
23
- const DEFAULT_SESSION = process.env.CHANNEL_SESSION || null;
24
- const URL_BASE = process.env.CHANNEL_URL_BASE || `http://localhost:${process.env.CHANNEL_HTTP_PORT || 8733}`;
25
- const store = new ChannelStore(PROJECT);
26
- const callId = () => 'call_' + randomBytes(5).toString('hex');
27
-
28
- // svamp binary (override for repo build, e.g. SVAMP_BIN="node …/dist/cli.js").
29
- const SVAMP = (process.env.SVAMP_BIN || 'svamp').split(' ');
30
-
31
- function targetSession(channel) {
32
- if (channel.bind && channel.bind.session) return channel.bind.session; // explicit pin (standalone)
33
- return DEFAULT_SESSION; // 'active' resolution is the daemon's job; standalone uses the configured session
34
- }
35
-
36
- // Inject the channel message as a PLAIN user message (`session send --plain`) so
37
- // the agent sees a single `<inbound-message>` provenance tag — not the inbox
38
- // `<svamp-message>` envelope. (Daemon-native channels do this in-process.)
39
- async function deliver(channel, sender, body, query) {
40
- const sid = targetSession(channel);
41
- if (!sid) return { ok: false, error: 'no bound session (set CHANNEL_SESSION or channel.bind.session)' };
42
- const id = callId();
43
- try {
44
- if (channel.action?.kind === 'loop') {
45
- // Start a loop in the bound session's project dir, seeded by the caller's
46
- // request. (Loop-action channels require auth — validateChannel forbids the
47
- // keyless caller-supplied case — so the caller-influenced task is gated.)
48
- // CAVEAT: the Stop-GATE only enforces if the session's Claude process started
49
- // AFTER the .claude/settings.json hooks existed (hooks load at startup). When
50
- // we loop-init an ALREADY-RUNNING session, the gate isn't active for that
51
- // process, so the kickoff is self-contained (task + read-LOOP.md + iterate)
52
- // and the work still gets done; full gate enforcement needs a fresh/restarted
53
- // session (which daemon-native delivery would do). We mark status accordingly.
54
- const dir = resolve(channel.action.loop?.dir || PROJECT);
55
- const ctx = { sender, body, query, channel: { name: channel.name, id: channel.id }, call: { id }, now: new Date().toISOString() };
56
- const task = (channel.action.task_template ? renderTemplate(channel.action.task_template, ctx) : body.message) || '(task from channel)';
57
- const labelled = `[via channel "${channel.name}" from ${sender.name}${sender.verified ? '' : ' (unverified)'}] ${task}`;
58
- const initArgs = [LOOP_INIT, dir, '--task', labelled];
59
- const oracle = channel.action.loop?.oracle;
60
- if (oracle) initArgs.push('--oracle', oracle);
61
- initArgs.push('--evaluator', channel.action.loop?.evaluator || 'off');
62
- execFileSync(process.execPath, initArgs, { stdio: 'pipe' });
63
- // Self-contained kickoff (works whether or not the Stop hook is active on the
64
- // running process): the task + how to verify + iterate.
65
- const kickoff = `🔁 Loop request via channel "${channel.name}".\nRead LOOP.md and work the task below until it is genuinely complete${oracle ? ` and the oracle passes: \`${oracle}\`` : ''}. Update LOOP.md progress as you go.\n\nTask: ${labelled}`;
66
- execFileSync(SVAMP[0], [...SVAMP.slice(1), 'session', 'send', sid, kickoff, '--plain'], { stdio: 'pipe' });
67
- store.recordCall(channel.id, { sender: sender.name, verified: sender.verified, callId: id, outcome: 'loop-started' });
68
- return { ok: true, call_id: id, status: 'loop-started' };
69
- }
70
- const message = renderMessage(channel, { sender, body, query, callId: id });
71
- execFileSync(SVAMP[0], [...SVAMP.slice(1), 'session', 'send', sid, message, '--plain'], { stdio: 'pipe' });
72
- store.recordCall(channel.id, { sender: sender.name, verified: sender.verified, callId: id, outcome: 'delivered' });
73
- return { ok: true, call_id: id, status: 'accepted' };
74
- } catch (e) {
75
- store.recordCall(channel.id, { sender: sender.name, verified: sender.verified, callId: id, outcome: 'error' });
76
- return { ok: false, error: String(e?.message || e) };
77
- }
78
- }
79
-
80
- const channelView = (c) => ({ id: c.id, name: c.name, description: c.description, identity: { mode: c.identity?.mode }, action: c.action?.kind });
81
-
82
- // ── Hypha RPC service ────────────────────────────────────────────────────
83
- async function registerHypha() {
84
- const token = process.env.HYPHA_TOKEN;
85
- const serverUrl = process.env.HYPHA_SERVER_URL || 'https://hypha.aicell.io';
86
- if (!token) { console.error('[channels] no HYPHA_TOKEN — skipping Hypha registration (HTTP only)'); return null; }
87
- const { hyphaWebsocketClient } = await import('hypha-rpc');
88
- const server = await hyphaWebsocketClient.connectToServer({ server_url: serverUrl, token, workspace: process.env.HYPHA_WORKSPACE || undefined, logger: null });
89
- const svc = await server.registerService({
90
- id: 'channels',
91
- name: 'Svamp Channels',
92
- type: 'svamp-channels',
93
- config: { visibility: 'public', require_context: true },
94
- async list() { return store.list().filter((c) => c.enabled !== false).map(channelView); },
95
- async describe(kwargs = {}) {
96
- const c = store.get(kwargs.channel); if (!c) return { error: 'not found' };
97
- return { ...channelView(c), skill: { body: generateSkillBody(c, URL_BASE) } };
98
- },
99
- async send(kwargs = {}, context) {
100
- const c = store.get(kwargs.channel);
101
- if (!c || c.enabled === false) return { error: 'channel not found' };
102
- const user = context?.user?.email || context?.user?.id;
103
- const r = resolveSender(c, { hyphaUser: user, hyphaAnonymous: context?.user?.is_anonymous === true, hyphaWorkspace: context?.ws || context?.user?.scope?.current_workspace, from: kwargs.from });
104
- if (r.error) return { error: r.error };
105
- return deliver(c, r.sender, { message: kwargs.message }, {});
106
- },
107
- });
108
- console.log(`[channels] registered Hypha service: ${svc.id} (type svamp-channels)`);
109
- return server;
110
- }
111
-
112
- // ── HTTP transport ───────────────────────────────────────────────────────
113
- function startHttp(port) {
114
- const server = createServer((req, res) => {
115
- const u = new URL(req.url, URL_BASE);
116
- if (u.pathname === '/' || u.pathname === '/health') { res.writeHead(200).end('channels ok'); return; }
117
- if (u.pathname === '/channels') {
118
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(store.list().filter(c => c.enabled !== false).map(channelView)));
119
- return;
120
- }
121
- let m = u.pathname.match(/^\/channel\/([\w.-]+)\/skill\.md$/);
122
- if (m) { const c = store.get(m[1]); if (!c) { res.writeHead(404).end('not found'); return; }
123
- res.writeHead(200, { 'content-type': 'text/markdown' }).end(generateSkillBody(c, URL_BASE)); return; }
124
- m = u.pathname.match(/^\/channel\/([\w.-]+)$/);
125
- if (!m) { res.writeHead(404).end('not found'); return; }
126
- const c = store.get(m[1]);
127
- if (!c || c.enabled === false) { res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'channel not found' })); return; }
128
- if (req.method === 'GET' && !u.searchParams.get('message')) {
129
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...channelView(c), skill_url: `${URL_BASE}/channel/${c.id}/skill.md` })); return;
130
- }
131
- let chunks = '';
132
- req.on('data', (d) => { chunks += d; if (chunks.length > 1e6) req.destroy(); });
133
- req.on('end', async () => {
134
- let body = {}; try { body = chunks ? JSON.parse(chunks) : {}; } catch {}
135
- const key = (req.headers.authorization || '').replace(/^Bearer\s+/i, '') || u.searchParams.get('key');
136
- const message = body.message ?? u.searchParams.get('message');
137
- const r = resolveSender(c, { key, from: body.from || u.searchParams.get('from') });
138
- if (r.error) { res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: r.error })); return; }
139
- const out = await deliver(c, r.sender, { message }, Object.fromEntries(u.searchParams));
140
- res.writeHead(out.ok ? 200 : 500, { 'content-type': 'application/json' }).end(JSON.stringify(out));
141
- });
142
- });
143
- server.listen(port, () => console.log(`[channels] HTTP on ${URL_BASE} (POST /channel/<id>)`));
144
- return server;
145
- }
146
-
147
- const port = Number(process.env.CHANNEL_HTTP_PORT) || 8733;
148
- const http = startHttp(port);
149
- const hyphaServer = await registerHypha().catch((e) => { console.error('[channels] Hypha registration failed:', e?.message || e); return null; });
150
- console.log(`[channels] serving ${store.list().length} channel(s) from ${PROJECT}`);
151
- process.on('SIGINT', () => { http.close(); hyphaServer?.disconnect?.(); process.exit(0); });
@@ -1,90 +0,0 @@
1
- #!/usr/bin/env node
2
- // inject-loop.mjs — Claude Code `SessionStart` / `UserPromptSubmit` hook.
3
- // Injects the current LOOP.md plus the loop protocol so every iteration starts
4
- // from the latest task/plan/progress without any daemon re-injection.
5
- import { readFileSync, writeFileSync, renameSync, existsSync } from 'node:fs';
6
- import { dirname, join, resolve, relative } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const HERE = dirname(fileURLToPath(import.meta.url));
10
- // Resolve the loop home from the per-process env (SVAMP_SESSION_ID + CLAUDE_PROJECT_DIR)
11
- // so a hook in the SHARED .claude/settings.json injects each session's OWN LOOP.md.
12
- // Fallback (manual/standalone): the parent of this copied bin/ dir.
13
- const SID = process.env.SVAMP_SESSION_ID || null;
14
- const PROJECT_ENV = process.env.CLAUDE_PROJECT_DIR || null;
15
- const LOOP_DIR = (SID && PROJECT_ENV) ? join(PROJECT_ENV, '.svamp', SID, 'loop') : resolve(HERE, '..');
16
-
17
- function readJSON(p, f) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return f; } }
18
- const cfg = readJSON(join(LOOP_DIR, 'loop.config.json'), null);
19
- const state = readJSON(join(LOOP_DIR, 'loop-state.json'), { active: false });
20
- if (!cfg || state.active === false) process.exit(0); // no active loop -> inject nothing
21
-
22
- // Self-heal the Stop gate (#0072). This hook runs on every SessionStart/UserPromptSubmit while a
23
- // loop is active, so it's the reliable place to GUARANTEE the gate is installed: in a shared working
24
- // tree another tool/agent can rewrite .claude/settings.json and silently drop the Stop hook, which
25
- // would let the loop end without the completion gate ever firing. If the loop's own stop-gate.mjs is
26
- // missing from settings.hooks.Stop, re-install all four loop hooks (idempotent; merges, never clobbers
27
- // unrelated settings). BIN points at THIS session's copied bin/, so each session heals its own gate.
28
- function ensureLoopHooks() {
29
- try {
30
- const settingsPath = join(PROJECT_ENV || resolve(LOOP_DIR, '..', '..', '..'), '.claude', 'settings.json');
31
- const binDir = join(LOOP_DIR, 'bin');
32
- const node = process.execPath;
33
- const cmd = (script) => `"${node}" "${join(binDir, script)}"`;
34
- const stopGate = join(binDir, 'stop-gate.mjs');
35
- if (!existsSync(stopGate)) return; // can't heal what isn't copied; loop-init owns first install
36
- let settings = {};
37
- if (existsSync(settingsPath)) { try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { settings = {}; } }
38
- const hooks = settings.hooks || {};
39
- // Is THIS loop's stop-gate present anywhere under hooks.Stop?
40
- const stopOk = Array.isArray(hooks.Stop) && hooks.Stop.some((g) =>
41
- Array.isArray(g?.hooks) && g.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(stopGate)));
42
- if (stopOk) return; // gate intact — nothing to do
43
- // Re-install the loop hook set (same shape loop-init writes).
44
- hooks.SessionStart = [{ hooks: [{ type: 'command', command: cmd('inject-loop.mjs') }] }];
45
- hooks.UserPromptSubmit = [{ hooks: [{ type: 'command', command: cmd('inject-loop.mjs') }] }];
46
- hooks.Stop = [{ hooks: [{ type: 'command', command: cmd('stop-gate.mjs') }] }];
47
- hooks.PreCompact = [{ hooks: [{ type: 'command', command: cmd('precompact.mjs') }] }];
48
- settings.hooks = hooks;
49
- const tmp = settingsPath + '.tmp';
50
- writeFileSync(tmp, JSON.stringify(settings, null, 2));
51
- renameSync(tmp, settingsPath);
52
- process.stderr.write('[loop] re-installed missing Stop gate into .claude/settings.json (#0072 self-heal)\n');
53
- } catch { /* best-effort; never block injection on a heal failure */ }
54
- }
55
- ensureLoopHooks();
56
-
57
- const PROJECT = (typeof cfg.project_dir === 'string' && cfg.project_dir)
58
- || process.env.CLAUDE_PROJECT_DIR || resolve(LOOP_DIR, '..', '..', '..');
59
-
60
- // LOOP.md now lives inside the (session-scoped) loop dir.
61
- const loopPath = join(LOOP_DIR, cfg.loop_file || 'LOOP.md');
62
- let loopMd = '';
63
- if (existsSync(loopPath)) loopMd = readFileSync(loopPath, 'utf8');
64
-
65
- const evaluatorOn = cfg.evaluator?.enabled !== false;
66
- const oracleCmd = cfg.oracle?.command || cfg.oracle?.test || cfg.oracle?.build || cfg.oracle || null;
67
- // Paths the agent must use, expressed relative to where it runs (PROJECT).
68
- const LOOPMD_REL = relative(PROJECT, loopPath) || 'LOOP.md';
69
- const VERDICT_REL = relative(PROJECT, join(LOOP_DIR, 'evaluator-verdict.json')) || join(LOOP_DIR, 'evaluator-verdict.json');
70
- const STATEFP_REL = relative(PROJECT, join(LOOP_DIR, 'bin', 'state-fp.mjs')) || join(LOOP_DIR, 'bin', 'state-fp.mjs');
71
-
72
- const protocol = `# 🔁 LOOP MODE IS ACTIVE
73
-
74
- You are running inside a loop. Each turn is one iteration. Work toward completing the task described in LOOP.md below. You CANNOT end the loop by simply saying you are done — a Stop gate independently re-checks the exit conditions and will send you back to work if they are not met.
75
-
76
- Your LOOP.md (durable memory) is at \`${LOOPMD_REL}\` — read and update it there.
77
-
78
- **Exit conditions (all must hold before the loop ends):**
79
- ${oracleCmd ? `1. The oracle command must pass: \`${oracleCmd}\` (exit 0). The gate runs this itself — do not fake it.\n` : '1. (No oracle configured.)\n'}${evaluatorOn ? `2. An INDEPENDENT evaluator must judge the work \"done\". Before you finish: spawn a fresh subagent (Task tool) named/acting as \`loop-evaluator\` with a skeptical reviewer prompt; give it ONLY the goal (from LOOP.md), the current diff, and the oracle output. Have IT decide. Then record its verdict to \`${VERDICT_REL}\`:\n {"verdict":"done"|"continue","reason":"...","guidance":"...","state_fp":"<output of: node ${STATEFP_REL}>"}\n Do NOT grade your own work — the verdict must come from the subagent, and it is only valid for the exact code state it reviewed.\n` : ''}
80
-
81
- **Each iteration:** read LOOP.md → make real progress on the task → update the Progress section of LOOP.md → run/verify the oracle → (if you believe it's done) get the evaluator verdict → end your turn to be re-checked. Keep LOOP.md current; it is your durable memory across iterations and restarts.
82
-
83
- ---
84
- ## LOOP.md (${LOOPMD_REL})
85
- ${loopMd || '(LOOP.md not found — create it with the task and a Progress section.)'}
86
- `;
87
-
88
- // SessionStart/UserPromptSubmit: stdout is added to the model's context.
89
- process.stdout.write(protocol);
90
- process.exit(0);
@@ -1,194 +0,0 @@
1
- #!/usr/bin/env node
2
- // loop-init.mjs — project the loop config into Claude Code's native files.
3
- // Generates, in a target project dir, a SESSION-SCOPED loop home:
4
- // .svamp/<sessionId>/loop/{LOOP.md,loop.config.json,loop-state.json,bin/*}
5
- // .claude/settings.json hooks (Stop gate + LOOP.md injection),
6
- // and (optional) .claude/agents/loop-evaluator.md.
7
- // Session-scoping keeps sibling sessions in the same working dir from colliding.
8
- // Usage:
9
- // node loop-init.mjs <dir> --task "..." [--criteria "..."] [--oracle "cmd"]
10
- // [--max N] [--evaluator on|off] [--model NAME] [--loop-file LOOP.md]
11
- import { mkdirSync, writeFileSync, copyFileSync, readFileSync, existsSync, chmodSync, rmSync } from 'node:fs';
12
- import { dirname, join, resolve } from 'node:path';
13
- import { fileURLToPath } from 'node:url';
14
-
15
- const HERE = dirname(fileURLToPath(import.meta.url));
16
-
17
- function parseArgs(argv) {
18
- const a = { _: [] };
19
- for (let i = 0; i < argv.length; i++) {
20
- const t = argv[i];
21
- if (t.startsWith('--')) { a[t.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true; }
22
- else a._.push(t);
23
- }
24
- return a;
25
- }
26
- const args = parseArgs(process.argv.slice(2));
27
- const dir = resolve(args._[0] || process.cwd());
28
- const loopFile = args['loop-file'] || 'LOOP.md';
29
- const oracle = typeof args.oracle === 'string' ? args.oracle : null;
30
- // #0156: default max_iterations to 10000 — a high but FINITE default suitable for long-running tasks,
31
- // configurable both here (--max N) and AFTER the loop starts (the limit-only modify-in-place path:
32
- // `svamp session loop <id> --max N` / the Issues-page loop banner). An explicit --max overrides it;
33
- // clearing it (∞, governed by a token-rate budget) is still available via the UI.
34
- const max = args.max ? Number(args.max) : 10000;
35
- const evaluatorOn = args.evaluator !== 'off';
36
- const model = typeof args.model === 'string' ? args.model : null;
37
- const taskProvided = typeof args.task === 'string';
38
- const task = taskProvided ? args.task : '(describe the task here)';
39
- const criteria = typeof args.criteria === 'string' ? args.criteria : null;
40
- // Owning session id, stamped into loop-state.json so the daemon can scope
41
- // "loop active" (auto-approve / AskUserQuestion auto-dismiss / loop resume) to the
42
- // session that started the loop instead of every session sharing this directory.
43
- const sessionId = typeof args.session === 'string' ? args.session
44
- : (typeof process.env.SVAMP_SESSION_ID === 'string' && process.env.SVAMP_SESSION_ID) ? process.env.SVAMP_SESSION_ID
45
- : null;
46
- // #0166: hooks-only mode — install the gate scripts + MERGE the hooks into settings.json, but DON'T
47
- // create a loop (no config/state/LOOP.md/evaluator). Run at SESSION SPAWN so the Stop-gate hook is
48
- // loaded before any loop exists; the hooks no-op while no loop is active (stop-gate: `!cfg` → allow).
49
- // This is what makes loop start/stop/config-update SEAMLESS — the daemon never restarts Claude to load
50
- // the hook. Idempotent + safe to call on every spawn (it does NOT touch an existing loop's state).
51
- const hooksOnly = args['hooks-only'] === true || args['hooks-only'] === 'true';
52
-
53
- // Session-scoped loop home so sessions sharing a working dir never collide:
54
- // <dir>/.svamp/<sessionId>/loop/ (falls back to <dir>/.svamp/loop/ with no session).
55
- // The Claude Code hook DEFINITIONS still live in <dir>/.claude/settings.json (that is
56
- // Claude Code's own config), but the loop's state + memory now live under .svamp.
57
- const loopDir = sessionId ? join(dir, '.svamp', sessionId, 'loop') : join(dir, '.svamp', 'loop');
58
- const binDir = join(loopDir, 'bin');
59
- mkdirSync(binDir, { recursive: true });
60
- mkdirSync(join(dir, '.claude', 'agents'), { recursive: true });
61
-
62
- // Re-arm cleanly: a NEW loop must NOT inherit the PREVIOUS loop's evaluator verdict or
63
- // history. A verdict is task+instance specific, but its freshness fingerprint only covers
64
- // the work tree (state-fp excludes LOOP.md + .svamp/). So a stale {verdict:done, state_fp}
65
- // left by a finished loop would FALSE-CLOSE a fresh loop at iteration 0 whenever the tree
66
- // is unchanged between loops. Always clear them so a new loop requires its own fresh verdict.
67
- // #0166: hooks-only NEVER re-arms — it must not disturb an existing loop's verdict/history/state.
68
- if (!hooksOnly) {
69
- rmSync(join(loopDir, 'evaluator-verdict.json'), { force: true });
70
- rmSync(join(loopDir, 'history.jsonl'), { force: true });
71
- }
72
-
73
- // 1. Copy hook scripts so the project is self-contained.
74
- for (const f of ['state-fp.mjs', 'stop-gate.mjs', 'inject-loop.mjs', 'loop-status.mjs', 'precompact.mjs']) {
75
- const dest = join(binDir, f);
76
- copyFileSync(join(HERE, f), dest);
77
- try { chmodSync(dest, 0o755); } catch {}
78
- }
79
-
80
- // #0166: steps 2-4 CREATE/refresh the loop — skipped in hooks-only mode (the spawn pre-install only
81
- // installs the dormant gate). The copy-scripts (1) + hooks (5) below always run.
82
- if (!hooksOnly) {
83
- // 2. loop.config.json
84
- const config = {
85
- loop_file: loopFile,
86
- // The repo/working root — copied hook scripts (which live under .svamp/<sid>/loop/bin
87
- // and resolve their own dir relatively) read this to run the oracle + fingerprint the
88
- // work product, since their depth no longer encodes the project root.
89
- project_dir: dir,
90
- // The success contract — the "until" the loop gate runs against. Read by the daemon
91
- // to populate the loop verdict event (legacy wire type: supervision:verdict).
92
- ...(criteria ? { criteria: criteria.trim() } : {}),
93
- oracle: oracle ? { command: oracle, timeout_sec: 600 } : null,
94
- evaluator: { enabled: evaluatorOn, model },
95
- max_iterations: max,
96
- budget: (args['max-runtime'] || args['max-tokens'] || args['max-tokens-per-hour']) ? {
97
- ...(args['max-runtime'] ? { max_runtime_sec: Number(args['max-runtime']) } : {}),
98
- ...(args['max-tokens'] ? { max_tokens: Number(args['max-tokens']) } : {}),
99
- // #0156: a ROLLING token-rate cap (tokens per the last hour) — the primary limiter for a
100
- // long-running loop. The gate sums only the last hour's transcript usage and stalls if over.
101
- ...(args['max-tokens-per-hour'] ? { max_tokens_per_hour: Number(args['max-tokens-per-hour']) } : {}),
102
- } : undefined,
103
- };
104
- writeFileSync(join(loopDir, 'loop.config.json'), JSON.stringify(config, null, 2));
105
-
106
- // 3. loop-state.json (machine state, daemon/gate-owned)
107
- writeFileSync(join(loopDir, 'loop-state.json'), JSON.stringify({
108
- active: true, iteration: 0, phase: 'running', started_at: new Date().toISOString(),
109
- ...(sessionId ? { session_id: sessionId } : {}),
110
- }, null, 2));
111
-
112
- // 4. LOOP.md (agent + human editable) — lives inside the session-scoped loop dir
113
- // so two sessions in one working dir keep separate memory. Write it when a task is
114
- // provided (a fresh loop-start must NOT inherit a previous loop's task left over in the
115
- // same session dir), or when none exists yet. Only a hand-authored LOOP.md with NO
116
- // --task passed is preserved (standalone "I pre-wrote the task" case).
117
- const loopPath = join(loopDir, loopFile);
118
- if (taskProvided || !existsSync(loopPath)) {
119
- writeFileSync(loopPath, `# Loop Task
120
-
121
- ## Task
122
- ${task}
123
-
124
- ## Success criteria
125
- ${criteria
126
- ? criteria.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => (l.startsWith('-') ? l : `- ${l}`)).join('\n')
127
- : '- (define objective success criteria)'}
128
- ${oracle ? `- The oracle passes: \`${oracle}\`` : ''}
129
- - An independent evaluator confirms the work is genuinely complete.
130
-
131
- ## Plan
132
- - (the agent fills this in)
133
-
134
- ## Progress
135
- - (the agent appends iteration notes here; this is durable memory)
136
- `);
137
- }
138
- } // end if(!hooksOnly): loop creation (config/state/LOOP.md)
139
-
140
- // 5. .claude/settings.json hooks — ALWAYS install/refresh the gate so it's loaded at the next Claude
141
- // start (pre-installed at spawn via hooks-only, so loop start/stop/config never needs a restart).
142
- // #0166: MERGE, don't clobber — preserve any user-defined hooks for these events; only replace OUR
143
- // own prior entry (matched by the loop script name) so re-running can't duplicate it.
144
- const settingsPath = join(dir, '.claude', 'settings.json');
145
- let settings = {};
146
- if (existsSync(settingsPath)) { try { settings = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch {} }
147
- const node = process.execPath;
148
- const cmd = (script) => `"${node}" "${join(binDir, script)}"`;
149
- settings.hooks = settings.hooks || {};
150
- const mergeHook = (event, script) => {
151
- const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
152
- // Drop any prior group that points at one of OUR loop scripts (so we refresh, never duplicate),
153
- // keeping all user-defined groups for this event intact.
154
- const others = existing.filter((g) => !(Array.isArray(g?.hooks) && g.hooks.some(
155
- (h) => typeof h?.command === 'string' && /loop[\\/](bin[\\/])?(inject-loop|stop-gate|precompact)\.mjs/.test(h.command))));
156
- settings.hooks[event] = [...others, { hooks: [{ type: 'command', command: cmd(script) }] }];
157
- };
158
- mergeHook('SessionStart', 'inject-loop.mjs');
159
- mergeHook('UserPromptSubmit', 'inject-loop.mjs');
160
- mergeHook('Stop', 'stop-gate.mjs');
161
- mergeHook('PreCompact', 'precompact.mjs');
162
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
163
-
164
- // 6. Evaluator agent (materialized) — optional (skipped in hooks-only: no loop yet)
165
- if (!hooksOnly && evaluatorOn) {
166
- const fm = ['---', 'name: loop-evaluator',
167
- 'description: Skeptical independent reviewer that decides if a loop task is genuinely complete.',
168
- 'tools: Read, Bash, Grep, Glob', ...(model ? [`model: ${model}`] : []), '---', ''].join('\n');
169
- writeFileSync(join(dir, '.claude', 'agents', 'loop-evaluator.md'), fm +
170
- `You are an INDEPENDENT, skeptical reviewer. You did NOT write the code under review.
171
-
172
- You are given: the task/goal (from LOOP.md), the current diff, and the oracle output.
173
- Decide whether the work is GENUINELY and COMPLETELY done — default to "continue" on any doubt.
174
-
175
- Check:
176
- - Does it actually satisfy every success criterion in LOOP.md (not just look plausible)?
177
- - Does the oracle genuinely pass, and does it actually cover the requirement?
178
- - Edge cases, stubbed/faked functionality, regressions.
179
- - If this loop works an ISSUE BACKLOG: is EACH issue closed this round genuinely resolved by the real change? A green oracle only means "no open issues", not that each closed issue actually works — reject "done" if any was closed without real resolution.
180
- - HOLISTIC, from the USER's perspective: re-read the ORIGINAL request behind each closed issue (not just its triaged title) and the OVERALL project goal, and confirm we delivered what the user actually wanted end-to-end — INCLUDING that the change was built / published / deployed wherever the request implied it, not merely committed. Reject "done" if the project goal isn't genuinely met from the user's point of view, even when no issues remain open.
181
-
182
- Return ONLY a JSON object:
183
- {"verdict":"done"|"continue","reason":"<concise>","guidance":"<what to fix if continue>"}
184
- Be strict. A false "done" is far worse than one more iteration.
185
- `);
186
- }
187
-
188
- console.log(`✅ loop initialised in ${dir}
189
- loop dir : ${loopDir}
190
- task file : ${join(loopDir, loopFile)}
191
- oracle : ${oracle || '(none)'}
192
- evaluator : ${evaluatorOn ? 'on' + (model ? ` (${model})` : '') : 'off'}
193
- max iters : ${max}
194
- hooks : SessionStart, UserPromptSubmit, Stop (.claude/settings.json)`);