From 8c6320cb5c8c322cecb3076445d0ad4af1a5126c Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 11:43:11 -0400 Subject: [PATCH 1/2] Add local-server mode and macOS support (auto-detected) PR #28 assumes a remote Linux server reached over SSH. These additive changes let the GM run on the same machine as the TaskYou daemon (including macOS) with zero change to the existing remote-Linux flow: - channel: runRemote() gains a LOCAL branch (bash -lc, no SSH) when SERVER_HOST is local/localhost/empty; the SSH path is unchanged. - setup.sh: OS-detect the hooks dir to match ty's os.UserConfigDir() (Linux ~/.config/task/hooks vs macOS ~/Library/Application Support/ task/hooks). The hardcoded ~/.config path silently misfired on macOS. - setup.sh: a local-server provisioning path that skips SSH/systemd when the daemon runs on this same box; remote-Linux/systemd intact. - config.example.env: document SERVER_HOST=local. Both paths are auto-detected; no existing behavior is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- config.example.env | 6 +- setup.sh | 118 ++++++++++++++++++++-- templates/channel/taskyou-channel.ts.tmpl | 37 +++++-- 3 files changed, 148 insertions(+), 13 deletions(-) diff --git a/config.example.env b/config.example.env index 2df7d2e..a6e6100 100644 --- a/config.example.env +++ b/config.example.env @@ -15,7 +15,11 @@ OWNER_NAME="YourName" # Shell alias for launching the GM (e.g. "mygm") GM_ALIAS="mygm" -# SSH connection string for the agents server +# SSH connection string for the agents server. +# For a LOCAL server (the GM runs on the same machine as the TaskYou daemon, +# including macOS), set this to "local" (or "localhost"). In that mode setup +# skips SSH/systemd provisioning and the channel runs ty commands directly via +# bash -lc instead of over SSH. SERVER_HOME should then be your local $HOME. SERVER_HOST="agents@your-server-ip" # Username on the server diff --git a/setup.sh b/setup.sh index 6ea0211..06ac54a 100755 --- a/setup.sh +++ b/setup.sh @@ -166,6 +166,25 @@ remote_with_path() { remote "export PATH=$SERVER_HOME/bin:$SERVER_HOME/.npm-global/bin:$SERVER_HOME/.local/bin:/home/deploy/.asdf/installs/nodejs/24.13.0/bin:\$PATH && $*" } +# Resolve the TaskYou hooks directory for a host's OS. +# ty's hooks.DefaultHooksDir() follows Go's os.UserConfigDir(): +# Linux -> $HOME/.config/task/hooks +# macOS -> $HOME/Library/Application Support/task/hooks +# $1 = a command runner (e.g. "remote" or "exe_remote") that runs a shell +# command on the target host; $2 = that host's home directory. +# Echoes the absolute hooks dir. Falls back to ~/.config on unknown OSes. +resolve_hooks_dir() { + local runner="$1" + local home_dir="$2" + local os_name + os_name=$("$runner" "uname -s" 2>/dev/null | tr -d '\r' | xargs || echo "") + if [[ "$os_name" == "Darwin" ]]; then + echo "$home_dir/Library/Application Support/task/hooks" + else + echo "$home_dir/.config/task/hooks" + fi +} + # ── Parse args ─────────────────────────────────────────────────────────────── if [[ $# -lt 2 ]]; then @@ -534,9 +553,88 @@ install_daemon_service() { fi } +# ── Local-server setup (server = this machine) ─────────────────────────────── + +# True when the "server" is the same box the GM runs on (no SSH, no systemd). +# Detected from SERVER_HOST being local/localhost/empty — mirrors the channel's +# IS_LOCAL detection so a single config drives both. +is_local_server() { + [[ "$SERVER_HOST" == "local" || "$SERVER_HOST" == "localhost" || -z "$SERVER_HOST" ]] +} + +# Provision when the daemon runs on this same machine: install hooks + the +# notifications file locally, skipping SSH provisioning and the systemd service +# (the GM and daemon share the box). TaskYou itself is assumed already installed +# locally (this is the machine the GM launches from). Cross-platform hooks dir. +setup_server_local() { + log "Setting up local server (this machine — no SSH, no systemd)" + + local home_dir="${SERVER_HOME:-$HOME}" + # Ensure templates that reference {{SERVER_HOME}} (e.g. the hooks' notifications + # path) render against the resolved local home even if SERVER_HOME was blank. + export SERVER_HOME="$home_dir" + + # Git identity + log "Configuring git identity" + git config --global user.name "$GIT_NAME" && git config --global user.email "$GIT_EMAIL" + ok "git: $GIT_NAME <$GIT_EMAIL>" + + # Create project repos and register them with TaskYou + log "Creating project repositories" + IFS=',' read -ra projs <<< "$PROJECTS" + for proj in "${projs[@]}"; do + proj=$(echo "$proj" | xargs) + local repo_path="$home_dir/projects/$proj" + if [[ -d "$repo_path/.git" ]]; then + ok "$proj (already exists)" + else + mkdir -p "$repo_path" && (cd "$repo_path" && git init -q) + ok "$proj" + fi + + if ty projects show "$proj" >/dev/null 2>&1; then + ok " ty project $proj (already registered)" + else + ty projects create "$proj" --path "$repo_path" + ok " ty project $proj registered" + fi + done + + # Install TaskYou hooks (OS-detected dir; matches ty's os.UserConfigDir()). + log "Installing TaskYou hooks" + local hooks_dir + if [[ "$(uname -s)" == "Darwin" ]]; then + hooks_dir="$home_dir/Library/Application Support/task/hooks" + else + hooks_dir="$home_dir/.config/task/hooks" + fi + mkdir -p "$hooks_dir" + ok "hooks dir: $hooks_dir" + + for hook_tmpl in "$TEMPLATES_DIR"/hooks/*.tmpl; do + local hook_name + hook_name=$(basename "$hook_tmpl" .tmpl) + render_file "$hook_tmpl" "$hooks_dir/$hook_name" + chmod +x "$hooks_dir/$hook_name" + ok "hook: $hook_name" + done + + # Notifications file + touch "$home_dir/notifications.jsonl" + ok "notifications.jsonl" + + log "Local server setup complete (daemon assumed running on this machine)" +} + # ── Server setup ───────────────────────────────────────────────────────────── setup_server() { + # Local-server mode: skip all SSH/systemd provisioning. + if is_local_server; then + setup_server_local + return + fi + log "Setting up server: $SERVER_HOST" # Test SSH connection @@ -615,15 +713,19 @@ with open(cf, 'w') as f: json.dump(data, f, indent=2) # Install TaskYou hooks log "Installing TaskYou hooks" - local hooks_dir="$SERVER_HOME/.config/task/hooks" - remote "mkdir -p $hooks_dir" + # OS-detect the hooks dir: Linux uses ~/.config, macOS uses + # ~/Library/Application Support (matches ty's os.UserConfigDir()). + local hooks_dir + hooks_dir=$(resolve_hooks_dir remote "$SERVER_HOME") + remote "mkdir -p \"$hooks_dir\"" + ok "hooks dir: $hooks_dir" for hook_tmpl in "$TEMPLATES_DIR"/hooks/*.tmpl; do local hook_name hook_name=$(basename "$hook_tmpl" .tmpl) local rendered rendered=$(render "$(<"$hook_tmpl")") - ssh "$SERVER_HOST" "cat > $hooks_dir/$hook_name && chmod +x $hooks_dir/$hook_name" <<< "$rendered" + ssh "$SERVER_HOST" "cat > \"$hooks_dir/$hook_name\" && chmod +x \"$hooks_dir/$hook_name\"" <<< "$rendered" ok "hook: $hook_name" done @@ -862,15 +964,19 @@ with open(cf, 'w') as f: json.dump(data, f, indent=2) # Install TaskYou hooks log "Installing TaskYou hooks" - local hooks_dir="$EXE_HOME/.config/task/hooks" - exe_remote "mkdir -p $hooks_dir" + # OS-detect the hooks dir (exe.dev VMs are Linux -> ~/.config, but resolve + # dynamically so a macOS target would get ~/Library/Application Support). + local hooks_dir + hooks_dir=$(resolve_hooks_dir exe_remote "$EXE_HOME") + exe_remote "mkdir -p \"$hooks_dir\"" + ok "hooks dir: $hooks_dir" for hook_tmpl in "$TEMPLATES_DIR"/hooks/*.tmpl; do local hook_name hook_name=$(basename "$hook_tmpl" .tmpl) local rendered rendered=$(render "$(<"$hook_tmpl")") - ssh "$EXE_HOST" "cat > $hooks_dir/$hook_name && chmod +x $hooks_dir/$hook_name" <<< "$rendered" + ssh "$EXE_HOST" "cat > \"$hooks_dir/$hook_name\" && chmod +x \"$hooks_dir/$hook_name\"" <<< "$rendered" ok "hook: $hook_name" done diff --git a/templates/channel/taskyou-channel.ts.tmpl b/templates/channel/taskyou-channel.ts.tmpl index 730e6e6..e9d46db 100644 --- a/templates/channel/taskyou-channel.ts.tmpl +++ b/templates/channel/taskyou-channel.ts.tmpl @@ -104,15 +104,40 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => { throw new Error(`unknown tool: ${name}`); }); -// ── SSH helper ─────────────────────────────────────────────────────────────── +// ── 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) => { - 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}`; + let proc; + + if (IS_LOCAL) { + // LOCAL mode: run on this machine via a login shell, no SSH. $HOME + // expands inside bash; we prepend ~/.local/bin and cd into SERVER_HOME + // (where notifications.jsonl lives) just like the remote path does. + proc = spawn( + "bash", + [ + "-lc", + `export PATH="$HOME/.local/bin:$HOME/bin:$PATH" && cd "${SERVER_HOME}" && ${command}`, + ], + { stdio: ["ignore", "pipe", "pipe"] } + ); + } else { + // Remote mode: run over SSH (original behavior — do not change). + 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"], - }); + proc = spawn("ssh", [SERVER_HOST, sshCmd], { + stdio: ["ignore", "pipe", "pipe"], + }); + } let stdout = ""; let stderr = ""; @@ -185,7 +210,7 @@ async function pollNotifications() { lastLineCount = currentLines; initialized = true; } catch { - // SSH failure — silently retry next interval + // Command failure (SSH or local) — silently retry next interval } finally { polling = false; } From 5a64acdf16e44bc7b1a312e4584f81fe189c43bd Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 9 Jun 2026 07:27:53 -0500 Subject: [PATCH 2/2] fix(setup): preflight ty + daemon in local-server mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local mode registers projects and installs hooks against a ty daemon assumed to run on this same machine. If ty is missing, fail loudly instead of erroring mid-registration; if the daemon isn't running, warn — otherwise setup 'succeeds' but no task events ever fire. Co-Authored-By: Claude Opus 4.8 (1M context) --- setup.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/setup.sh b/setup.sh index 06ac54a..43dd5b5 100755 --- a/setup.sh +++ b/setup.sh @@ -569,6 +569,21 @@ is_local_server() { setup_server_local() { log "Setting up local server (this machine — no SSH, no systemd)" + # Preflight: local mode assumes ty is installed and its daemon runs on THIS + # box (we register projects + install hooks locally). Without these, setup + # would "succeed" but no task events would ever fire — fail loudly instead. + if ! command -v ty >/dev/null 2>&1; then + echo "Error: 'ty' is not on PATH, but SERVER_HOST is local." + echo "Install TaskYou first (the daemon must run on this machine), then re-run." + exit 1 + fi + if ty daemon status >/dev/null 2>&1 || pgrep -f 'ty daemon' >/dev/null 2>&1; then + ok "ty daemon detected" + else + warn "ty daemon does not appear to be running on this machine." + warn "Start it (e.g. 'ty daemon') so task hooks fire and the channel sees events." + fi + local home_dir="${SERVER_HOME:-$HOME}" # Ensure templates that reference {{SERVER_HOME}} (e.g. the hooks' notifications # path) render against the resolved local home even if SERVER_HOME was blank.