From c9d5067965d5e03dfa7f326abcc4efc846fc4b02 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 11 Apr 2026 08:51:00 -0500 Subject: [PATCH 1/5] Add Claude Code channel for push-based task event notifications Replace the pull-based monitoring approach (background agents running ssh tail -f, 20-minute timeouts, manual /gm-babysit polling) with a Claude Code channel that pushes task events directly into the GM session. The channel is an MCP server (taskyou-channel.ts) that polls the server's notifications.jsonl over SSH and emits events as tags. Each GM gets its own channel, baked with that GM's server config at setup time. Also exposes ty_command and ssh_command tools so the GM can run server commands through the channel instead of shelling out. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/commands/gm-babysit.md | 6 +- setup.sh | 16 +- templates/CLAUDE.md.tmpl | 30 ++-- templates/channel/package.json.tmpl | 8 + templates/channel/taskyou-channel.ts.tmpl | 186 ++++++++++++++++++++++ templates/mcp.json.tmpl | 8 + templates/settings.json.tmpl | 4 +- 7 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 templates/channel/package.json.tmpl create mode 100644 templates/channel/taskyou-channel.ts.tmpl create mode 100644 templates/mcp.json.tmpl diff --git a/.claude/commands/gm-babysit.md b/.claude/commands/gm-babysit.md index a866698..c6e6936 100644 --- a/.claude/commands/gm-babysit.md +++ b/.claude/commands/gm-babysit.md @@ -5,6 +5,8 @@ description: Check on all tracked tasks for an immediate status update Check on all tasks you're currently tracking. Use this for an immediate status update. +Note: Task events normally arrive automatically via the taskyou channel. This command is for a manual spot-check when you want an immediate snapshot. + First, load the project configuration: ```bash source ./config.env @@ -29,8 +31,6 @@ source ./config.env - **Blocked**: Explain what's blocking it. Suggest next steps (retry, send input, review output). - **Still processing**: Note it's still running — no action needed unless it's been unusually long. -5. **Re-launch the background notification watcher** if there are tasks still in progress and no background agent is currently watching. - -6. **If all tracked tasks are done**, let the user know there's nothing left to monitor. +5. **If all tracked tasks are done**, let the user know there's nothing left to monitor. Keep updates brief — one line per task. diff --git a/setup.sh b/setup.sh index 6be40e3..05cc347 100755 --- a/setup.sh +++ b/setup.sh @@ -301,6 +301,20 @@ setup_local() { chmod +x "$LOCAL_PROJECT_DIR/bin/${PROJECT_NAME}-open-board" ok "bin/${PROJECT_NAME}-open-board" + # Channel (push-based task event notifications) + log "Setting up task event channel" + mkdir -p "$LOCAL_PROJECT_DIR/channel" + render_file "$TEMPLATES_DIR/channel/taskyou-channel.ts.tmpl" "$LOCAL_PROJECT_DIR/channel/taskyou-channel.ts" + ok "channel/taskyou-channel.ts" + render_file "$TEMPLATES_DIR/channel/package.json.tmpl" "$LOCAL_PROJECT_DIR/channel/package.json" + ok "channel/package.json" + # Install channel dependencies + (cd "$LOCAL_PROJECT_DIR/channel" && bun install --silent 2>/dev/null) || warn "bun install failed — run 'cd $LOCAL_PROJECT_DIR/channel && bun install' manually" + + # .mcp.json (registers channel with Claude Code) + render_file "$TEMPLATES_DIR/mcp.json.tmpl" "$LOCAL_PROJECT_DIR/.mcp.json" + ok ".mcp.json" + # R2 wrangler.toml if [[ "$R2_ENABLED" == "true" ]]; then log "Setting up R2" @@ -311,7 +325,7 @@ setup_local() { # Shell alias log "Shell alias" - local alias_line="alias ${GM_ALIAS}='cd ${LOCAL_PROJECT_DIR} && CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR} claude'" + local alias_line="alias ${GM_ALIAS}='cd ${LOCAL_PROJECT_DIR} && CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR} claude --dangerously-load-development-channels server:taskyou'" echo " Add this to your shell profile (~/.zshrc or ~/.bashrc):" echo "" echo " $alias_line" diff --git a/templates/CLAUDE.md.tmpl b/templates/CLAUDE.md.tmpl index c46db1a..5cd3bad 100644 --- a/templates/CLAUDE.md.tmpl +++ b/templates/CLAUDE.md.tmpl @@ -279,25 +279,29 @@ Humans can request revisions on agent-delivered work by commenting `@agent` (or You are responsible for following up on tasks you execute. This is automatic — {{OWNER_NAME}} should never have to ask "what happened with that task?" -### Automatic monitoring +### Task event channel + +A channel server pushes task events directly into this session. When a remote agent completes or gets blocked, you'll see a `` event appear automatically — no polling or background agents needed. When you execute a task (`ty execute `): 1. **Add it to your todos** via TodoWrite with the task ID, title, and current status. -2. **Launch a background monitoring agent** to watch for task events. The agent should: - - Watch the server's notification stream: `./bin/ssh-remote "tail -n 0 -f {{SERVER_HOME}}/notifications.jsonl"` - - The server hooks automatically write to this file when tasks complete or get blocked - - When a line appears matching a tracked task ID, return immediately with the event details - - Time out after 20 minutes and return (you can re-launch if needed) - - One background agent can watch for ALL tracked tasks — no need for one per task -3. **When the background agent returns with an event**, update your todo and inform {{OWNER_NAME}}: - - **Completed**: Briefly note it finished, offer to show output (`ty output `). Mark todo done. - - **Blocked**: Note what's blocking it, suggest next steps (retry, send input, review). - - Then re-launch the background agent if there are still tasks being tracked. +2. **Wait for the channel event.** Events arrive automatically: + - `event="completed"` — The agent finished. Briefly note it, offer to show output (`ty output `). Mark todo done. + - `event="blocked"` — The agent is stuck. Check output, suggest next steps (retry, send input, review). + +### Channel tools + +The channel also provides tools you can use instead of `./bin/ty-remote` and `./bin/ssh-remote`: + +- `ty_command` — Run any TaskYou command on the server (e.g. `ty_command("list")`, `ty_command("execute 42")`) +- `ssh_command` — Run any shell command on the server (e.g. `ssh_command("git -C projects/marketing log --oneline -5")`) + +You can use either the channel tools or the bin/ scripts — they do the same thing. ### Be non-disruptive -When a background agent notifies you of a task update, be smart about surfacing it: +When a task event arrives, be smart about surfacing it: - If {{OWNER_NAME}} is mid-thought or you're in the middle of a complex discussion, hold the update and surface it at a natural pause. - Keep updates brief — one line is ideal. "Task #12 (competitor research) just finished. Want to see the output?" - Don't pile up multiple updates at once. Space them out if several tasks complete simultaneously. @@ -311,7 +315,7 @@ When a background agent notifies you of a task update, be smart about surfacing ### Manual check -{{OWNER_NAME}} can use `/gm-babysit` for an immediate status check on all tracked tasks without waiting for background agents. +{{OWNER_NAME}} can use `/gm-babysit` for an immediate status check on all tracked tasks. ## Advanced: Raw SSH Access diff --git a/templates/channel/package.json.tmpl b/templates/channel/package.json.tmpl new file mode 100644 index 0000000..49afb67 --- /dev/null +++ b/templates/channel/package.json.tmpl @@ -0,0 +1,8 @@ +{ + "name": "taskyou-channel", + "version": "0.1.0", + "private": true, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1" + } +} diff --git a/templates/channel/taskyou-channel.ts.tmpl b/templates/channel/taskyou-channel.ts.tmpl new file mode 100644 index 0000000..0e68ae8 --- /dev/null +++ b/templates/channel/taskyou-channel.ts.tmpl @@ -0,0 +1,186 @@ +#!/usr/bin/env bun +// TaskYou Channel — pushes task events into the GM's Claude Code session +// Generated by TaskYouOS setup. Do not edit — edit the template instead. + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { spawn } from "child_process"; + +const SERVER_HOST = "{{SERVER_HOST}}"; +const SERVER_HOME = "{{SERVER_HOME}}"; +const POLL_INTERVAL_MS = 10_000; // 10 seconds + +// Track where we are in the notifications file +let lastLineCount = 0; + +const mcp = new Server( + { name: "taskyou", version: "0.1.0" }, + { + capabilities: { + experimental: { "claude/channel": {} }, + tools: {}, + }, + instructions: [ + 'Task events from remote agents arrive as .', + "Events:", + ' - event="completed": An agent finished its task. Offer to show output (ty output ). Mark your todo done.', + ' - event="blocked": An agent is stuck. Check output, suggest retry or escalate.', + "When you see a task event, update your todos and briefly inform the user.", + "If the user is mid-thought, hold the update until a natural pause.", + "", + "You can also use the channel tools to run TaskYou commands without shelling out:", + " - ty_command: Run any ty command on the server (e.g. list, board, create, execute, retry, output, close)", + " - ssh_command: Run any shell command on the server", + ].join("\n"), + } +); + +// ── Tools: let the GM run ty/ssh commands through the channel ──────────────── + +mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: "ty_command", + description: + "Run a TaskYou command on the remote server (e.g. list, board, create, execute, retry, output, close)", + inputSchema: { + type: "object", + properties: { + args: { + type: "string", + description: + 'The ty command and arguments, e.g. "list", "execute 42", "output 42 --tail 50"', + }, + }, + required: ["args"], + }, + }, + { + name: "ssh_command", + description: + "Run a shell command on the remote server (e.g. git log, tail logs)", + inputSchema: { + type: "object", + properties: { + command: { + type: "string", + description: "The shell command to run on the server", + }, + }, + required: ["command"], + }, + }, + ], +})); + +mcp.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params; + + if (name === "ty_command") { + const { args: tyArgs } = args as { args: string }; + const result = await runRemote(`ty ${tyArgs}`); + return { content: [{ type: "text", text: result }] }; + } + + if (name === "ssh_command") { + const { command } = args as { command: string }; + const result = await runRemote(command); + return { content: [{ type: "text", text: result }] }; + } + + throw new Error(`unknown tool: ${name}`); +}); + +// ── SSH helper ─────────────────────────────────────────────────────────────── + +function runRemote(command: string): Promise { + return new Promise((resolve) => { + const sshCmd = `export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && export PATH="${SERVER_HOME}/.local/bin:${SERVER_HOME}/bin:${SERVER_HOME}/.npm-global/bin:$PATH" && cd ${SERVER_HOME} && ${command}`; + + const proc = spawn("ssh", [SERVER_HOST, sshCmd], { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (d: Buffer) => (stdout += d.toString())); + proc.stderr.on("data", (d: Buffer) => (stderr += d.toString())); + + proc.on("close", (code) => { + if (code !== 0 && stderr) { + resolve(`[exit ${code}] ${stderr.trim()}\n${stdout.trim()}`.trim()); + } else { + resolve(stdout.trim()); + } + }); + + // Timeout after 30 seconds + setTimeout(() => { + proc.kill(); + resolve("[timeout] command took longer than 30 seconds"); + }, 30_000); + }); +} + +// ── Notification polling ───────────────────────────────────────────────────── + +async function pollNotifications() { + try { + // Get current line count + const wcResult = await runRemote( + `wc -l < ${SERVER_HOME}/notifications.jsonl 2>/dev/null || echo 0` + ); + const currentLines = parseInt(wcResult.trim(), 10) || 0; + + if (currentLines > lastLineCount && lastLineCount > 0) { + // Read only new lines + const newLines = await runRemote( + `tail -n +${lastLineCount + 1} ${SERVER_HOME}/notifications.jsonl` + ); + + for (const line of newLines.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const event = JSON.parse(trimmed); + await mcp.notification({ + method: "notifications/claude/channel", + params: { + content: trimmed, + meta: { + event: event.event || "unknown", + task_id: event.task_id || "", + title: event.title || "", + project: event.project || "", + timestamp: event.timestamp || "", + }, + }, + }); + } catch { + // Skip malformed lines + } + } + } + + // On first run, just record the position (don't replay old events) + lastLineCount = currentLines; + } catch { + // SSH failure — silently retry next interval + } +} + +// ── Start ──────────────────────────────────────────────────────────────────── + +await mcp.connect(new StdioServerTransport()); + +// Initialize position before starting poll loop +await pollNotifications(); + +// Poll for new notifications +setInterval(pollNotifications, POLL_INTERVAL_MS); diff --git a/templates/mcp.json.tmpl b/templates/mcp.json.tmpl new file mode 100644 index 0000000..7ad227b --- /dev/null +++ b/templates/mcp.json.tmpl @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "taskyou": { + "command": "bun", + "args": ["./channel/taskyou-channel.ts"] + } + } +} diff --git a/templates/settings.json.tmpl b/templates/settings.json.tmpl index d9b8f47..0e423c5 100644 --- a/templates/settings.json.tmpl +++ b/templates/settings.json.tmpl @@ -6,7 +6,9 @@ "Read", "WebFetch", "WebSearch", - "Task(*)" + "Task(*)", + "mcp__taskyou__ty_command", + "mcp__taskyou__ssh_command" ], "deny": [ "Bash(rm *)", From b2d353c4b78ebc1d522672a0f7798fc3ec5c36f1 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 11 Apr 2026 08:53:27 -0500 Subject: [PATCH 2/5] Add channel health check to /doctor Doctor Check 7 detects missing channel files, deploys them from plugin templates using config.env, installs bun deps, checks the shell alias for the channels flag, and verifies CLAUDE.md has the new monitoring section. Follows the same drift-detection pattern as the nono check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/commands/gm-doctor.md | 80 ++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/.claude/commands/gm-doctor.md b/.claude/commands/gm-doctor.md index 7f58184..471af94 100644 --- a/.claude/commands/gm-doctor.md +++ b/.claude/commands/gm-doctor.md @@ -310,7 +310,82 @@ If `config.env` is missing, report FAIL — the plugin commands won't work witho --- -### Check 7: Security Audit +### Check 7: Task Event Channel + +Check that the Claude Code channel for push-based task notifications is set up and working. + +**Steps:** + +1. Check if the channel files exist in the GM directory: +```bash +test -f "$LOCAL_PROJECT_DIR/channel/taskyou-channel.ts" && echo "CHANNEL_EXISTS" || echo "CHANNEL_MISSING" +test -f "$LOCAL_PROJECT_DIR/.mcp.json" && echo "MCP_JSON_EXISTS" || echo "MCP_JSON_MISSING" +test -d "$LOCAL_PROJECT_DIR/channel/node_modules" && echo "DEPS_INSTALLED" || echo "DEPS_MISSING" +``` + +2. **If channel files are missing**, deploy them from the plugin templates: + + a. Find the TaskYou-OS plugin directory (or repo checkout): + ```bash + TASKYOU_OS_DIR="" + if [ -f "./templates/channel/taskyou-channel.ts.tmpl" ]; then + TASKYOU_OS_DIR="." + else + PLUGIN_DIR=$(python3 -c "import json; d=json.load(open('$HOME/.claude/plugins/installed_plugins.json')); entries=d.get('plugins',{}).get('taskyou-os@taskyou-os',[]); print(entries[0]['installPath'] if entries else '')" 2>/dev/null) + if [ -n "$PLUGIN_DIR" ] && [ -f "$PLUGIN_DIR/templates/channel/taskyou-channel.ts.tmpl" ]; then + TASKYOU_OS_DIR="$PLUGIN_DIR" + fi + fi + ``` + + b. If templates are available, render and deploy them. Use `config.env` to substitute variables: + ```bash + source "$LOCAL_PROJECT_DIR/config.env" + mkdir -p "$LOCAL_PROJECT_DIR/channel" + ``` + - Read `$TASKYOU_OS_DIR/templates/channel/taskyou-channel.ts.tmpl`, substitute `{{SERVER_HOST}}` and `{{SERVER_HOME}}` with values from config.env, write to `$LOCAL_PROJECT_DIR/channel/taskyou-channel.ts` + - Read `$TASKYOU_OS_DIR/templates/channel/package.json.tmpl`, write to `$LOCAL_PROJECT_DIR/channel/package.json` + - Read `$TASKYOU_OS_DIR/templates/mcp.json.tmpl`, write to `$LOCAL_PROJECT_DIR/.mcp.json` + + c. Install dependencies: + ```bash + cd "$LOCAL_PROJECT_DIR/channel" && bun install --silent + ``` + + d. Report WARN: "Deployed task event channel. Restart Claude Code to activate." + +3. **If channel files exist but deps are missing**, install them: + ```bash + cd "$LOCAL_PROJECT_DIR/channel" && bun install --silent + ``` + Report WARN: "Installed missing channel dependencies." + +4. **If channel exists, check for drift** — compare deployed channel against the plugin template (same approach as nono drift detection in Check 8). If the template is newer, update the deployed file and report WARN. + +5. **Check the shell alias** includes `--dangerously-load-development-channels server:taskyou`: + ```bash + grep "$GM_ALIAS" ~/.zshrc 2>/dev/null || grep "$GM_ALIAS" ~/.bashrc 2>/dev/null + ``` + - If the alias exists but doesn't include `--dangerously-load-development-channels server:taskyou`, report WARN and show the user the updated alias line they should use: + ``` + alias ='cd && CLAUDE_CONFIG_DIR= claude --dangerously-load-development-channels server:taskyou' + ``` + - If the alias already includes the flag, report PASS. + +6. **Check the CLAUDE.md** has the channel-based monitoring section (not the old background-agent approach): + ```bash + grep -c "Task event channel" "$LOCAL_PROJECT_DIR/CLAUDE.md" + grep -c "background monitoring agent" "$LOCAL_PROJECT_DIR/CLAUDE.md" + ``` + - If it has "background monitoring agent" but not "Task event channel", the CLAUDE.md needs updating. Render the Task Tracking section from the template and show the user the diff, offering to update it. + +**If all channel files exist, deps installed, alias correct:** Report PASS with "Task event channel active." +**If deployed or fixed anything:** Report WARN with summary. +**If templates not found:** Report FAIL with "Channel templates not found. Update the TaskYou-OS plugin first." + +--- + +### Check 8: Security Audit Run the server-side security audit script to check credentials, permissions, and exposed services. @@ -340,7 +415,7 @@ ssh -o ConnectTimeout=5 "$SERVER_HOST" '$HOME/.local/bin/audit.sh' 2>/dev/null --- -## Check 8: Credential Isolation (nono) +## Check 9: Credential Isolation (nono) This check verifies if nono is set up, and if not, strongly recommends it. Always run this check regardless of whether credentials are currently configured. @@ -441,6 +516,7 @@ TaskYou-OS Doctor Daemon mode PASS/WARN/FAIL Executor health PASS/WARN/FAIL GM templates PASS/WARN/FAIL + Task event channel PASS/WARN/FAIL Security audit PASS/WARN/FAIL Credential isolation PASS/WARN ───────────────────────────────── From 44557e6e65ef932c1ad1821a1a6d54c858ef33da Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 11 Apr 2026 09:17:16 -0500 Subject: [PATCH 3/5] Add channel MCP smoke test Spawns the channel server, performs the MCP handshake, and verifies the claude/channel capability, tools capability, instructions, and both ty_command and ssh_command tools are registered. Run: cd channel && bun run smoke-test.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/channel/smoke-test.ts | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 templates/channel/smoke-test.ts diff --git a/templates/channel/smoke-test.ts b/templates/channel/smoke-test.ts new file mode 100644 index 0000000..44d5f73 --- /dev/null +++ b/templates/channel/smoke-test.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env bun +// Smoke test: spawn the channel server, do the MCP handshake, verify capabilities and tools. +// Usage: bun run smoke-test.ts +// Requires: taskyou-channel.ts in the same directory (rendered with any server values — they don't matter for this test). + +import { spawn } from "child_process"; + +const proc = spawn("bun", ["run", "taskyou-channel.ts"], { + cwd: import.meta.dir, + stdio: ["pipe", "pipe", "pipe"], +}); + +let stdout = ""; +proc.stdout.on("data", (d: Buffer) => { + stdout += d.toString(); + const lines = stdout.split("\n"); + for (const line of lines.slice(0, -1)) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 1) { + handleInitResponse(msg); + } else if (msg.id === 2) { + handleToolsResponse(msg); + } + } catch {} + } + stdout = lines[lines.length - 1]; +}); + +let stderr = ""; +proc.stderr.on("data", (d: Buffer) => { + stderr += d.toString(); +}); + +proc.on("close", (code) => { + if (code !== 0 && code !== null) { + console.log(`Process exited with code ${code}`); + if (stderr) console.log("stderr:", stderr); + process.exit(1); + } +}); + +function handleInitResponse(msg: any) { + const caps = msg.result?.capabilities?.experimental; + const hasChannel = !!caps?.["claude/channel"]; + const hasTools = !!msg.result?.capabilities?.tools; + const hasInstructions = msg.result?.instructions?.includes("taskyou"); + + console.log(`${hasChannel ? "✓" : "✗"} claude/channel capability`); + console.log(`${hasTools ? "✓" : "✗"} tools capability`); + console.log(`${hasInstructions ? "✓" : "✗"} instructions`); + + if (!hasChannel || !hasTools || !hasInstructions) { + console.log("\nFAILED — missing capabilities"); + proc.kill(); + process.exit(1); + } + + // Request tool list + proc.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }) + "\n" + ); +} + +function handleToolsResponse(msg: any) { + const tools = msg.result?.tools || []; + const names = tools.map((t: any) => t.name); + + const hasTy = names.includes("ty_command"); + const hasSsh = names.includes("ssh_command"); + + console.log(`${hasTy ? "✓" : "✗"} ty_command tool`); + console.log(`${hasSsh ? "✓" : "✗"} ssh_command tool`); + + if (!hasTy || !hasSsh) { + console.log("\nFAILED — missing tools"); + proc.kill(); + process.exit(1); + } + + console.log("\nPASSED"); + proc.kill(); + process.exit(0); +} + +// Send MCP initialize +proc.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "smoke-test", version: "0.0.1" }, + }, + }) + "\n" +); + +setTimeout(() => { + console.log("FAILED — timed out"); + if (stderr) console.log("stderr:", stderr); + proc.kill(); + process.exit(1); +}, 10_000); From cf02d603aa1c2ea2cfd4d0992a78477b3bdce4b7 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 07:26:18 -0500 Subject: [PATCH 4/5] fix(channel): guard poll reentrancy and deliver first event on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two latent bugs in the notification poller: - Overlapping polls: a slow runRemote (up to its 30s timeout) can outlast the 10s interval, so two polls share lastLineCount and can double-deliver or skip events. Add a `polling` in-flight guard. - Cold start: the `lastLineCount > 0` guard meant to skip the startup backlog also swallowed the very FIRST live event when the channel started from an empty notifications.jsonl (exactly what a fresh setup creates). Replace it with an `initialized` sentinel: skip the backlog on the first poll, deliver everything after — including the first new line. Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/channel/taskyou-channel.ts.tmpl | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/templates/channel/taskyou-channel.ts.tmpl b/templates/channel/taskyou-channel.ts.tmpl index 0e68ae8..730e6e6 100644 --- a/templates/channel/taskyou-channel.ts.tmpl +++ b/templates/channel/taskyou-channel.ts.tmpl @@ -16,6 +16,15 @@ const POLL_INTERVAL_MS = 10_000; // 10 seconds // Track where we are in the notifications file let lastLineCount = 0; +// Set once the startup poll has recorded its position. Until then we don't +// emit — so we skip the pre-existing backlog on startup WITHOUT swallowing the +// first live event (the empty-file cold-start case the old `lastLineCount > 0` +// guard dropped). +let initialized = false; +// Guard against overlapping polls: a slow runRemote (up to its 30s timeout) can +// outlast the 10s interval; without this, two polls share lastLineCount and can +// double-deliver or skip events. +let polling = false; const mcp = new Server( { name: "taskyou", version: "0.1.0" }, @@ -130,6 +139,8 @@ function runRemote(command: string): Promise { // ── Notification polling ───────────────────────────────────────────────────── async function pollNotifications() { + if (polling) return; // a previous poll is still in flight — skip this tick + polling = true; try { // Get current line count const wcResult = await runRemote( @@ -137,7 +148,7 @@ async function pollNotifications() { ); const currentLines = parseInt(wcResult.trim(), 10) || 0; - if (currentLines > lastLineCount && lastLineCount > 0) { + if (initialized && currentLines > lastLineCount) { // Read only new lines const newLines = await runRemote( `tail -n +${lastLineCount + 1} ${SERVER_HOME}/notifications.jsonl` @@ -168,10 +179,15 @@ async function pollNotifications() { } } - // On first run, just record the position (don't replay old events) + // Record position. On the first run this just skips the existing backlog; + // from here on, `initialized` lets new lines through (including the very + // first one written after startup). lastLineCount = currentLines; + initialized = true; } catch { // SSH failure — silently retry next interval + } finally { + polling = false; } } From 188555e0b15459462a709c880f87a4da08dc75c5 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 07:26:18 -0500 Subject: [PATCH 5/5] test(qa): add hermetic channel QA harness, CI, and /gm-doctor self-check - qa/run-qa.sh + qa/channel-notify-test.ts: sandboxed ($HOME) end-to-end harness that runs the real setup.sh, smoke-tests the channel, and drives a real hook through to a channel push. Never touches the real ty/GMs. Proves both steady-state (no backlog replay) and cold-start (first event delivered). - .github/workflows/qa.yml: runs the harness on PRs touching setup/channel/hooks. - setup.sh: ship smoke-test.ts alongside the channel so a GM can self-check. - gm-doctor Check 7: add a runtime smoke-test step; fix nono cross-ref (Check 9). - CLAUDE.md + qa/README.md: document the harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/gm-doctor.md | 13 ++- .github/workflows/qa.yml | 30 +++++++ CLAUDE.md | 1 + qa/README.md | 38 ++++++++ qa/channel-notify-test.ts | 160 ++++++++++++++++++++++++++++++++++ qa/run-qa.sh | 146 +++++++++++++++++++++++++++++++ setup.sh | 3 + 7 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/qa.yml create mode 100644 qa/README.md create mode 100644 qa/channel-notify-test.ts create mode 100755 qa/run-qa.sh diff --git a/.claude/commands/gm-doctor.md b/.claude/commands/gm-doctor.md index 471af94..0f6f472 100644 --- a/.claude/commands/gm-doctor.md +++ b/.claude/commands/gm-doctor.md @@ -360,7 +360,7 @@ test -d "$LOCAL_PROJECT_DIR/channel/node_modules" && echo "DEPS_INSTALLED" || ec ``` Report WARN: "Installed missing channel dependencies." -4. **If channel exists, check for drift** — compare deployed channel against the plugin template (same approach as nono drift detection in Check 8). If the template is newer, update the deployed file and report WARN. +4. **If channel exists, check for drift** — compare deployed channel against the plugin template (same approach as nono drift detection in Check 9). If the template is newer, update the deployed file and report WARN. 5. **Check the shell alias** includes `--dangerously-load-development-channels server:taskyou`: ```bash @@ -379,7 +379,16 @@ test -d "$LOCAL_PROJECT_DIR/channel/node_modules" && echo "DEPS_INSTALLED" || ec ``` - If it has "background monitoring agent" but not "Task event channel", the CLAUDE.md needs updating. Render the Task Tracking section from the template and show the user the diff, offering to update it. -**If all channel files exist, deps installed, alias correct:** Report PASS with "Task event channel active." +7. **Runtime self-check** — confirm the channel actually boots and completes the MCP handshake (catches a broken render, a missing/corrupt dep, or a bad bun). The smoke test ships next to the channel: + ```bash + test -f "$LOCAL_PROJECT_DIR/channel/smoke-test.ts" && \ + ( cd "$LOCAL_PROJECT_DIR/channel" && timeout 30 bun run smoke-test.ts ) + ``` + - Exit 0 (ends with `PASSED`): report PASS "channel boots + handshakes." + - Non-zero or `FAILED`: report WARN with the smoke-test output — the channel is registered but won't push events until fixed. + - If `smoke-test.ts` is absent (GM predates this), copy it from `templates/channel/smoke-test.ts` in the plugin and re-run. + +**If all channel files exist, deps installed, alias correct, and the smoke test passes:** Report PASS with "Task event channel active." **If deployed or fixed anything:** Report WARN with summary. **If templates not found:** Report FAIL with "Channel templates not found. Update the TaskYou-OS plugin first." diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..2fa2411 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,30 @@ +name: QA harness + +# Validates the generated GM system end-to-end: renders the real setup.sh into a +# throwaway $HOME, smoke-tests the channel MCP server, and drives a real hook all +# the way to a channel push. Hermetic — nothing touches the runner's home beyond +# the sandbox dir. ty is not installed; its assertions are skipped (CI mode). + +on: + pull_request: + paths: + - "setup.sh" + - "templates/channel/**" + - "templates/hooks/**" + - "templates/mcp.json.tmpl" + - "qa/**" + - ".github/workflows/qa.yml" + push: + branches: [main] + workflow_dispatch: + +jobs: + qa: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # run-qa.sh creates a worktree of HEAD + - uses: oven-sh/setup-bun@v2 + - name: Run QA harness + run: bash qa/run-qa.sh diff --git a/CLAUDE.md b/CLAUDE.md index cbcf05c..dc43e0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ This is the TaskYouOS template repo. It generates fully working AI agent managem - Templates are the source of truth. Never edit generated output — edit the template. - Test changes by running `./setup.sh local /tmp/test-project` with a test config.env. +- For channel/hook/setup changes, run the hermetic QA harness: `qa/run-qa.sh` (sandboxed `$HOME`, never touches your real ty/GMs). See `qa/README.md`. - If you have an existing GM at `~/Projects/gms//`, use it as a reference implementation. ## Adding New Templates diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..4fc2ac8 --- /dev/null +++ b/qa/README.md @@ -0,0 +1,38 @@ +# QA harness + +Hermetic, non-destructive end-to-end tests for the GM system that `setup.sh` +generates. It runs the **real** `setup.sh` against a throwaway `$HOME` and a git +worktree of a branch, then exercises the task-event channel all the way through. + +It never touches your real `~/.local/share/task`, `~/Library/Application Support/task`, +`~/.gitconfig`, your GMs, or your running daemon — everything keys off a sandbox +`$HOME`, which is removed on exit. + +## Run + +```bash +qa/run-qa.sh # tests the current checkout (HEAD) +qa/run-qa.sh pr-31 # tests a specific local branch/ref +``` + +Requires `bun` and `python3`. `ty` is optional — when it's absent (CI), the ty +project-registration assertions are skipped; render + smoke + notification +checks still run. + +## What it covers + +1. **`setup.sh local`** — renders the GM + channel; asserts files, deps, alias + flag, no unresolved `{{placeholders}}`. +2. **Channel smoke test** — MCP handshake, capabilities, and tool list + (`templates/channel/smoke-test.ts`). +3. **`setup.sh server` (local mode)** — hooks land in the OS-correct dir + (the macOS `~/Library/Application Support` fix), `notifications.jsonl` is + created, and the project registers in the *sandbox* ty. +4. **Notification e2e** — fires the real `task.completed` hook and asserts the + channel pushes a matching `notifications/claude/channel` event. Two modes: + - `steady` — pre-existing backlog is **not** replayed, next event delivered. + - `cold` — first event after an **empty-file** start **is** delivered + (regression test for the cold-start fix). + +CI runs this on every PR touching `setup.sh`, `templates/channel`, +`templates/hooks`, or `qa/` (see `.github/workflows/qa.yml`). diff --git a/qa/channel-notify-test.ts b/qa/channel-notify-test.ts new file mode 100644 index 0000000..0b55753 --- /dev/null +++ b/qa/channel-notify-test.ts @@ -0,0 +1,160 @@ +#!/usr/bin/env bun +// Channel notification end-to-end test. +// +// Unlike smoke-test.ts (handshake + capabilities only), this exercises the full +// chain the feature actually depends on: +// +// rendered hook → notifications.jsonl → channel runner (local/SSH) → push +// +// It spawns the *rendered* channel server, does the MCP handshake, then fires the +// real rendered task.completed hook and asserts the channel pushes a matching +// `notifications/claude/channel` event back out over stdio. +// +// Driven by env vars (set by run-qa.sh): +// CHANNEL_TS absolute path to the rendered taskyou-channel.ts +// NOTIF_FILE absolute path to notifications.jsonl (== {{SERVER_HOME}}/notifications.jsonl) +// HOOK_SCRIPT absolute path to the rendered task.completed hook +// MODE "steady" (default): start with a pre-existing line, assert the +// new event is delivered and the pre-existing backlog is NOT replayed. +// "cold": start from an EMPTY file, assert the very first event +// is still delivered (regression test for the cold-start fix). +// POLL_WAIT_MS optional override for how long we wait for a poll cycle (default 14000) +// +// Exit 0 = PASS, non-zero = FAIL. + +import { spawn, spawnSync } from "child_process"; +import { appendFileSync, existsSync } from "fs"; + +const CHANNEL_TS = process.env.CHANNEL_TS!; +const NOTIF_FILE = process.env.NOTIF_FILE!; +const HOOK_SCRIPT = process.env.HOOK_SCRIPT!; +const POLL_WAIT_MS = parseInt(process.env.POLL_WAIT_MS || "14000", 10); +const MODE = process.env.MODE === "cold" ? "cold" : "steady"; + +const TASK_ID = "qa-9999"; + +function die(msg: string): never { + console.log(`✗ ${msg}`); + console.log("\nFAILED"); + process.exit(1); +} + +for (const [k, v] of Object.entries({ CHANNEL_TS, NOTIF_FILE, HOOK_SCRIPT })) { + if (!v || !existsSync(v)) die(`${k} missing or not found: ${v}`); +} + +// 1. steady mode: seed one pre-existing line, so we can assert the channel skips +// the startup backlog (must NOT replay it) yet still delivers the next event. +// cold mode: leave the file empty, so we assert the very first event written +// after startup IS delivered (the bug the `initialized` fix closes). +if (MODE === "steady") { + appendFileSync( + NOTIF_FILE, + JSON.stringify({ + event: "completed", + task_id: "qa-seed", + title: "seed (must not be replayed)", + project: "qa-test", + timestamp: new Date(0).toISOString(), + }) + "\n" + ); +} + +// 2. Spawn the rendered channel server. +const proc = spawn("bun", ["run", CHANNEL_TS], { + stdio: ["pipe", "pipe", "pipe"], +}); + +let pushed: any = null; +let sawSeedReplay = false; +let buf = ""; + +proc.stdout.on("data", (d: Buffer) => { + buf += d.toString(); + const lines = buf.split("\n"); + buf = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + let msg: any; + try { + msg = JSON.parse(line); + } catch { + continue; + } + if (msg.method === "notifications/claude/channel") { + const meta = msg.params?.meta || {}; + if (meta.task_id === "qa-seed") sawSeedReplay = true; + if (meta.task_id === TASK_ID) pushed = msg; + } + } +}); + +let stderr = ""; +proc.stderr.on("data", (d: Buffer) => (stderr += d.toString())); + +const fail = (msg: string) => { + console.log(`✗ ${msg}`); + if (stderr.trim()) console.log("channel stderr:", stderr.trim()); + console.log("\nFAILED"); + proc.kill(); + process.exit(1); +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +(async () => { + // 3. MCP initialize handshake. + proc.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "channel-notify-test", version: "0.0.1" }, + }, + }) + "\n" + ); + + // 4. Let the startup poll record its position (lastLineCount = 1 from the seed). + await sleep(2500); + + // 5. Fire the REAL rendered hook — appends a new line to notifications.jsonl. + const r = spawnSync("bash", [HOOK_SCRIPT], { + env: { + ...process.env, + TASK_ID, + TASK_TITLE: 'QA notify "end to end"', // embedded quotes test JSON escaping + TASK_PROJECT: "qa-test", + }, + encoding: "utf8", + }); + if (r.status !== 0) fail(`hook exited ${r.status}: ${r.stderr}`); + + // 6. Wait for the next poll cycle to detect + push the new line. + await sleep(POLL_WAIT_MS); + + if (MODE === "steady" && sawSeedReplay) + fail("channel REPLAYED the pre-existing seed line on startup (should not)"); + if (!pushed) + fail( + MODE === "cold" + ? `cold start: first event (${TASK_ID}) was swallowed — the initialized fix is missing` + : `no channel push for task ${TASK_ID} within ${POLL_WAIT_MS}ms` + ); + + const content = pushed.params?.content || ""; + if (!content.includes(TASK_ID)) fail("pushed event content missing task id"); + if (!content.includes("end to end")) fail("pushed event lost the hook title (JSON escaping?)"); + + console.log(`✓ [${MODE}] channel pushed completed event for ${TASK_ID}`); + if (MODE === "steady") console.log(`✓ did not replay pre-existing notifications on startup`); + if (MODE === "cold") console.log(`✓ first event after empty-file start was delivered (cold-start fix)`); + console.log(`✓ title with embedded quotes survived hook→channel`); + console.log("\nPASSED"); + proc.kill(); + process.exit(0); +})(); + +setTimeout(() => fail("overall test timeout"), POLL_WAIT_MS + 12000); diff --git a/qa/run-qa.sh b/qa/run-qa.sh new file mode 100755 index 0000000..6be6086 --- /dev/null +++ b/qa/run-qa.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# TaskYouOS QA harness — hermetic, sandboxed, non-destructive. +# +# Runs the real setup.sh against a throwaway $HOME and a git worktree of a PR +# branch, then exercises the channel end to end. It NEVER touches your real +# ~/.local/share/task, ~/Library/Application Support/task, ~/.gitconfig, your +# GMs, or your running daemon — everything keys off a sandbox HOME. +# +# Usage: +# qa/run-qa.sh [git-ref] # default: HEAD (CI); pass pr-31 etc. locally +# +# Requires: bun, python3. ty is optional — its assertions are skipped when it's +# absent (CI mode), so the render + smoke + notification chain still gets tested. + +set -uo pipefail + +REF="${1:-HEAD}" # default: the current checkout (CI passes nothing) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/taskyou-qa.XXXXXX")" +SBX_HOME="$SANDBOX/home" +GM_DIR="$SANDBOX/gm" +SRC="$SANDBOX/src" + +PASS=0; FAIL=0 +pass() { echo " ✓ $1"; PASS=$((PASS+1)); } +fail() { echo " ✗ $1"; FAIL=$((FAIL+1)); } +hr() { echo "────────────────────────────────────────────────────────"; } + +cleanup() { + git -C "$REPO_ROOT" worktree remove --force "$SRC" >/dev/null 2>&1 || true + rm -rf "$SANDBOX" +} +trap cleanup EXIT + +echo "TaskYouOS QA harness" +hr +echo " ref: $REF" +echo " sandbox: $SANDBOX" +echo " HOME → $SBX_HOME (real HOME untouched)" +echo " ty: $(command -v ty) ($(ty --version 2>/dev/null | head -1))" +hr + +# ── Worktree of the PR (keeps your real checkout on its current branch) ─────── +git -C "$REPO_ROOT" worktree add --detach "$SRC" "$REF" >/dev/null 2>&1 \ + && pass "worktree of $REF created" \ + || { fail "could not create worktree of $REF"; exit 1; } + +# ── Sandbox HOME + test config ─────────────────────────────────────────────── +mkdir -p "$SBX_HOME" "$GM_DIR" +export HOME="$SBX_HOME" # the isolation boundary +export CLAUDE_CONFIG_DIR="$SBX_HOME/.claude" + +cat > "$GM_DIR/config.env" <"$SANDBOX/local.log" 2>&1 \ + && pass "setup.sh local exited 0" \ + || fail "setup.sh local failed (see $SANDBOX/local.log)" + +[ -f "$GM_DIR/channel/taskyou-channel.ts" ] && pass "channel/taskyou-channel.ts rendered" || fail "channel server not rendered" +[ -f "$GM_DIR/.mcp.json" ] && pass ".mcp.json rendered" || fail ".mcp.json missing" +grep -q 'server:taskyou' "$SANDBOX/local.log" && pass "alias includes channel flag" || fail "alias missing channel flag" +[ -d "$GM_DIR/channel/node_modules" ] && pass "channel deps installed (bun)" || fail "channel node_modules missing" +# rendered channel must have no unresolved {{...}} placeholders +if grep -q '{{' "$GM_DIR/channel/taskyou-channel.ts"; then fail "unresolved {{placeholders}} in channel"; else pass "no unresolved placeholders"; fi +# LOCAL mode must be baked (SERVER_HOST=local) +grep -q 'IS_LOCAL' "$GM_DIR/channel/taskyou-channel.ts" && pass "local-mode branch present (PR #31)" || fail "local-mode branch absent" + +# ── 2. Channel smoke test (PR #28's own test) ──────────────────────────────── +echo; echo "[2] channel smoke-test (handshake + capabilities + tools)" +cp "$SRC/templates/channel/smoke-test.ts" "$GM_DIR/channel/smoke-test.ts" +if ( cd "$GM_DIR/channel" && bun run smoke-test.ts ) >"$SANDBOX/smoke.log" 2>&1; then + pass "smoke-test PASSED"; sed 's/^/ /' "$SANDBOX/smoke.log" +else + fail "smoke-test FAILED"; sed 's/^/ /' "$SANDBOX/smoke.log" +fi + +# OS-correct hooks dir (where the macOS fix should land them) +if [[ "$(uname -s)" == "Darwin" ]]; then + HOOKS_DIR="$SBX_HOME/Library/Application Support/task/hooks" +else + HOOKS_DIR="$SBX_HOME/.config/task/hooks" +fi + +# ── 3. Local server provisioning (setup_server_local) ──────────────────────── +# Needs ty (local mode registers projects + relies on the daemon). When ty is +# absent (CI), skip provisioning and render the hook directly so step 4 — the +# notification chain, which needs no ty — can still run. +echo; echo "[3] setup.sh server — local mode (hooks + ty project, no SSH/systemd)" +if command -v ty >/dev/null 2>&1; then + ( cd "$SRC" && ./setup.sh server "$GM_DIR" ) >"$SANDBOX/server.log" 2>&1 \ + && pass "setup.sh server (local) exited 0" \ + || fail "setup.sh server failed (see $SANDBOX/server.log)" + + [ -f "$HOOKS_DIR/task.completed" ] && pass "hooks installed to OS-correct dir: $HOOKS_DIR" || fail "hooks not in expected dir: $HOOKS_DIR" + if [[ "$(uname -s)" == "Darwin" ]]; then + [ -f "$SBX_HOME/.config/task/hooks/task.completed" ] && fail "hooks wrongly in ~/.config on macOS (the bug PR#31 fixes)" || pass "hooks NOT mis-placed in ~/.config (macOS fix works)" + fi + [ -f "$SBX_HOME/notifications.jsonl" ] && pass "notifications.jsonl created" || fail "notifications.jsonl missing" + HOME="$SBX_HOME" ty projects show qa-test >/dev/null 2>&1 && pass "ty project 'qa-test' registered in SANDBOX ty" || fail "ty project not registered" + [ -f "$SBX_HOME/.local/share/task/tasks.db" ] && pass "sandbox ty db is separate from real ~/.local/share/task" || echo " · (sandbox ty db not found — check ty data layout)" +else + echo " · ty not on PATH (CI mode) — skipping ty provisioning; rendering hook directly" + mkdir -p "$HOOKS_DIR" + sed "s#{{SERVER_HOME}}#$SBX_HOME#g" "$SRC/templates/hooks/task.completed.tmpl" > "$HOOKS_DIR/task.completed" + chmod +x "$HOOKS_DIR/task.completed" + touch "$SBX_HOME/notifications.jsonl" + [ -f "$HOOKS_DIR/task.completed" ] && pass "hook rendered to OS-correct dir: $HOOKS_DIR" || fail "hook render failed" +fi + +# ── 4. Notification end-to-end (hook → file → channel push) ────────────────── +# Two modes: steady-state (skip backlog, deliver next) and cold-start (deliver +# the very first event from an empty file — the regression test for the fix). +for mode in steady cold; do + echo; echo "[4:$mode] channel notification e2e (real hook → channel push)" + : > "$SBX_HOME/notifications.jsonl" # reset file between modes + MODE="$mode" \ + CHANNEL_TS="$GM_DIR/channel/taskyou-channel.ts" \ + NOTIF_FILE="$SBX_HOME/notifications.jsonl" \ + HOOK_SCRIPT="$HOOKS_DIR/task.completed" \ + bun run "$REPO_ROOT/qa/channel-notify-test.ts" >"$SANDBOX/notify-$mode.log" 2>&1 \ + && pass "notification e2e ($mode) PASSED" \ + || fail "notification e2e ($mode) FAILED" + sed 's/^/ /' "$SANDBOX/notify-$mode.log" +done + +# ── Summary ────────────────────────────────────────────────────────────────── +echo; hr +echo " RESULT: $PASS passed, $FAIL failed" +hr +[ "$FAIL" -eq 0 ] diff --git a/setup.sh b/setup.sh index 05cc347..6ea0211 100755 --- a/setup.sh +++ b/setup.sh @@ -308,6 +308,9 @@ setup_local() { ok "channel/taskyou-channel.ts" render_file "$TEMPLATES_DIR/channel/package.json.tmpl" "$LOCAL_PROJECT_DIR/channel/package.json" ok "channel/package.json" + # Ship the smoke test alongside the channel so /gm-doctor can self-check it + cp "$TEMPLATES_DIR/channel/smoke-test.ts" "$LOCAL_PROJECT_DIR/channel/smoke-test.ts" + ok "channel/smoke-test.ts" # Install channel dependencies (cd "$LOCAL_PROJECT_DIR/channel" && bun install --silent 2>/dev/null) || warn "bun install failed — run 'cd $LOCAL_PROJECT_DIR/channel && bun install' manually"