Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion qa/channel-notify-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 || "";
}
});

Expand Down Expand Up @@ -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,
Expand All @@ -144,13 +175,22 @@ 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?)");

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();
Expand Down
16 changes: 16 additions & 0 deletions qa/run-qa.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
62 changes: 45 additions & 17 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}"
Expand Down Expand Up @@ -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:"
Expand Down
6 changes: 5 additions & 1 deletion templates/CLAUDE.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [args...]
Expand Down
83 changes: 63 additions & 20 deletions templates/channel/taskyou-channel.ts.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,16 +53,19 @@ const mcp = new Server(
tools: {},
},
instructions: [
'Task events from remote agents arrive as <channel source="taskyou" event="..." task_id="..." ...>.',
`Task events from agents on ${LOCATION} arrive as <channel source="taskyou" event="..." task_id="..." ...>.`,
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 <id>). 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"),
}
);
Expand All @@ -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: {
Expand All @@ -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"],
Expand All @@ -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<string, unknown>;
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 }] };
}
Expand All @@ -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<string> {
return new Promise((resolve) => {
let proc;
Expand Down Expand Up @@ -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: {
Expand All @@ -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 || "",
},
},
Expand Down
13 changes: 11 additions & 2 deletions templates/ssh-remote.tmpl
Original file line number Diff line number Diff line change
@@ -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 <command>
# e.g., ./bin/ssh-remote "pm2 status"
# ./bin/ssh-remote "tail -20 /tmp/ty-daemon.log"
Expand All @@ -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
Loading
Loading