svamp-cli 0.2.223-canary.2 → 0.2.224

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-BignK4hn.mjs} +5 -5
  2. package/dist/{auth-FvyDIAch.mjs → auth-tP-DHfsI.mjs} +1 -1
  3. package/dist/cli.mjs +61 -61
  4. package/dist/{commands-Cmjb9Je1.mjs → commands-1IupY9qh.mjs} +6 -6
  5. package/dist/{commands-Dm3EQICq.mjs → commands-Bpq8_9OU.mjs} +2 -2
  6. package/dist/{commands-BhFr0kUL.mjs → commands-C1VIwqOH.mjs} +1 -1
  7. package/dist/{commands-_RXnsA1v.mjs → commands-CA8AMvq-.mjs} +1 -1
  8. package/dist/{commands-BiIkCdGB.mjs → commands-DMoXgLUh.mjs} +1 -1
  9. package/dist/{commands-BuecUAk8.mjs → commands-DqvzIDMK.mjs} +13 -1
  10. package/dist/{commands-nlra_-D0.mjs → commands-ez1xdcH5.mjs} +2 -2
  11. package/dist/{fleet-Bi3KIev7.mjs → fleet-B6Rh4mje.mjs} +1 -1
  12. package/dist/{frpc-BpQyeWvI.mjs → frpc-vpwKk0kV.mjs} +1 -1
  13. package/dist/{headlessCli-Be0bYOUB.mjs → headlessCli-C51w8i7T.mjs} +2 -2
  14. package/dist/index.mjs +1 -1
  15. package/dist/package-CzzAMi8h.mjs +63 -0
  16. package/dist/{rpc-CRZWx5YY.mjs → rpc-CcQQrbBO.mjs} +1 -1
  17. package/dist/{rpc-WS0yDVqB.mjs → rpc-D7QFTk_g.mjs} +1 -1
  18. package/dist/{run-8_mnLg6R.mjs → run-BZrPFG_w.mjs} +1 -1
  19. package/dist/{run-DVdqRhtp.mjs → run-C-9ZtEi4.mjs} +77 -33
  20. package/dist/{scheduler-Dx-QrQfP.mjs → scheduler-jlX6Mkxf.mjs} +1 -1
  21. package/dist/{serveCommands-CI2P6buK.mjs → serveCommands-Dhrv-HAD.mjs} +5 -5
  22. package/dist/{serveManager-B-keYGE9.mjs → serveManager-DtBN8E9D.mjs} +2 -2
  23. package/dist/{sideband-BdYb3ezu.mjs → sideband-DxH9bMhv.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,100 +0,0 @@
1
- ---
2
- name: loop
3
- version: 0.4.2
4
- description: Run a task as a reliable, self-verifying loop — iterate until objective exit conditions are met, with an independent evaluator instead of self-judging. Use when a task needs repeated iterations until "done" (fix until tests pass, refactor until clean, build until a spec is met, autonomous long-running work).
5
- ---
6
-
7
- # Loop — loop engineering for long-running, self-verifying agents
8
-
9
- This skill turns a task into a **loop**: the agent iterates until the work is *objectively* done, not until the agent *feels* done. It is built on one hard-won lesson from loop engineering — **the thing that decides "done" must be independent of the thing doing the work.**
10
-
11
- ## Why loops, and why this design
12
-
13
- Prompting an agent turn-by-turn doesn't scale to long or autonomous work. Loop engineering is the shift from *running the agent yourself* to *designing the system that runs it*. A good loop has a small number of durable parts, and most of the leverage is in **the loop, not the prompt**.
14
-
15
- The failure this design exists to prevent is **early quitting**: a model asked to judge its own output will confidently declare success on mediocre or incomplete work, and models also tend to "wrap up" as they approach context limits ("context anxiety"). Trusting the actor to say `DONE` is the single biggest source of unreliable loops.
16
-
17
- So this loop is built on these principles:
18
-
19
- 1. **Actor ≠ judge.** The agent doing the work never decides completion. An *independent* evaluator — a fresh, skeptical agent that only sees the artifacts, not your reasoning — does. Calibrating a separate evaluator toward skepticism is far more reliable than asking yourself to be self-critical.
20
- 2. **Verification needs an oracle.** The strongest signal is something *outside any model* that returns a hard yes/no: a test suite, a build, a linter, a type-check, a script. The loop runs the oracle itself; you cannot fake it.
21
- 3. **State lives on disk, not in context.** *The model forgets; the repo doesn't.* The task, plan, and progress live in **`LOOP.md`** in the project — readable and editable by both you and the human, surviving compaction, restarts, and fresh sessions. It is your durable memory and your self-improving harness.
22
- 4. **The harness enforces, the prompt only requests.** Completion is gated by a Claude Code **`Stop` hook** that the runtime *obeys* — it physically refuses to let the turn end until the exit conditions hold. This is what makes the loop reliable rather than aspirational.
23
- 5. **Bounded by construction.** Every loop has a `max_iterations` and a token/cost budget so it cannot run away.
24
- 6. **Stay auditable.** Every iteration leaves a trail (oracle result, evaluator verdict, progress notes) so a human can review *why* it stopped, not just *that* it stopped. Build the loop like someone who intends to stay the engineer.
25
-
26
- These principles are stable even as models get stronger. As the actor improves, you simply lean on lighter scaffolding (e.g. trust longer continuous context, fewer resets) — but *independent verification of "done"* and *durable on-disk state* remain correct regardless of model capability. Don't reimplement what the runtime already gives you (subagents, compaction, worktrees, usage accounting); compose them.
27
-
28
- ## The loop, in one picture
29
-
30
- ```
31
- read LOOP.md ─► do real work on the task ─► update LOOP.md progress
32
- ▲ │
33
- │ (think you're done?)
34
- │ ▼
35
- │ run ORACLE + spawn independent EVALUATOR
36
- │ │
37
- Stop hook blocks ◄── not done ── GATE ── done ──► loop ends
38
- (oracle/verdict fails)
39
- ```
40
-
41
- Each turn is one iteration. When you try to end your turn, the gate re-checks the exit conditions. If they don't hold, you are sent back with specific feedback. You converge by *fixing the blocking issue*, not by re-asserting that you're done.
42
-
43
- ## Exit conditions (what "done" means)
44
-
45
- The loop ends only when **all** configured conditions hold:
46
- - **Oracle passes** — the configured command exits 0. The gate runs it; do not stub or fake it.
47
- - **Independent evaluator says "done"** (when enabled) — a *fresh* verdict tied to the *current* code state. A verdict written before later edits is stale and rejected.
48
-
49
- ## Your protocol each iteration
50
-
51
- 1. **Read `LOOP.md`.** It holds the task, success criteria, plan, and progress. (It is auto-injected each turn.)
52
- 2. **Make real progress** on the task. Prefer the smallest change that advances a success criterion.
53
- 3. **Keep `LOOP.md` current** — refine the plan, append what you did, what passed, what's left. This is the memory the *next* iteration (or a post-restart fresh session) relies on. You may restructure it; you may **not** weaken the success criteria to make quitting easier (that's visible in git and to the human).
54
- 4. **Run the oracle yourself** to check you're actually passing before claiming done.
55
- 5. **When you believe it's complete, get an independent verdict — do not grade yourself:**
56
- - Spawn a fresh subagent with the `Task` tool (use the `loop-evaluator` agent if present, otherwise an inline *skeptical reviewer* prompt). Give it **only** the goal (from `LOOP.md`), the current diff, and the oracle output — never your chain of thought.
57
- - For an **issue-backlog** loop the evaluator must go further than the oracle: confirm EACH issue closed this round is genuinely resolved by the real change, AND judge **holistically from the user's perspective** — re-read the *original request* behind each closed issue and the *overall project goal*, and confirm we delivered what the user actually wanted end-to-end (built / published / deployed wherever the request implied it, not merely committed). A green oracle only means "no open issues"; reject `done` if the project goal isn't truly met for the user.
58
- - It returns `{"verdict":"done"|"continue","reason":...,"guidance":...}`. It should default to `continue` on any doubt.
59
- - Record it to `.claude/loop/evaluator-verdict.json`, adding the current state fingerprint:
60
- `{"verdict":"done","reason":"...","guidance":"...","state_fp":"<output of: node .claude/loop/bin/state-fp.mjs>"}`
61
- 6. **End your turn.** The Stop gate verifies oracle + fresh verdict and either ends the loop or sends you back with the exact blocking reason. Address that reason next iteration.
62
-
63
- If you're blocked by something genuinely outside the task (missing secret, ambiguous requirement, external outage), record it clearly in `LOOP.md` so the human sees it on review.
64
-
65
- ## Setting up a loop
66
-
67
- Initialise the loop scaffolding in a project (idempotent):
68
-
69
- ```bash
70
- node <skill>/bin/loop-init.mjs <project-dir> \
71
- --task "Make the failing auth tests pass without weakening them" \
72
- --criteria "All auth tests pass; no test was weakened or skipped" \
73
- --oracle "npm test --silent" \
74
- --evaluator on \
75
- --max 20
76
- ```
77
-
78
- This generates: `LOOP.md` (task/criteria/progress), `.claude/loop/loop.config.json`, machine state in `.claude/loop/loop-state.json`, the `Stop`/`SessionStart` hooks in `.claude/settings.json`, and (optionally) a materialised `.claude/agents/loop-evaluator.md` skeptical reviewer. Re-running with new flags retunes a live loop.
79
-
80
- ## Anatomy (files & ownership)
81
-
82
- | File | Owner | Purpose |
83
- |---|---|---|
84
- | `LOOP.md` | you + human | task · success criteria · plan · progress (durable memory, git-visible) |
85
- | `.claude/loop/loop.config.json` | config | oracle command, evaluator on/off + model, max_iterations |
86
- | `.claude/loop/loop-state.json` | the gate | iteration, phase, last oracle/eval result (machine state — don't hand-edit) |
87
- | `.claude/loop/evaluator-verdict.json` | written from the evaluator subagent | latest verdict + state fingerprint |
88
- | `.claude/settings.json` (hooks) | generated | `Stop` gate + `SessionStart`/`UserPromptSubmit` LOOP.md injection |
89
- | `.claude/agents/loop-evaluator.md` | generated (optional) | skeptical reviewer persona + model |
90
-
91
- ## Tuning & extending (future-proofing)
92
-
93
- - **No oracle?** Lean fully on the evaluator (weaker — prefer adding *some* objective check, even a smoke script).
94
- - **Stronger judgment?** Point the evaluator at a stronger model via `--model`.
95
- - **Cheaper?** Disable the evaluator (`--evaluator off`) and rely on the oracle alone for purely objective tasks.
96
- - **Different exit logic** (scores/thresholds, FINISH/REFINE/PIVOT strategies, multi-dimensional rubrics): the verdict file is just JSON and the gate reads it — richer verdicts can be added without changing the protocol.
97
- - **Scheduling & lifecycle** (run on a schedule): handled by Workflows (`svamp workflow add … --on schedule --cron "…"`, GitHub-Actions-style), not this skill — a workflow step can start a loop (`svamp session loop …`). The loop itself stays a pure iterate-until-done engine.
98
- - **Compaction handoff**: progress in `LOOP.md` is the handoff artifact; a `PreCompact` hook can flush it automatically for very long runs.
99
-
100
- The contract that must never change: **work is decided done by something other than the worker, and the loop's memory lives on disk.** Everything else is tunable.
@@ -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);