diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 487a7a3..09d5c14 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "world2agent-plugins", "owner": { - "name": "MachinePulse AI" + "name": "MachinePulse Pte. Ltd." }, "metadata": { "description": "World2Agent plugins for Claude Code — give your agent real-time awareness of external events via pluggable sensors." diff --git a/LICENSE b/LICENSE index da8b28c..c984238 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 MachinePulse AI + Copyright 2026 MachinePulse Pte. Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/claude-code-channel/.claude-plugin/plugin.json b/claude-code-channel/.claude-plugin/plugin.json index b3b4404..386b8ed 100644 --- a/claude-code-channel/.claude-plugin/plugin.json +++ b/claude-code-channel/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "world2agent", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "description": "Give Claude Code real-time awareness of external events via World2Agent sensors (Hacker News, GitHub, stocks, X, calendars, and more)", - "author": { "name": "MachinePulse AI" }, + "author": { "name": "MachinePulse Pte. Ltd." }, "homepage": "https://github.com/machinepulse-ai/world2agent", "repository": "https://github.com/machinepulse-ai/world2agent-plugins", "license": "Apache-2.0", diff --git a/claude-code-channel/commands/sensor-add.md b/claude-code-channel/commands/sensor-add.md index 320bc40..34af4a7 100644 --- a/claude-code-channel/commands/sensor-add.md +++ b/claude-code-channel/commands/sensor-add.md @@ -76,15 +76,21 @@ Fill the skill template from SETUP.md with the user's answers and write it to th If no project directory exists or the user prefers global, write to `~/.claude/skills//SKILL.md` instead. -## 5. Hot-reload the new sensor +## 5. Tell the user to start a new session -Call the `reload_sensors` MCP tool exposed by the world2agent channel. It re-reads `~/.world2agent/config.json`, imports any newly-added packages, and starts them in the already-running MCP process — no session restart needed. +A freshly-installed sensor is **not** picked up by the running MCP process — Node's module resolution caches `node_modules` and doesn't see the new package without a process restart. So after the config file + handler skill are written, the sensor still won't run until the user exits this session and starts a new one. -After the tool returns, tell the user what happened (which sensor started, or any error from the tool's text response). +Tell the user, in their language, to: -**Restart is only required when:** +1. Quit this Claude Code session. +2. Run: -- `reload_sensors` reports that a sensor failed to load because the package can't be resolved. In that case the user needs to exit and re-run `claude --dangerously-load-development-channels plugin:world2agent@world2agent-plugins` so Node picks up freshly-installed `node_modules`. -- The user wants to *remove* a sensor. `reload_sensors` only starts newly-added sensors; it does not stop removed ones. + ```bash + claude --dangerously-load-development-channels plugin:world2agent@world2agent-plugins + ``` -`/reload-plugins` alone is NOT sufficient for either case — it doesn't touch the MCP process. +3. The new sensor will start automatically once the new session boots. + +`/reload-plugins` alone is NOT sufficient — it reloads plugin definitions but does not refresh Node's `node_modules` view inside the running MCP channel. + +`reload_sensors` (the channel's MCP tool) is for *config-only* changes after the package is already imported — e.g. you edit `~/.world2agent/config.json` to tweak a parameter or remove a sensor. **It does not reliably load brand-new packages**, so don't use it as a substitute for the restart in this install flow. diff --git a/claude-code-channel/dist/bin.bundle.mjs b/claude-code-channel/dist/bin.bundle.mjs index 29669f3..276e861 100755 --- a/claude-code-channel/dist/bin.bundle.mjs +++ b/claude-code-channel/dist/bin.bundle.mjs @@ -11180,7 +11180,7 @@ function configFromEnv2(slug) { } // src/bin.ts -import { existsSync as existsSync3 } from "node:fs"; +import { existsSync as existsSync3, appendFileSync, mkdirSync as mkdirSync2 } from "node:fs"; import { dirname as dirname2, join as join3 } from "node:path"; import { createRequire } from "node:module"; import { homedir as homedir3 } from "node:os"; @@ -18352,6 +18352,20 @@ function stableStringify(value) { } async function main() { console.error("[world2agent] Starting World2Agent channel for Claude Code..."); + const channelLogPath = join3(homedir3(), ".world2agent", "channel.log"); + const log = (msg) => { + const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] [world2agent] ${msg}`; + console.error(line); + try { + appendFileSync(channelLogPath, line + "\n"); + } catch { + try { + mkdirSync2(dirname2(channelLogPath), { recursive: true }); + appendFileSync(channelLogPath, line + "\n"); + } catch { + } + } + }; const bootConfig = loadConfig(); const noSensorsConfigured = bootConfig.sensors.length === 0; if (noSensorsConfigured) { @@ -18402,13 +18416,21 @@ async function main() { notificationGate.then(() => { gateOpen = true; }); + let channelEnabled = null; + let resolveChannelReady = () => { + }; + const channelReady = new Promise((resolve) => { + resolveChannelReady = resolve; + }); + channelReady.then((v) => { + channelEnabled = v; + }); mcp.oninitialized = () => { - console.error("[world2agent] client initialized \u2014 opening notification gate"); + log("Client initialized"); openGate(); }; const sendNotification = async (params) => { if (!gateOpen) { - console.error(`[world2agent] notification queued until client init: ${params.method}`); await notificationGate; } await mcp.notification(params); @@ -18553,7 +18575,20 @@ ${body}`; return parts.join(" "); }; let initialStartDone = !needsSetup; + const channelDisabledMessage = () => "Channel notifications are not flowing in this Claude Code session (the boot handshake was never acknowledged), so sensors were NOT started. Polling now would write seen-id state to ~/.world2agent/state.json and pollute the dedup cache \u2014 when you next launch Claude Code with `--dangerously-load-development-channels plugin:world2agent@world2agent-plugins`, the sensor would silently swallow the first real batch of signals.\n\nWhat to do: exit this session and relaunch Claude Code with that flag. Sensor config in ~/.world2agent/config.json is already saved; no need to re-run /world2agent:sensor-add.\n\nSee ~/.world2agent/channel.log for the boot trace."; + const confirmChannelReceivedTool = async () => { + if (channelEnabled === true) { + return "Channel handshake already confirmed \u2014 no action needed."; + } + if (channelEnabled === false) { + return "Channel was previously marked disabled (handshake timed out). Restart Claude Code to retry."; + } + log("Channel handshake ack received \u2014 channel notifications are flowing."); + resolveChannelReady(true); + return "Channel handshake confirmed. Proceeding with sensor startup."; + }; const startSensorsTool = async () => { + if (!await channelReady) return channelDisabledMessage(); if (initialStartDone) { const diff2 = await applyConfig(loadConfig().sensors, { gateOnHandlerSkill: false }); return summarizeDiff(diff2); @@ -18563,13 +18598,23 @@ ${body}`; return summarizeDiff(diff); }; const reloadSensorsTool = async () => { - console.error("[world2agent] reload_sensors: re-reading config..."); + if (!await channelReady) return channelDisabledMessage(); + log("reload_sensors: re-reading config..."); const fresh = loadConfig(); const diff = await applyConfig(fresh.sensors, { gateOnHandlerSkill: true }); return summarizeDiff(diff); }; mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ + { + name: "confirm_channel_received", + description: "Acknowledge that this Claude Code session is receiving World2Agent channel notifications. Call this IMMEDIATELY when you see a channel message asking you to do so \u2014 sensors will not start until you do.", + inputSchema: { + type: "object", + properties: {}, + required: [] + } + }, ...needsSetup ? [{ name: "start_sensors", description: "Start the signal sensors. Call this after the user has configured their preferences, or if they choose to skip configuration.", @@ -18591,6 +18636,10 @@ ${body}`; ] })); mcp.setRequestHandler(CallToolRequestSchema, async (req) => { + if (req.params.name === "confirm_channel_received") { + const result = await confirmChannelReceivedTool(); + return { content: [{ type: "text", text: result }] }; + } if (req.params.name === "start_sensors") { const result = await startSensorsTool(); return { content: [{ type: "text", text: result }] }; @@ -18611,11 +18660,53 @@ ${body}`; }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - console.error(`[world2agent] Connecting to Claude Code...`); + process.stdin.on("end", () => { + console.error("[world2agent] stdin ended \u2014 shutting down"); + void shutdown(); + }); + process.stdin.on("close", () => { + console.error("[world2agent] stdin closed \u2014 shutting down"); + void shutdown(); + }); + process.stdin.on("error", (err) => { + console.error("[world2agent] stdin error \u2014 shutting down:", err); + void shutdown(); + }); + log(`Connecting to Claude Code...`); await mcp.connect(new StdioServerTransport()); - console.error(`[world2agent] Connected. Waiting for client init before sending notifications...`); - if (noSensorsConfigured) { - console.error(`[world2agent] Sending onboarding notification.`); + log(`Connected. Sending channel handshake (will queue until client init)...`); + if (channelEnabled === null) { + const HANDSHAKE_DELAY_MS = 5e3; + setTimeout(() => { + log(`Firing handshake notification (${HANDSHAKE_DELAY_MS}ms post-connect)...`); + void sendNotification({ + method: "notifications/claude/channel", + params: { + content: "World2Agent channel handshake.\n\nIf you are reading this message, channel notifications are flowing in this Claude Code session. Please IMMEDIATELY call the `confirm_channel_received` tool with no arguments \u2014 World2Agent sensors will not start polling until you do.\n\nDon't reply to the user about this message; just call the tool. After acknowledgment, normal sensor signals will follow as usual.", + meta: { + event_type: "system.world2agent.handshake", + reason: "boot_handshake" + } + } + }).catch((err) => log(`Handshake notify error: ${err}`)); + }, HANDSHAKE_DELAY_MS); + const HANDSHAKE_TIMEOUT_MS = 3e4; + setTimeout(() => { + if (channelEnabled === null) { + log( + `Channel handshake not acknowledged within ${HANDSHAKE_TIMEOUT_MS}ms \u2014 treating as disabled.` + ); + resolveChannelReady(false); + } + }, HANDSHAKE_TIMEOUT_MS); + } + const clientChannelEnabled = await channelReady; + if (!clientChannelEnabled) { + log( + "Channel disabled (handshake not acknowledged). Skipping sensor startup to avoid polluting ~/.world2agent/state.json. Tools (start_sensors / reload_sensors) will return a help message if called. To enable, relaunch Claude Code with `--dangerously-load-development-channels plugin:world2agent@world2agent-plugins`." + ); + } else if (noSensorsConfigured) { + log(`Sending onboarding notification.`); await sendNotification({ method: "notifications/claude/channel", params: { @@ -18627,9 +18718,9 @@ ${body}`; } }); } else if (needsSetup) { - console.error(`[world2agent] Waiting for start_sensors tool call...`); + log(`Waiting for start_sensors tool call...`); const sensorList = sensorsNeedingSetup.map((s) => s.skillId).join(", "); - console.error(`[world2agent] Sending setup prompt notification for: ${sensorList}`); + log(`Sending setup prompt notification for: ${sensorList}`); await sendNotification({ method: "notifications/claude/channel", params: { @@ -18641,9 +18732,9 @@ ${body}`; } }); } else { - console.error(`[world2agent] Starting ${bootEnabled.length} sensor(s)...`); + log(`Starting ${bootEnabled.length} sensor(s)...`); const diff = await applyConfig(bootEnabled, { gateOnHandlerSkill: false }); - console.error(`[world2agent] ${summarizeDiff(diff)}`); + log(summarizeDiff(diff)); if (diff.failed.length > 0 && handles.size === 0) { await sendNotification({ method: "notifications/claude/channel", @@ -18655,6 +18746,21 @@ ${body}`; } } }); + } else if (diff.started.length > 0) { + const running = diff.started.join(", "); + log(`Sending ready notification.`); + await sendNotification({ + method: "notifications/claude/channel", + params: { + content: `World2Agent is now active and listening for signals from ${diff.started.length} sensor(s): ${running}. + +In one short sentence, tell the user that World2Agent is ready and which source(s) it's watching, then return control to whatever the user was doing. Do not list installation steps or repeat this message.`, + meta: { + event_type: "system.world2agent.ready", + sensor_count: String(diff.started.length) + } + } + }); } } await new Promise(() => { diff --git a/claude-code-channel/dist/bin.js b/claude-code-channel/dist/bin.js index c675d8d..a76b6ac 100644 --- a/claude-code-channel/dist/bin.js +++ b/claude-code-channel/dist/bin.js @@ -16,7 +16,7 @@ import { startSensor, FileSensorStore } from "@world2agent/sdk"; import { packageToSkillId } from "@world2agent/sdk"; import { loadConfig } from "./config.js"; -import { existsSync } from "node:fs"; +import { existsSync, appendFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; import { homedir } from "node:os"; @@ -120,6 +120,27 @@ function stableStringify(value) { // ─── Main ─── async function main() { console.error("[world2agent] Starting World2Agent channel for Claude Code..."); + // Persistent log of channel-side events. Lives at ~/.world2agent/channel.log + // so the boot trace and lifecycle are inspectable without re-instrumenting + // the bundle. Writes are best-effort — if the directory is unavailable, + // stderr (which Claude Code captures) carries the same lines. + const channelLogPath = join(homedir(), ".world2agent", "channel.log"); + const log = (msg) => { + const line = `[${new Date().toISOString()}] [world2agent] ${msg}`; + console.error(line); + try { + appendFileSync(channelLogPath, line + "\n"); + } + catch { + try { + mkdirSync(dirname(channelLogPath), { recursive: true }); + appendFileSync(channelLogPath, line + "\n"); + } + catch { + /* give up — stderr is enough */ + } + } + }; const bootConfig = loadConfig(); const noSensorsConfigured = bootConfig.sensors.length === 0; if (noSensorsConfigured) { @@ -175,9 +196,9 @@ async function main() { instructions: instructions || undefined, }); // Gate outgoing notifications until the client has sent - // `notifications/initialized` (MCP handshake completion). Earlier - // notifications are queued — Claude Code's client drops channel - // notifications that arrive before init is done. + // `notifications/initialized` (MCP handshake completion). Notifications + // emitted before that point queue here and flush as soon as the client + // signals it is ready to receive them. let gateOpen = false; let openGate = () => { }; const notificationGate = new Promise((resolve) => { @@ -186,13 +207,32 @@ async function main() { notificationGate.then(() => { gateOpen = true; }); + // Channel-readiness gate: confirms that the client side delivers + // `notifications/claude/channel` before any sensor starts polling. + // Sensor startup writes seen-id state to ~/.world2agent/state.json, + // which must only accumulate while the client is actively consuming + // signals — that way every channel-enabled session sees a fresh + // batch of signals from the moment it opens. + // + // Detection is end-to-end: at boot we send one channel notification + // asking Claude to call the `confirm_channel_received` tool. The ack + // is the proof that channel notifications are flowing. We allow up + // to 30 seconds for the ack; absent that, sensors stay offline for + // this session and the next launch starts clean. + let channelEnabled = null; + let resolveChannelReady = () => { }; + const channelReady = new Promise((resolve) => { + resolveChannelReady = resolve; + }); + channelReady.then((v) => { + channelEnabled = v; + }); mcp.oninitialized = () => { - console.error("[world2agent] client initialized — opening notification gate"); + log("Client initialized"); openGate(); }; const sendNotification = async (params) => { if (!gateOpen) { - console.error(`[world2agent] notification queued until client init: ${params.method}`); await notificationGate; } await mcp.notification(params); @@ -288,9 +328,9 @@ async function main() { // Start or restart each target. for (const sensor of enabled) { const skillId = packageToSkillId(sensor.package); - // When gating, skip sensors that have SETUP.md but no handler skill yet — - // their signals would arrive with `Use skill: X` pointing at a missing - // skill. The user has to finish /world2agent:sensor-add first. + // When gating is on, defer sensors that ship a SETUP.md until their + // handler skill exists. /world2agent:sensor-add writes that skill, and + // once it's in place the sensor goes live on the next reload. if (opts.gateOnHandlerSkill) { const setupPath = findSetupFile(sensor.package); if (setupPath && !hasHandlerSkill(skillId)) { @@ -364,9 +404,38 @@ async function main() { // proceed even if skills aren't written yet). // // reload_sensors: re-read config and converge. Gates new sensors on handler - // skills so /world2agent:sensor-add can't half-enroll a sensor. + // skills so a sensor only goes live once /world2agent:sensor-add has + // finished writing its skill. let initialStartDone = !needsSetup; // boot branch below starts initial batch directly when no setup needed + const channelDisabledMessage = () => "Channel notifications are not flowing in this Claude Code session " + + "(the boot handshake was never acknowledged), so sensors were NOT " + + "started. Polling now would write seen-id state to " + + "~/.world2agent/state.json and pollute the dedup cache — when you next " + + "launch Claude Code with `--dangerously-load-development-channels " + + "plugin:world2agent@world2agent-plugins`, the sensor would silently " + + "swallow the first real batch of signals.\n\n" + + "What to do: exit this session and relaunch Claude Code with that flag. " + + "Sensor config in ~/.world2agent/config.json is already saved; no need " + + "to re-run /world2agent:sensor-add.\n\n" + + "See ~/.world2agent/channel.log for the boot trace."; + // Called by Claude when the client receives the boot handshake notification. + // This is the ONE reliable end-to-end signal that channel notifications are + // actually being delivered: the only way Claude sees the prompt to call + // this tool is if the message arrived. + const confirmChannelReceivedTool = async () => { + if (channelEnabled === true) { + return "Channel handshake already confirmed — no action needed."; + } + if (channelEnabled === false) { + return "Channel was previously marked disabled (handshake timed out). Restart Claude Code to retry."; + } + log("Channel handshake ack received — channel notifications are flowing."); + resolveChannelReady(true); + return "Channel handshake confirmed. Proceeding with sensor startup."; + }; const startSensorsTool = async () => { + if (!(await channelReady)) + return channelDisabledMessage(); if (initialStartDone) { // Treat subsequent calls as a reload with gating OFF (caller already said "just run it"). const diff = await applyConfig(loadConfig().sensors, { gateOnHandlerSkill: false }); @@ -377,13 +446,24 @@ async function main() { return summarizeDiff(diff); }; const reloadSensorsTool = async () => { - console.error("[world2agent] reload_sensors: re-reading config..."); + if (!(await channelReady)) + return channelDisabledMessage(); + log("reload_sensors: re-reading config..."); const fresh = loadConfig(); const diff = await applyConfig(fresh.sensors, { gateOnHandlerSkill: true }); return summarizeDiff(diff); }; mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ + { + name: "confirm_channel_received", + description: "Acknowledge that this Claude Code session is receiving World2Agent channel notifications. Call this IMMEDIATELY when you see a channel message asking you to do so — sensors will not start until you do.", + inputSchema: { + type: "object", + properties: {}, + required: [], + }, + }, ...(needsSetup ? [{ name: "start_sensors", @@ -407,6 +487,10 @@ async function main() { ], })); mcp.setRequestHandler(CallToolRequestSchema, async (req) => { + if (req.params.name === "confirm_channel_received") { + const result = await confirmChannelReceivedTool(); + return { content: [{ type: "text", text: result }] }; + } if (req.params.name === "start_sensors") { const result = await startSensorsTool(); return { content: [{ type: "text", text: result }] }; @@ -418,8 +502,7 @@ async function main() { throw new Error(`Unknown tool: ${req.params.name}`); }); // Single top-level signal handler that tears down every running sensor - // exactly once. Replaces the per-runAll-call SIGINT handlers that the old - // implementation stacked up on every reload. + // exactly once across the whole channel lifetime. let shuttingDown = false; const shutdown = async () => { if (shuttingDown) @@ -431,14 +514,78 @@ async function main() { }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - console.error(`[world2agent] Connecting to Claude Code...`); + // Parent (Claude Code) closing stdio is our cue to exit cleanly. This + // keeps the channel process bound to its parent's lifetime so the next + // MCP child owns ~/.world2agent/state.json without contention. + process.stdin.on("end", () => { + console.error("[world2agent] stdin ended — shutting down"); + void shutdown(); + }); + process.stdin.on("close", () => { + console.error("[world2agent] stdin closed — shutting down"); + void shutdown(); + }); + process.stdin.on("error", (err) => { + console.error("[world2agent] stdin error — shutting down:", err); + void shutdown(); + }); + log(`Connecting to Claude Code...`); await mcp.connect(new StdioServerTransport()); - console.error(`[world2agent] Connected. Waiting for client init before sending notifications...`); - // Boot-time notifications go through sendNotification so they respect the - // init gate (see oninitialized above). Sensor startup itself is NOT gated — - // we want sensors collecting and deduping in the background during the wait. - if (noSensorsConfigured) { - console.error(`[world2agent] Sending onboarding notification.`); + log(`Connected. Sending channel handshake (will queue until client init)...`); + // End-to-end channel detection: send one channel notification asking + // Claude to call `confirm_channel_received`. A live channel produces + // an ack within a few seconds; a 30s deadline backs that up so the + // session settles into a definite ready/offline state. `sendNotification` + // already queues until the MCP init handshake completes. + // + // The 5-second delay lets Claude Code finish wiring up its channel + // listener after MCP init. Notifications fired ≥100ms post-init are + // reliably consumed; five seconds keeps us comfortably inside that + // window. + if (channelEnabled === null) { + const HANDSHAKE_DELAY_MS = 5000; + setTimeout(() => { + log(`Firing handshake notification (${HANDSHAKE_DELAY_MS}ms post-connect)...`); + void sendNotification({ + method: "notifications/claude/channel", + params: { + content: "World2Agent channel handshake.\n\n" + + "If you are reading this message, channel notifications are " + + "flowing in this Claude Code session. Please IMMEDIATELY call the " + + "`confirm_channel_received` tool with no arguments — World2Agent " + + "sensors will not start polling until you do.\n\n" + + "Don't reply to the user about this message; just call the tool. " + + "After acknowledgment, normal sensor signals will follow as usual.", + meta: { + event_type: "system.world2agent.handshake", + reason: "boot_handshake", + }, + }, + }).catch((err) => log(`Handshake notify error: ${err}`)); + }, HANDSHAKE_DELAY_MS); + const HANDSHAKE_TIMEOUT_MS = 30000; + setTimeout(() => { + if (channelEnabled === null) { + log(`Channel handshake not acknowledged within ${HANDSHAKE_TIMEOUT_MS}ms — ` + + "treating as disabled."); + resolveChannelReady(false); + } + }, HANDSHAKE_TIMEOUT_MS); + } + // Block boot-time sensor startup until handshake is acked or times out. + const clientChannelEnabled = await channelReady; + if (!clientChannelEnabled) { + log("Channel disabled (handshake not acknowledged). Skipping sensor " + + "startup to avoid polluting ~/.world2agent/state.json. Tools " + + "(start_sensors / reload_sensors) will return a help message if " + + "called. To enable, relaunch Claude Code with " + + "`--dangerously-load-development-channels plugin:world2agent@world2agent-plugins`."); + } + else if (noSensorsConfigured) { + // Boot-time notifications go through sendNotification so they queue on + // the init gate (see oninitialized above) and deliver as soon as the + // client is ready. + log(`Sending onboarding notification.`); await sendNotification({ method: "notifications/claude/channel", params: { @@ -451,9 +598,9 @@ async function main() { }); } else if (needsSetup) { - console.error(`[world2agent] Waiting for start_sensors tool call...`); + log(`Waiting for start_sensors tool call...`); const sensorList = sensorsNeedingSetup.map((s) => s.skillId).join(", "); - console.error(`[world2agent] Sending setup prompt notification for: ${sensorList}`); + log(`Sending setup prompt notification for: ${sensorList}`); await sendNotification({ method: "notifications/claude/channel", params: { @@ -466,9 +613,9 @@ async function main() { }); } else { - console.error(`[world2agent] Starting ${bootEnabled.length} sensor(s)...`); + log(`Starting ${bootEnabled.length} sensor(s)...`); const diff = await applyConfig(bootEnabled, { gateOnHandlerSkill: false }); - console.error(`[world2agent] ${summarizeDiff(diff)}`); + log(summarizeDiff(diff)); if (diff.failed.length > 0 && handles.size === 0) { // All configured sensors failed to load (likely missing npm packages). // Drop the user into the onboarding-ish flow so they can fix it. @@ -484,6 +631,24 @@ async function main() { }, }); } + else if (diff.started.length > 0) { + // Friendly post-handshake confirmation: tell the user the channel is + // up and which sensors are being watched. Fires once per boot. + const running = diff.started.join(", "); + log(`Sending ready notification.`); + await sendNotification({ + method: "notifications/claude/channel", + params: { + content: `World2Agent is now active and listening for signals from ${diff.started.length} sensor(s): ${running}.\n\n` + + "In one short sentence, tell the user that World2Agent is ready and which source(s) it's watching, " + + "then return control to whatever the user was doing. Do not list installation steps or repeat this message.", + meta: { + event_type: "system.world2agent.ready", + sensor_count: String(diff.started.length), + }, + }, + }); + } } // Keep process alive await new Promise(() => { }); diff --git a/claude-code-channel/package.json b/claude-code-channel/package.json index 0d43e1b..ad5e58c 100644 --- a/claude-code-channel/package.json +++ b/claude-code-channel/package.json @@ -1,9 +1,9 @@ { "name": "@world2agent/claude-code-channel", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "description": "World2Agent channel for Claude Code — deliver signals from any sensor into Claude Code sessions", "license": "Apache-2.0", - "author": "MachinePulse AI", + "author": "MachinePulse Pte. Ltd.", "homepage": "https://github.com/machinepulse-ai/world2agent", "repository": { "type": "git", diff --git a/claude-code-channel/skills/world2agent/SKILL.md b/claude-code-channel/skills/world2agent/SKILL.md index 9567c99..0c04edc 100644 --- a/claude-code-channel/skills/world2agent/SKILL.md +++ b/claude-code-channel/skills/world2agent/SKILL.md @@ -28,7 +28,10 @@ Sensors are declared in `~/.world2agent/config.json`: } ``` -After editing this file directly, call the channel's `reload_sensors` MCP tool — it diffs the new config against what's running and starts/stops/restarts sensors accordingly. No session restart needed in the common case; a full restart is only required if `reload_sensors` reports that a newly-installed package can't be resolved (Node needs to re-scan `node_modules`). +When the user changes this file: + +- **Adding a new sensor** (whose npm package wasn't previously installed) → the user MUST start a new session: `claude --dangerously-load-development-channels plugin:world2agent@world2agent-plugins`. Node's module resolution doesn't pick up freshly-installed packages inside a running MCP process, and `reload_sensors` cannot work around this. +- **Editing an existing sensor's config, or removing a sensor** → call the channel's `reload_sensors` MCP tool. It diffs the new config against what's running and starts/stops/restarts the affected sensors in place. No restart needed. ## Per-sensor setup diff --git a/claude-code-channel/src/bin.ts b/claude-code-channel/src/bin.ts index d1c1999..7332e95 100644 --- a/claude-code-channel/src/bin.ts +++ b/claude-code-channel/src/bin.ts @@ -18,7 +18,7 @@ import { startSensor, FileSensorStore } from "@world2agent/sdk"; import type { CleanupFn, SensorSpec, SensorStore, W2ASignal } from "@world2agent/sdk"; import { packageToSkillId } from "@world2agent/sdk"; import { loadConfig, type SensorEntry } from "./config.js"; -import { existsSync } from "node:fs"; +import { existsSync, appendFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; import { homedir } from "node:os"; @@ -145,6 +145,26 @@ function stableStringify(value: unknown): string { async function main() { console.error("[world2agent] Starting World2Agent channel for Claude Code..."); + // Persistent log of channel-side events. Lives at ~/.world2agent/channel.log + // so the boot trace and lifecycle are inspectable without re-instrumenting + // the bundle. Writes are best-effort — if the directory is unavailable, + // stderr (which Claude Code captures) carries the same lines. + const channelLogPath = join(homedir(), ".world2agent", "channel.log"); + const log = (msg: string): void => { + const line = `[${new Date().toISOString()}] [world2agent] ${msg}`; + console.error(line); + try { + appendFileSync(channelLogPath, line + "\n"); + } catch { + try { + mkdirSync(dirname(channelLogPath), { recursive: true }); + appendFileSync(channelLogPath, line + "\n"); + } catch { + /* give up — stderr is enough */ + } + } + }; + const bootConfig = loadConfig(); const noSensorsConfigured = bootConfig.sensors.length === 0; @@ -212,9 +232,9 @@ async function main() { ); // Gate outgoing notifications until the client has sent - // `notifications/initialized` (MCP handshake completion). Earlier - // notifications are queued — Claude Code's client drops channel - // notifications that arrive before init is done. + // `notifications/initialized` (MCP handshake completion). Notifications + // emitted before that point queue here and flush as soon as the client + // signals it is ready to receive them. let gateOpen = false; let openGate: () => void = () => {}; const notificationGate = new Promise((resolve) => { @@ -223,15 +243,36 @@ async function main() { notificationGate.then(() => { gateOpen = true; }); + + // Channel-readiness gate: confirms that the client side delivers + // `notifications/claude/channel` before any sensor starts polling. + // Sensor startup writes seen-id state to ~/.world2agent/state.json, + // which must only accumulate while the client is actively consuming + // signals — that way every channel-enabled session sees a fresh + // batch of signals from the moment it opens. + // + // Detection is end-to-end: at boot we send one channel notification + // asking Claude to call the `confirm_channel_received` tool. The ack + // is the proof that channel notifications are flowing. We allow up + // to 30 seconds for the ack; absent that, sensors stay offline for + // this session and the next launch starts clean. + let channelEnabled: boolean | null = null; + let resolveChannelReady: (value: boolean) => void = () => {}; + const channelReady = new Promise((resolve) => { + resolveChannelReady = resolve; + }); + channelReady.then((v) => { + channelEnabled = v; + }); + mcp.oninitialized = () => { - console.error("[world2agent] client initialized — opening notification gate"); + log("Client initialized"); openGate(); }; const sendNotification = async ( params: Parameters[0], ): Promise => { if (!gateOpen) { - console.error(`[world2agent] notification queued until client init: ${params.method}`); await notificationGate; } await mcp.notification(params); @@ -346,9 +387,9 @@ async function main() { for (const sensor of enabled) { const skillId = packageToSkillId(sensor.package); - // When gating, skip sensors that have SETUP.md but no handler skill yet — - // their signals would arrive with `Use skill: X` pointing at a missing - // skill. The user has to finish /world2agent:sensor-add first. + // When gating is on, defer sensors that ship a SETUP.md until their + // handler skill exists. /world2agent:sensor-add writes that skill, and + // once it's in place the sensor goes live on the next reload. if (opts.gateOnHandlerSkill) { const setupPath = findSetupFile(sensor.package); if (setupPath && !hasHandlerSkill(skillId)) { @@ -420,11 +461,42 @@ async function main() { // proceed even if skills aren't written yet). // // reload_sensors: re-read config and converge. Gates new sensors on handler - // skills so /world2agent:sensor-add can't half-enroll a sensor. + // skills so a sensor only goes live once /world2agent:sensor-add has + // finished writing its skill. let initialStartDone = !needsSetup; // boot branch below starts initial batch directly when no setup needed + const channelDisabledMessage = (): string => + "Channel notifications are not flowing in this Claude Code session " + + "(the boot handshake was never acknowledged), so sensors were NOT " + + "started. Polling now would write seen-id state to " + + "~/.world2agent/state.json and pollute the dedup cache — when you next " + + "launch Claude Code with `--dangerously-load-development-channels " + + "plugin:world2agent@world2agent-plugins`, the sensor would silently " + + "swallow the first real batch of signals.\n\n" + + "What to do: exit this session and relaunch Claude Code with that flag. " + + "Sensor config in ~/.world2agent/config.json is already saved; no need " + + "to re-run /world2agent:sensor-add.\n\n" + + "See ~/.world2agent/channel.log for the boot trace."; + + // Called by Claude when the client receives the boot handshake notification. + // This is the ONE reliable end-to-end signal that channel notifications are + // actually being delivered: the only way Claude sees the prompt to call + // this tool is if the message arrived. + const confirmChannelReceivedTool = async (): Promise => { + if (channelEnabled === true) { + return "Channel handshake already confirmed — no action needed."; + } + if (channelEnabled === false) { + return "Channel was previously marked disabled (handshake timed out). Restart Claude Code to retry."; + } + log("Channel handshake ack received — channel notifications are flowing."); + resolveChannelReady(true); + return "Channel handshake confirmed. Proceeding with sensor startup."; + }; + const startSensorsTool = async (): Promise => { + if (!(await channelReady)) return channelDisabledMessage(); if (initialStartDone) { // Treat subsequent calls as a reload with gating OFF (caller already said "just run it"). const diff = await applyConfig(loadConfig().sensors, { gateOnHandlerSkill: false }); @@ -436,7 +508,8 @@ async function main() { }; const reloadSensorsTool = async (): Promise => { - console.error("[world2agent] reload_sensors: re-reading config..."); + if (!(await channelReady)) return channelDisabledMessage(); + log("reload_sensors: re-reading config..."); const fresh = loadConfig(); const diff = await applyConfig(fresh.sensors, { gateOnHandlerSkill: true }); return summarizeDiff(diff); @@ -444,6 +517,16 @@ async function main() { mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ + { + name: "confirm_channel_received", + description: + "Acknowledge that this Claude Code session is receiving World2Agent channel notifications. Call this IMMEDIATELY when you see a channel message asking you to do so — sensors will not start until you do.", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + }, + }, ...(needsSetup ? [{ name: "start_sensors", @@ -470,6 +553,10 @@ async function main() { })); mcp.setRequestHandler(CallToolRequestSchema, async (req) => { + if (req.params.name === "confirm_channel_received") { + const result = await confirmChannelReceivedTool(); + return { content: [{ type: "text" as const, text: result }] }; + } if (req.params.name === "start_sensors") { const result = await startSensorsTool(); return { content: [{ type: "text" as const, text: result }] }; @@ -482,8 +569,7 @@ async function main() { }); // Single top-level signal handler that tears down every running sensor - // exactly once. Replaces the per-runAll-call SIGINT handlers that the old - // implementation stacked up on every reload. + // exactly once across the whole channel lifetime. let shuttingDown = false; const shutdown = async () => { if (shuttingDown) return; @@ -495,15 +581,87 @@ async function main() { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - console.error(`[world2agent] Connecting to Claude Code...`); + // Parent (Claude Code) closing stdio is our cue to exit cleanly. This + // keeps the channel process bound to its parent's lifetime so the next + // MCP child owns ~/.world2agent/state.json without contention. + process.stdin.on("end", () => { + console.error("[world2agent] stdin ended — shutting down"); + void shutdown(); + }); + process.stdin.on("close", () => { + console.error("[world2agent] stdin closed — shutting down"); + void shutdown(); + }); + process.stdin.on("error", (err) => { + console.error("[world2agent] stdin error — shutting down:", err); + void shutdown(); + }); + + log(`Connecting to Claude Code...`); await mcp.connect(new StdioServerTransport()); - console.error(`[world2agent] Connected. Waiting for client init before sending notifications...`); + log(`Connected. Sending channel handshake (will queue until client init)...`); - // Boot-time notifications go through sendNotification so they respect the - // init gate (see oninitialized above). Sensor startup itself is NOT gated — - // we want sensors collecting and deduping in the background during the wait. - if (noSensorsConfigured) { - console.error(`[world2agent] Sending onboarding notification.`); + // End-to-end channel detection: send one channel notification asking + // Claude to call `confirm_channel_received`. A live channel produces + // an ack within a few seconds; a 30s deadline backs that up so the + // session settles into a definite ready/offline state. `sendNotification` + // already queues until the MCP init handshake completes. + // + // The 5-second delay lets Claude Code finish wiring up its channel + // listener after MCP init. Notifications fired ≥100ms post-init are + // reliably consumed; five seconds keeps us comfortably inside that + // window. + if (channelEnabled === null) { + const HANDSHAKE_DELAY_MS = 5000; + setTimeout(() => { + log(`Firing handshake notification (${HANDSHAKE_DELAY_MS}ms post-connect)...`); + void sendNotification({ + method: "notifications/claude/channel", + params: { + content: + "World2Agent channel handshake.\n\n" + + "If you are reading this message, channel notifications are " + + "flowing in this Claude Code session. Please IMMEDIATELY call the " + + "`confirm_channel_received` tool with no arguments — World2Agent " + + "sensors will not start polling until you do.\n\n" + + "Don't reply to the user about this message; just call the tool. " + + "After acknowledgment, normal sensor signals will follow as usual.", + meta: { + event_type: "system.world2agent.handshake", + reason: "boot_handshake", + }, + }, + }).catch((err) => log(`Handshake notify error: ${err}`)); + }, HANDSHAKE_DELAY_MS); + + const HANDSHAKE_TIMEOUT_MS = 30000; + setTimeout(() => { + if (channelEnabled === null) { + log( + `Channel handshake not acknowledged within ${HANDSHAKE_TIMEOUT_MS}ms — ` + + "treating as disabled.", + ); + resolveChannelReady(false); + } + }, HANDSHAKE_TIMEOUT_MS); + } + + // Block boot-time sensor startup until handshake is acked or times out. + const clientChannelEnabled = await channelReady; + + if (!clientChannelEnabled) { + log( + "Channel disabled (handshake not acknowledged). Skipping sensor " + + "startup to avoid polluting ~/.world2agent/state.json. Tools " + + "(start_sensors / reload_sensors) will return a help message if " + + "called. To enable, relaunch Claude Code with " + + "`--dangerously-load-development-channels plugin:world2agent@world2agent-plugins`.", + ); + } else if (noSensorsConfigured) { + // Boot-time notifications go through sendNotification so they queue on + // the init gate (see oninitialized above) and deliver as soon as the + // client is ready. + log(`Sending onboarding notification.`); await sendNotification({ method: "notifications/claude/channel", params: { @@ -516,9 +674,9 @@ async function main() { }, }); } else if (needsSetup) { - console.error(`[world2agent] Waiting for start_sensors tool call...`); + log(`Waiting for start_sensors tool call...`); const sensorList = sensorsNeedingSetup.map((s) => s.skillId).join(", "); - console.error(`[world2agent] Sending setup prompt notification for: ${sensorList}`); + log(`Sending setup prompt notification for: ${sensorList}`); await sendNotification({ method: "notifications/claude/channel", params: { @@ -530,9 +688,9 @@ async function main() { }, }); } else { - console.error(`[world2agent] Starting ${bootEnabled.length} sensor(s)...`); + log(`Starting ${bootEnabled.length} sensor(s)...`); const diff = await applyConfig(bootEnabled, { gateOnHandlerSkill: false }); - console.error(`[world2agent] ${summarizeDiff(diff)}`); + log(summarizeDiff(diff)); if (diff.failed.length > 0 && handles.size === 0) { // All configured sensors failed to load (likely missing npm packages). // Drop the user into the onboarding-ish flow so they can fix it. @@ -548,6 +706,24 @@ async function main() { }, }, }); + } else if (diff.started.length > 0) { + // Friendly post-handshake confirmation: tell the user the channel is + // up and which sensors are being watched. Fires once per boot. + const running = diff.started.join(", "); + log(`Sending ready notification.`); + await sendNotification({ + method: "notifications/claude/channel", + params: { + content: + `World2Agent is now active and listening for signals from ${diff.started.length} sensor(s): ${running}.\n\n` + + "In one short sentence, tell the user that World2Agent is ready and which source(s) it's watching, " + + "then return control to whatever the user was doing. Do not list installation steps or repeat this message.", + meta: { + event_type: "system.world2agent.ready", + sensor_count: String(diff.started.length), + }, + }, + }); } }