diff --git a/README.md b/README.md index fea62217..0cd5544a 100644 --- a/README.md +++ b/README.md @@ -39,21 +39,24 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.m ### Public (binary) -Use this for users on production machines where you don't want to expose source: +Use this for users on production machines where you don't want to expose source. ```bash curl -fsSL https://raw.githubusercontent.com/getcompanion-ai/co-mono/main/public-install.sh | bash ``` -Then run: +Install everything and keep it always-on (recommended for new devices): ```bash -co-mono +curl -fsSL https://raw.githubusercontent.com/getcompanion-ai/co-mono/main/public-install.sh | bash -s -- --daemon --start ``` -The installer downloads the latest release archive, writes a launcher to -`~/.local/bin/co-mono`, and creates a private agent settings directory at -`~/.co-mono/agent/settings.json` with remote packages. +This installer: +- Downloads the latest release (or falls back to source when needed), +- writes `~/.local/bin/co-mono` launcher, +- populates `~/.co-mono/agent/settings.json` with package list, +- installs packages (if `npm` is available), +- and can install a user systemd service for `co-mono daemon` so it stays alive. Preinstalled package sources are: @@ -65,41 +68,19 @@ Preinstalled package sources are: ] ``` -If `npm` is available, it also tries to install these packages during install. +If `npm` is available, it also installs these packages during install. -If a release has not been published yet, the installer can fallback to a source checkout automatically. Set this explicitly with: +If no release asset is found, the installer falls back to source. ```bash -CO_MONO_FALLBACK_TO_SOURCE=1 curl -fsSL https://raw.githubusercontent.com/getcompanion-ai/co-mono/main/public-install.sh | bash +CO_MONO_FALLBACK_TO_SOURCE=0 \ + curl -fsSL https://raw.githubusercontent.com/getcompanion-ai/co-mono/main/public-install.sh | bash -s -- --daemon --start ``` -### Keep it running - -Start and keep `co-mono` alive with your process supervisor of choice (systemd, launchd, supervisor, Docker, etc). - -For public installs, a minimal systemd user service is: +`public-install.sh` options: ```bash -mkdir -p ~/.config/systemd/user -cat > ~/.config/systemd/user/co-mono.service <<'EOF' -[Unit] -Description=co-mono -After=network-online.target - -[Service] -Type=simple -Environment=PI_CODING_AGENT_DIR=%h/.co-mono/agent -Environment=CO_MONO_AGENT_DIR=%h/.co-mono/agent -ExecStart=%h/.local/bin/co-mono -Restart=always -RestartSec=5 - -[Install] -WantedBy=default.target -EOF - -systemctl --user daemon-reload -systemctl --user enable --now co-mono +curl -fsSL https://raw.githubusercontent.com/getcompanion-ai/co-mono/main/public-install.sh | bash -s -- --help ``` ### Local (source) @@ -116,11 +97,10 @@ Run: ./co-mono ``` -Run with built-in runtime watchdog: +Run in background with extensions active: ```bash -CO_MONO_RUNTIME_COMMAND="python -m http.server 8765" \ - ./co-mono --with-runtime-daemon +./co-mono daemon ``` For a user systemd setup, create `~/.config/systemd/user/co-mono.service` with: @@ -134,7 +114,7 @@ After=network-online.target Type=simple Environment=PI_CODING_AGENT_DIR=%h/.co-mono/agent Environment=CO_MONO_AGENT_DIR=%h/.co-mono/agent -ExecStart=/absolute/path/to/repo/co-mono --with-runtime-daemon +ExecStart=/absolute/path/to/repo/co-mono daemon Restart=always RestartSec=5 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index bab7cbca..d04ba0f3 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -441,6 +441,7 @@ pi config # Enable/disable package resources |------|-------------| | (default) | Interactive mode | | `-p`, `--print` | Print response and exit | +| `daemon` | Start a long-lived, non-interactive daemon that keeps extensions running | | `--mode json` | Output all events as JSON lines (see [docs/json.md](docs/json.md)) | | `--mode rpc` | RPC mode for process integration (see [docs/rpc.md](docs/rpc.md)) | | `--export [out]` | Export session to HTML | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 9edfb9b0..40dcffa4 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -184,9 +184,10 @@ ${chalk.bold("Usage:")} ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings - ${APP_NAME} remove [-l] Remove extension source from settings + ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} update [source] Update installed extensions (skips pinned sources) ${APP_NAME} list List installed extensions from settings + ${APP_NAME} daemon Run in long-lived daemon mode (extensions stay active) ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/update/list diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 3b457fab..7852eca1 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -28,7 +28,7 @@ import { SettingsManager } from "./core/settings-manager.js"; import { printTimings, time } from "./core/timings.js"; import { allTools } from "./core/tools/index.js"; import { runMigrations, showDeprecationWarnings } from "./migrations.js"; -import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; +import { type DaemonModeOptions, InteractiveMode, runDaemonMode, runPrintMode, runRpcMode } from "./modes/index.js"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"; /** @@ -79,6 +79,19 @@ interface PackageCommandOptions { invalidOption?: string; } +function printDaemonHelp(): void { + console.log(`${chalk.bold("Usage:")} + ${APP_NAME} daemon [options] [messages...] + +Run pi as a long-lived daemon (non-interactive) with extensions enabled. +Messages passed as positional args are sent once at startup. + +Options: + --list-models [search] List available models and exit + --help, -h Show this help +`); +} + function getPackageCommandUsage(command: PackageCommand): string { switch (command) { case "install": @@ -540,6 +553,8 @@ async function handleConfigCommand(args: string[]): Promise { } export async function main(args: string[]) { + const isDaemonCommand = args[0] === "daemon"; + const parsedArgs = isDaemonCommand ? args.slice(1) : args; const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE); if (offlineMode) { process.env.PI_OFFLINE = "1"; @@ -558,7 +573,7 @@ export async function main(args: string[]) { const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd()); // First pass: parse args to get --extension paths - const firstPass = parseArgs(args); + const firstPass = parseArgs(parsedArgs); // Early load extensions to discover their CLI flags const cwd = process.cwd(); @@ -606,7 +621,7 @@ export async function main(args: string[]) { } // Second pass: parse args with extension flags - const parsed = parseArgs(args, extensionFlags); + const parsed = parseArgs(parsedArgs, extensionFlags); // Pass flag values to extensions via runtime for (const [name, value] of parsed.unknownFlags) { @@ -619,7 +634,11 @@ export async function main(args: string[]) { } if (parsed.help) { - printHelp(); + if (isDaemonCommand) { + printDaemonHelp(); + } else { + printHelp(); + } process.exit(0); } @@ -629,8 +648,13 @@ export async function main(args: string[]) { process.exit(0); } - // Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC - if (parsed.mode !== "rpc") { + if (isDaemonCommand && parsed.mode === "rpc") { + console.error(chalk.red("Cannot use --mode rpc with the daemon command.")); + process.exit(1); + } + + // Read piped stdin content (if any) - skip for daemon and RPC modes + if (!isDaemonCommand && parsed.mode !== "rpc") { const stdinContent = await readPipedStdin(); if (stdinContent !== undefined) { // Force print mode since interactive mode requires a TTY for keyboard input @@ -660,7 +684,7 @@ export async function main(args: string[]) { } const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); - const isInteractive = !parsed.print && parsed.mode === undefined; + const isInteractive = !isDaemonCommand && !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; initTheme(settingsManager.getTheme(), isInteractive); @@ -765,6 +789,13 @@ export async function main(args: string[]) { verbose: parsed.verbose, }); await mode.run(); + } else if (isDaemonCommand) { + const daemonOptions: DaemonModeOptions = { + initialMessage, + initialImages, + messages: parsed.messages, + }; + await runDaemonMode(session, daemonOptions); } else { await runPrintMode(session, { mode, diff --git a/packages/coding-agent/src/modes/daemon-mode.ts b/packages/coding-agent/src/modes/daemon-mode.ts new file mode 100644 index 00000000..fc1707e4 --- /dev/null +++ b/packages/coding-agent/src/modes/daemon-mode.ts @@ -0,0 +1,143 @@ +/** + * Daemon mode (always-on background execution). + * + * Starts agent extensions, accepts messages from extension sources + * (webhooks, queues, Telegram/Slack gateways, etc.), and stays alive + * until explicitly stopped. + */ + +import type { ImageContent } from "@mariozechner/pi-ai"; +import type { AgentSession } from "../core/agent-session.js"; + +/** + * Options for daemon mode. + */ +export interface DaemonModeOptions { + /** First message to send at startup (can include @file content expansion by caller). */ + initialMessage?: string; + /** Images to attach to the startup message. */ + initialImages?: ImageContent[]; + /** Additional startup messages (sent after initialMessage, one by one). */ + messages?: string[]; +} + +function createCommandContextActions(session: AgentSession) { + return { + waitForIdle: () => session.agent.waitForIdle(), + newSession: async (options?: { + parentSession?: string; + setup?: (sessionManager: typeof session.sessionManager) => Promise | void; + }) => { + const success = await session.newSession({ parentSession: options?.parentSession }); + if (success && options?.setup) { + await options.setup(session.sessionManager); + } + return { cancelled: !success }; + }, + fork: async (entryId: string) => { + const result = await session.fork(entryId); + return { cancelled: result.cancelled }; + }, + navigateTree: async ( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, + ) => { + const result = await session.navigateTree(targetId, { + summarize: options?.summarize, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }); + return { cancelled: result.cancelled }; + }, + switchSession: async (sessionPath: string) => { + const success = await session.switchSession(sessionPath); + return { cancelled: !success }; + }, + reload: async () => { + await session.reload(); + }, + }; +} + +/** + * Run in daemon mode. + * Stays alive indefinitely unless stopped by signal or extension trigger. + */ +export async function runDaemonMode(session: AgentSession, options: DaemonModeOptions): Promise { + const { initialMessage, initialImages, messages = [] } = options; + let isShuttingDown = false; + let resolveReady: () => void = () => {}; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + + const shutdown = async (reason: "signal" | "extension"): Promise => { + if (isShuttingDown) return; + isShuttingDown = true; + + console.error(`[co-mono-daemon] shutdown requested: ${reason}`); + + const runner = session.extensionRunner; + if (runner?.hasHandlers("session_shutdown")) { + await runner.emit({ type: "session_shutdown" }); + } + + session.dispose(); + resolveReady(); + }; + + const handleShutdownSignal = (signal: NodeJS.Signals) => { + void shutdown("signal").catch((error) => { + console.error( + `[co-mono-daemon] shutdown failed for ${signal}: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + }); + }; + + process.once("SIGINT", () => handleShutdownSignal("SIGINT")); + process.once("SIGTERM", () => handleShutdownSignal("SIGTERM")); + process.once("SIGQUIT", () => handleShutdownSignal("SIGQUIT")); + process.once("SIGHUP", () => handleShutdownSignal("SIGHUP")); + + process.on("unhandledRejection", (error) => { + console.error(`[co-mono-daemon] unhandled rejection: ${error instanceof Error ? error.message : String(error)}`); + }); + + await session.bindExtensions({ + commandContextActions: createCommandContextActions(session), + shutdownHandler: () => { + void shutdown("extension").catch((error) => { + console.error( + `[co-mono-daemon] extension shutdown failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + }); + }, + onError: (err) => { + console.error(`Extension error (${err.extensionPath}): ${err.error}`); + }, + }); + + // Emit structured events to stderr for supervisor logs. + session.subscribe((event) => { + console.error( + JSON.stringify({ type: event.type, sessionId: session.sessionId, messageCount: session.messages.length }), + ); + }); + + // Startup probes/messages. + if (initialMessage) { + await session.prompt(initialMessage, { images: initialImages }); + } + for (const message of messages) { + await session.prompt(message); + } + + console.error(`[co-mono-daemon] startup complete (session=${session.sessionId ?? "unknown"})`); + + // Keep process alive forever. + await ready; + process.exit(0); +} diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index 205e9f54..a2237403 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -2,6 +2,7 @@ * Run modes for the coding agent. */ +export { type DaemonModeOptions, runDaemonMode } from "./daemon-mode.js"; export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode.js"; export { type PrintModeOptions, runPrintMode } from "./print-mode.js"; export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.js"; diff --git a/public-install.sh b/public-install.sh index b049c960..7615cc32 100755 --- a/public-install.sh +++ b/public-install.sh @@ -2,15 +2,19 @@ set -euo pipefail +# Defaults REPO="${CO_MONO_REPO:-getcompanion-ai/co-mono}" VERSION="${CO_MONO_VERSION:-latest}" INSTALL_DIR="${CO_MONO_INSTALL_DIR:-$HOME/.co-mono}" BIN_DIR="${CO_MONO_BIN_DIR:-$HOME/.local/bin}" AGENT_DIR="${CO_MONO_AGENT_DIR:-$INSTALL_DIR/agent}" -RUN_INSTALL_PACKAGES="${CO_MONO_INSTALL_PACKAGES:-1}" -SKIP_REINSTALL="${CO_MONO_SKIP_REINSTALL:-0}" -INSTALL_RUNTIME_DAEMON="${CO_MONO_INSTALL_RUNTIME_DAEMON:-0}" +SERVICE_NAME="${CO_MONO_SERVICE_NAME:-co-mono}" FALLBACK_TO_SOURCE="${CO_MONO_FALLBACK_TO_SOURCE:-1}" +SKIP_REINSTALL="${CO_MONO_SKIP_REINSTALL:-0}" +RUN_INSTALL_PACKAGES="${CO_MONO_INSTALL_PACKAGES:-1}" +SETUP_DAEMON="${CO_MONO_SETUP_DAEMON:-0}" +START_DAEMON="${CO_MONO_START_DAEMON:-0}" +SKIP_SERVICE="${CO_MONO_SKIP_SERVICE:-0}" DEFAULT_PACKAGES=( "npm:@e9n/pi-channels" @@ -18,6 +22,9 @@ DEFAULT_PACKAGES=( "npm:pi-teams" ) +declare -a EXTRA_PACKAGES=() +USE_DEFAULT_PACKAGES=1 + log() { echo "==> $*" } @@ -27,26 +34,142 @@ fail() { exit 1 } -need() { - if ! command -v "$1" >/dev/null 2>&1; then - fail "required tool not found: $1" - fi +has() { + command -v "$1" >/dev/null 2>&1 } -need tar +usage() { + cat <<'EOF' +Usage: + curl -fsSL https://raw.githubusercontent.com/getcompanion-ai/co-mono/main/public-install.sh | bash + bash public-install.sh [options] -if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then +Options: + --repo Override GitHub repo for install (default: getcompanion-ai/co-mono) + --version |latest Release tag to install (default: latest) + --install-dir Target directory for release contents (default: ~/.co-mono) + --bin-dir Directory for co-mono launcher (default: ~/.local/bin) + --agent-dir Agent config directory (default: /agent) + --package Add package to installation list (repeatable) + --no-default-packages Skip default packages list + --skip-packages Skip package installation step + --daemon Install user systemd service for long-lived mode + --start Start service after install (implies --daemon) + --skip-daemon Force skip service setup/start + --fallback-to-source <0|1> Allow source fallback when release is unavailable + --skip-reinstall Keep existing install directory + --help + +Env vars: + CO_MONO_INSTALL_PACKAGES=0/1 + CO_MONO_SETUP_DAEMON=0/1 + CO_MONO_START_DAEMON=0/1 + CO_MONO_FALLBACK_TO_SOURCE=0/1 + CO_MONO_SKIP_REINSTALL=1 +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +if ! has tar; then + fail "required tool not found: tar" +fi +if ! has curl && ! has wget; then fail "required tool not found: curl or wget" fi -if ! command -v git >/dev/null 2>&1; then - log "git not found; this is fine unless package install is triggered" +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + REPO="${2:?missing repo value}" + shift 2 + ;; + --version) + VERSION="${2:?missing version value}" + shift 2 + ;; + --install-dir) + INSTALL_DIR="${2:?missing install dir}" + shift 2 + ;; + --bin-dir) + BIN_DIR="${2:?missing bin dir}" + shift 2 + ;; + --agent-dir) + AGENT_DIR="${2:?missing agent dir}" + shift 2 + ;; + --package) + EXTRA_PACKAGES+=("${2:?missing package}") + shift 2 + ;; + --no-default-packages) + USE_DEFAULT_PACKAGES=0 + shift + ;; + --skip-packages) + RUN_INSTALL_PACKAGES=0 + shift + ;; + --daemon) + SETUP_DAEMON=1 + shift + ;; + --start) + START_DAEMON=1 + SETUP_DAEMON=1 + shift + ;; + --skip-daemon) + SETUP_DAEMON=0 + START_DAEMON=0 + SKIP_SERVICE=1 + shift + ;; + --fallback-to-source) + FALLBACK_TO_SOURCE="${2:?missing fallback value}" + shift 2 + ;; + --skip-reinstall) + SKIP_REINSTALL=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +if [[ "$FALLBACK_TO_SOURCE" != "0" && "$FALLBACK_TO_SOURCE" != "1" ]]; then + fail "CO_MONO_FALLBACK_TO_SOURCE must be 0 or 1" fi -if [[ -d "$INSTALL_DIR" && "${SKIP_REINSTALL}" != "1" ]]; then +if [[ -d "$INSTALL_DIR" && "$SKIP_REINSTALL" != "1" ]]; then rm -rf "$INSTALL_DIR" fi +if [[ -z "${SERVICE_NAME:-}" ]]; then + SERVICE_NAME="co-mono" +fi + +download_file() { + local url="$1" + local out="$2" + if has curl; then + curl -fsSL "$url" -o "$out" + else + wget -qO "$out" "$url" + fi +} + detect_platform() { local os local arch @@ -57,9 +180,7 @@ detect_platform() { case "$os" in darwin) os="darwin" ;; linux) os="linux" ;; - mingw*|msys*|cygwin*) - os="windows" - ;; + mingw*|msys*|cygwin*) os="windows" ;; *) fail "unsupported OS: $os" ;; @@ -80,73 +201,222 @@ detect_platform() { PLATFORM="${os}-${arch}" } -download_json() { - local url="$1" - local out="$2" - - if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$out" - elif command -v wget >/dev/null 2>&1; then - wget -qO "$out" "$url" - else - fail "neither curl nor wget is available" - fi -} - resolve_release_tag() { - if [[ "$VERSION" != latest ]]; then + if [[ "$VERSION" != "latest" ]]; then echo "$VERSION" return fi local api_json api_json="$(mktemp)" - if ! download_json "https://api.github.com/repos/${REPO}/releases/latest" "$api_json"; then + if ! download_file "https://api.github.com/repos/${REPO}/releases/latest" "$api_json"; then rm -f "$api_json" return 1 fi - TAG="$(awk -F '"tag_name": "' 'index($0, "\"tag_name\"") { split($2, a, "\""); print a[1] }' "$api_json" | head -n 1)" + + local tag + if has jq; then + tag="$(jq -r '.tag_name // empty' "$api_json")" + else + tag="$(awk '/"tag_name":/ { gsub(/[",]/, "", $3); print $3; exit }' "$api_json")" + fi rm -f "$api_json" - if [[ -z "$TAG" ]]; then - fail "could not determine latest tag from GitHub API" + if [[ -z "$tag" || "$tag" == "null" ]]; then + return 1 + fi + echo "$tag" +} + +platform_assets() { + if [[ "$PLATFORM" == "windows"* ]]; then + echo "pi-${PLATFORM}.zip" + echo "co-mono-${PLATFORM}.zip" + else + echo "pi-${PLATFORM}.tar.gz" + echo "co-mono-${PLATFORM}.tar.gz" + fi +} + +extract_archive() { + local archive="$1" + local out_dir="$2" + mkdir -p "$out_dir" + if [[ "$archive" == *.zip ]]; then + if ! has unzip; then + fail "unzip required for zip archive" + fi + unzip -q "$archive" -d "$out_dir" + else + tar -xzf "$archive" -C "$out_dir" + fi +} + +collect_packages() { + local -a packages=() + if [[ "$USE_DEFAULT_PACKAGES" == "1" ]]; then + packages=("${DEFAULT_PACKAGES[@]}") + fi + if [[ "${#EXTRA_PACKAGES[@]}" -gt 0 ]]; then + packages+=("${EXTRA_PACKAGES[@]}") + fi + printf '%s\n' "${packages[@]}" +} + +write_launcher() { + local output="$1" + local runtime_dir="$2" + + mkdir -p "$(dirname "$output")" + cat > "$output" < "$settings_file" <<'EOF' +{ + "packages": [] +} +EOF + return + fi + + { + echo "{" + echo ' "packages": [' + } > "$settings_file" + local idx=0 + local total="${#packages[@]}" + for package in "${packages[@]}"; do + local suffix="" + if [[ "$idx" -lt $((total - 1)) ]]; then + suffix="," + fi + printf ' "%s"%s\n' "$package" "$suffix" >> "$settings_file" + idx=$((idx + 1)) + done + { + echo " ]" + echo "}" + } >> "$settings_file" +} + +install_packages() { + if [[ "$RUN_INSTALL_PACKAGES" != "1" ]]; then + return + fi + + if ! has npm; then + log "npm not found. Skipping package installation." + return + fi + + while IFS= read -r package; do + [[ -z "$package" ]] && continue + if "$BIN_DIR/co-mono" install "$package" >/dev/null 2>&1; then + log "Installed package: $package" + else + log "Could not install ${package} now. It will install on first run when available." + fi + done < <(collect_packages) +} + +write_service_file() { + if ! has systemctl; then + log "systemctl unavailable; skipping service setup." + return 1 + fi + mkdir -p "$HOME/.config/systemd/user" + local service_path="$HOME/.config/systemd/user/${SERVICE_NAME}.service" + cat > "$service_path" </dev/null 2>&1; then - fail "Node.js is required for source fallback. Install nodejs first." - fi - - if ! command -v npm >/dev/null 2>&1; then - fail "npm is required for source fallback. Install npm first." - fi - - if ! command -v git >/dev/null 2>&1; then + if ! has git; then fail "git is required for source fallback." fi + if ! has node; then + fail "node is required for source fallback." + fi + if ! has npm; then + fail "npm is required for source fallback." + fi - mkdir -p "$INSTALL_DIR" - if [[ -d "$source_dir" && "${SKIP_REINSTALL}" != "1" ]]; then + local source_dir="$INSTALL_DIR/source" + local ref="${1:-main}" + + if [[ -d "$source_dir" && "$SKIP_REINSTALL" != "1" ]]; then rm -rf "$source_dir" fi if [[ ! -d "$source_dir" ]]; then - log "Cloning ${REPO} (${ref})" + log "Cloning ${REPO}@${ref}" git clone --depth 1 --branch "$ref" "https://github.com/${REPO}.git" "$source_dir" - else - log "Updating existing source checkout at $source_dir" - git -C "$source_dir" fetch --depth 1 origin "$ref" - git -C "$source_dir" checkout "$ref" - git -C "$source_dir" pull --ff-only origin "$ref" fi - log "Running local install for source checkout" + log "Running source install" ( cd "$source_dir" CO_MONO_AGENT_DIR="$AGENT_DIR" \ @@ -155,209 +425,113 @@ bootstrap_from_source() { ) if [[ ! -x "$source_dir/co-mono" ]]; then - fail "co-mono source launcher was not created in fallback checkout." + fail "co-mono executable not found in source checkout." fi - write_source_launcher "$source_dir" - export CO_MONO_BIN_PATH="$source_dir/co-mono" - maybe_install_packages - print_next_steps_source + write_launcher "$BIN_DIR/co-mono" "$source_dir/co-mono" + ensure_agent_settings + install_packages } -write_source_launcher() { - local source_dir="$1" - mkdir -p "$BIN_DIR" - local launcher="$BIN_DIR/co-mono" - cat > "$launcher" </dev/null 2>&1; then - fail "unzip not found for windows archive" - fi - unzip -q "$archive" -d "$out_dir" - else - tar -xzf "$archive" -C "$out_dir" - fi -} - -ensure_agent_settings() { - mkdir -p "$AGENT_DIR" - - local settings_file="$AGENT_DIR/settings.json" - if [[ -f "$settings_file" ]]; then - return - fi - - cat > "$settings_file" <<'EOF' -{ - "packages": [ - "npm:@e9n/pi-channels", - "npm:pi-memory-md", - "npm:pi-teams" - ] -} -EOF -} - -maybe_install_packages() { - if [[ "$RUN_INSTALL_PACKAGES" == "0" ]]; then - return - fi - - if ! command -v npm >/dev/null 2>&1; then - log "npm not found. Skipping package installation (settings.json was still written)." - return - fi - - for pkg in "${DEFAULT_PACKAGES[@]}"; do - if [[ -n "$CO_MONO_BIN_PATH" ]]; then - log "Installing package: $pkg" - if ! PI_CODING_AGENT_DIR="$AGENT_DIR" CO_MONO_AGENT_DIR="$AGENT_DIR" "$CO_MONO_BIN_PATH" install "$pkg" >/dev/null 2>&1; then - log "Could not install $pkg now. It will be installed on first run if network/API access is available." - fi - fi - done -} - -write_launcher() { - mkdir -p "$BIN_DIR" - local launcher="$BIN_DIR/co-mono" - cat > "$launcher" <