From 935fc8446665cfdb138551703b1b4b0014dace2a Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 09:16:05 -0500 Subject: [PATCH 1/2] fix(channel): make local mode coherent (no phantom 'remote server') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding a local-mode GM surfaced several rough edges where local mode still behaved/spoke as if there were a remote server: - bin/ty-remote & bin/ssh-remote ran 'ssh local' (no such host) — add an IS_LOCAL branch that runs commands directly on this machine. - Channel instructions + ty_command/ssh_command descriptions said 'the remote server' even locally, so the GM chased a connection that doesn't exist — now phrased as 'this machine' via a LOCATION constant. - ty_command/ssh_command sent 'undefined' to the shell when the GM mixed up the args/command param names — add tolerant commandArg() + clear errors. - The channel surfaced EVERY project's events (shared notifications.jsonl); add a PROJECTS allowlist so a GM only sees its own projects. - print_checklist printed ssh/claude-login steps + a flag-less alias in local mode — branch on is_local_server, and include the channel flag. - CLAUDE.md gains a local-mode banner (SERVER_IS_LOCAL/REMOTE flags). All additive: remote-server GMs render and behave exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- setup.sh | 62 ++++++++++++----- templates/CLAUDE.md.tmpl | 6 +- templates/channel/taskyou-channel.ts.tmpl | 83 +++++++++++++++++------ templates/ssh-remote.tmpl | 13 +++- templates/ty-remote.tmpl | 13 +++- 5 files changed, 135 insertions(+), 42 deletions(-) diff --git a/setup.sh b/setup.sh index 43dd5b5..8d48800 100755 --- a/setup.sh +++ b/setup.sh @@ -229,6 +229,13 @@ done PROJECT_NAME_UPPER=$(echo "$PROJECT_NAME" | tr '[:lower:]' '[:upper:]') export PROJECT_NAME PROJECT_DISPLAY_NAME GM_ALIAS GIT_NAME GIT_EMAIL PROJECTS export SERVER_HOST="${SERVER_HOST:-}" SERVER_USER="${SERVER_USER:-}" SERVER_HOME="${SERVER_HOME:-}" +# Local-vs-remote flags for template conditionals ({{#SERVER_IS_LOCAL}} …). +# Inlined (not the is_local_server function, which is defined later in the file). +if [[ "$SERVER_HOST" == "local" || "$SERVER_HOST" == "localhost" || -z "$SERVER_HOST" ]]; then + export SERVER_IS_LOCAL="true" SERVER_IS_REMOTE="false" +else + export SERVER_IS_LOCAL="false" SERVER_IS_REMOTE="true" +fi export LOCAL_PROJECT_DIR="${LOCAL_PROJECT_DIR:-}" CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-}" export PROJECT_NAME_UPPER export PROJECT_DESCRIPTION="${PROJECT_DESCRIPTION:-}" @@ -1170,26 +1177,47 @@ print_checklist() { echo "════════════════════════════════════════════════════════════" echo "" echo " 1. Add the shell alias to ~/.zshrc:" - echo " alias ${GM_ALIAS}='cd ${LOCAL_PROJECT_DIR} && CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR} claude'" - echo "" - echo " 2. Log into Claude on the server:" - echo " ssh $SERVER_HOST" - echo " claude login" - echo "" - echo " 3. Authenticate GitHub on the server:" - echo " ssh $SERVER_HOST" - echo " gh auth login" + echo " alias ${GM_ALIAS}='cd ${LOCAL_PROJECT_DIR} && CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR} claude --dangerously-load-development-channels server:taskyou'" echo "" - if [[ -n "${GITHUB_REPOS:-}" ]]; then - echo " 4. Add GitHub remotes to project repos:" - IFS=',' read -ra mappings <<< "$GITHUB_REPOS" - for mapping in "${mappings[@]}"; do - local proj="${mapping%%:*}" - local repo="${mapping#*:}" - echo " ssh $SERVER_HOST 'cd $SERVER_HOME/projects/$proj && git remote add origin https://github.com/$repo.git'" - done + if is_local_server; then + # Local mode: GM + daemon share this machine — no SSH, no server login. + echo " 2. Make sure the TaskYou daemon is running on this machine:" + echo " ty daemon status # start it with: ty daemon" echo "" + echo " 3. You're already logged into Claude + GitHub here — nothing to do on a server." + echo "" + + if [[ -n "${GITHUB_REPOS:-}" ]]; then + echo " 4. Add GitHub remotes to project repos:" + IFS=',' read -ra mappings <<< "$GITHUB_REPOS" + for mapping in "${mappings[@]}"; do + local proj="${mapping%%:*}" + local repo="${mapping#*:}" + echo " (cd $SERVER_HOME/projects/$proj && git remote add origin https://github.com/$repo.git)" + done + echo "" + fi + else + echo " 2. Log into Claude on the server:" + echo " ssh $SERVER_HOST" + echo " claude login" + echo "" + echo " 3. Authenticate GitHub on the server:" + echo " ssh $SERVER_HOST" + echo " gh auth login" + echo "" + + if [[ -n "${GITHUB_REPOS:-}" ]]; then + echo " 4. Add GitHub remotes to project repos:" + IFS=',' read -ra mappings <<< "$GITHUB_REPOS" + for mapping in "${mappings[@]}"; do + local proj="${mapping%%:*}" + local repo="${mapping#*:}" + echo " ssh $SERVER_HOST 'cd $SERVER_HOME/projects/$proj && git remote add origin https://github.com/$repo.git'" + done + echo "" + fi fi echo " 5. Start the GM:" diff --git a/templates/CLAUDE.md.tmpl b/templates/CLAUDE.md.tmpl index 5cd3bad..ebacb53 100644 --- a/templates/CLAUDE.md.tmpl +++ b/templates/CLAUDE.md.tmpl @@ -18,9 +18,13 @@ You are the General Manager for {{PROJECT_DISPLAY_NAME}}. You translate {{OWNER_ - Think strategically about marketing, sales, content, and business operations ## Server Connection +{{#SERVER_IS_LOCAL}} +**Local mode: agents run on THIS machine — there is no remote server and no SSH.** The `./bin/ty-remote` and `./bin/ssh-remote` wrappers and the channel's `ty_command`/`ssh_command` tools all run locally. Wherever this doc says "the server", "remote", or "SSH", read it as "this machine" — do not look for or try to connect to a remote host. If a `ty` command can't find a task, it's a local lookup issue, not a connection problem. +Two wrapper scripts run commands for you (locally, no SSH): +{{/SERVER_IS_LOCAL}}{{#SERVER_IS_REMOTE}} The agents live on a remote server. Two wrapper scripts handle SSH for you: - +{{/SERVER_IS_REMOTE}} ```bash # Run any TaskYou command on the server ./bin/ty-remote [args...] diff --git a/templates/channel/taskyou-channel.ts.tmpl b/templates/channel/taskyou-channel.ts.tmpl index e9d46db..a40e4e7 100644 --- a/templates/channel/taskyou-channel.ts.tmpl +++ b/templates/channel/taskyou-channel.ts.tmpl @@ -14,6 +14,25 @@ const SERVER_HOST = "{{SERVER_HOST}}"; const SERVER_HOME = "{{SERVER_HOME}}"; const POLL_INTERVAL_MS = 10_000; // 10 seconds +// When the GM runs on the same machine as the TaskYou daemon, SERVER_HOST is +// "local"/"localhost"/"" — commands run directly via a login shell, no SSH. +const IS_LOCAL = + SERVER_HOST === "local" || + SERVER_HOST === "localhost" || + SERVER_HOST === ""; + +// How we describe where commands run, so the GM's mental model matches reality. +// In local mode there is NO remote server — saying "remote server" makes the GM +// chase a connection that doesn't exist. +const LOCATION = IS_LOCAL ? "this machine" : "the remote server"; + +// Only surface events for this GM's own projects. Rendered from config's +// PROJECTS (comma-separated). Empty list = no filter (surface everything). +const ALLOWED_PROJECTS = "{{PROJECTS}}" + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + // 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 @@ -34,16 +53,19 @@ const mcp = new Server( tools: {}, }, instructions: [ - 'Task events from remote agents arrive as .', + `Task events from agents on ${LOCATION} arrive as .`, + IS_LOCAL + ? "Agents run on THIS machine (no SSH). Run ty directly — do not look for a remote server." + : "Agents run on the remote server, reached over SSH.", "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", + `You can also use the channel tools to run TaskYou commands on ${LOCATION} without shelling out:`, + " - ty_command: Run any ty command (e.g. list, board, create, execute, retry, output, close)", + " - ssh_command: Run any shell command", ].join("\n"), } ); @@ -54,8 +76,7 @@ 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)", + description: `Run a TaskYou command on ${LOCATION} (e.g. list, board, create, execute, retry, output, close)`, inputSchema: { type: "object", properties: { @@ -70,14 +91,13 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ }, { name: "ssh_command", - description: - "Run a shell command on the remote server (e.g. git log, tail logs)", + description: `Run a shell command on ${LOCATION} (e.g. git log, tail logs)`, inputSchema: { type: "object", properties: { command: { type: "string", - description: "The shell command to run on the server", + description: `The shell command to run on ${LOCATION}`, }, }, required: ["command"], @@ -86,17 +106,38 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ ], })); +// Pull the command string out of a tool call, tolerant of the GM mixing up the +// two parameter names (`args` vs `command`) — otherwise a wrong key silently +// becomes `undefined` and we'd run literally `ty undefined`. +function commandArg(args: unknown): string { + const a = (args ?? {}) as Record; + const val = a.args ?? a.command; + return typeof val === "string" ? val.trim() : ""; +} + mcp.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; if (name === "ty_command") { - const { args: tyArgs } = args as { args: string }; + const tyArgs = commandArg(args); + if (!tyArgs) { + return { + content: [{ type: "text", text: 'ty_command needs an "args" string, e.g. {"args":"output 42"}.' }], + isError: true, + }; + } const result = await runRemote(`ty ${tyArgs}`); return { content: [{ type: "text", text: result }] }; } if (name === "ssh_command") { - const { command } = args as { command: string }; + const command = commandArg(args); + if (!command) { + return { + content: [{ type: "text", text: 'ssh_command needs a "command" string, e.g. {"command":"git log -1"}.' }], + isError: true, + }; + } const result = await runRemote(command); return { content: [{ type: "text", text: result }] }; } @@ -106,14 +147,6 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => { // ── Command runner (local or SSH) ──────────────────────────────────────────── -// When the GM runs on the same machine as the TaskYou daemon, SERVER_HOST is -// "local"/"localhost"/"" — run commands directly via a login shell instead of -// over SSH. Otherwise keep the original remote-Linux SSH behavior unchanged. -const IS_LOCAL = - SERVER_HOST === "local" || - SERVER_HOST === "localhost" || - SERVER_HOST === ""; - function runRemote(command: string): Promise { return new Promise((resolve) => { let proc; @@ -185,6 +218,16 @@ async function pollNotifications() { try { const event = JSON.parse(trimmed); + + // Project filter: skip events from projects this GM doesn't own. + // (notifications.jsonl is shared by every project on the daemon, so + // without this a local GM would surface unrelated GMs' task events.) + // Events with no project are always passed — can't safely filter them. + const proj = event.project || ""; + if (ALLOWED_PROJECTS.length && proj && !ALLOWED_PROJECTS.includes(proj)) { + continue; + } + await mcp.notification({ method: "notifications/claude/channel", params: { @@ -193,7 +236,7 @@ async function pollNotifications() { event: event.event || "unknown", task_id: event.task_id || "", title: event.title || "", - project: event.project || "", + project: proj, timestamp: event.timestamp || "", }, }, diff --git a/templates/ssh-remote.tmpl b/templates/ssh-remote.tmpl index 5f69330..3e8a017 100644 --- a/templates/ssh-remote.tmpl +++ b/templates/ssh-remote.tmpl @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# ssh-remote — Run commands on your server +# ssh-remote — Run commands on your server (or this machine in local mode) # Usage: ./bin/ssh-remote # e.g., ./bin/ssh-remote "pm2 status" # ./bin/ssh-remote "tail -20 /tmp/ty-daemon.log" @@ -12,4 +12,13 @@ if [ $# -eq 0 ]; then exit 1 fi -ssh {{SERVER_HOST}} "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\" && $*" +SERVER_HOST="{{SERVER_HOST}}" +SERVER_HOME="{{SERVER_HOME}}" + +if [[ "$SERVER_HOST" == "local" || "$SERVER_HOST" == "localhost" || -z "$SERVER_HOST" ]]; then + # Local mode: run on this machine via a login shell, no SSH. + export PATH="$HOME/.local/bin:$HOME/bin:$PATH" + exec bash -lc "$*" +else + ssh "$SERVER_HOST" "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\" && $*" +fi diff --git a/templates/ty-remote.tmpl b/templates/ty-remote.tmpl index a31e410..0241019 100644 --- a/templates/ty-remote.tmpl +++ b/templates/ty-remote.tmpl @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# ty-remote — Run TaskYou commands on your server +# ty-remote — Run TaskYou commands on your server (or this machine in local mode) # Usage: ./bin/ty-remote [args...] # e.g., ./bin/ty-remote list # ./bin/ty-remote create "My task" --project myproject @@ -12,4 +12,13 @@ if [ $# -eq 0 ]; then exit 1 fi -ssh {{SERVER_HOST}} "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}} && ty $*" +SERVER_HOST="{{SERVER_HOST}}" +SERVER_HOME="{{SERVER_HOME}}" + +if [[ "$SERVER_HOST" == "local" || "$SERVER_HOST" == "localhost" || -z "$SERVER_HOST" ]]; then + # Local mode: the daemon runs on this machine — run ty directly, no SSH. + export PATH="$HOME/.local/bin:$HOME/bin:$PATH" + cd "$SERVER_HOME" && exec ty "$@" +else + ssh "$SERVER_HOST" "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 && ty $*" +fi From 2f89404293e756cac0dcf0e61c92fb46ab22e7ba Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 09:16:05 -0500 Subject: [PATCH 2/2] =?UTF-8?q?test(qa):=20cover=20local-mode=20surface=20?= =?UTF-8?q?=E2=80=94=20wrappers,=20language,=20project=20filter,=20tool=20?= =?UTF-8?q?args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the harness to assert the local-mode polish: bin wrappers run locally (no 'ssh local'), channel language says 'this machine', CLAUDE.md banner present, checklist is local-aware, foreign-project events are filtered, and ssh_command runs locally + tolerates the wrong param name (no 'ty undefined'). Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/channel-notify-test.ts | 42 ++++++++++++++++++++++++++++++++++++++- qa/run-qa.sh | 16 +++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/qa/channel-notify-test.ts b/qa/channel-notify-test.ts index 0b55753..988e447 100644 --- a/qa/channel-notify-test.ts +++ b/qa/channel-notify-test.ts @@ -67,7 +67,11 @@ const proc = spawn("bun", ["run", CHANNEL_TS], { let pushed: any = null; let sawSeedReplay = false; +let sawForeign = false; // an event from a project this GM doesn't own — must be filtered +let toolEcho = ""; // ssh_command({command}) result — proves the tool runs locally +let toolEchoAlt = ""; // ssh_command({args}) result — proves robust arg parsing (no "ty undefined") let buf = ""; +const FOREIGN_ID = "qa-foreign"; proc.stdout.on("data", (d: Buffer) => { buf += d.toString(); @@ -84,8 +88,12 @@ proc.stdout.on("data", (d: Buffer) => { if (msg.method === "notifications/claude/channel") { const meta = msg.params?.meta || {}; if (meta.task_id === "qa-seed") sawSeedReplay = true; + if (meta.task_id === FOREIGN_ID) sawForeign = true; if (meta.task_id === TASK_ID) pushed = msg; } + // tool-call responses (id 3 = correct param, id 4 = wrong param name) + if (msg.id === 3) toolEcho = msg.result?.content?.[0]?.text || ""; + if (msg.id === 4) toolEchoAlt = msg.result?.content?.[0]?.text || ""; } }); @@ -117,10 +125,33 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); }) + "\n" ); + // 3b. Exercise the tools through MCP. Both must run locally (no SSH) and + // return output. id=4 deliberately uses the WRONG param name (`args` on + // ssh_command) to prove the robust parsing doesn't send `undefined`. + proc.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", id: 3, method: "tools/call", + params: { name: "ssh_command", arguments: { command: "echo TOOLOK" } }, + }) + "\n" + ); + proc.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", id: 4, method: "tools/call", + params: { name: "ssh_command", arguments: { args: "echo VIAARGS" } }, + }) + "\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. + // 5a. Fire a FOREIGN-project event first — the channel (rendered with + // PROJECTS=qa-test) must filter it out and never push it. + spawnSync("bash", [HOOK_SCRIPT], { + env: { ...process.env, TASK_ID: FOREIGN_ID, TASK_TITLE: "other GM's task", TASK_PROJECT: "some-other-gm" }, + encoding: "utf8", + }); + + // 5b. Fire the REAL rendered hook for THIS GM's project — appends to notifications.jsonl. const r = spawnSync("bash", [HOOK_SCRIPT], { env: { ...process.env, @@ -144,6 +175,13 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); : `no channel push for task ${TASK_ID} within ${POLL_WAIT_MS}ms` ); + if (sawForeign) + fail("channel pushed a foreign-project event — project filter not working"); + if (!toolEcho.includes("TOOLOK")) + fail(`ssh_command({command}) did not run locally — got: ${JSON.stringify(toolEcho)}`); + if (!toolEchoAlt.includes("VIAARGS")) + fail(`ssh_command({args}) (wrong param) not handled robustly — got: ${JSON.stringify(toolEchoAlt)}`); + 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?)"); @@ -151,6 +189,8 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 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(`✓ foreign-project event was filtered out (not pushed)`); + console.log(`✓ ssh_command ran locally; robust to wrong param name (no "undefined")`); console.log(`✓ title with embedded quotes survived hook→channel`); console.log("\nPASSED"); proc.kill(); diff --git a/qa/run-qa.sh b/qa/run-qa.sh index 6be6086..d6bfd13 100755 --- a/qa/run-qa.sh +++ b/qa/run-qa.sh @@ -81,6 +81,22 @@ if grep -q '{{' "$GM_DIR/channel/taskyou-channel.ts"; then fail "unresolved {{pl # 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" +# ── 1b. Local-mode surface (no "remote server" confusion) ──────────────────── +echo; echo "[1b] local-mode surface — wrappers + language + checklist" +CH="$GM_DIR/channel/taskyou-channel.ts" +grep -q 'this machine' "$CH" && pass "channel tool/instruction language is local-aware (\"this machine\")" || fail "channel still says \"remote server\" in local mode" +grep -q 'ALLOWED_PROJECTS' "$CH" && pass "project allowlist baked into channel" || fail "no project filter in channel" +grep -q 'commandArg' "$CH" && pass "ty_command/ssh_command arg parsing is robust (no 'ty undefined')" || fail "tool arg parsing not hardened" +# bin wrappers must run locally, not 'ssh local' +if grep -q 'ssh local' "$GM_DIR/bin/ty-remote"; then fail "bin/ty-remote still does 'ssh local'"; else pass "bin/ty-remote has no 'ssh local'"; fi +"$GM_DIR/bin/ssh-remote" "echo WRAPPER_OK" 2>/dev/null | grep -q WRAPPER_OK && pass "bin/ssh-remote runs locally" || fail "bin/ssh-remote does not run locally" +# CLAUDE.md local banner + no leftover remote-only sentence +grep -q 'Local mode: agents run on THIS machine' "$GM_DIR/CLAUDE.md" && pass "CLAUDE.md has local-mode banner" || fail "CLAUDE.md missing local-mode banner" +grep -q 'The agents live on a remote server' "$GM_DIR/CLAUDE.md" && fail "CLAUDE.md kept the remote-only sentence in local mode" || pass "CLAUDE.md dropped the remote-only sentence" +# checklist: local steps, no 'claude login' on a server +grep -q 'daemon is running on this machine' "$SANDBOX/local.log" && pass "checklist shows local daemon step" || fail "checklist not local-aware" +grep -q 'claude login' "$SANDBOX/local.log" && fail "checklist still tells you to 'claude login' on a server" || pass "checklist drops server-login steps" + # ── 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"