🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

sentoagent

Package Overview
Dependencies
Maintainers
1
Versions
72
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

sentoagent - npm Package Compare versions

Comparing version
2.1.0
to
2.2.0
+86
src/templates/hooks/enforce-reply.js
#!/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("");

@@ -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")