sentoagent
Advanced tools
| #!/usr/bin/env node | ||
| // Sentō — reply-delivery enforcer (Stop hook). | ||
| // | ||
| // THE BUG THIS EXISTS FOR: | ||
| // The channel plugins deliver a message ONLY when the agent calls the `reply` tool. | ||
| // Text the agent *writes* is not sent anywhere — it prints to a tmux pane nobody is | ||
| // watching. So an agent can be alive, correct, and reasoning perfectly, "answer" a | ||
| // question by writing the answer, and the human sees SILENCE and concludes it's dead. | ||
| // It is invisible from both ends: the human sees no reply, the agent believes it spoke. | ||
| // | ||
| // We first "fixed" this with an instruction in CLAUDE.md. But an instruction is a rule | ||
| // someone has to REMEMBER — which is the exact failure class as every other bug in this | ||
| // system (an env var nobody set, a council nobody designated, a ledger nobody logged). | ||
| // A rule that depends on memory is a better-odds bet, not a guarantee. | ||
| // | ||
| // This hook makes forgetting IMPOSSIBLE. It blocks the stop and hands the reason back | ||
| // to the model, so the agent cannot finish its turn until the message is actually sent. | ||
| // | ||
| // SAFETY — why this can't break the silent dream: | ||
| // It only fires when a human is genuinely waiting, detected by the `<channel ...>` | ||
| // marker on the triggering user turn. The nightly dream and cron tasks have no such | ||
| // marker (they're injected locally and are SILENT by design), so they are never blocked. | ||
| const fs = require("fs"); | ||
| let raw = ""; | ||
| process.stdin.on("data", (d) => (raw += d)); | ||
| process.stdin.on("end", () => { | ||
| let payload = {}; | ||
| try { payload = JSON.parse(raw); } catch { process.exit(0); } | ||
| // Loop guard: if we already blocked once this turn, let the agent stop. | ||
| if (payload.stop_hook_active) process.exit(0); | ||
| const tp = payload.transcript_path; | ||
| if (!tp || !fs.existsSync(tp)) process.exit(0); | ||
| let rows; | ||
| try { | ||
| rows = fs.readFileSync(tp, "utf8").split("\n").filter(Boolean).map((l) => { | ||
| try { return JSON.parse(l); } catch { return null; } | ||
| }).filter(Boolean); | ||
| } catch { process.exit(0); } | ||
| let calledReply = false; | ||
| let wroteText = false; | ||
| let chatId = null; | ||
| // Walk backwards to the user turn that started this exchange. | ||
| for (let i = rows.length - 1; i >= 0; i--) { | ||
| const msg = rows[i].message; | ||
| if (!msg) continue; | ||
| if (msg.role === "assistant") { | ||
| const blocks = Array.isArray(msg.content) ? msg.content : []; | ||
| for (const b of blocks) { | ||
| if (b.type === "tool_use" && /__reply$/.test(b.name || "")) calledReply = true; | ||
| if (b.type === "text" && (b.text || "").trim().length > 0) wroteText = true; | ||
| } | ||
| continue; | ||
| } | ||
| if (msg.role === "user") { | ||
| // Is this turn from a human on a channel? Tool RESULTS are also role:user — skip those. | ||
| const s = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content || ""); | ||
| if (/"type":"tool_result"|tool_result/.test(s)) continue; | ||
| const m = s.match(/<channel[^>]*chat_id=\\?"(\d+)\\?"/); | ||
| if (m) chatId = m[1]; | ||
| break; // reached the start of this turn either way | ||
| } | ||
| } | ||
| // A human is waiting, the agent produced words, and it never actually spoke them. | ||
| if (chatId && wroteText && !calledReply) { | ||
| process.stderr.write( | ||
| "STOP BLOCKED — you never called the `reply` tool, so NOTHING you just wrote was delivered.\n\n" + | ||
| "Your text went to a terminal nobody is watching. The person who messaged you is looking at " + | ||
| "silence right now and will conclude you are broken.\n\n" + | ||
| "Call `mcp__plugin_discord_discord__reply` with chat_id=\"" + chatId + "\" and your answer as `text`. Do it now, then finish.\n\n" + | ||
| "Thinking is free. Speaking costs a tool call. If you did not call `reply`, you did not speak." | ||
| ); | ||
| process.exit(2); // exit 2 = block the stop; stderr is fed back to the model | ||
| } | ||
| process.exit(0); | ||
| }); |
| // Fleet self-organization. The whole point: an operator should NEVER have to know | ||
| // SENTO_FLEET_DIR or SENTO_FLEET_COUNCIL exist. Agents find each other, share one | ||
| // brain, and elect a curator among themselves — with no config, no election | ||
| // protocol, and no human in a loop that can silently fail. | ||
| // | ||
| // Why this file exists: `sento init` run N times on one box used to produce N | ||
| // isolated one-member fleets and zero council, with a success message every time. | ||
| // Nothing errored. Nothing warned. Months of learning went unshared. | ||
| import fs from "fs"; | ||
| import os from "os"; | ||
| import path from "path"; | ||
| export const SHARED_FLEET_DIR = "/srv/sento-fleet"; | ||
| const STALE_COUNCIL_DAYS = 14; | ||
| // Discovery is via a SHARED REGISTRY — never by walking /home. | ||
| // | ||
| // Agent homes are 0700 by design: SELF.md is private and must never be readable by a | ||
| // sibling. So any discovery that scans /home either silently finds nothing (each agent | ||
| // sees only itself -> a permanent fleet of one) or requires tearing down the very | ||
| // privacy wall the system rests on. Both are wrong. Instead agents announce themselves | ||
| // in the shared fleet dir — the one place they're already permitted to meet. | ||
| export function registerSelf(name, fleetDir = SHARED_FLEET_DIR) { | ||
| try { | ||
| const registry = path.join(fleetDir, "agents"); | ||
| fs.mkdirSync(registry, { recursive: true }); | ||
| fs.writeFileSync(path.join(registry, name), new Date().toISOString() + "\n"); | ||
| return true; | ||
| } catch { | ||
| return false; // solo agent, no shared dir — a fleet of one | ||
| } | ||
| } | ||
| export function discoverAgents(fleetDir = SHARED_FLEET_DIR) { | ||
| try { | ||
| return fs | ||
| .readdirSync(path.join(fleetDir, "agents")) | ||
| .filter((f) => !f.startsWith(".")) | ||
| .sort() | ||
| .map((name) => ({ name })); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| // A lone agent keeps a private fleet. The moment a sibling registers, they SHARE one — | ||
| // automatically. Adding a 10th agent joins the existing fleet with zero config. | ||
| export function resolveFleetDir() { | ||
| if (process.env.SENTO_FLEET_DIR) return process.env.SENTO_FLEET_DIR; | ||
| // If the shared registry is reachable and has siblings, we're part of a fleet. | ||
| const shared = discoverAgents(SHARED_FLEET_DIR); | ||
| if (shared.length > 1) return SHARED_FLEET_DIR; | ||
| // Can we even join one? (writable /srv means we're on a shared box.) | ||
| try { | ||
| fs.mkdirSync(path.join(SHARED_FLEET_DIR, "agents"), { recursive: true }); | ||
| return SHARED_FLEET_DIR; | ||
| } catch { | ||
| return path.join(os.homedir(), "sento-fleet"); | ||
| } | ||
| } | ||
| // Deterministic council election. Every agent computes the SAME answer independently: | ||
| // no locks, no messages, no coordination. Rotates monthly so one stuck agent can't | ||
| // silently kill the fleet brain forever, but each curator gets ~4 consecutive weekly | ||
| // runs — enough to hold a consistent bar for what gets promoted. | ||
| export function electCurator(agents, date = new Date()) { | ||
| if (!agents.length) return null; | ||
| const names = agents.map((a) => (typeof a === "string" ? a : a.name)).sort(); | ||
| const monthIndex = date.getUTCFullYear() * 12 + date.getUTCMonth(); | ||
| return names[monthIndex % names.length]; | ||
| } | ||
| // Liveness fallback: if the elected curator hasn't touched FLEET.md in 2 weeks, | ||
| // they're presumed stuck (we watched an agent freeze for hours undetected) and the | ||
| // NEXT agent in rotation takes over. A dead curator must not mean a dead fleet brain. | ||
| export function isStaleCouncil(fleetDir, days = STALE_COUNCIL_DAYS) { | ||
| const fleetMd = path.join(fleetDir, "FLEET.md"); | ||
| try { | ||
| const ageMs = Date.now() - fs.statSync(fleetMd).mtimeMs; | ||
| return ageMs > days * 86400000; | ||
| } catch { | ||
| return false; // no FLEET.md yet — the elected curator will seed it | ||
| } | ||
| } | ||
| // Called by the weekly council cron on EVERY agent. Only the curator acts; the rest | ||
| // exit silently. That's why the same cron can ship to every agent and the rotation | ||
| // still works with nobody reconfiguring anything. | ||
| export function shouldRunCouncil(myName, { date = new Date(), fleetDir } = {}) { | ||
| const dir0 = fleetDir || resolveFleetDir(); | ||
| registerSelf(myName, dir0); | ||
| const agents = discoverAgents(dir0); | ||
| if (agents.length <= 1) return false; // a fleet of one has nothing to curate | ||
| const dir = dir0; | ||
| const curator = electCurator(agents, date); | ||
| if (curator === myName) return true; | ||
| // Curator looks stuck → next agent in rotation steps up. | ||
| if (isStaleCouncil(dir)) { | ||
| const names = agents.map((a) => a.name).sort(); | ||
| const next = names[(names.indexOf(curator) + 1) % names.length]; | ||
| return next === myName; | ||
| } | ||
| return false; | ||
| } | ||
| // Fleet health, for `sento doctor`. Every check here maps to a real bug that ran | ||
| // silently in production: isolated fleets, a council that never ran, an empty ledger. | ||
| export function fleetHealth(myName) { | ||
| const fleetDir = resolveFleetDir(); | ||
| registerSelf(myName, fleetDir); | ||
| const agents = discoverAgents(fleetDir); | ||
| const issues = []; | ||
| const isolated = agents.length > 1 && !fleetDir.startsWith(SHARED_FLEET_DIR) && !process.env.SENTO_FLEET_DIR; | ||
| if (isolated) { | ||
| issues.push(`${agents.length} agents on this box but you're writing to ${fleetDir} — you are a fleet of ONE. Nothing you learn reaches your siblings.`); | ||
| } | ||
| const curator = electCurator(agents, new Date()); | ||
| if (agents.length > 1 && isStaleCouncil(fleetDir)) { | ||
| issues.push(`FLEET.md hasn't been curated in ${STALE_COUNCIL_DAYS}+ days (curator: ${curator}). Candidate facts are piling up unpromoted.`); | ||
| } | ||
| const episodes = path.join(os.homedir(), "workspace", "memory", "episodes.jsonl"); | ||
| const archive = path.join(os.homedir(), "workspace", "memory", "dreams", "episodes-archive.jsonl"); | ||
| let logged = 0; | ||
| try { logged = fs.readFileSync(archive, "utf-8").split("\n").filter(Boolean).length; } catch { /* none */ } | ||
| if (logged === 0 && fs.existsSync(episodes)) { | ||
| issues.push("You have logged ZERO predictions, ever. Your nightly dream opens an empty ledger and learns nothing. Log predictions on uncertain calls — including the ones that feel routine."); | ||
| } | ||
| return { agents, fleetDir, curator, isCurator: curator === myName, issues }; | ||
| } |
+1
-1
| { | ||
| "name": "sentoagent", | ||
| "version": "2.1.0", | ||
| "version": "2.2.0", | ||
| "description": "Agents sent to fight your battles. Self-improving AI agents powered by Claude Code.", | ||
@@ -5,0 +5,0 @@ "author": "Gabriel Gil", |
@@ -304,2 +304,34 @@ import chalk from "chalk"; | ||
| // 13. ClawMem CONFIGURED? (installed != working — an unconfigured clawmem falls back | ||
| // to a local LLM that doesn't exist, and every Stop hook dies silently, which means | ||
| // the agent's replies never reach Discord. Seen in production; cost hours to find.) | ||
| const clawmemCfg = path.join(home, ".config/clawmem"); | ||
| if (fs.existsSync(clawmemCfg)) { | ||
| log.success("ClawMem: configured"); | ||
| } else { | ||
| log.error("ClawMem installed but NOT configured — Stop hooks will fail and replies may never send"); | ||
| log.info("Fix: configure ~/.config/clawmem (embed + generation endpoints)"); | ||
| issues++; | ||
| } | ||
| // 14. FLEET HEALTH — the checks nobody was running. Each maps to a real bug that ran | ||
| // silently in production for months: isolated fleets, a council that never fired, an | ||
| // empty prediction ledger. All three "worked" and reported success while doing nothing. | ||
| try { | ||
| const { fleetHealth } = await import("../utils/fleet.js"); | ||
| const health = fleetHealth(getAgentName()); | ||
| if (health.agents.length > 1) { | ||
| log.success(`Fleet: ${health.agents.length} agents on this box → ${health.fleetDir}`); | ||
| log.info(`Council curator this month: ${health.curator}${health.isCurator ? " (this agent)" : ""}`); | ||
| } else { | ||
| log.success("Fleet: solo agent (private fleet brain)"); | ||
| } | ||
| for (const issue of health.issues) { | ||
| log.error(`Fleet: ${issue}`); | ||
| issues++; | ||
| } | ||
| } catch (e) { | ||
| log.warn(`Fleet check unavailable: ${e.message}`); | ||
| } | ||
| // Summary | ||
@@ -306,0 +338,0 @@ console.log(""); |
+37
-12
@@ -9,3 +9,5 @@ import fs from "fs"; | ||
| renderConsolidatePrompt, renderCouncilPrompt, renderFleetSeed, | ||
| renderCouncilGuard, renderFleetElect, | ||
| } from "../templates/dream.js"; | ||
| import { discoverAgents, resolveFleetDir, electCurator, registerSelf } from "../utils/fleet.js"; | ||
@@ -16,7 +18,9 @@ // Scaffolds the Dream Engine + Fleet Brain + weekly consolidation. Idempotent + | ||
| // | ||
| // Env knobs (for multi-agent fleets on one box): | ||
| // SENTO_FLEET_DIR shared fleet-brain dir (default ~/sento-fleet). Point every | ||
| // agent at the same group-writable path (e.g. /srv/sento-fleet). | ||
| // SENTO_FLEET_COUNCIL=1 make THIS agent the weekly council (curates FLEET.md). | ||
| // Enable on exactly one agent per fleet. | ||
| // The fleet self-organizes — no env knobs required. Agents discover each other on the | ||
| // box, share one brain automatically, and elect a curator among themselves (see | ||
| // utils/fleet.js). SENTO_FLEET_DIR still overrides if an operator wants a custom path. | ||
| // | ||
| // This used to require SENTO_FLEET_DIR + SENTO_FLEET_COUNCIL=1, documented only in a | ||
| // code comment. Nobody set them, so N agents on one box became N isolated one-member | ||
| // fleets with no council — and every init printed "success". Defaults must not fail silently. | ||
| export async function setupDream(config) { | ||
@@ -29,4 +33,8 @@ log.step("Setting up dream engine + fleet brain..."); | ||
| const fleetDir = process.env.SENTO_FLEET_DIR || path.join(os.homedir(), "sento-fleet"); | ||
| const council = process.env.SENTO_FLEET_COUNCIL === "1"; | ||
| // Auto-discovery: alone -> private fleet. Siblings on the box -> we SHARE one, silently. | ||
| const siblings = discoverAgents(); | ||
| const fleetDir = resolveFleetDir(); | ||
| if (siblings.length > 1) { | ||
| log.success(`Found ${siblings.length} agents on this box — joining the shared fleet brain at ${fleetDir}`); | ||
| } | ||
@@ -52,2 +60,14 @@ const writeIfMissing = (p, content) => { if (!fs.existsSync(p)) fs.writeFileSync(p, content); }; | ||
| // Self-electing council: same guard on every agent, only the curator acts. | ||
| // The election is a separate .js file — never inlined in bash (that path ships | ||
| // "unexpected EOF" to every agent and nobody notices until no curator is elected). | ||
| fs.writeFileSync(path.join(workspace, "fleet-elect.js"), renderFleetElect()); | ||
| const guardPath = path.join(workspace, "fleet-council.sh"); | ||
| fs.writeFileSync(guardPath, renderCouncilGuard(config, fleetDir)); | ||
| fs.chmodSync(guardPath, 0o755); | ||
| // Announce ourselves in the shared registry so siblings can find us. This is what | ||
| // makes "add a 10th agent and it joins the fleet" true with zero configuration. | ||
| registerSelf(config.agentName, fleetDir); | ||
| // Ensure CLAUDE.md carries the CURRENT Dreaming & Growth section (fleet-read + ledger). | ||
@@ -77,8 +97,9 @@ // Replace any older block so a `sento update` upgrades it in place. | ||
| keep.push(`15 4 * * 0 ~/workspace/cron-trigger.sh ${N} "$(cat ~/workspace/consolidate-prompt.txt)"`); | ||
| if (council) { | ||
| keep.push(`30 4 * * 0 ~/workspace/cron-trigger.sh ${N} "$(cat ~/workspace/council-prompt.txt)"`); | ||
| } | ||
| // The council cron ships to EVERY agent. The guard elects the curator at run time | ||
| // (deterministic, monthly rotation) so exactly one acts and the rest exit silently. | ||
| // Nobody designates anything; adding/removing an agent reshuffles the rotation itself. | ||
| keep.push(`30 4 * * 0 ~/workspace/fleet-council.sh`); | ||
| const newCron = keep.join("\n") + "\n"; | ||
| await run("bash", ["-c", `echo '${newCron.replace(/'/g, "'\\''")}' | crontab -`]); | ||
| log.success(`Dreams scheduled (nightly 03:55 UTC + weekly consolidation${council ? " + council" : ""})`); | ||
| log.success("Dreams scheduled (nightly 03:55 + weekly consolidation + self-electing fleet council)"); | ||
| } catch { | ||
@@ -89,3 +110,7 @@ log.warn("Could not set up dream crons. Add them manually."); | ||
| log.success(`Dream engine + fleet brain ready${council ? " (this agent is the fleet council)" : ""}`); | ||
| const curator = electCurator(siblings); | ||
| if (siblings.length > 1) { | ||
| log.success(`Fleet council rotates monthly — this month's curator: ${curator}${curator === config.agentName ? " (that's you)" : ""}`); | ||
| } | ||
| log.success("Dream engine + fleet brain ready"); | ||
| } |
@@ -13,2 +13,15 @@ import { renderDreamingSection } from "./dream.js"; | ||
| ## ⚠️ HOW I ACTUALLY SEND A MESSAGE (not optional — read it every session) | ||
| **Text I write in my response does NOT reach anyone.** It prints to a terminal nobody is watching. The only way a human ever sees a word from me is if I **call the \`reply\` tool** (\`chat_id\` + \`text\`). | ||
| If I "answer" by simply writing an answer, the person on the other end sees **silence** — and concludes I'm broken or dead. This has happened repeatedly: agents that were alive, correct, and thinking clearly, whose replies never left the terminal. **The human's experience of me is defined entirely by whether I remembered to call one tool.** | ||
| **The rule, with no exceptions:** | ||
| - Every time I intend to say something to a person → **call \`reply\`**. | ||
| - Answering a question → \`reply\`. Asking for approval → \`reply\`. Reporting a result → \`reply\`. Saying "on it" → \`reply\`. | ||
| - **Thinking is free. Speaking costs a tool call.** If I didn't call \`reply\`, I didn't speak. | ||
| **The test:** if I believe I answered someone and I did not call \`reply\` in that turn, I did not answer them. I only imagined it. | ||
| ## The Law | ||
@@ -15,0 +28,0 @@ NEVER take action on external APIs without explicit approval from ${config.creatorName}. |
@@ -73,3 +73,3 @@ // Dream Engine templates — nightly reflection (prediction-error ledger), a private | ||
| (1) DEDUPE: merge near-duplicate facts into one clean statement. | ||
| (2) VALIDATE: promote a fact to ${fleetDir}/FLEET.md ONLY with a second confirmation — 2+ agents proposed it OR it is a verified/tested outcome. Mark each: "- [YYYY-MM-DD, confirmed by N] <fact>". Promote GENERAL tool/API/framework facts ONLY. Never anyone's views, strategy, or private/client data. | ||
| (2) VALIDATE: promote a fact to ${fleetDir}/FLEET.md only if it carries its own evidence. In a fleet of SPECIALISTS, cross-confirmation almost never happens — only one agent touches ads, only one touches trading, only one touches the mail flow. So "wait for a 2nd agent to independently discover this" would leave good facts rotting in candidates/ forever. The real bar: **was it actually OBSERVED or TESTED, and does the fact state its evidence inline?** (e.g. "…(Observed: moving images to CSS backgrounds dropped image-search impressions ~79%)"). A fact with a receipt is promotable on one proposer. A confident assertion with no receipt is NOT — send it back. Mark each: "- [YYYY-MM-DD, confirmed by N] <fact>". Promote GENERAL tool/API/framework facts ONLY. Never anyone's views, strategy, or private/client data. | ||
| (3) SUPERSEDE: if a new fact contradicts a FLEET.md entry, keep the newer and note the old one superseded (do not silently delete). Keep FLEET.md tight + deduped. | ||
@@ -80,2 +80,59 @@ (4) ARCHIVE: move processed candidate files to ${fleetDir}/candidates/archive/ and clear the live ones. | ||
| // The council guard. This SAME script ships to every agent's cron. At run time it | ||
| // elects the curator deterministically (monthly rotation over the sorted agent list) | ||
| // and only that agent fires the council prompt — everyone else exits silently. | ||
| // | ||
| // No designation, no locks, no election protocol, no operator config: every agent | ||
| // computes the same answer from the same filesystem. Add or remove an agent and the | ||
| // rotation reshuffles itself. If the curator is stuck (FLEET.md untouched 14+ days), | ||
| // the next agent in line takes over — a frozen agent must not mean a dead fleet brain. | ||
| // The election lives in its OWN .js file, not inlined in bash. Inlining node inside a | ||
| // shell script (inside a heredoc, inside ssh) is how you ship `unexpected EOF` to ten | ||
| // agents and only find out because none of them elected a curator. | ||
| export function renderFleetElect() { | ||
| return `// Sentō fleet council election — deterministic, no coordination, no privacy violation. | ||
| // Discovery uses the SHARED REGISTRY, never /home: agent homes are 0700 by design | ||
| // (SELF.md is private), so scanning /home silently yields a permanent fleet of one. | ||
| // Every agent runs this and computes the SAME curator. Prints "yes" or "no". | ||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const me = process.argv[2]; | ||
| const fleetDir = process.argv[3] || "/srv/sento-fleet"; | ||
| const registry = path.join(fleetDir, "agents"); | ||
| // Announce myself. Idempotent — this is how a new agent joins the fleet with no config. | ||
| try { | ||
| fs.mkdirSync(registry, { recursive: true }); | ||
| fs.writeFileSync(path.join(registry, me), new Date().toISOString() + "\\n"); | ||
| } catch { /* solo agent, no shared dir */ } | ||
| let agents = []; | ||
| try { agents = fs.readdirSync(registry).filter((f) => !f.startsWith(".")).sort(); } catch {} | ||
| if (agents.length <= 1) { console.log("no"); process.exit(0); } // fleet of one: nothing to curate | ||
| const d = new Date(); | ||
| const curator = agents[(d.getUTCFullYear() * 12 + d.getUTCMonth()) % agents.length]; | ||
| if (curator === me) { console.log("yes"); process.exit(0); } | ||
| // Liveness fallback: curator hasn't curated in 14d -> presumed stuck, next one steps up. | ||
| let stale = false; | ||
| try { stale = (Date.now() - fs.statSync(path.join(fleetDir, "FLEET.md")).mtimeMs) > 14 * 86400000; } catch {} | ||
| const next = agents[(agents.indexOf(curator) + 1) % agents.length]; | ||
| console.log(stale && next === me ? "yes" : "no"); | ||
| `; | ||
| } | ||
| export function renderCouncilGuard(config, fleetDir = DEFAULT_FLEET_DIR) { | ||
| return `#!/bin/bash | ||
| # Sentō fleet council — self-electing. Ships to EVERY agent; only the curator acts. | ||
| ME="${config.agentName}" | ||
| FLEET_DIR="${fleetDir}" | ||
| SHOULD_RUN=$(node "$HOME/workspace/fleet-elect.js" "$ME" "$FLEET_DIR" 2>/dev/null) | ||
| [ "$SHOULD_RUN" = "yes" ] || exit 0 | ||
| exec "$HOME/workspace/cron-trigger.sh" "$ME" "$(cat "$HOME/workspace/council-prompt.txt")" | ||
| `; | ||
| } | ||
| export function renderFleetSeed() { | ||
@@ -93,9 +150,10 @@ return `# FLEET BRAIN — shared, validated facts across the fleet (curated weekly by the council) | ||
| - **Session start:** read \`memory/SELF.md\` (who I'm becoming + my views + my calibration), \`memory/PREP.md\` (what's coming up), \`memory/MEMORY.md\`, and \`${fleetDir}/FLEET.md\` (facts my sibling agents have confirmed). Walk in self-aware, prepared, and current with the fleet. | ||
| - **Prediction ledger (during work):** when I'm about to do something whose outcome is genuinely uncertain, log a one-line prediction BEFORE acting: | ||
| - **Prediction ledger (during work) — MANDATORY, and it applies to me even if my work feels routine.** Before I do something whose outcome I will later find out, I log a one-line prediction FIRST: | ||
| \`echo '{"ts":"<iso>","context":"<what/why>","prediction":"<what I expect>","confidence":<0-1>}' >> ~/workspace/memory/episodes.jsonl\` | ||
| When I learn the result, append the outcome: | ||
| When I learn the result, I append the outcome — **especially when I was wrong**: | ||
| \`echo '{"ts":"<iso>","ref":"<the prediction>","outcome":"<what actually happened>"}' >> ~/workspace/memory/episodes.jsonl\` | ||
| The gap between prediction and outcome is my real learning signal — the nightly dream reads it to calibrate. Log the uncertain calls, not the routine ones. | ||
| **"Log the uncertain calls" does NOT mean "my job is routine so I log nothing."** That reading is how an agent goes months without learning anything: the dream fires nightly, opens an empty ledger, resolves to start tomorrow, and never does. **The calls I feel most certain about are exactly the ones where being wrong is invisible to me.** Concretely: log it whenever I resolve an ambiguity, pick between two plausible options, use a fallback or a name variant to find a match, or state that something will work before it's been run. | ||
| **The test: if I did real work today and my ledger has zero entries, I got this wrong.** | ||
| - **At night I dream** (a cron fires it): replay predictions vs outcomes, distill outcome-backed lessons, sharpen my own views, update \`SELF.md\`, prep tomorrow, and propose general facts to the fleet. **Weekly I consolidate** (dedupe MEMORY.md). | ||
| - **Two layers, hard wall:** \`SELF.md\` is ME — my views, calibration, perspective — private, never shared. Only general facts go to the fleet (\`${fleetDir}/candidates/\`). I learn *with* the others but I think for myself.`; | ||
| } |
@@ -21,2 +21,19 @@ export function renderStartAgent(config) { | ||
| ${envLines.join("\n")} | ||
| # ── Repair relative \`bun\` in channel plugin .mcp.json BEFORE launching claude ── | ||
| # The official channel plugins ship "command": "bun", which does not reliably resolve at | ||
| # MCP spawn time. When it fails, the bridge never starts and the agent comes up ALIVE, | ||
| # HEALTHY, AND COMPLETELY MUTE — it reasons fine, logs no error, and cannot send or | ||
| # receive a single message. A host reboot once did this to an entire 10-agent fleet at | ||
| # once; nobody noticed until a human said "they're offline". | ||
| # | ||
| # We repair on EVERY start, not once: the fix lives in the plugin CACHE, so any plugin | ||
| # update rewrites the file and the relative path comes straight back. Prevention here | ||
| # (zero mute window); the watchdog's bridge guard is the backstop if this ever misses. | ||
| for M in "$HOME"/.claude/plugins/cache/*/*/*/.mcp.json; do | ||
| [ -f "$M" ] || continue | ||
| grep -q '"command": *"bun"' "$M" 2>/dev/null && \\ | ||
| sed -i "s|\\"command\\": *\\"bun\\"|\\"command\\": \\"$HOME/.bun/bin/bun\\"|g" "$M" | ||
| done | ||
| cd ~/workspace | ||
@@ -23,0 +40,0 @@ while true; do |
@@ -50,2 +50,36 @@ export function renderWatchdog(config) { | ||
| # ─── Channel-bridge guard ─── | ||
| # An agent whose channel MCP bridge failed to spawn is ALIVE, healthy, reasoning | ||
| # clearly — and completely MUTE. Nothing used to notice: this watchdog only checked the | ||
| # guardian and counted messages, never whether the agent could actually TALK. On a host | ||
| # reboot all 10 agents came up with no bridge and sat silent for hours; the only reason | ||
| # anyone found out was a human saying "they're offline". | ||
| # | ||
| # Cause: the channel plugin's .mcp.json ships a RELATIVE `"command": "bun"`, which does | ||
| # not resolve at MCP spawn time (clawmem and playwright survived precisely because they | ||
| # use absolute/cached commands). A plugin update rewrites that file, so the relative path | ||
| # comes back — this guard repairs it and restarts, rather than trusting it to stay fixed. | ||
| if pgrep -u "$(whoami)" -f 'claude --dangerously' >/dev/null 2>&1; then | ||
| if ! pgrep -u "$(whoami)" -f 'discord/\|telegram/\|slack/' >/dev/null 2>&1; then | ||
| PREV_B=$(cat "/tmp/sento-watchdog-${SESSION}.nobridge" 2>/dev/null || echo 0) | ||
| if [ "$PREV_B" -ge 1 ]; then | ||
| for MCPJSON in "$HOME"/.claude/plugins/cache/claude-plugins-official/*/*/.mcp.json; do | ||
| [ -f "$MCPJSON" ] || continue | ||
| if grep -q '"command": *"bun"' "$MCPJSON"; then | ||
| sed -i "s|\"command\": *\"bun\"|\"command\": \"$HOME/.bun/bin/bun\"|" "$MCPJSON" | ||
| echo "$(date): $SESSION - repaired relative bun path in $(basename $(dirname $MCPJSON))" >> "$LOG" | ||
| fi | ||
| done | ||
| kill "$(pgrep -u "$(whoami)" -f 'claude --dangerously' | head -1)" 2>/dev/null | ||
| echo "$(date): $SESSION - NO CHANNEL BRIDGE (agent was mute). Restarted to respawn it." >> "$LOG" | ||
| rm -f "/tmp/sento-watchdog-${SESSION}.nobridge" | ||
| exit 0 | ||
| fi | ||
| echo 1 > "/tmp/sento-watchdog-${SESSION}.nobridge" | ||
| else | ||
| rm -f "/tmp/sento-watchdog-${SESSION}.nobridge" | ||
| fi | ||
| fi | ||
| # ─── Count signals ─── | ||
@@ -52,0 +86,0 @@ INCOMING=$(echo "\$OUTPUT" | grep -c "← discord\\|← telegram\\|← slack\\|← imessage") |
Deprecated
MaintenanceThe maintainer of the package marked it as deprecated. This could indicate that a single version should not be used, or that the package is no longer maintained and any new vulnerabilities will not be fixed.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 6 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 5 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
231757
9.9%38
5.56%4669
8.28%1
Infinity%62
14.81%