From 7bdc7550c828bd301d2750723bf22f5a5ca3a85f Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:01:29 +0300 Subject: [PATCH 01/15] feat(paths): XDG-compliant data dir resolution for installed daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDataDir() now resolves in order: GOLEM_DATA_DIR env var, ./data/ in cwd (dev workflow preserved), or OS-native default (~/Library/Application Support/golem on macOS, $XDG_DATA_HOME/golem on Linux, %APPDATA%/golem on Windows). Data dir is chmod 700 on first creation. Prerequisite for shipping as 'npm i -g golem-agent' — global daemon can't read data/ from cwd. All persistent state already routes through dataPath()/getDataDir() so no per-store-ctor changes were needed. Adds describeDataDirResolution() so the platform startup log (and forthcoming golem doctor + first-run banner) can show which branch resolved. Refs: bd h6t. --- src/platform/platform.ts | 11 ++- src/utils/paths.test.ts | 144 +++++++++++++++++++++++++++++++++++++++ src/utils/paths.ts | 99 ++++++++++++++++++++++++--- 3 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 src/utils/paths.test.ts diff --git a/src/platform/platform.ts b/src/platform/platform.ts index e5963ea..5b0ee8d 100644 --- a/src/platform/platform.ts +++ b/src/platform/platform.ts @@ -25,7 +25,7 @@ import type { Memory } from "@mastra/memory"; import { LibSQLStore } from "@mastra/libsql"; import { CronExpressionParser } from "cron-parser"; -import { dataPath } from "../utils/paths.js"; +import { dataPath, describeDataDirResolution } from "../utils/paths.js"; import { initMCPClient, getMCPTools, disconnectMCP } from "../agent/mcp-client.js"; import { getModelForId } from "../agent/model.js"; import { allTools } from "../agent/tools/index.js"; @@ -969,7 +969,14 @@ function registerAgentTransport( export async function startPlatform(): Promise { console.log("[platform] starting multi-agent platform..."); - logger.info("platform starting"); + const dataDir = describeDataDirResolution(); + const sourceLabel = { + env: "GOLEM_DATA_DIR env var", + cwd: "./data/ in current directory", + default: "OS default", + }[dataDir.source]; + console.log(`[platform] data dir: ${dataDir.path} (${sourceLabel})`); + logger.info("platform starting", { dataDir: dataDir.path, dataDirSource: dataDir.source }); const startedAt = Date.now(); // 1. Shared memory storage diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts new file mode 100644 index 0000000..22098af --- /dev/null +++ b/src/utils/paths.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + _resetPathCacheForTests, + dataPath, + describeDataDirResolution, + getDataDir, + getSkillsDir, +} from "./paths.js"; + +// Each test gets a fresh tmpdir and a clean env snapshot. paths.ts memoizes +// getDataDir() per-process, so we also reset its cache. +const ENV_KEYS = ["GOLEM_DATA_DIR", "XDG_DATA_HOME", "HOME", "APPDATA"]; + +let savedEnv: Record; +let tmpRoot: string; +let originalCwd: string; + +beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + // realpath: on macOS, mkdtemp returns /var/... but path.resolve canonicalizes + // to /private/var/..., which breaks toBe(...) comparisons. + tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "golem-paths-"))); + originalCwd = process.cwd(); + process.chdir(tmpRoot); + for (const k of ENV_KEYS) delete process.env[k]; + _resetPathCacheForTests(); +}); + +afterEach(() => { + process.chdir(originalCwd); + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + _resetPathCacheForTests(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe("getDataDir resolution", () => { + test("GOLEM_DATA_DIR overrides everything", () => { + const explicit = path.join(tmpRoot, "explicit"); + process.env.GOLEM_DATA_DIR = explicit; + // even if ./data/ exists in cwd, env var wins + fs.mkdirSync(path.join(tmpRoot, "data")); + + expect(getDataDir()).toBe(explicit); + expect(describeDataDirResolution().source).toBe("env"); + expect(fs.existsSync(explicit)).toBe(true); + }); + + test("falls back to ./data/ in cwd when it exists (dev workflow preserved)", () => { + const cwdData = path.join(tmpRoot, "data"); + fs.mkdirSync(cwdData); + + expect(getDataDir()).toBe(cwdData); + expect(describeDataDirResolution().source).toBe("cwd"); + }); + + test("falls back to OS-default when neither env nor ./data/ is present", () => { + process.env.HOME = tmpRoot; + delete process.env.XDG_DATA_HOME; + + const dir = getDataDir(); + expect(describeDataDirResolution().source).toBe("default"); + if (process.platform === "darwin") { + expect(dir).toBe(path.join(tmpRoot, "Library", "Application Support", "golem")); + } else if (process.platform === "win32") { + // win32 uses APPDATA; without it, falls back under HOME + expect(dir.endsWith(path.join("golem"))).toBe(true); + } else { + expect(dir).toBe(path.join(tmpRoot, ".local", "share", "golem")); + } + expect(fs.existsSync(dir)).toBe(true); + }); + + test("Linux honors XDG_DATA_HOME when set", () => { + if (process.platform === "darwin" || process.platform === "win32") return; + process.env.HOME = tmpRoot; + const xdg = path.join(tmpRoot, "custom-xdg"); + process.env.XDG_DATA_HOME = xdg; + + expect(getDataDir()).toBe(path.join(xdg, "golem")); + }); + + test("creates the data directory if missing", () => { + const target = path.join(tmpRoot, "new", "nested", "dir"); + process.env.GOLEM_DATA_DIR = target; + + getDataDir(); + + expect(fs.statSync(target).isDirectory()).toBe(true); + }); + + test("chmod 0700 on the data dir (best effort)", () => { + if (process.platform === "win32") return; // chmod is meaningless on Windows + const target = path.join(tmpRoot, "secured"); + process.env.GOLEM_DATA_DIR = target; + + getDataDir(); + + const mode = fs.statSync(target).mode & 0o777; + expect(mode).toBe(0o700); + }); + + test("memoizes — repeated calls return the same path", () => { + process.env.GOLEM_DATA_DIR = path.join(tmpRoot, "first"); + const a = getDataDir(); + process.env.GOLEM_DATA_DIR = path.join(tmpRoot, "second"); // changing env after first call has no effect + const b = getDataDir(); + + expect(a).toBe(b); + }); + + test("ignores a ./data/ file that isn't a directory", () => { + fs.writeFileSync(path.join(tmpRoot, "data"), "not a dir"); + process.env.HOME = tmpRoot; + + expect(describeDataDirResolution().source).toBe("default"); + }); +}); + +describe("dataPath", () => { + test("joins relative to data dir", () => { + const root = path.join(tmpRoot, "d"); + process.env.GOLEM_DATA_DIR = root; + + expect(dataPath("agents.db")).toBe(path.join(root, "agents.db")); + expect(dataPath("logs/agent.log")).toBe(path.join(root, "logs", "agent.log")); + }); +}); + +describe("getSkillsDir", () => { + test("defaults to cwd/skills, honors GOLEM_SKILLS_DIR override", () => { + expect(getSkillsDir()).toBe(path.resolve("skills")); + + _resetPathCacheForTests(); + process.env.GOLEM_SKILLS_DIR = path.join(tmpRoot, "my-skills"); + expect(getSkillsDir()).toBe(path.join(tmpRoot, "my-skills")); + }); +}); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 67ef454..5c4deec 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -1,23 +1,93 @@ /** * Centralized path resolution for runtime data and skills. * - * Uses lazy resolution so environment variables from .env are available - * (dotenv loads after module imports in ESM). + * Resolution order for the data directory: + * 1. GOLEM_DATA_DIR env var (explicit override — anything goes) + * 2. ./data/ in cwd if it exists as a directory (preserves dev workflow: + * cloning the repo and running `npm start` keeps writing to repo-local data/) + * 3. OS-native default: + * macOS: ~/Library/Application Support/golem + * Linux: $XDG_DATA_HOME/golem (default ~/.local/share/golem) + * Windows: %APPDATA%/golem + * + * The directory is created on first access and chmod'd to 0700 (best-effort; + * silently ignored on filesystems that don't support unix modes). + * + * Lazy resolution so dotenv (loaded after module imports in ESM) wins. */ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -let _dataDir: string | null = null; +export type DataDirSource = "env" | "cwd" | "default"; + +interface ResolvedDataDir { + path: string; + source: DataDirSource; +} + +let _resolved: ResolvedDataDir | null = null; let _skillsDir: string | null = null; +function homeDir(): string { + // Prefer $HOME so tests and users can override portably. Node's homeDir() + // honors $HOME on POSIX, but Bun's doesn't in all cases. + return process.env.HOME || (process.platform === "win32" ? process.env.USERPROFILE : null) || os.homedir(); +} + +function osDefaultDataDir(): string { + const home = homeDir(); + if (process.platform === "darwin") { + return path.join(home, "Library", "Application Support", "golem"); + } + if (process.platform === "win32") { + const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming"); + return path.join(appData, "golem"); + } + // Linux / other *nix: XDG Base Directory spec + const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share"); + return path.join(xdgDataHome, "golem"); +} + +function resolveDataDir(): ResolvedDataDir { + if (process.env.GOLEM_DATA_DIR) { + return { path: path.resolve(process.env.GOLEM_DATA_DIR), source: "env" }; + } + const cwdData = path.resolve("data"); + try { + if (fs.statSync(cwdData).isDirectory()) { + return { path: cwdData, source: "cwd" }; + } + } catch { + // not present — fall through + } + return { path: osDefaultDataDir(), source: "default" }; +} + +function ensureDataDir(): ResolvedDataDir { + if (!_resolved) { + _resolved = resolveDataDir(); + fs.mkdirSync(_resolved.path, { recursive: true }); + try { + fs.chmodSync(_resolved.path, 0o700); + } catch { + // best-effort: ignore on filesystems without unix mode support + } + } + return _resolved; +} + /** Root directory for all runtime data (SQLite DBs, logs, handoffs, approvals) */ export function getDataDir(): string { - if (!_dataDir) { - _dataDir = path.resolve(process.env.GOLEM_DATA_DIR || "data"); - fs.mkdirSync(_dataDir, { recursive: true }); - } - return _dataDir; + return ensureDataDir().path; +} + +/** + * Describe how the data directory was resolved. Useful for the first-run banner + * and `golem doctor`. Calling this triggers resolution if it hasn't happened yet. + */ +export function describeDataDirResolution(): ResolvedDataDir { + return { ...ensureDataDir() }; } /** Root directory for skill definitions */ @@ -40,7 +110,16 @@ export function dataPath(filename: string): string { /** Expand a leading ~ or ~/ in a path to the user's home directory. */ export function expandTilde(p: string): string { - if (p === "~") return os.homedir(); - if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + if (p === "~") return homeDir(); + if (p.startsWith("~/")) return path.join(homeDir(), p.slice(2)); return p; } + +/** + * Test-only: clear the memoized data/skills paths so the next call re-resolves + * from env + cwd. Do not call from production code. + */ +export function _resetPathCacheForTests(): void { + _resolved = null; + _skillsDir = null; +} From ff0710f64dc601ef3cbfed671da92fee3bd8ef09 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:06:21 +0300 Subject: [PATCH 02/15] feat(cli): subcommand dispatcher with start/stop/status/logs/version/doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the previously monolithic src/cli.ts into a dispatcher that routes to per-subcommand modules under src/cli/. Running `golem` with no args still starts the platform (backwards-compatible default). - start: refuses to launch a second instance via pid file at $DATA/golem.pid; pid + start time are written for status/stop to read. - stop: SIGTERM with 10s grace, escalates to SIGKILL. - status: reports resolved data dir and (pid, uptime) or 'not running' with LSB-conventional exit code 3. - logs: detects systemd user unit / launchd plist, falls back to tailing $DATA/logs/*.log most-recent. - version: reads package.json by walking up from the entry path so it works in both dev and installed layouts. - doctor: scaffolded with Node-version / data-dir-writable / daemon-running checks; extended by bd 49x to cover OpenRouter + Telegram + disk. - update: stubbed pending publishing pipeline (bd vx9). bin/golem.js now forwards argv, prefers compiled dist/cli.js when present (npm-installed layout), and only re-spawns on exit code 75 for start. Drops the chdir(root) — paths.ts handles install-vs-dev resolution. Refs: bd dxn. --- bin/golem.js | 39 +++++++++----- src/cli.ts | 97 ++++++++++++++++++++++++++++++----- src/cli/doctor.ts | 64 +++++++++++++++++++++++ src/cli/logs.ts | 111 ++++++++++++++++++++++++++++++++++++++++ src/cli/pid.test.ts | 89 ++++++++++++++++++++++++++++++++ src/cli/pid.ts | 73 ++++++++++++++++++++++++++ src/cli/start.ts | 53 +++++++++++++++++++ src/cli/status.ts | 28 ++++++++++ src/cli/stop.ts | 48 +++++++++++++++++ src/cli/update.ts | 18 +++++++ src/cli/version.test.ts | 10 ++++ src/cli/version.ts | 27 ++++++++++ 12 files changed, 632 insertions(+), 25 deletions(-) create mode 100644 src/cli/doctor.ts create mode 100644 src/cli/logs.ts create mode 100644 src/cli/pid.test.ts create mode 100644 src/cli/pid.ts create mode 100644 src/cli/start.ts create mode 100644 src/cli/status.ts create mode 100644 src/cli/stop.ts create mode 100644 src/cli/update.ts create mode 100644 src/cli/version.test.ts create mode 100644 src/cli/version.ts diff --git a/bin/golem.js b/bin/golem.js index fb73c6e..86fa589 100755 --- a/bin/golem.js +++ b/bin/golem.js @@ -1,29 +1,44 @@ #!/usr/bin/env node -// Golem — start the platform with auto-restart support -// Exit code 75 = restart requested (e.g. after onboarding writes config) +// Golem CLI wrapper. +// +// - Forwards subcommands and args to src/cli.ts (or dist/cli.js when installed). +// - For `start` (default) only: re-spawns the child on exit code 75 +// (onboarding-requested restart). Other subcommands exit naturally. +// - Does NOT chdir; src/utils/paths.ts resolves the data dir from +// $GOLEM_DATA_DIR > ./data/ > OS-native default, so cwd matters. + import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); -process.chdir(root); +const builtEntry = resolve(root, "dist", "cli.js"); +const sourceEntry = resolve(root, "src", "cli.ts"); -function start() { - const child = spawn("npx", ["tsx", "src/cli.ts"], { - cwd: root, - stdio: "inherit", - shell: true, - }); +const argv = process.argv.slice(2); +const subcommand = argv[0] ?? "start"; +const isStart = subcommand === "start" || subcommand.startsWith("-") || !["stop", "status", "logs", "version", "update", "doctor", "help", "-h", "--help"].includes(subcommand); + +function spawnChild() { + // Prefer compiled dist/ if present (npm-installed), else use tsx on src/. + if (existsSync(builtEntry)) { + return spawn(process.execPath, [builtEntry, ...argv], { stdio: "inherit" }); + } + return spawn("npx", ["tsx", sourceEntry, ...argv], { stdio: "inherit", shell: true }); +} +function run() { + const child = spawnChild(); child.on("exit", (code) => { - if (code === 75) { + if (isStart && code === 75) { console.log("[golem] restarting platform..."); - setTimeout(start, 1000); + setTimeout(run, 1000); } else { process.exit(code ?? 1); } }); } -start(); +run(); diff --git a/src/cli.ts b/src/cli.ts index 79e0018..89dd9a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,21 +1,92 @@ /** - * Golem entry point — starts the platform. + * Golem CLI entry point — dispatches subcommands. + * + * Subcommand surface (v1): + * start Start the platform in the foreground (default) + * stop Stop the running daemon + * status Report whether the daemon is running + * logs [-n N] [-f] Tail daemon logs (journald / launchd log / data/logs/*) + * version Print the package version + * update (Stub) Pull the latest published version — see bd vx9 + * doctor Health checks — extended by bd 49x + * install-daemon Install systemd unit / launchd plist — bd 3mm/8if (TBD) + * uninstall-daemon Reverse install-daemon — bd 3mm/8if (TBD) + * + * Running with no subcommand is equivalent to `start`. */ import "dotenv/config"; -if (!process.env.OPENROUTER_API_KEY) { - console.log("\n No OPENROUTER_API_KEY found — starting in onboarding mode."); - console.log(" Open http://localhost:3015 to configure your platform.\n"); -} +type Subcommand = (args: string[]) => Promise; + +const COMMANDS: Record Promise<{ run: Subcommand }>> = { + start: () => import("./cli/start.js"), + stop: () => import("./cli/stop.js"), + status: () => import("./cli/status.js"), + logs: () => import("./cli/logs.js"), + version: () => import("./cli/version.js"), + update: () => import("./cli/update.js"), + doctor: () => import("./cli/doctor.js"), +}; + +const ALIASES: Record = { + "-v": "version", + "--version": "version", +}; + +const HELP = `golem — multi-agent platform + +Usage: + golem [command] [args] + +Commands: + start Start the platform (default if no command given) + stop Stop the running daemon + status Report whether the daemon is running + logs [-n N] [-f] Tail the daemon logs + version Print the version + update Pull the latest published version (not yet wired) + doctor Run health checks + help, -h, --help Show this message -const { startPlatform } = await import("./platform/platform.js"); +Environment: + GOLEM_DATA_DIR Override the data directory (default: OS-native). + See \`golem status\` for the resolved path. +`; + +async function main(): Promise { + const argv = process.argv.slice(2); + let cmd = argv[0] ?? "start"; + let args = argv.slice(1); + + if (cmd in ALIASES) cmd = ALIASES[cmd]; + if (cmd === "help" || cmd === "-h" || cmd === "--help") { + console.log(HELP); + return 0; + } + // If the first token is a flag and isn't a known alias, treat everything as + // arguments to `start`. + if (cmd.startsWith("-") && !(cmd in COMMANDS)) { + args = argv; + cmd = "start"; + } + + const loader = COMMANDS[cmd]; + if (!loader) { + console.error(`Unknown command: ${cmd}\n`); + console.error(HELP); + return 64; // EX_USAGE + } + + const mod = await loader(); + return mod.run(args); +} -startPlatform() - .then(() => { - // Keep the process alive — transports and scheduler run on intervals/callbacks - return new Promise(() => {}); - }) - .catch((err: unknown) => { +main().then( + (code) => { + process.exit(code); + }, + (err) => { console.error(err); process.exit(1); - }); + }, +); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..b5468d5 --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,64 @@ +/** + * `golem doctor` — diagnostic health checks. + * + * Stub: dispatch wiring lives here; the actual checks (Node version, write + * access, OpenRouter key validity, Telegram bot tokens via getMe, daemon + * status, disk space) are bd 49x which builds on this scaffold. + */ +import fs from "node:fs"; + +import { describeDataDirResolution } from "../utils/paths.js"; +import { getRunningDaemon } from "./pid.js"; + +interface Check { + name: string; + status: "ok" | "warn" | "fail"; + detail: string; +} + +function checkNodeVersion(): Check { + const major = Number(process.versions.node.split(".")[0]); + if (major >= 20) return { name: "Node version", status: "ok", detail: `${process.versions.node}` }; + return { + name: "Node version", + status: "fail", + detail: `${process.versions.node} (need >= 20)`, + }; +} + +function checkDataDir(): Check { + const r = describeDataDirResolution(); + try { + fs.accessSync(r.path, fs.constants.W_OK); + return { name: "Data dir writable", status: "ok", detail: `${r.path} (${r.source})` }; + } catch { + return { name: "Data dir writable", status: "fail", detail: `${r.path} — not writable` }; + } +} + +function checkDaemon(): Check { + const record = getRunningDaemon(); + if (record) return { name: "Daemon running", status: "ok", detail: `pid ${record.pid}` }; + return { name: "Daemon running", status: "warn", detail: "not running (use `golem start`)" }; +} + +function renderCheck(c: Check): string { + const icon = c.status === "ok" ? "✓" : c.status === "warn" ? "!" : "✗"; + return ` ${icon} ${c.name.padEnd(22)} ${c.detail}`; +} + +export async function run(_args: string[]): Promise { + console.log("Golem doctor — running health checks"); + console.log(""); + + const checks: Check[] = [checkNodeVersion(), checkDataDir(), checkDaemon()]; + let exit = 0; + for (const c of checks) { + console.log(renderCheck(c)); + if (c.status === "fail") exit = 1; + } + + console.log(""); + console.log("Additional checks (OpenRouter key, Telegram tokens, disk space) come with bd 49x."); + return exit; +} diff --git a/src/cli/logs.ts b/src/cli/logs.ts new file mode 100644 index 0000000..dc12da9 --- /dev/null +++ b/src/cli/logs.ts @@ -0,0 +1,111 @@ +/** + * `golem logs` — tail the daemon's logs. + * + * Detection order: + * 1. Linux with a `--user` systemd unit named `golem` → journalctl -f + * 2. macOS with the launchd plist `com.golem.agent` → tail the configured log + * 3. Fallback: tail the most recent file under /logs/ + * + * Supports `-f`/`--follow` (default on) and `-n ` for line count. + */ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { dataPath } from "../utils/paths.js"; + +interface LogOptions { + follow: boolean; + lines: number; +} + +function parseArgs(args: string[]): LogOptions { + let follow = true; + let lines = 100; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--no-follow") follow = false; + else if (a === "-f" || a === "--follow") follow = true; + else if (a === "-n" || a === "--lines") { + const n = Number(args[++i]); + if (Number.isFinite(n) && n > 0) lines = n; + } + } + return { follow, lines }; +} + +function hasSystemdUnit(): boolean { + if (process.platform !== "linux") return false; + const unitPath = path.join(os.homedir(), ".config", "systemd", "user", "golem.service"); + return fs.existsSync(unitPath); +} + +function hasLaunchdPlist(): string | null { + if (process.platform !== "darwin") return null; + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.golem.agent.plist"); + return fs.existsSync(plistPath) ? plistPath : null; +} + +function findMostRecentLogFile(): string | null { + const logsDir = dataPath("logs"); + try { + const entries = fs + .readdirSync(logsDir, { withFileTypes: true }) + .filter((e) => e.isFile() && e.name.endsWith(".log")) + .map((e) => path.join(logsDir, e.name)) + .map((p) => ({ p, mtime: fs.statSync(p).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + return entries[0]?.p ?? null; + } catch { + return null; + } +} + +function tailJournald(opts: LogOptions): number { + const args = ["--user", "-u", "golem", "-n", String(opts.lines)]; + if (opts.follow) args.push("-f"); + const child = spawn("journalctl", args, { stdio: "inherit" }); + child.on("exit", (code) => process.exit(code ?? 0)); + return -1; // never returns to caller +} + +function tailFile(file: string, opts: LogOptions): number { + const args = ["-n", String(opts.lines)]; + if (opts.follow) args.push("-F"); // -F follows by name across rotations + args.push(file); + const child = spawn("tail", args, { stdio: "inherit" }); + child.on("exit", (code) => process.exit(code ?? 0)); + return -1; +} + +export async function run(args: string[]): Promise { + const opts = parseArgs(args); + + if (hasSystemdUnit()) { + console.error("# tailing systemd user journal for golem.service"); + return tailJournald(opts); + } + + const plist = hasLaunchdPlist(); + if (plist) { + // The plist points at ~/Library/Logs/com.golem.agent.log per CLAUDE.md; + // not parsed dynamically — fall back to the conventional location. + const macLog = path.join(os.homedir(), "Library", "Logs", "com.golem.agent.log"); + if (fs.existsSync(macLog)) { + console.error(`# tailing ${macLog}`); + return tailFile(macLog, opts); + } + } + + const recent = findMostRecentLogFile(); + if (recent) { + console.error(`# tailing ${recent}`); + return tailFile(recent, opts); + } + + console.error("No logs found."); + console.error(`Checked: systemd user unit, macOS launchd plist, ${dataPath("logs")}/*.log`); + console.error("If the daemon is running, redirect stdout/stderr or install the daemon with `golem install-daemon`."); + return 1; +} diff --git a/src/cli/pid.test.ts b/src/cli/pid.test.ts new file mode 100644 index 0000000..ce08204 --- /dev/null +++ b/src/cli/pid.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { _resetPathCacheForTests } from "../utils/paths.js"; +import { + getRunningDaemon, + isProcessAlive, + pidFilePath, + readPidFile, + removePidFile, + writePidFile, +} from "./pid.js"; + +let tmp: string; +let savedEnv: string | undefined; + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "golem-pid-"))); + savedEnv = process.env.GOLEM_DATA_DIR; + process.env.GOLEM_DATA_DIR = tmp; + _resetPathCacheForTests(); +}); + +afterEach(() => { + if (savedEnv === undefined) delete process.env.GOLEM_DATA_DIR; + else process.env.GOLEM_DATA_DIR = savedEnv; + _resetPathCacheForTests(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe("pid file round-trip", () => { + test("write then read returns the same record", () => { + writePidFile({ pid: 12345, startedAt: 1700000000000 }); + const r = readPidFile(); + expect(r).toEqual({ pid: 12345, startedAt: 1700000000000 }); + }); + + test("read returns null when file does not exist", () => { + expect(readPidFile()).toBeNull(); + }); + + test("read returns null on corrupted contents", () => { + fs.writeFileSync(pidFilePath(), "not-a-pid\n"); + expect(readPidFile()).toBeNull(); + }); + + test("remove is idempotent", () => { + expect(() => removePidFile()).not.toThrow(); + writePidFile({ pid: 1, startedAt: 0 }); + removePidFile(); + expect(readPidFile()).toBeNull(); + expect(() => removePidFile()).not.toThrow(); + }); + + test("pidFilePath lives under the data dir", () => { + expect(pidFilePath()).toBe(path.join(tmp, "golem.pid")); + }); +}); + +describe("isProcessAlive", () => { + test("returns true for our own pid", () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + test("returns false for a clearly-dead pid", () => { + // pid 1 is init/launchd; instead use a very large pid that can't exist. + expect(isProcessAlive(2 ** 22)).toBe(false); + }); +}); + +describe("getRunningDaemon", () => { + test("returns the record when the process is alive", () => { + writePidFile({ pid: process.pid, startedAt: Date.now() }); + const r = getRunningDaemon(); + expect(r?.pid).toBe(process.pid); + }); + + test("removes a stale pid file and returns null", () => { + writePidFile({ pid: 2 ** 22, startedAt: 0 }); + expect(getRunningDaemon()).toBeNull(); + expect(fs.existsSync(pidFilePath())).toBe(false); + }); + + test("returns null when no pid file exists", () => { + expect(getRunningDaemon()).toBeNull(); + }); +}); diff --git a/src/cli/pid.ts b/src/cli/pid.ts new file mode 100644 index 0000000..2636f8b --- /dev/null +++ b/src/cli/pid.ts @@ -0,0 +1,73 @@ +/** + * PID file management for the Golem daemon. + * + * Stored at /golem.pid. Written on platform start, removed on + * graceful shutdown. status/stop subcommands read it to find the running + * daemon. + * + * The file holds two whitespace-separated fields: . + * That lets `status` report uptime without an extra syscall. + */ +import fs from "node:fs"; + +import { dataPath } from "../utils/paths.js"; + +export interface PidRecord { + pid: number; + startedAt: number; +} + +export function pidFilePath(): string { + return dataPath("golem.pid"); +} + +export function writePidFile(record: PidRecord = { pid: process.pid, startedAt: Date.now() }): void { + fs.writeFileSync(pidFilePath(), `${record.pid} ${record.startedAt}\n`); +} + +export function removePidFile(): void { + try { + fs.unlinkSync(pidFilePath()); + } catch { + // already gone — fine + } +} + +export function readPidFile(): PidRecord | null { + try { + const raw = fs.readFileSync(pidFilePath(), "utf-8").trim(); + const [pidStr, startedAtStr] = raw.split(/\s+/); + const pid = Number(pidStr); + const startedAt = Number(startedAtStr); + if (!Number.isFinite(pid) || pid <= 0) return null; + return { pid, startedAt: Number.isFinite(startedAt) ? startedAt : 0 }; + } catch { + return null; + } +} + +/** Returns true if a process with the given pid is currently alive on this system. */ +export function isProcessAlive(pid: number): boolean { + try { + // signal 0 = existence check; throws ESRCH if dead, EPERM if alive but unowned. + process.kill(pid, 0); + return true; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + return code === "EPERM"; // EPERM means it exists; we just can't signal it + } +} + +/** + * Read the pid file and return the record only if the recorded process is + * still alive. Removes stale pid files as a side effect. + */ +export function getRunningDaemon(): PidRecord | null { + const record = readPidFile(); + if (!record) return null; + if (!isProcessAlive(record.pid)) { + removePidFile(); + return null; + } + return record; +} diff --git a/src/cli/start.ts b/src/cli/start.ts new file mode 100644 index 0000000..98165d0 --- /dev/null +++ b/src/cli/start.ts @@ -0,0 +1,53 @@ +/** + * `golem start` — start the platform in the foreground. + * + * Refuses to start a second instance if a live PID file is present. The + * platform writes its PID on first successful start, removes it on graceful + * shutdown, and runs until SIGINT/SIGTERM. + */ +import { getRunningDaemon, removePidFile, writePidFile } from "./pid.js"; + +export async function run(_args: string[]): Promise { + const running = getRunningDaemon(); + if (running) { + console.error( + `golem is already running (pid ${running.pid}, started ${new Date(running.startedAt).toISOString()}).`, + ); + console.error("Stop it first with `golem stop`, or remove the pid file if you're sure it's dead."); + return 1; + } + + if (!process.env.OPENROUTER_API_KEY) { + console.log("\n No OPENROUTER_API_KEY found — starting in onboarding mode."); + console.log(" Open http://localhost:3015 to configure your platform.\n"); + } + + const { startPlatform } = await import("../platform/platform.js"); + + let shuttingDown = false; + const shutdown = (signal: NodeJS.Signals): void => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[golem] received ${signal}, shutting down...`); + removePidFile(); + // The platform registers its own graceful-shutdown handlers; we just need to + // make sure the pid file is gone. Re-raise so Node exits naturally. + process.kill(process.pid, signal); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + process.on("exit", removePidFile); + + try { + await startPlatform(); + } catch (err) { + console.error(err); + removePidFile(); + return 1; + } + + writePidFile(); + // Keep the process alive — transports and scheduler run on intervals/callbacks. + await new Promise(() => {}); + return 0; +} diff --git a/src/cli/status.ts b/src/cli/status.ts new file mode 100644 index 0000000..4b53d41 --- /dev/null +++ b/src/cli/status.ts @@ -0,0 +1,28 @@ +import { describeDataDirResolution } from "../utils/paths.js"; +import { getRunningDaemon } from "./pid.js"; + +function formatUptime(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ${m % 60}m`; + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; +} + +export async function run(_args: string[]): Promise { + const dataDir = describeDataDirResolution(); + console.log(`data dir: ${dataDir.path} (${dataDir.source})`); + + const record = getRunningDaemon(); + if (!record) { + console.log("status: not running"); + return 3; // LSB convention: 3 = program not running + } + const uptime = record.startedAt > 0 ? formatUptime(Date.now() - record.startedAt) : "unknown"; + console.log(`status: running (pid ${record.pid}, up ${uptime})`); + return 0; +} diff --git a/src/cli/stop.ts b/src/cli/stop.ts new file mode 100644 index 0000000..e30ce4a --- /dev/null +++ b/src/cli/stop.ts @@ -0,0 +1,48 @@ +/** + * `golem stop` — signal the running daemon to shut down gracefully. + * + * Sends SIGTERM, polls the pid for up to ~10s, escalates to SIGKILL on timeout. + */ +import { getRunningDaemon, isProcessAlive, removePidFile } from "./pid.js"; + +const GRACE_MS = 10_000; +const POLL_MS = 200; + +async function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function run(_args: string[]): Promise { + const record = getRunningDaemon(); + if (!record) { + console.log("golem is not running."); + return 0; + } + + console.log(`Stopping golem (pid ${record.pid})...`); + try { + process.kill(record.pid, "SIGTERM"); + } catch (err) { + console.error(`Failed to signal pid ${record.pid}:`, (err as Error).message); + return 1; + } + + const deadline = Date.now() + GRACE_MS; + while (Date.now() < deadline) { + if (!isProcessAlive(record.pid)) { + removePidFile(); + console.log("Stopped."); + return 0; + } + await sleep(POLL_MS); + } + + console.warn(`pid ${record.pid} did not exit within ${GRACE_MS / 1000}s — sending SIGKILL.`); + try { + process.kill(record.pid, "SIGKILL"); + } catch { + // race: process exited between checks + } + removePidFile(); + return 0; +} diff --git a/src/cli/update.ts b/src/cli/update.ts new file mode 100644 index 0000000..2bf9e39 --- /dev/null +++ b/src/cli/update.ts @@ -0,0 +1,18 @@ +/** + * `golem update` — pull the latest published version. + * + * Stub: full implementation depends on the npm publishing pipeline (bd vx9). + * Until the package is published as `golem-agent` on npm, there's nothing to + * update to. + */ +export async function run(_args: string[]): Promise { + console.log("`golem update` is not yet implemented."); + console.log(""); + console.log("Once Golem is published to npm, this will run:"); + console.log(" npm install -g golem-agent@latest"); + console.log(" systemctl --user restart golem # (Linux)"); + console.log(" launchctl kickstart -k gui/$(id -u)/com.golem.agent # (macOS)"); + console.log(""); + console.log("For now, run those commands manually after `git pull` in your dev checkout."); + return 0; +} diff --git a/src/cli/version.test.ts b/src/cli/version.test.ts new file mode 100644 index 0000000..eeac7f3 --- /dev/null +++ b/src/cli/version.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; + +import { readPackageVersion } from "./version.js"; + +describe("readPackageVersion", () => { + test("returns a non-empty version string", () => { + const v = readPackageVersion(); + expect(v).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/src/cli/version.ts b/src/cli/version.ts new file mode 100644 index 0000000..d5e3d17 --- /dev/null +++ b/src/cli/version.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function readPackageVersion(): string { + // Walk up from src/cli/ to find package.json. Works in both dev (src/cli/version.ts) + // and installed (dist/cli/version.js inside node_modules/golem-agent/). + let dir = __dirname; + for (let i = 0; i < 5; i++) { + const candidate = path.join(dir, "package.json"); + if (fs.existsSync(candidate)) { + const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8")) as { name?: string; version?: string }; + if (pkg.name && (pkg.name === "golem-agent" || pkg.name.endsWith("/golem-agent"))) { + return pkg.version ?? "0.0.0"; + } + } + dir = path.dirname(dir); + } + return "unknown"; +} + +export async function run(_args: string[]): Promise { + console.log(readPackageVersion()); + return 0; +} From 3ced9d053e0181598c143309d864a60d59efbdc8 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:08:17 +0300 Subject: [PATCH 03/15] feat(cli): first-run banner with SSH tunnel instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline 'no OPENROUTER_API_KEY' nag in start.ts with a proper startup banner that adapts to the user's situation: - Always shows resolved data dir + source (env / cwd / OS default) - Onboarding mode: prints localhost:3015, plus a copy-paste-ready 'ssh -L 3015:localhost:3015 user@host' when $SSH_CONNECTION is set - Already-configured + SSH: still shows the tunnel command so the user knows how to re-reach the UI - Already-configured + local: one-liner with the UI URL $SSH_CONNECTION parsing handles IPv4, IPv6 (bracketed for shell safety), and missing USER (falls back to LOGNAME). Drops the redundant data-dir console.log added in the previous commit — the banner covers it. This is the highest-leverage UX writing in the project — see docs/brain/pages/decision/vps-deployment-and-cli.md. Refs: bd i30. --- src/cli/first-run-banner.test.ts | 97 ++++++++++++++++++++++++++++++++ src/cli/first-run-banner.ts | 94 +++++++++++++++++++++++++++++++ src/cli/start.ts | 9 +-- src/platform/platform.ts | 6 -- 4 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 src/cli/first-run-banner.test.ts create mode 100644 src/cli/first-run-banner.ts diff --git a/src/cli/first-run-banner.test.ts b/src/cli/first-run-banner.test.ts new file mode 100644 index 0000000..6abe7be --- /dev/null +++ b/src/cli/first-run-banner.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { _resetPathCacheForTests } from "../utils/paths.js"; +import { detectSshSession, renderBanner } from "./first-run-banner.js"; + +let savedEnv: Record; +const KEYS = ["SSH_CONNECTION", "USER", "LOGNAME", "GOLEM_DATA_DIR"]; + +beforeEach(() => { + savedEnv = Object.fromEntries(KEYS.map((k) => [k, process.env[k]])); + for (const k of KEYS) delete process.env[k]; + process.env.GOLEM_DATA_DIR = "/tmp/golem-banner-test"; + _resetPathCacheForTests(); +}); + +afterEach(() => { + for (const k of KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + _resetPathCacheForTests(); +}); + +describe("detectSshSession", () => { + test("returns null when SSH_CONNECTION is unset", () => { + expect(detectSshSession({})).toBeNull(); + }); + + test("parses standard IPv4 SSH_CONNECTION", () => { + expect( + detectSshSession({ SSH_CONNECTION: "10.0.0.5 41234 192.168.1.10 22", USER: "aviv" }), + ).toEqual({ user: "aviv", host: "192.168.1.10" }); + }); + + test("wraps IPv6 server addresses in brackets", () => { + expect( + detectSshSession({ + SSH_CONNECTION: "fe80::1 41234 2001:db8::1 22", + USER: "aviv", + }), + ).toEqual({ user: "aviv", host: "[2001:db8::1]" }); + }); + + test("falls back to LOGNAME when USER is unset", () => { + expect( + detectSshSession({ SSH_CONNECTION: "10.0.0.5 41234 1.2.3.4 22", LOGNAME: "root" }), + ).toEqual({ user: "root", host: "1.2.3.4" }); + }); + + test("returns null on malformed SSH_CONNECTION", () => { + expect(detectSshSession({ SSH_CONNECTION: "incomplete", USER: "aviv" })).toBeNull(); + }); + + test("returns null when no user can be determined", () => { + expect(detectSshSession({ SSH_CONNECTION: "1 2 3 4" })).toBeNull(); + }); +}); + +describe("renderBanner", () => { + test("onboarding + no SSH points at localhost", () => { + const out = renderBanner({ hasApiKey: false, ssh: null }); + expect(out).toContain("Not yet configured"); + expect(out).toContain("http://localhost:3015"); + expect(out).not.toContain("ssh -L"); + }); + + test("onboarding + SSH renders a copy-paste tunnel command", () => { + const out = renderBanner({ + hasApiKey: false, + ssh: { user: "aviv", host: "203.0.113.7" }, + }); + expect(out).toContain("ssh -L 3015:localhost:3015 aviv@203.0.113.7"); + expect(out).toContain("http://localhost:3015"); + }); + + test("configured + SSH still shows the tunnel command for re-access", () => { + const out = renderBanner({ + hasApiKey: true, + ssh: { user: "root", host: "[2001:db8::1]" }, + }); + expect(out).toContain("ssh -L 3015:localhost:3015 root@[2001:db8::1]"); + expect(out).not.toContain("Not yet configured"); + }); + + test("configured + no SSH is a one-liner", () => { + const out = renderBanner({ hasApiKey: true, ssh: null }); + expect(out).toContain("Running."); + expect(out).toContain("http://localhost:3015"); + expect(out).not.toContain("ssh -L"); + }); + + test("always shows the resolved data dir", () => { + const out = renderBanner({ hasApiKey: true, ssh: null }); + expect(out).toContain("/tmp/golem-banner-test"); + expect(out).toContain("GOLEM_DATA_DIR env var"); + }); +}); diff --git a/src/cli/first-run-banner.ts b/src/cli/first-run-banner.ts new file mode 100644 index 0000000..d205c49 --- /dev/null +++ b/src/cli/first-run-banner.ts @@ -0,0 +1,94 @@ +/** + * First-run / startup banner. + * + * On every start prints the resolved data directory. When the platform is not + * yet onboarded (no OPENROUTER_API_KEY), prints the path to localhost:3015 and, + * if the user is in an SSH session, the copy-paste-ready ssh tunnel command. + * + * This is the highest-leverage UX copy in the project — the moment between + * "I ran the install" and "I can see the UI". Keep it terse, scannable, and + * accurate; treat changes here like changes to a public API. + */ +import { describeDataDirResolution } from "../utils/paths.js"; + +interface SshSession { + user: string; + host: string; +} + +const UI_PORT = 3015; + +/** + * Parse $SSH_CONNECTION = " " + * into the address the user should target in `ssh user@host`. IPv6 addresses + * are wrapped in brackets for shell-safety. Returns null when not in an SSH + * session or the env var is malformed. + */ +export function detectSshSession(env: NodeJS.ProcessEnv = process.env): SshSession | null { + const conn = env.SSH_CONNECTION?.trim(); + if (!conn) return null; + const parts = conn.split(/\s+/); + if (parts.length < 4) return null; + const serverIp = parts[2]; + if (!serverIp) return null; + const user = env.USER || env.LOGNAME; + if (!user) return null; + const host = serverIp.includes(":") ? `[${serverIp}]` : serverIp; + return { user, host }; +} + +interface BannerOptions { + hasApiKey: boolean; + ssh: SshSession | null; +} + +export function renderBanner(opts: BannerOptions): string { + const dataDir = describeDataDirResolution(); + const sourceLabel = { + env: "GOLEM_DATA_DIR env var", + cwd: "./data/ in current directory", + default: "OS default", + }[dataDir.source]; + + const lines: string[] = []; + lines.push(""); + lines.push(" ╔══════════════════════════════════════════════════════════════╗"); + lines.push(" ║ Golem ║"); + lines.push(" ╚══════════════════════════════════════════════════════════════╝"); + lines.push(""); + lines.push(` Data: ${dataDir.path}`); + lines.push(` (${sourceLabel})`); + lines.push(""); + + if (!opts.hasApiKey) { + lines.push(" Not yet configured — let's get you set up."); + lines.push(""); + if (opts.ssh) { + lines.push(" You're SSH'd in. To configure Golem, open an SSH tunnel from"); + lines.push(" your laptop in a separate terminal:"); + lines.push(""); + lines.push(` ssh -L ${UI_PORT}:localhost:${UI_PORT} ${opts.ssh.user}@${opts.ssh.host}`); + lines.push(""); + lines.push(` Then open http://localhost:${UI_PORT} in your laptop's browser.`); + } else { + lines.push(` Open http://localhost:${UI_PORT} to configure your platform.`); + } + lines.push(""); + } else { + if (opts.ssh) { + lines.push(" Running. To access the UI from your laptop:"); + lines.push(""); + lines.push(` ssh -L ${UI_PORT}:localhost:${UI_PORT} ${opts.ssh.user}@${opts.ssh.host}`); + lines.push(` open http://localhost:${UI_PORT}`); + } else { + lines.push(` Running. UI at http://localhost:${UI_PORT}`); + } + lines.push(""); + } + + return lines.join("\n"); +} + +export function printFirstRunBanner(opts: BannerOptions): void { + process.stdout.write(renderBanner(opts) + "\n"); +} diff --git a/src/cli/start.ts b/src/cli/start.ts index 98165d0..96446f4 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -5,6 +5,7 @@ * platform writes its PID on first successful start, removes it on graceful * shutdown, and runs until SIGINT/SIGTERM. */ +import { detectSshSession, printFirstRunBanner } from "./first-run-banner.js"; import { getRunningDaemon, removePidFile, writePidFile } from "./pid.js"; export async function run(_args: string[]): Promise { @@ -17,10 +18,10 @@ export async function run(_args: string[]): Promise { return 1; } - if (!process.env.OPENROUTER_API_KEY) { - console.log("\n No OPENROUTER_API_KEY found — starting in onboarding mode."); - console.log(" Open http://localhost:3015 to configure your platform.\n"); - } + printFirstRunBanner({ + hasApiKey: Boolean(process.env.OPENROUTER_API_KEY), + ssh: detectSshSession(), + }); const { startPlatform } = await import("../platform/platform.js"); diff --git a/src/platform/platform.ts b/src/platform/platform.ts index 5b0ee8d..d18ae87 100644 --- a/src/platform/platform.ts +++ b/src/platform/platform.ts @@ -970,12 +970,6 @@ function registerAgentTransport( export async function startPlatform(): Promise { console.log("[platform] starting multi-agent platform..."); const dataDir = describeDataDirResolution(); - const sourceLabel = { - env: "GOLEM_DATA_DIR env var", - cwd: "./data/ in current directory", - default: "OS default", - }[dataDir.source]; - console.log(`[platform] data dir: ${dataDir.path} (${sourceLabel})`); logger.info("platform starting", { dataDir: dataDir.path, dataDirSource: dataDir.source }); const startedAt = Date.now(); From 4f9fd8667b7a0adecc0c5206cba2af81cd971d2a Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:32:33 +0300 Subject: [PATCH 04/15] =?UTF-8?q?feat(doctor):=20real=20health=20checks=20?= =?UTF-8?q?=E2=80=94=20OpenRouter,=20Telegram=20getMe,=20disk=20space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the doctor scaffold with the full check matrix: - Node version (>= 20) - Data dir writable + resolution source - Disk space (warn <1 GiB, fail <100 MiB) via fs.statfs - OpenRouter key valid via /api/v1/key (5s timeout) - Each agent's Telegram bot token via getMe — opens agents.db readonly so a running daemon is undisturbed; resolves ${ENV_VAR} refs in the bot token field. Reports @username when valid. - Daemon running (from PID file) - Logs dir present Extracts the validators to src/utils/{telegram,openrouter}-validate.ts so the onboarding wizard (bd v0n.23) can call the same getMe code path — 'valid in the wizard' and 'valid in doctor' use the exact same definition. 12 new tests for the validators (with injected fetch — no live network). ffmpeg deliberately not checked: Whisper API accepts Telegram OGG directly. Refs: bd 49x. --- src/cli/doctor.ts | 163 +++++++++++++++++++++++--- src/utils/openrouter-validate.test.ts | 43 +++++++ src/utils/openrouter-validate.ts | 60 ++++++++++ src/utils/telegram-validate.test.ts | 92 +++++++++++++++ src/utils/telegram-validate.ts | 82 +++++++++++++ 5 files changed, 422 insertions(+), 18 deletions(-) create mode 100644 src/utils/openrouter-validate.test.ts create mode 100644 src/utils/openrouter-validate.ts create mode 100644 src/utils/telegram-validate.test.ts create mode 100644 src/utils/telegram-validate.ts diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index b5468d5..bddab0a 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,13 +1,28 @@ /** * `golem doctor` — diagnostic health checks. * - * Stub: dispatch wiring lives here; the actual checks (Node version, write - * access, OpenRouter key validity, Telegram bot tokens via getMe, daemon - * status, disk space) are bd 49x which builds on this scaffold. + * Highest-leverage subcommand for hesitating-user trust: the single command + * that answers "is this thing actually wired up correctly on this machine?" + * + * Checks (each independent, run concurrently where possible): + * - Node version (>=20) + * - Data dir writable + * - Disk space (warn <1GB, fail <100MB) + * - OpenRouter key set and accepted by /api/v1/key + * - Each agent's Telegram bot token via getMe (reads agents.db readonly) + * - Daemon running (via PID file) + * - Logs directory present and readable + * + * Exit codes: 0 = all green or warnings only; 1 = at least one fail. + * + * ffmpeg is NOT checked — Whisper API accepts Telegram OGG directly. */ import fs from "node:fs"; +import fsp from "node:fs/promises"; -import { describeDataDirResolution } from "../utils/paths.js"; +import { dataPath, describeDataDirResolution } from "../utils/paths.js"; +import { validateOpenRouterKey } from "../utils/openrouter-validate.js"; +import { validateTelegramToken } from "../utils/telegram-validate.js"; import { getRunningDaemon } from "./pid.js"; interface Check { @@ -16,49 +31,161 @@ interface Check { detail: string; } +function check(name: string, status: Check["status"], detail: string): Check { + return { name, status, detail }; +} + function checkNodeVersion(): Check { const major = Number(process.versions.node.split(".")[0]); - if (major >= 20) return { name: "Node version", status: "ok", detail: `${process.versions.node}` }; - return { - name: "Node version", - status: "fail", - detail: `${process.versions.node} (need >= 20)`, - }; + if (major >= 20) return check("Node version", "ok", process.versions.node); + return check("Node version", "fail", `${process.versions.node} (need >= 20)`); } function checkDataDir(): Check { const r = describeDataDirResolution(); try { fs.accessSync(r.path, fs.constants.W_OK); - return { name: "Data dir writable", status: "ok", detail: `${r.path} (${r.source})` }; + return check("Data dir writable", "ok", `${r.path} (${r.source})`); } catch { - return { name: "Data dir writable", status: "fail", detail: `${r.path} — not writable` }; + return check("Data dir writable", "fail", `${r.path} — not writable`); + } +} + +async function checkDiskSpace(): Promise { + try { + const stats = await fsp.statfs(describeDataDirResolution().path); + const freeBytes = stats.bavail * stats.bsize; + const freeGiB = freeBytes / 1024 ** 3; + const human = freeGiB >= 1 ? `${freeGiB.toFixed(1)} GiB free` : `${(freeBytes / 1024 ** 2).toFixed(0)} MiB free`; + if (freeBytes < 100 * 1024 ** 2) return check("Disk space", "fail", `${human} — under 100 MiB threshold`); + if (freeBytes < 1024 ** 3) return check("Disk space", "warn", `${human} — under 1 GiB`); + return check("Disk space", "ok", human); + } catch (err) { + return check("Disk space", "warn", `couldn't statfs data dir: ${(err as Error).message}`); + } +} + +async function checkOpenRouterKey(): Promise { + const key = process.env.OPENROUTER_API_KEY; + if (!key) return check("OpenRouter key", "fail", "OPENROUTER_API_KEY not set — run setup first"); + const r = await validateOpenRouterKey(key); + if (r.ok) { + const detail = r.limitRemaining !== undefined ? `valid (credit ${r.limitRemaining.toFixed(2)})` : "valid"; + return check("OpenRouter key", "ok", detail); + } + return check("OpenRouter key", "fail", r.error); +} + +function expandEnvVarsLocal(value: string): string { + return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => process.env[name] ?? ""); +} + +interface TelegramAgentSummary { + id: string; + tokenRef: string; + resolved: string; +} + +function readAgentTelegramTokens(): TelegramAgentSummary[] | null { + const dbPath = dataPath("agents.db"); + if (!fs.existsSync(dbPath)) return null; + + // Open readonly so a running daemon's writes are unaffected. Using better-sqlite3 + // dynamically here keeps doctor decoupled from AgentStore's lifecycle (no + // migration runs, no schema upgrades). + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { default: Database } = require("better-sqlite3") as { default: new (p: string, o?: { readonly?: boolean; fileMustExist?: boolean }) => { prepare(sql: string): { all(): unknown[] }; close(): void } }; + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + type Row = { id: string; config_json: string }; + const rows = db.prepare("SELECT id, config_json FROM agents").all() as Row[]; + const out: TelegramAgentSummary[] = []; + for (const row of rows) { + try { + const cfg = JSON.parse(row.config_json) as { + transport?: { platform?: string; botToken?: string }; + }; + if (cfg.transport?.platform !== "telegram" || !cfg.transport.botToken) continue; + out.push({ + id: row.id, + tokenRef: cfg.transport.botToken, + resolved: expandEnvVarsLocal(cfg.transport.botToken), + }); + } catch { + // skip malformed configs; doctor reports failures elsewhere + } + } + return out; + } finally { + db.close(); } } +async function checkTelegramTokens(): Promise { + const agents = readAgentTelegramTokens(); + if (agents === null) return [check("Telegram tokens", "warn", "no agents.db yet (run setup)")]; + if (agents.length === 0) return [check("Telegram tokens", "warn", "no Telegram agents configured")]; + + const results = await Promise.all( + agents.map(async (a) => { + if (!a.resolved) return check(`Telegram: ${a.id}`, "fail", `env var ${a.tokenRef} not resolved`); + const r = await validateTelegramToken(a.resolved); + return r.ok + ? check(`Telegram: ${a.id}`, "ok", `@${r.botUsername}`) + : check(`Telegram: ${a.id}`, "fail", r.error); + }), + ); + return results; +} + function checkDaemon(): Check { const record = getRunningDaemon(); - if (record) return { name: "Daemon running", status: "ok", detail: `pid ${record.pid}` }; - return { name: "Daemon running", status: "warn", detail: "not running (use `golem start`)" }; + if (record) return check("Daemon running", "ok", `pid ${record.pid}`); + return check("Daemon running", "warn", "not running (use `golem start`)"); +} + +function checkLogsDir(): Check { + const dir = dataPath("logs"); + try { + const entries = fs.readdirSync(dir); + return check("Logs dir", "ok", `${dir} (${entries.length} files)`); + } catch { + return check("Logs dir", "warn", `${dir} — not present yet (created on first run)`); + } } function renderCheck(c: Check): string { const icon = c.status === "ok" ? "✓" : c.status === "warn" ? "!" : "✗"; - return ` ${icon} ${c.name.padEnd(22)} ${c.detail}`; + return ` ${icon} ${c.name.padEnd(24)} ${c.detail}`; } export async function run(_args: string[]): Promise { console.log("Golem doctor — running health checks"); console.log(""); - const checks: Check[] = [checkNodeVersion(), checkDataDir(), checkDaemon()]; + // Run async checks concurrently; sync ones inline. + const [diskSpace, openrouter, telegram] = await Promise.all([ + checkDiskSpace(), + checkOpenRouterKey(), + checkTelegramTokens(), + ]); + + const checks: Check[] = [ + checkNodeVersion(), + checkDataDir(), + diskSpace, + openrouter, + ...telegram, + checkDaemon(), + checkLogsDir(), + ]; + let exit = 0; for (const c of checks) { console.log(renderCheck(c)); if (c.status === "fail") exit = 1; } - console.log(""); - console.log("Additional checks (OpenRouter key, Telegram tokens, disk space) come with bd 49x."); + console.log(exit === 0 ? "All checks passed." : "Some checks failed — see details above."); return exit; } diff --git a/src/utils/openrouter-validate.test.ts b/src/utils/openrouter-validate.test.ts new file mode 100644 index 0000000..ba6683c --- /dev/null +++ b/src/utils/openrouter-validate.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import { validateOpenRouterKey } from "./openrouter-validate.js"; + +function mockFetch(response: Partial & { json?: () => Promise }): typeof fetch { + return ((async () => ({ + ok: response.ok ?? true, + status: response.status ?? 200, + json: response.json ?? (async () => ({})), + } as Response))) as unknown as typeof fetch; +} + +describe("validateOpenRouterKey", () => { + test("empty key rejected without a network call", async () => { + const r = await validateOpenRouterKey(""); + expect(r.ok).toBe(false); + }); + + test("happy path returns ok with optional limit_remaining", async () => { + const fetchImpl = mockFetch({ + ok: true, + status: 200, + json: async () => ({ data: { limit_remaining: 9.95, usage: 0.05 } }), + }); + const r = await validateOpenRouterKey("sk-or-v1-xxx", { fetchImpl }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.limitRemaining).toBeCloseTo(9.95); + }); + + test("401 maps to clear error", async () => { + const fetchImpl = mockFetch({ ok: false, status: 401 }); + const r = await validateOpenRouterKey("sk-or-bad", { fetchImpl }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("401"); + }); + + test("500 returns generic HTTP error", async () => { + const fetchImpl = mockFetch({ ok: false, status: 503 }); + const r = await validateOpenRouterKey("sk-or-x", { fetchImpl }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("503"); + }); +}); diff --git a/src/utils/openrouter-validate.ts b/src/utils/openrouter-validate.ts new file mode 100644 index 0000000..cb991c9 --- /dev/null +++ b/src/utils/openrouter-validate.ts @@ -0,0 +1,60 @@ +/** + * Validate an OpenRouter API key by calling /api/v1/key. + * + * Returns 200 with usage/limit info when valid, 401 when not. Consumed by + * `golem doctor` and (potentially) the onboarding wizard. + */ + +export interface OpenRouterValidationOk { + ok: true; + /** Optional remaining credit, when OpenRouter returns it. */ + limitRemaining?: number; +} + +export interface OpenRouterValidationErr { + ok: false; + error: string; +} + +export type OpenRouterValidationResult = OpenRouterValidationOk | OpenRouterValidationErr; + +interface Options { + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +interface KeyResponse { + data?: { limit_remaining?: number; usage?: number }; +} + +const DEFAULT_TIMEOUT_MS = 5_000; + +export async function validateOpenRouterKey( + key: string, + opts: Options = {}, +): Promise { + const trimmed = key?.trim(); + if (!trimmed) return { ok: false, error: "key is empty" }; + + const fetchImpl = opts.fetchImpl ?? fetch; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + try { + const resp = await fetchImpl("https://openrouter.ai/api/v1/key", { + method: "GET", + headers: { Authorization: `Bearer ${trimmed}` }, + signal: ctrl.signal, + }); + if (resp.status === 401) return { ok: false, error: "key rejected by OpenRouter (401)" }; + if (!resp.ok) return { ok: false, error: `OpenRouter returned HTTP ${resp.status}` }; + + const body = (await resp.json()) as KeyResponse; + return { ok: true, limitRemaining: body.data?.limit_remaining }; + } catch (err: unknown) { + if ((err as Error).name === "AbortError") return { ok: false, error: "request timed out" }; + return { ok: false, error: (err as Error).message }; + } finally { + clearTimeout(t); + } +} diff --git a/src/utils/telegram-validate.test.ts b/src/utils/telegram-validate.test.ts new file mode 100644 index 0000000..87c7315 --- /dev/null +++ b/src/utils/telegram-validate.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; + +import { looksLikeTelegramToken, validateTelegramToken } from "./telegram-validate.js"; + +const VALID_SHAPE = "1234567890:AAExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + +function mockFetch(responses: Array & { json?: () => Promise }>): typeof fetch { + let i = 0; + return ((async () => { + const r = responses[i++]; + return { + ok: r.ok ?? true, + status: r.status ?? 200, + json: r.json ?? (async () => ({})), + } as Response; + }) as unknown) as typeof fetch; +} + +describe("looksLikeTelegramToken", () => { + test("accepts well-formed tokens", () => { + expect(looksLikeTelegramToken(VALID_SHAPE)).toBe(true); + }); + test("rejects obviously malformed input", () => { + expect(looksLikeTelegramToken("nope")).toBe(false); + expect(looksLikeTelegramToken("12345:short")).toBe(false); + expect(looksLikeTelegramToken("")).toBe(false); + }); +}); + +describe("validateTelegramToken", () => { + test("empty token rejected without a network call", async () => { + const fetchImpl = mockFetch([]); + const r = await validateTelegramToken("", { fetchImpl }); + expect(r.ok).toBe(false); + }); + + test("malformed token rejected without a network call", async () => { + const r = await validateTelegramToken("not-a-token", { fetchImpl: mockFetch([]) }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/format/); + }); + + test("happy path returns username + id", async () => { + const fetchImpl = mockFetch([ + { + ok: true, + status: 200, + json: async () => ({ ok: true, result: { id: 42, is_bot: true, username: "test_bot" } }), + }, + ]); + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.botUsername).toBe("test_bot"); + expect(r.botId).toBe(42); + } + }); + + test("401 maps to clear error", async () => { + const fetchImpl = mockFetch([{ ok: false, status: 401 }]); + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("401"); + }); + + test("non-bot account rejected", async () => { + const fetchImpl = mockFetch([ + { + ok: true, + status: 200, + json: async () => ({ ok: true, result: { id: 1, is_bot: false, username: "human" } }), + }, + ]); + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl }); + expect(r.ok).toBe(false); + }); + + test("timeout returns a timed-out error", async () => { + const slowFetch: typeof fetch = ((_url: string, opts?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + opts?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }); + }) as unknown as typeof fetch; + const r = await validateTelegramToken(VALID_SHAPE, { fetchImpl: slowFetch, timeoutMs: 10 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/timed out/); + }); +}); diff --git a/src/utils/telegram-validate.ts b/src/utils/telegram-validate.ts new file mode 100644 index 0000000..e41962b --- /dev/null +++ b/src/utils/telegram-validate.ts @@ -0,0 +1,82 @@ +/** + * Validate a Telegram bot token via the getMe endpoint. + * + * Consumed by both the onboarding wizard (bd v0n.23) and `golem doctor` (bd 49x) + * so the same "is this token usable" rules apply at config time and run time. + * + * Cheap idempotent GET; rate-limited by Telegram per-bot but a single call from + * the wizard or doctor stays comfortably under the limit. + */ + +export interface TelegramValidationOk { + ok: true; + botUsername: string; + botId: number; +} + +export interface TelegramValidationErr { + ok: false; + error: string; +} + +export type TelegramValidationResult = TelegramValidationOk | TelegramValidationErr; + +interface GetMeResponse { + ok: boolean; + result?: { id: number; is_bot: boolean; username?: string; first_name?: string }; + description?: string; +} + +interface Options { + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +const DEFAULT_TIMEOUT_MS = 5_000; + +/** Quick shape check before we send a network request. Matches `:`. */ +export function looksLikeTelegramToken(token: string): boolean { + return /^\d{6,}:[A-Za-z0-9_-]{20,}$/.test(token); +} + +export async function validateTelegramToken( + token: string, + opts: Options = {}, +): Promise { + const trimmed = token?.trim(); + if (!trimmed) return { ok: false, error: "token is empty" }; + if (!looksLikeTelegramToken(trimmed)) { + return { ok: false, error: "token format invalid (expected :)" }; + } + + const fetchImpl = opts.fetchImpl ?? fetch; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + try { + const resp = await fetchImpl(`https://api.telegram.org/bot${trimmed}/getMe`, { + method: "GET", + signal: ctrl.signal, + }); + if (resp.status === 401) return { ok: false, error: "token rejected by Telegram (401)" }; + if (!resp.ok) return { ok: false, error: `Telegram returned HTTP ${resp.status}` }; + + const body = (await resp.json()) as GetMeResponse; + if (!body.ok || !body.result) { + return { ok: false, error: body.description || "Telegram returned ok=false" }; + } + if (!body.result.is_bot) { + return { ok: false, error: "credentials are not for a bot account" }; + } + return { + ok: true, + botId: body.result.id, + botUsername: body.result.username ?? body.result.first_name ?? "", + }; + } catch (err: unknown) { + if ((err as Error).name === "AbortError") return { ok: false, error: "request timed out" }; + return { ok: false, error: (err as Error).message }; + } finally { + clearTimeout(t); + } +} From 21236c2545cfd8ad34aeefb400c884081be8f26c Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:37:25 +0300 Subject: [PATCH 05/15] fix(onboarding): validate bot token via getMe + handle restart-timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/telegram/verify-token (backend) consuming the shared validateTelegramToken util — same getMe round-trip 'golem doctor' uses. Wizard StepTelegram now calls it on Next: shows 'Verifying...', then either 'Connected as @username' before advancing or the error inline. Replaces the prior 'token contains a colon and is >30 chars' heuristic that let typos pass and produced a silently-dead bot. StepDone gains a failed state: after the 60s health-poll budget runs out, renders an AlertCircle, points to `golem logs` / journalctl, and offers a Try again button. Previously the UI stayed on the shimmer forever with no recourse. Audit findings C1.5/C1.6. Refs: bd v0n.23. --- src/server.ts | 12 ++++++ ui/app/onboarding/page.tsx | 87 +++++++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/server.ts b/src/server.ts index aad8688..e0278d9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -209,6 +209,18 @@ export function startServer(deps: ServerDeps) { return json(res, { status: "ok", uptime: Math.floor((Date.now() - startedAt) / 1000) }); } + // ── Telegram token validation (wizard + tooling) ─────── + if (req.method === "POST" && pathname === "/api/telegram/verify-token") { + try { + const body = JSON.parse(await readBody(req)) as { token?: string }; + const { validateTelegramToken } = await import("./utils/telegram-validate.js"); + const result = await validateTelegramToken(body.token ?? ""); + return json(res, result, result.ok ? 200 : 400); + } catch (err) { + return json(res, { ok: false, error: (err as Error).message }, 500); + } + } + // ── Setup / Onboarding ───────────────────────────────── if (req.method === "GET" && pathname === "/api/setup/status") { const hasApiKey = !!process.env.OPENROUTER_API_KEY; diff --git a/ui/app/onboarding/page.tsx b/ui/app/onboarding/page.tsx index 3159acf..f51ce0d 100644 --- a/ui/app/onboarding/page.tsx +++ b/ui/app/onboarding/page.tsx @@ -31,6 +31,7 @@ import { Wrench, ExternalLink, CheckCircle2, + AlertCircle, } from "lucide-react"; import type { OpenRouterModel } from "@/lib/types"; @@ -502,7 +503,43 @@ function StepTelegram({ onNext: () => void; onBack: () => void; }) { - const isValid = botToken.includes(":") && botToken.length > 30; + const looksValid = botToken.includes(":") && botToken.length > 30; + const [verifyStatus, setVerifyStatus] = useState<"idle" | "checking" | "ok" | "fail">("idle"); + const [verifyMessage, setVerifyMessage] = useState(""); + + // Re-arm verification whenever the token changes + useEffect(() => { + setVerifyStatus("idle"); + setVerifyMessage(""); + }, [botToken]); + + async function handleNext() { + if (!looksValid) return; + setVerifyStatus("checking"); + setVerifyMessage("Talking to Telegram..."); + try { + const resp = await fetch("/api/telegram/verify-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: botToken }), + }); + const result = (await resp.json()) as + | { ok: true; botUsername: string } + | { ok: false; error: string }; + if (result.ok) { + setVerifyStatus("ok"); + setVerifyMessage(`Connected as @${result.botUsername}`); + // Brief pause so the user sees the confirmation, then advance + setTimeout(onNext, 600); + } else { + setVerifyStatus("fail"); + setVerifyMessage(result.error); + } + } catch (err) { + setVerifyStatus("fail"); + setVerifyMessage((err as Error).message); + } + } return (
@@ -537,6 +574,19 @@ function StepTelegram({ placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v..." className="font-mono" /> + {verifyStatus !== "idle" && ( +

+ {verifyMessage} +

+ )}
@@ -561,8 +611,8 @@ function StepTelegram({ -
@@ -910,21 +960,25 @@ function StepDone({ agentName }: { agentName: string }) { const router = useRouter(); const [restarting, setRestarting] = useState(false); const [ready, setReady] = useState(false); + const [failed, setFailed] = useState(false); async function handleRestart() { setRestarting(true); + setFailed(false); try { await fetch("/api/restart", { method: "POST" }); - // Poll for health + // Poll for health — 30 attempts × 2s = 60s budget for (let i = 0; i < 30; i++) { await new Promise((r) => setTimeout(r, 2000)); try { const res = await fetch("/api/health"); - if (res.ok) { setReady(true); return; } + if (res.ok) { setReady(true); setRestarting(false); return; } } catch { /* still down */ } } + // exhausted the poll budget without seeing /api/health come back + setFailed(true); } catch { - toast.error("Restart failed"); + setFailed(true); } setRestarting(false); } @@ -935,7 +989,26 @@ function StepDone({ agentName }: { agentName: string }) { return (
- {!ready ? ( + {failed ? ( + <> +
+ +
+
+

Restart didn't complete in time

+

+ The platform took longer than 60 seconds to come back. It may still be + starting, or something went wrong. Check the daemon logs and try again. +

+

+ golem logs # or: journalctl --user -u golem +

+
+ + + ) : !ready ? ( <>
From 208abc15b2e5f50220f238cb5b8d936d9b71684d Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:40:52 +0300 Subject: [PATCH 06/15] feat(cli): install-daemon / uninstall-daemon for systemd + launchd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds platform-specific daemon installation under src/cli/install-daemon/: - linux.ts: writes ~/.config/systemd/user/golem.service, runs daemon-reload + enable --now. Detects 'no linger' on VPSes and prints the (sudo) loginctl enable-linger reminder rather than running it. - macos.ts: writes ~/Library/LaunchAgents/com.golem.agent.plist, bootstraps + kickstarts via launchctl. Detects an existing repo-local plist (the dev install) and refuses without --force, printing the exact migration steps (bootout, mv data, --force) rather than silently destroying state. - unit-renderers.ts: pure functions for systemd unit + launchd plist text; inferPlistOrigin() classifies an existing plist as matches / repo-local / unknown. ExecStart resolution: only accepts argv[1] if it ends in /bin/golem(.js), so running install-daemon in dev (via tsx) still writes a unit pointing at bin/golem.js — not src/cli.ts. Both subcommands support --dry-run (print without writing) and --force (overwrite + restart). 12 new tests for renderers + origin detection. Refs: bd 3mm, bd 8if. --- bin/golem.js | 2 +- src/cli.ts | 22 +-- src/cli/install-daemon/index.ts | 67 +++++++++ src/cli/install-daemon/linux.ts | 127 +++++++++++++++++ src/cli/install-daemon/macos.ts | 128 ++++++++++++++++++ src/cli/install-daemon/unit-renderers.test.ts | 62 +++++++++ src/cli/install-daemon/unit-renderers.ts | 106 +++++++++++++++ 7 files changed, 505 insertions(+), 9 deletions(-) create mode 100644 src/cli/install-daemon/index.ts create mode 100644 src/cli/install-daemon/linux.ts create mode 100644 src/cli/install-daemon/macos.ts create mode 100644 src/cli/install-daemon/unit-renderers.test.ts create mode 100644 src/cli/install-daemon/unit-renderers.ts diff --git a/bin/golem.js b/bin/golem.js index 86fa589..1f628e0 100755 --- a/bin/golem.js +++ b/bin/golem.js @@ -19,7 +19,7 @@ const sourceEntry = resolve(root, "src", "cli.ts"); const argv = process.argv.slice(2); const subcommand = argv[0] ?? "start"; -const isStart = subcommand === "start" || subcommand.startsWith("-") || !["stop", "status", "logs", "version", "update", "doctor", "help", "-h", "--help"].includes(subcommand); +const isStart = subcommand === "start" || subcommand.startsWith("-") || !["stop", "status", "logs", "version", "update", "doctor", "install-daemon", "uninstall-daemon", "help", "-h", "--help"].includes(subcommand); function spawnChild() { // Prefer compiled dist/ if present (npm-installed), else use tsx on src/. diff --git a/src/cli.ts b/src/cli.ts index 89dd9a1..870a060 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,6 +26,10 @@ const COMMANDS: Record Promise<{ run: Subcommand }>> = { version: () => import("./cli/version.js"), update: () => import("./cli/update.js"), doctor: () => import("./cli/doctor.js"), + "install-daemon": () => + import("./cli/install-daemon/index.js").then((m) => ({ run: m.runInstall })), + "uninstall-daemon": () => + import("./cli/install-daemon/index.js").then((m) => ({ run: m.runUninstall })), }; const ALIASES: Record = { @@ -39,14 +43,16 @@ Usage: golem [command] [args] Commands: - start Start the platform (default if no command given) - stop Stop the running daemon - status Report whether the daemon is running - logs [-n N] [-f] Tail the daemon logs - version Print the version - update Pull the latest published version (not yet wired) - doctor Run health checks - help, -h, --help Show this message + start Start the platform (default if no command given) + stop Stop the running daemon + status Report whether the daemon is running + logs [-n N] [-f] Tail the daemon logs + install-daemon [--force] Install user systemd unit / launchd plist + uninstall-daemon Remove the unit/plist and stop the daemon + version Print the version + update Pull the latest published version (not yet wired) + doctor Run health checks + help, -h, --help Show this message Environment: GOLEM_DATA_DIR Override the data directory (default: OS-native). diff --git a/src/cli/install-daemon/index.ts b/src/cli/install-daemon/index.ts new file mode 100644 index 0000000..0ac65c1 --- /dev/null +++ b/src/cli/install-daemon/index.ts @@ -0,0 +1,67 @@ +/** + * `golem install-daemon` / `golem uninstall-daemon` — top-level dispatch. + * + * Routes to the platform-specific implementation. Resolves the absolute path + * of the `golem` binary that the unit/plist should launch by inspecting + * `process.argv[1]` (the entry the user invoked). + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as linux from "./linux.js"; +import * as macos from "./macos.js"; + +interface ParsedArgs { + dryRun: boolean; + force: boolean; +} + +function parseArgs(args: string[]): ParsedArgs { + return { + dryRun: args.includes("--dry-run") || args.includes("-n"), + force: args.includes("--force") || args.includes("-f"), + }; +} + +/** + * Resolve the absolute path of the golem CLI entry that the unit/plist should + * launch. Only accepts argv[1] if it's the bin shim — running via tsx in dev + * gives argv[1]=src/cli.ts, which we definitely don't want in a unit file. + */ +function resolveGolemBinary(): string { + const argv1 = process.argv[1]; + if (argv1 && path.isAbsolute(argv1) && /[\\/]bin[\\/]golem(\.js)?$/.test(argv1)) { + return argv1; + } + // Dev / unusual layouts: derive from this file's location. Both src/cli/install-daemon/ + // and dist/cli/install-daemon/ sit two levels under the package root. + const here = path.dirname(fileURLToPath(import.meta.url)); + // src/cli/install-daemon/ -> src/cli/ -> src/ -> -> /bin/golem.js + return path.resolve(here, "..", "..", "..", "bin", "golem.js"); +} + +export async function runInstall(args: string[]): Promise { + const opts = parseArgs(args); + const golemBinary = resolveGolemBinary(); + if (process.platform === "linux") { + return linux.install({ ...opts, golemBinary }); + } + if (process.platform === "darwin") { + return macos.install({ ...opts, golemBinary }); + } + console.error(`install-daemon is not supported on ${process.platform}. Linux and macOS only.`); + return 1; +} + +export async function runUninstall(args: string[]): Promise { + const opts = parseArgs(args); + if (process.platform === "linux") return linux.uninstall(opts); + if (process.platform === "darwin") return macos.uninstall(opts); + console.error(`uninstall-daemon is not supported on ${process.platform}. Linux and macOS only.`); + return 1; +} + +// Subcommand entry points conforming to the CLI dispatcher contract. +export async function run(args: string[]): Promise { + return runInstall(args); +} diff --git a/src/cli/install-daemon/linux.ts b/src/cli/install-daemon/linux.ts new file mode 100644 index 0000000..ad2c7d7 --- /dev/null +++ b/src/cli/install-daemon/linux.ts @@ -0,0 +1,127 @@ +/** + * `golem install-daemon` / `uninstall-daemon` on Linux — manages a user-level + * systemd unit at ~/.config/systemd/user/golem.service. + * + * User-level only (no system-wide install). This keeps the personal-tool + * model intact and avoids sudo. On VPS providers the user typically needs to + * `loginctl enable-linger` so the unit keeps running after logout — we + * detect and remind, but don't enable it ourselves (requires sudo). + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getDataDir } from "../../utils/paths.js"; +import { renderSystemdUnit } from "./unit-renderers.js"; + +const UNIT_NAME = "golem.service"; + +function unitPath(): string { + return path.join(os.homedir(), ".config", "systemd", "user", UNIT_NAME); +} + +function runSystemctl(...args: string[]): { ok: boolean; output: string } { + const r = spawnSync("systemctl", ["--user", ...args], { encoding: "utf-8" }); + return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") }; +} + +function lingerEnabled(): boolean { + const user = os.userInfo().username; + const r = spawnSync("loginctl", ["show-user", user, "-p", "Linger"], { encoding: "utf-8" }); + return /Linger=yes/i.test(r.stdout || ""); +} + +interface InstallOptions { + dryRun: boolean; + force: boolean; + golemBinary: string; +} + +export async function install(opts: InstallOptions): Promise { + const target = unitPath(); + const dataDir = getDataDir(); + const unit = renderSystemdUnit({ + golemBinary: opts.golemBinary, + homeDir: os.homedir(), + dataDir, + }); + + if (fs.existsSync(target) && !opts.force) { + const existing = fs.readFileSync(target, "utf-8"); + if (existing === unit) { + console.log(`Unit already installed and up to date at ${target}.`); + } else { + console.error(`A different golem.service exists at ${target}.`); + console.error("Re-run with --force to overwrite, or remove it manually first."); + return 1; + } + } else { + if (opts.dryRun) { + console.log("--- would write to", target, "---"); + console.log(unit); + return 0; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, unit); + console.log(`Wrote ${target}`); + } + + if (opts.dryRun) { + console.log("--- would run: systemctl --user daemon-reload && enable --now golem.service ---"); + return 0; + } + + console.log("Reloading systemd..."); + let r = runSystemctl("daemon-reload"); + if (!r.ok) { + console.error(r.output); + return 1; + } + r = runSystemctl("enable", "--now", UNIT_NAME); + if (!r.ok) { + console.error(r.output); + return 1; + } + + console.log(""); + console.log("Golem is installed and running."); + console.log(" Status: systemctl --user status golem"); + console.log(" Logs: journalctl --user -u golem -f"); + console.log(""); + + if (!lingerEnabled()) { + console.log("Heads up: linger is NOT enabled for your user."); + console.log("On most VPSes, systemd kills user services when you log out."); + console.log("To keep golem running across logouts, run (once, requires sudo):"); + console.log(""); + console.log(" sudo loginctl enable-linger $USER"); + console.log(""); + } + + return 0; +} + +interface UninstallOptions { + dryRun: boolean; +} + +export async function uninstall(opts: UninstallOptions): Promise { + const target = unitPath(); + if (!fs.existsSync(target)) { + console.log(`No unit at ${target} — nothing to uninstall.`); + return 0; + } + + if (opts.dryRun) { + console.log("--- would run: systemctl --user disable --now golem.service && rm", target, "---"); + return 0; + } + + console.log("Stopping and disabling golem.service..."); + runSystemctl("disable", "--now", UNIT_NAME); // tolerate failure: unit may already be down + fs.unlinkSync(target); + runSystemctl("daemon-reload"); + console.log(`Removed ${target}.`); + return 0; +} diff --git a/src/cli/install-daemon/macos.ts b/src/cli/install-daemon/macos.ts new file mode 100644 index 0000000..96f2a1b --- /dev/null +++ b/src/cli/install-daemon/macos.ts @@ -0,0 +1,128 @@ +/** + * `golem install-daemon` / `uninstall-daemon` on macOS — manages a launchd + * agent at ~/Library/LaunchAgents/com.golem.agent.plist. + * + * Migration: existing local-checkout users have a plist pointing at the + * repo's bin/golem.js. We detect that, refuse to overwrite without --force, + * and tell the user exactly what'll change. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getDataDir } from "../../utils/paths.js"; +import { inferPlistOrigin, renderLaunchdPlist } from "./unit-renderers.js"; + +const LABEL = "com.golem.agent"; + +function plistPath(): string { + return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`); +} + +function gui(): string { + return `gui/${os.userInfo().uid}`; +} + +function launchctl(...args: string[]): { ok: boolean; output: string } { + const r = spawnSync("launchctl", args, { encoding: "utf-8" }); + return { ok: r.status === 0, output: (r.stdout || "") + (r.stderr || "") }; +} + +interface InstallOptions { + dryRun: boolean; + force: boolean; + golemBinary: string; +} + +export async function install(opts: InstallOptions): Promise { + const target = plistPath(); + const dataDir = getDataDir(); + const plist = renderLaunchdPlist({ + golemBinary: opts.golemBinary, + homeDir: os.homedir(), + dataDir, + }); + + if (fs.existsSync(target) && !opts.force) { + const existing = fs.readFileSync(target, "utf-8"); + const origin = inferPlistOrigin(existing, opts.golemBinary); + if (origin === "matches" && existing === plist) { + console.log(`Plist already installed and up to date at ${target}.`); + } else if (origin === "repo-local") { + console.error(`Existing ${target} points at a local repo checkout.`); + console.error("Migrating an existing dev install is destructive — review and confirm:"); + console.error(""); + console.error(` 1. Stop the old daemon: launchctl bootout ${gui()}/${LABEL}`); + console.error(" 2. Move your data: mv data/* " + dataDir + "/ (run from your repo)"); + console.error(" 3. Re-run with --force: golem install-daemon --force"); + console.error(""); + console.error("Your data directory will be: " + dataDir); + return 1; + } else { + console.error(`A different ${LABEL}.plist exists at ${target}.`); + console.error("Re-run with --force to overwrite, or remove it manually first."); + return 1; + } + } else { + if (opts.dryRun) { + console.log("--- would write to", target, "---"); + console.log(plist); + return 0; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + // If --force and the agent is running, bootout first so the rewrite is clean. + if (opts.force && fs.existsSync(target)) { + launchctl("bootout", `${gui()}/${LABEL}`); // tolerate failure + } + fs.writeFileSync(target, plist); + console.log(`Wrote ${target}`); + } + + if (opts.dryRun) { + console.log(`--- would run: launchctl bootstrap ${gui()} ${target} && kickstart -k ${gui()}/${LABEL} ---`); + return 0; + } + + // bootstrap can fail if it's already loaded — that's fine; kickstart -k handles restart. + const bootstrap = launchctl("bootstrap", gui(), target); + if (!bootstrap.ok && !/already loaded|service exists/i.test(bootstrap.output)) { + console.error(bootstrap.output); + return 1; + } + const kick = launchctl("kickstart", "-k", `${gui()}/${LABEL}`); + if (!kick.ok) { + console.error(kick.output); + return 1; + } + + console.log(""); + console.log("Golem is installed and running."); + console.log(` Status: launchctl print ${gui()}/${LABEL} | head`); + console.log(` Logs: tail -F ~/Library/Logs/${LABEL}.log`); + console.log(""); + return 0; +} + +interface UninstallOptions { + dryRun: boolean; +} + +export async function uninstall(opts: UninstallOptions): Promise { + const target = plistPath(); + if (!fs.existsSync(target)) { + console.log(`No plist at ${target} — nothing to uninstall.`); + return 0; + } + + if (opts.dryRun) { + console.log(`--- would run: launchctl bootout ${gui()}/${LABEL} && rm ${target} ---`); + return 0; + } + + console.log("Stopping and unloading com.golem.agent..."); + launchctl("bootout", `${gui()}/${LABEL}`); // tolerate failure: may already be down + fs.unlinkSync(target); + console.log(`Removed ${target}.`); + return 0; +} diff --git a/src/cli/install-daemon/unit-renderers.test.ts b/src/cli/install-daemon/unit-renderers.test.ts new file mode 100644 index 0000000..8e6a2f3 --- /dev/null +++ b/src/cli/install-daemon/unit-renderers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; + +import { inferPlistOrigin, renderLaunchdPlist, renderSystemdUnit } from "./unit-renderers.js"; + +const CTX = { + golemBinary: "/Users/aviv/.bun/install/global/node_modules/golem-agent/bin/golem.js", + homeDir: "/Users/aviv", + dataDir: "/Users/aviv/.local/share/golem", +}; + +describe("renderSystemdUnit", () => { + test("references the supplied binary and data dir", () => { + const out = renderSystemdUnit(CTX); + expect(out).toContain(`ExecStart=${CTX.golemBinary} start`); + expect(out).toContain(`WorkingDirectory=${CTX.dataDir}`); + expect(out).toContain(`Environment=GOLEM_DATA_DIR=${CTX.dataDir}`); + }); + test("uses Restart=on-failure (not always) so `golem stop` works", () => { + expect(renderSystemdUnit(CTX)).toContain("Restart=on-failure"); + }); + test("installs as a default.target dep so it starts at user login", () => { + expect(renderSystemdUnit(CTX)).toContain("WantedBy=default.target"); + }); +}); + +describe("renderLaunchdPlist", () => { + test("uses absolute paths everywhere — launchd doesn't expand ~", () => { + const out = renderLaunchdPlist(CTX); + expect(out).toContain(`${CTX.golemBinary}`); + expect(out).toContain(`${CTX.dataDir}`); + expect(out).not.toContain("~/Library"); + }); + test("includes KeepAlive and RunAtLoad", () => { + const out = renderLaunchdPlist(CTX); + expect(out).toContain("KeepAlive\n "); + expect(out).toContain("RunAtLoad\n "); + }); + test("logs go to ~/Library/Logs", () => { + const out = renderLaunchdPlist(CTX); + expect(out).toContain(`${CTX.homeDir}/Library/Logs/com.golem.agent.log`); + }); +}); + +describe("inferPlistOrigin", () => { + test("recognizes a plist that matches the new binary path", () => { + expect(inferPlistOrigin(renderLaunchdPlist(CTX), CTX.golemBinary)).toBe("matches"); + }); + test("recognizes the old repo-local plist (bin/golem.js)", () => { + const oldPlist = ` + ProgramArguments + + /usr/bin/env + node + /Users/aviv/src/agents/Personal_Agent/bin/golem.js + + `; + expect(inferPlistOrigin(oldPlist, CTX.golemBinary)).toBe("repo-local"); + }); + test("returns unknown for arbitrary other plists", () => { + expect(inferPlistOrigin("", CTX.golemBinary)).toBe("unknown"); + }); +}); diff --git a/src/cli/install-daemon/unit-renderers.ts b/src/cli/install-daemon/unit-renderers.ts new file mode 100644 index 0000000..c62bff5 --- /dev/null +++ b/src/cli/install-daemon/unit-renderers.ts @@ -0,0 +1,106 @@ +/** + * Pure renderers for systemd unit and launchd plist contents. + * + * Split out so the rendering is unit-testable without invoking systemctl / + * launchctl. The write + activate logic lives in linux.ts / macos.ts. + */ + +export interface UnitContext { + /** Absolute path to the `golem` executable that the unit should launch. */ + golemBinary: string; + /** Absolute path to the user's home directory (paths inside the unit must be absolute). */ + homeDir: string; + /** Absolute path to the data directory the daemon should treat as its working dir. */ + dataDir: string; +} + +/** + * systemd user unit. Lives at ~/.config/systemd/user/golem.service. + * + * Type=simple because we want systemd to track our PID directly (the start + * subcommand stays in the foreground). Restart=on-failure (not 'always') so + * an intentional `golem stop` doesn't loop forever. WantedBy=default.target + * means it auto-starts at user login (and after lingering is enabled, at boot). + */ +export function renderSystemdUnit(ctx: UnitContext): string { + return [ + "[Unit]", + "Description=Golem multi-agent platform", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${ctx.golemBinary} start`, + `WorkingDirectory=${ctx.dataDir}`, + `Environment=GOLEM_DATA_DIR=${ctx.dataDir}`, + "Environment=NODE_ENV=production", + "Restart=on-failure", + "RestartSec=5", + "StandardOutput=journal", + "StandardError=journal", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +/** + * launchd plist. Lives at ~/Library/LaunchAgents/com.golem.agent.plist. + * + * KeepAlive=true matches the existing repo's convention so the daemon is + * always running while the user is logged in. WorkingDirectory must be an + * absolute path — launchd doesn't expand ~. + */ +export function renderLaunchdPlist(ctx: UnitContext): string { + const logDir = `${ctx.homeDir}/Library/Logs`; + return ` + + + + Label + com.golem.agent + ProgramArguments + + ${ctx.golemBinary} + start + + KeepAlive + + RunAtLoad + + WorkingDirectory + ${ctx.dataDir} + EnvironmentVariables + + GOLEM_DATA_DIR + ${ctx.dataDir} + NODE_ENV + production + + StandardOutPath + ${logDir}/com.golem.agent.log + StandardErrorPath + ${logDir}/com.golem.agent.error.log + + +`; +} + +/** + * Inspect an existing plist's to detect whether it points + * at the new installed binary or at the old repo-local `bin/golem.js`. The + * regex is intentionally loose — we just want to recognize "uses an absolute + * path under a checkout that isn't the global install." + */ +export function inferPlistOrigin( + plistContent: string, + expectedBinary: string, +): "matches" | "repo-local" | "unknown" { + const args = plistContent.match(/ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/); + if (!args) return "unknown"; + if (args[1].includes(expectedBinary)) return "matches"; + if (/[^<]*\/bin\/golem\.js<\/string>/.test(args[1])) return "repo-local"; + return "unknown"; +} From 495d03a871238cf43f07b5cd9ac9206ff182cf78 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:42:42 +0300 Subject: [PATCH 07/15] =?UTF-8?q?chore(release):=20publish=20prep=20?= =?UTF-8?q?=E2=80=94=20build=20script,=20prepublishOnly,=20v0.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the version to 0.2.0 (first XDG-aware release / new CLI surface) and wires the package up to be publishable with `npm publish`: - build: rm -rf dist && tsc — emits to dist/ (tsconfig already targets it) - prepublishOnly: typecheck + test + build, so we can't ship something broken - files: now includes dist/, bin/, skills/, ui/ (minus .next + node_modules), and src/ as a fallback for tsx-based dev installs; excludes *.test.ts and the test harness bin/golem.js already prefers dist/cli.js when present, so an installed copy runs the compiled JS directly via plain node — no tsx dependency at runtime. Not auto-publishing: that's an irreversible outward-facing action and needs your explicit approval. To ship: `npm publish` (or `npm publish --tag beta` for a pre-release channel). Refs: bd vx9. --- package.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index aa81bbd..57770b8 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,23 @@ { "name": "golem-agent", - "version": "0.1.5", + "version": "0.2.0", "description": "Workflow-first personal AI agent. Multi-platform messaging. Semantic memory. Autonomous operation.", "type": "module", "bin": { "golem": "./bin/golem.js" }, "files": [ + "dist/", + "bin/", + "skills/", + "ui/", + "!ui/.next/", + "!ui/node_modules/", "src/", + "!src/**/*.test.ts", "!src/__tests__/", "!src/**/__tests__/", "!src/test-harness.ts", - "bin/", "README.md", "LICENSE" ], @@ -42,6 +48,8 @@ "test": "bun test", "lint": "eslint src/", "typecheck": "tsc --noEmit", + "build": "rm -rf dist && tsc", + "prepublishOnly": "npm run typecheck && npm run test && npm run build", "prepare": "husky" }, "dependencies": { From 12e6eb43df612429addcc77b98d3e49983a6e48f Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 17:45:27 +0300 Subject: [PATCH 08/15] docs(readme): VPS-install voice + FAQ + tsconfig.build.json Rewrites README around the install-first story for hesitating VPS users. Two-command install up front, dev clone path demoted to a later 'Development' section. New 'Managing your install' section documents the CLI surface (status/logs/doctor/stop/start/update). New FAQ covers the questions a self-hoster actually asks: SSH tunnels vs public UI with auth, ffmpeg, Docker, backup/restore, uninstall, model selection. Adds tsconfig.build.json that extends the root config and excludes *.test.ts + test-harness from the emit. Build script now uses it so dist/ no longer contains compiled tests (which were getting picked up by `bun test` and double-running with mismatched module identity). Typecheck still covers tests because it uses the unconstrained root config. Refs: bd 88h. --- README.md | 231 ++++++++++++++++++-------------------------- package.json | 2 +- tsconfig.build.json | 8 +- 3 files changed, 102 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 9c2000b..a35e37d 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,30 @@ ![Welcome](screenshots/welcome.png) -## From zero to your first agent in 2 minutes +## Install on a VPS in two commands ```bash -git clone https://github.com/AvivK5498/Golem.git -cd Golem && npm install -cp .env.example .env # add your OpenRouter API key -npm start # opens http://localhost:3015 +npm install -g golem-agent +golem install-daemon ``` -The onboarding wizard takes it from there — providers, model tiers, your Telegram bot, your first agent's persona. Done. +That's it. The daemon is now running under systemd (Linux) or launchd (macOS), starts at login, and survives reboots. To configure your first agent: -## Philosophy +1. SSH to your VPS with port-forwarding: + ```bash + ssh -L 3015:localhost:3015 you@your-vps + ``` +2. Open **http://localhost:3015** in your laptop's browser. +3. Walk the five-step onboarding wizard. -- **Agents act, they don't chat.** Every agent has tools, schedules, webhooks, and the agency to use them. Conversation is one input among many. -- **One bot per job.** Specialized agents beat one mega-prompt. Spin up a research agent, a code agent, a personal assistant — each with its own bot, its own boundaries. -- **Telegram-native, not Telegram-bolted-on.** Your agents live where you already are. Voice notes in, voice replies out. Group chats, media, buttons, identity. -- **You own the stack.** Your machine, your SQLite, your API keys, your bot tokens. Portable. Forkable. No cloud account required. -- **Configuration is data.** No YAML to edit by hand. The web UI writes SQLite; everything is hot-reloadable. +The UI binds to `127.0.0.1` only — never to a public interface. SSH provides the auth and the tunnel; there's no separate password to manage and no TLS to provision. Tailscale Serve and similar tools work the same way if you'd rather not tunnel every time. + +> First time on a Linux VPS? After `install-daemon` runs, you'll see a reminder to enable lingering: `sudo loginctl enable-linger $USER`. Without it, systemd kills your user services when you log out. ## What your agent can do out of the box +![Dashboard](screenshots/dashboard.png) + | Capability | What it actually means | |---|---| | **Multi-agent platform** | Run N agents in one process, each with its own Telegram bot, persona, model tier, and toolset. | @@ -47,138 +50,89 @@ The onboarding wizard takes it from there — providers, model tiers, your Teleg | **Sub-agent delegation** | A parent agent hands a job to a specialist child. Results compacted before they return. | | **Code agent** | Delegate coding tasks to Claude Code with live progress. Effort-based model selection. | | **Tool approval** | Destructive operations ping you on Telegram with Approve/Deny buttons. 15-minute expiry. | -| **Command security** | Allowlist-based binary execution for `run_command`. You decide what shell commands an agent may run. | -| **Conversation tempo** | Agents see elapsed time between messages and adapt — greetings, stale references, context freshness. | | **Phoenix observability** | OpenTelemetry traces for every turn. Full prompt/response history in the UI for debugging. | -![Dashboard](screenshots/dashboard.png) - -## How it works - -1. **`npm start`** boots the platform daemon and the Next.js control plane. -2. **The onboarding wizard** asks for an OpenRouter key (or a Codex login), three model tiers (low/med/high), and a Telegram bot token from [@BotFather](https://t.me/BotFather). -3. **You describe your first agent** in a sentence. Golem generates a persona, picks tools, and seeds working memory. -4. **You message the bot on Telegram.** That's it. The agent is live. Configure tools, skills, schedules, and webhooks from the web UI as you go. +## Managing your install -## Skills +Everyday operation is the `golem` CLI: -Skills are markdown files that teach agents how to use tools for specific tasks. Each skill lives in `skills//SKILL.md`: - -```yaml ---- -name: my-skill -description: "What this skill does" -requires: - env: [API_KEY] - bins: [some-cli] ---- - -# My Skill - -Instructions for the agent on how to use this skill... +```bash +golem status # is the daemon running? where's the data dir? +golem logs -f # tail the daemon logs (journald / launchd / data-dir fallback) +golem doctor # validate Node, disk, OpenRouter key, every bot token via getMe +golem stop # graceful SIGTERM with a 10s grace window +golem start # foreground start (the daemon uses this under systemd/launchd) +golem update # pull the latest published version (placeholder — manual `npm i -g` for now) ``` -![Skills](screenshots/skills.png) - ---- - -## Under the hood - -For the engineers. Golem is a **single Node.js process**, multi-tenant by convention. No microservices, no external DB, no Kubernetes. A laptop under `launchd` is the production target. +**Configuration lives at:** +- `~/.local/share/golem/` on Linux (or `$XDG_DATA_HOME/golem`) +- `~/Library/Application Support/golem/` on macOS +- Override with `GOLEM_DATA_DIR` -### Architecture +`golem status` prints the resolved path, and the data directory is chmod'd to `700` so secrets aren't world-readable. -- **Backend** (port 3847) — HTTP API, Telegram transports, agent runners, job scheduler. -- **Web UI** (port 3015) — Next.js 16 control plane, proxied to backend. +**Logs live at:** +- Linux: `journalctl --user -u golem` (or `golem logs`) +- macOS: `~/Library/Logs/com.golem.agent.log` (or `golem logs`) -All state lives in SQLite under the data directory: +## Philosophy -| Database | Purpose | -|---|---| -| `agents.db` | Agent definitions (config, persona, memory template) | -| `settings.db` | Runtime settings (model tiers, behavior, integrations) | -| `platform-memory.db` | Conversation history and working memory | -| `crons.db` | Scheduled tasks | -| `jobs.db` | Async job queue (coding, HTTP polling, workflows) | -| `feed.db` | Activity audit log | +- **Agents act, they don't chat.** Every agent has tools, schedules, webhooks, and the agency to use them. Conversation is one input among many. +- **One bot per job.** Specialized agents beat one mega-prompt. Spin up a research agent, a code agent, a personal assistant — each with its own bot, its own boundaries. +- **Telegram-native, not Telegram-bolted-on.** Your agents live where you already are. Voice notes in, voice replies out. Group chats, media, buttons, identity. +- **You own the stack.** Your machine, your SQLite, your API keys, your bot tokens. Portable. Forkable. No cloud account required. +- **Configuration is data.** No YAML to edit by hand. The web UI writes SQLite; everything is hot-reloadable. -### Agent message flow +## How it works ``` -Telegram → Transport → Dedup → Chat classification - → Media processing (vision / voice transcription) - → AgentRunner → agent.generate() with memory + tools - → Response → Telegram +You ──Telegram──▶ Bot ──▶ Agent (Mastra) + │ + ├─▶ Tools (read/write workspace, web search, code agent, ...) + ├─▶ Skills (declarative SKILL.md capabilities) + ├─▶ Sub-agents (delegate specialised work) + ├─▶ MCP servers (any tool you can speak MCP to) + ├─▶ Working memory (your coffee order) + └─▶ Recall (semantic + recency) ``` -### Processors pipeline - -Input processors run before each LLM step, in order: - -- **PromptCache** — marks the Anthropic prompt-cache boundary -- **ImageStripper** — strips base64 image data from history -- **AsyncJobGuard** — stops the agent loop after an async job dispatch -- **ToolCallFilter** — strips tool calls from recalled history -- **SubAgentResultCompactor** — compacts verbose sub-agent results -- **ToolResultSanitizer** — caps oversized tool results -- **MessageTimestamp** — prepends `[Apr 18 09:35]` markers for tempo awareness -- **OwnerStepBudget** — enforces a per-step tool-call budget (8 primary, 4 sub-agent) -- **TokenLimiter** — 170K-token-per-turn ceiling -- **ToolErrorGate** — disables tools after repeated failures - -Output processors run after each turn, before memory persistence: +The web UI doesn't reach Mastra directly. It reads/writes SQLite tables (`agents.db`, `settings.db`, `crons.db`, `feed.db`) and the platform reacts. That's why everything is hot-reloadable: change a persona, the next message uses the new one. -- **SubAgentResultCompactor**, **ToolResultSanitizer**, **ImageStripper**, **ReasoningStripper**, **GroupIdentity**. - -### LLM tiers, not model IDs - -Three global tiers (low / med / high) map to specific OpenRouter model IDs. Each agent stores a *tier*, not a model. Change the tier's model and every agent on it updates. Override per-agent when you need to. - -### Behavior dropdowns +![Skills](screenshots/skills.png) -Each agent's response style is governed by five dropdowns: +## Skills -| Setting | Options | -|---|---| -| Response Length | Brief / Balanced / Detailed | -| Agency | Execute first / Ask before acting / Consultative | -| Tone | Casual / Balanced / Professional | -| Format | Texting / Conversational / Structured | -| Language | English / Hebrew / Auto-detect | +A skill is a `SKILL.md` file with YAML frontmatter and human-readable instructions. The agent decides when to invoke one based on `description`. Two bundled skills out of the box: web-search and time-zone-aware scheduling. Add your own under `~/.local/share/golem/skills/` or set `GOLEM_SKILLS_DIR`. ## Requirements -- macOS or Linux (Windows not supported yet) -- Node.js 20+ -- An [OpenRouter](https://openrouter.ai/keys) API key, or a ChatGPT Plus/Pro subscription for Codex models -- A Telegram bot token from [@BotFather](https://t.me/BotFather) +- **OS:** Linux (x86_64 or arm64) or macOS. Windows isn't supported yet. +- **Node:** 20 or newer. +- **An OpenRouter API key** ([get one](https://openrouter.ai/keys)). +- **A Telegram bot token** ([@BotFather](https://t.me/BotFather)). +- **Optional:** Groq API key (free Whisper tier), ElevenLabs (TTS), a Claude Code login (delegated coding). -### Environment variables +Environment overrides: -| Variable | Required | Description | -|---|---|---| -| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for LLM access | -| `GROQ_API_KEY` | No | Voice transcription (Whisper, free tier) | -| `ELEVENLABS_API_KEY` | No | Voice replies (TTS) | -| `GOLEM_DATA_DIR` | No | Custom data directory (default: `./data`) | -| `GOLEM_SKILLS_DIR` | No | Custom skills directory (default: `./skills`) | - -### Optional: code agent +| Variable | Description | +|---|---| +| `OPENROUTER_API_KEY` | LLM access (set by the wizard or in your `.env`) | +| `GOLEM_DATA_DIR` | Override the data directory | +| `GOLEM_SKILLS_DIR` | Override the skills directory | +| `GROQ_API_KEY` | Voice transcription | +| `ELEVENLABS_API_KEY` | Voice replies | -To let your agents delegate coding tasks to [Claude Code](https://claude.ai/code): +## Development ```bash -npm install -g @anthropic-ai/claude-code -claude login # one-time OAuth in browser +git clone https://github.com/AvivK5498/Golem.git +cd Golem && npm install +cp .env.example .env +npm start # runs platform + Next.js dev UI together on :3015 ``` -Then enable the `code_agent` tool on any agent. It can write code, refactor, run tests, and install dependencies by spawning Claude Code sessions. - -## Development - -### Test harness - -A standalone CLI that exercises the full agent pipeline without Telegram. Real LLM calls, Phoenix traces, no transport mocks. +`npm start` uses `concurrently` to bring up both processes. `bun test` runs the unit suite. `npm run test:agent "your prompt"` exercises an agent end-to-end against real LLMs without Telegram. ```bash npm run test:agent "Hello, what can you do?" @@ -187,36 +141,39 @@ npm run test:agent -- --image /path/to/image.jpg "What do you see?" npm run test:agent -- --mount vault:/path/to/dir:rw "Read and write files under /mnt/vault" ``` -### Unit tests +To publish a release: `npm run build && npm publish` (prepublishOnly runs typecheck + tests + build automatically). -```bash -bun test -``` +## FAQ -## Tech stack +**Why SSH tunnels instead of a public web UI with a password?** +Exposing a self-hosted admin panel to the internet — even with auth — is one of the most reliable ways to get owned. Locking the UI to `127.0.0.1` removes that whole class of vulnerability. The trade-off is one extra `ssh -L` flag, which we think is worth it. OpenClaw and similar tools land on the same answer. + +**Can I run it without the daemon, just for testing?** +Yes — `git clone` + `npm start` runs both the platform and the dev UI without installing anything system-wide. The daemon is only needed when you want Golem to run unattended. -- **Runtime**: Node.js 20+ / TypeScript (ES modules) -- **Agent framework**: [Mastra](https://mastra.ai) (`@mastra/core`) -- **LLM providers**: [OpenRouter](https://openrouter.ai) (300+ models) + [OpenAI Codex](https://openai.com/index/introducing-codex/) (ChatGPT subscription, fair-use quota) -- **Messaging**: Telegram ([grammY](https://grammy.dev)) -- **Memory**: LibSQL (conversation history + working memory) -- **Storage**: SQLite (better-sqlite3) -- **UI**: Next.js 16 + shadcn/ui + Tailwind CSS v4 -- **Observability**: Phoenix (OpenTelemetry) -- **Testing**: Bun test runner +**Does it need ffmpeg?** +No. Voice transcription sends the OGG/Opus blob straight to the Whisper API — no transcoding needed. ---- +**What about Docker?** +Not officially supported in v1. Per-agent filesystem mounts (Obsidian vaults at `/mnt/`) get awkward in Docker because every new mount needs a compose-file edit and a container restart. -## Ready to ship your AI agent? +**How do I back up my install?** +Stop the daemon, tar the data directory, copy it somewhere safe. The data directory is the install — everything else is reproducible from `npm install`. ```bash -git clone https://github.com/AvivK5498/Golem.git -cd Golem && npm install -cp .env.example .env -npm start +golem stop +tar czf golem-backup-$(date +%F).tgz -C ~/.local/share golem ``` -Open **http://localhost:3015**, walk the wizard, message your bot. Welcome to Golem. +**How do I uninstall?** +`golem uninstall-daemon` removes the systemd unit or launchd plist and stops the daemon. `npm uninstall -g golem-agent` removes the package itself. Your data directory stays put; remove it manually if you want a clean slate. + +**Which models does it use?** +You pick three OpenRouter model IDs during onboarding — one each for the low, medium, and high tiers. Agents reference a tier (or override with a specific model). Cheap thinking on small queries, expensive thinking when it matters. + +## Tech stack + +Node.js 20+ · TypeScript · [Mastra](https://mastra.ai) · [OpenRouter](https://openrouter.ai) · Telegram ([grammY](https://grammy.dev)) · LibSQL + SQLite · Next.js 16 + shadcn/ui · Phoenix (OpenTelemetry) · Bun test ## License diff --git a/package.json b/package.json index 57770b8..f46b63b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "test": "bun test", "lint": "eslint src/", "typecheck": "tsc --noEmit", - "build": "rm -rf dist && tsc", + "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepublishOnly": "npm run typecheck && npm run test && npm run build", "prepare": "husky" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index e707937..8a54412 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,10 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "dist", "data", "src/__tests__"] + "exclude": [ + "node_modules", + "dist", + "data", + "src/**/*.test.ts", + "src/test-harness.ts" + ] } From 7fb6a6d270010bc74a09b0a22bd7db3d0d65ab50 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 18:48:07 +0300 Subject: [PATCH 09/15] fix(doctor): ESM-safe require + handle null limit_remaining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions surfaced by a real VPS install: - doctor crashed with 'ReferenceError: require is not defined' on the dynamic require('better-sqlite3'). ESM has no global require — use createRequire(import.meta.url) bound to this module. - OpenRouter returns limit_remaining=null for keys with no cap; the check called .toFixed on it and crashed. Guard with typeof === 'number'. Caught during the 2026-05-15 Vultr smoke test. --- src/cli/doctor.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index bddab0a..931f427 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -17,10 +17,16 @@ * * ffmpeg is NOT checked — Whisper API accepts Telegram OGG directly. */ +import { createRequire } from "node:module"; import fs from "node:fs"; import fsp from "node:fs/promises"; import { dataPath, describeDataDirResolution } from "../utils/paths.js"; + +// ESM has no global require; synthesize one bound to this module so we can +// lazy-load the optional better-sqlite3 dependency without forcing a top-level +// import (and without making doctor depend on AgentStore's full lifecycle). +const require = createRequire(import.meta.url); import { validateOpenRouterKey } from "../utils/openrouter-validate.js"; import { validateTelegramToken } from "../utils/telegram-validate.js"; import { getRunningDaemon } from "./pid.js"; @@ -70,7 +76,10 @@ async function checkOpenRouterKey(): Promise { if (!key) return check("OpenRouter key", "fail", "OPENROUTER_API_KEY not set — run setup first"); const r = await validateOpenRouterKey(key); if (r.ok) { - const detail = r.limitRemaining !== undefined ? `valid (credit ${r.limitRemaining.toFixed(2)})` : "valid"; + // Some accounts have no limit set, in which case OpenRouter returns null + // for limit_remaining. Only format when we actually have a number. + const detail = + typeof r.limitRemaining === "number" ? `valid (credit ${r.limitRemaining.toFixed(2)})` : "valid"; return check("OpenRouter key", "ok", detail); } return check("OpenRouter key", "fail", r.error); @@ -91,10 +100,12 @@ function readAgentTelegramTokens(): TelegramAgentSummary[] | null { if (!fs.existsSync(dbPath)) return null; // Open readonly so a running daemon's writes are unaffected. Using better-sqlite3 - // dynamically here keeps doctor decoupled from AgentStore's lifecycle (no - // migration runs, no schema upgrades). - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { default: Database } = require("better-sqlite3") as { default: new (p: string, o?: { readonly?: boolean; fileMustExist?: boolean }) => { prepare(sql: string): { all(): unknown[] }; close(): void } }; + // via createRequire here keeps doctor decoupled from AgentStore's lifecycle + // (no migration runs, no schema upgrades). + const Database = require("better-sqlite3") as new ( + p: string, + o?: { readonly?: boolean; fileMustExist?: boolean }, + ) => { prepare(sql: string): { all(): unknown[] }; close(): void }; const db = new Database(dbPath, { readonly: true, fileMustExist: true }); try { type Row = { id: string; config_json: string }; From d99ada23f1ff8ab2a0cf1af7625517bb80c0c91d Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 18:48:25 +0300 Subject: [PATCH 10/15] feat(cli): bundle + spawn Next.js UI from `golem start` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, an installed `npm i -g golem-agent` only ran the platform API on :3847 — nothing listened on :3015 and the SSH tunnel got 'connection reset' when the user tried to open the wizard. The earlier README claim that `golem install-daemon` was enough was wrong. Fix: - ui/next.config.ts: output: 'standalone' plus outputFileTracingRoot / turbopack.root pointed at the workspace, so Next emits a self-contained ui/.next/standalone/ui/server.js bundle (with its own node_modules). - src/cli/ui-server.ts: spawn that server.js as a child of `golem start`, binding HOSTNAME=127.0.0.1 (loopback only) + PORT=3015. Handle clean SIGTERM on shutdown; warn cleanly if the bundle isn't built (dev clone). - src/cli/start.ts: wire startUi() into the lifecycle. - package.json: build script now also runs the UI build and copies ui/public + ui/.next/static into the standalone tree (Next standalone's required layout). files: includes ui/.next/standalone/. - src/cli.ts: load .env from BOTH cwd and the data dir (override:false so cwd wins on conflict). Onboarding wizard writes .env to the daemon's cwd which is the data dir; `golem doctor` from an interactive shell has cwd=$HOME and was finding nothing. With this, doctor and the daemon see the same env. Verified end-to-end on a fresh Vultr Ubuntu 24.04: npm i -g, install-daemon, SSH tunnel, walked the wizard, agent responded on Telegram, doctor all green. --- .gitignore | 1 + package-lock.json | 4 +-- package.json | 6 ++-- src/cli/start.ts | 13 +++++++- src/cli/ui-server.ts | 73 ++++++++++++++++++++++++++++++++++++++++++++ ui/next.config.ts | 15 +++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 src/cli/ui-server.ts diff --git a/.gitignore b/.gitignore index ecd2396..b646ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ docs/brain/ # Beads / Dolt files (added by bd init) .dolt/ .beads-credential-key +golem-agent-*.tgz diff --git a/package-lock.json b/package-lock.json index 8abf87e..3931696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "golem-agent", - "version": "0.1.5", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "golem-agent", - "version": "0.1.5", + "version": "0.2.0", "license": "MIT", "workspaces": [ "ui" diff --git a/package.json b/package.json index f46b63b..9309e45 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,7 @@ "dist/", "bin/", "skills/", - "ui/", - "!ui/.next/", - "!ui/node_modules/", + "ui/.next/standalone/", "src/", "!src/**/*.test.ts", "!src/__tests__/", @@ -48,7 +46,7 @@ "test": "bun test", "lint": "eslint src/", "typecheck": "tsc --noEmit", - "build": "rm -rf dist && tsc -p tsconfig.build.json", + "build": "rm -rf dist ui/.next && tsc -p tsconfig.build.json && npm run build --workspace=ui && cp -r ui/public ui/.next/standalone/ui/ && cp -r ui/.next/static ui/.next/standalone/ui/.next/", "prepublishOnly": "npm run typecheck && npm run test && npm run build", "prepare": "husky" }, diff --git a/src/cli/start.ts b/src/cli/start.ts index 96446f4..9490dbc 100644 --- a/src/cli/start.ts +++ b/src/cli/start.ts @@ -7,6 +7,7 @@ */ import { detectSshSession, printFirstRunBanner } from "./first-run-banner.js"; import { getRunningDaemon, removePidFile, writePidFile } from "./pid.js"; +import { startUi, type UiHandle } from "./ui-server.js"; export async function run(_args: string[]): Promise { const running = getRunningDaemon(); @@ -26,10 +27,12 @@ export async function run(_args: string[]): Promise { const { startPlatform } = await import("../platform/platform.js"); let shuttingDown = false; + let uiHandle: UiHandle | null = null; const shutdown = (signal: NodeJS.Signals): void => { if (shuttingDown) return; shuttingDown = true; console.log(`[golem] received ${signal}, shutting down...`); + uiHandle?.stop(); removePidFile(); // The platform registers its own graceful-shutdown handlers; we just need to // make sure the pid file is gone. Re-raise so Node exits naturally. @@ -37,7 +40,10 @@ export async function run(_args: string[]): Promise { }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); - process.on("exit", removePidFile); + process.on("exit", () => { + uiHandle?.stop(); + removePidFile(); + }); try { await startPlatform(); @@ -47,6 +53,11 @@ export async function run(_args: string[]): Promise { return 1; } + // Spawn the Next.js UI after the platform is up so its rewrite proxy (/api/* -> :3847) + // has something to proxy to. If the standalone bundle isn't present (dev clone + // without a build) startUi warns and returns null — the platform still runs. + uiHandle = startUi(); + writePidFile(); // Keep the process alive — transports and scheduler run on intervals/callbacks. await new Promise(() => {}); diff --git a/src/cli/ui-server.ts b/src/cli/ui-server.ts new file mode 100644 index 0000000..0742a6d --- /dev/null +++ b/src/cli/ui-server.ts @@ -0,0 +1,73 @@ +/** + * Spawn the bundled Next.js UI as a child process during `golem start`. + * + * The UI is built with `output: "standalone"` so the bundle is self-contained + * (its own node_modules, no workspace resolution). The server.js entrypoint + * lives at /ui/.next/standalone/ui/server.js — the extra `/ui/` + * segment is preserved by outputFileTracingRoot pointing at the workspace. + * + * In a dev checkout the standalone bundle doesn't exist (unless someone ran + * `npm run build` from the root). We detect that and skip the spawn with a + * clear warning rather than crashing. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const UI_PORT = 3015; + +/** + * Locate the standalone server.js. Walks up from this module to find the + * package root, then checks the canonical standalone location. Returns null + * when the bundle isn't present (e.g. dev clone without a UI build). + */ +function findStandaloneServer(): string | null { + const here = path.dirname(fileURLToPath(import.meta.url)); + // src/cli/ or dist/cli/ -> ../.. -> package root + const packageRoot = path.resolve(here, "..", ".."); + const candidate = path.join(packageRoot, "ui", ".next", "standalone", "ui", "server.js"); + return fs.existsSync(candidate) ? candidate : null; +} + +export interface UiHandle { + child: ChildProcess; + stop(): void; +} + +export function startUi(): UiHandle | null { + const serverPath = findStandaloneServer(); + if (!serverPath) { + console.warn("[ui] standalone bundle not found — UI will not be served."); + console.warn("[ui] If you cloned the repo, run `npm install && npm run build`."); + console.warn("[ui] If you installed via npm, this is a packaging bug — please report."); + return null; + } + + console.log(`[ui] starting Next.js on 127.0.0.1:${UI_PORT}`); + const child = spawn(process.execPath, [serverPath], { + stdio: ["ignore", "inherit", "inherit"], + env: { + ...process.env, + // Loopback-only — never expose the UI publicly. SSH tunnel / Tailscale is the auth. + HOSTNAME: "127.0.0.1", + PORT: String(UI_PORT), + }, + }); + + child.on("exit", (code, signal) => { + // Only complain if we didn't intend to stop it. shuttingDown sets killed=true. + if (!child.killed) { + console.error(`[ui] exited unexpectedly (code=${code} signal=${signal})`); + } + }); + + return { + child, + stop() { + if (!child.killed && child.pid) { + child.kill("SIGTERM"); + } + }, + }; +} diff --git a/ui/next.config.ts b/ui/next.config.ts index 037096c..c2c276c 100644 --- a/ui/next.config.ts +++ b/ui/next.config.ts @@ -1,7 +1,22 @@ import type { NextConfig } from "next"; +import path from "node:path"; + +// Workspace root: / — one level up from ui/. Tells Next.js where the +// real package.json + node_modules root lives so Turbopack can resolve `next` +// at build time, and so standalone bundling preserves the right relative +// structure (server.js lands at .next/standalone/ui/server.js). +const workspaceRoot = path.resolve(process.cwd(), ".."); const nextConfig: NextConfig = { devIndicators: false, + // Bundle into a self-contained server.js so the UI runs without npm-installing + // workspace deps on the target machine. Required for the `npm i -g golem-agent` + // story to work end-to-end. + output: "standalone", + outputFileTracingRoot: workspaceRoot, + turbopack: { + root: workspaceRoot, + }, async rewrites() { return [ { From e4165ce2ab20960f701b83c7741ad884bc11ac32 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 18:51:08 +0300 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20restructure=20=E2=80=94=20slim=20?= =?UTF-8?q?README,=20INSTALL/CLI/FAQ=20in=20docs/,=20Vultr=20one-click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README goes from a 180-line everything-doc to a 79-line landing page: quickstart (VPS + local), Deploy on Vultr badge, feature list, philosophy. Everything else moves to docs/ where it's deeper and discoverable. - docs/INSTALL.md: three install paths (Vultr one-click, manual VPS, local dev) with detailed steps, including the 'use any free local port' SSH tunnel guidance and backup/restore/update/uninstall. - docs/CLI.md: full subcommand reference with flags, exit codes, environment vars. - docs/FAQ.md: the FAQ from the old README, expanded. scripts/vultr-startup.sh: first-boot bootstrap for the Deploy on Vultr button. Idempotent — installs Node 24 if missing, npm-installs golem, runs install-daemon, enables linger, writes a helpful MOTD telling the SSH'ing user what to do next. The deploy URL in the badge currently has __VULTR_SCRIPT_ID__ and __VULTR_AFFILIATE_REF__ placeholders — owner needs to register the script in Vultr's dashboard, get the ID, and substitute both in the README before publishing. --- README.md | 171 ++++++++++----------------------------------- docs/CLI.md | 112 ++++++++++++++++++++++++++++++ docs/FAQ.md | 79 +++++++++++++++++++++ docs/INSTALL.md | 179 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 136 deletions(-) create mode 100644 docs/CLI.md create mode 100644 docs/FAQ.md create mode 100644 docs/INSTALL.md diff --git a/README.md b/README.md index a35e37d..eaeca06 100644 --- a/README.md +++ b/README.md @@ -5,171 +5,70 @@

🤖 Multi-agent  •  💬 Telegram-native  •  🧠 Working memory  •  🔧 Skills & MCP  •  ⏰ Schedules & webhooks

- Built on Mastra - LLMs - MIT License + Deploy on Vultr + Built on Mastra + OpenRouter + MIT License

![Welcome](screenshots/welcome.png) -## Install on a VPS in two commands +## Quick start + +**On a VPS:** ```bash npm install -g golem-agent golem install-daemon ``` -That's it. The daemon is now running under systemd (Linux) or launchd (macOS), starts at login, and survives reboots. To configure your first agent: - -1. SSH to your VPS with port-forwarding: - ```bash - ssh -L 3015:localhost:3015 you@your-vps - ``` -2. Open **http://localhost:3015** in your laptop's browser. -3. Walk the five-step onboarding wizard. - -The UI binds to `127.0.0.1` only — never to a public interface. SSH provides the auth and the tunnel; there's no separate password to manage and no TLS to provision. Tailscale Serve and similar tools work the same way if you'd rather not tunnel every time. - -> First time on a Linux VPS? After `install-daemon` runs, you'll see a reminder to enable lingering: `sudo loginctl enable-linger $USER`. Without it, systemd kills your user services when you log out. - -## What your agent can do out of the box - -![Dashboard](screenshots/dashboard.png) - -| Capability | What it actually means | -|---|---| -| **Multi-agent platform** | Run N agents in one process, each with its own Telegram bot, persona, model tier, and toolset. | -| **AI-generated personas** | Describe the job in a sentence; Golem writes the persona — identity, boundaries, domain expertise. | -| **Working memory** | A persistent scratchpad per agent. Tell it your coffee order on Monday, it remembers on Friday. | -| **Skills & MCP** | Drop a `SKILL.md` into `skills/` or wire up an MCP server. Your agent learns a new trick. | -| **Filesystem mounts** | Mount an Obsidian vault or any directory at `/mnt/`. Read-only or read-write. Sub-agents inherit. | -| **Schedules & webhooks** | Cron-driven check-ins, webhook handlers (GitHub, Strava, CI) with LLM-based scenario routing. | -| **Proactive check-ins** | Agents initiate. Configurable cadence, probability gates, active-hours windows. | -| **Voice in, voice out** | Whisper transcription via Groq, optional ElevenLabs TTS replies — per-agent modes. | -| **Group chats, handled** | LLM classifier decides when to chime in. Identity tagging keeps multi-bot rooms sane. | -| **Sub-agent delegation** | A parent agent hands a job to a specialist child. Results compacted before they return. | -| **Code agent** | Delegate coding tasks to Claude Code with live progress. Effort-based model selection. | -| **Tool approval** | Destructive operations ping you on Telegram with Approve/Deny buttons. 15-minute expiry. | -| **Phoenix observability** | OpenTelemetry traces for every turn. Full prompt/response history in the UI for debugging. | - -## Managing your install - -Everyday operation is the `golem` CLI: +That's it. The daemon is running under systemd (Linux) or launchd (macOS), survives reboots and SSH logouts. To configure your first agent, open an SSH tunnel from your laptop and visit the wizard: ```bash -golem status # is the daemon running? where's the data dir? -golem logs -f # tail the daemon logs (journald / launchd / data-dir fallback) -golem doctor # validate Node, disk, OpenRouter key, every bot token via getMe -golem stop # graceful SIGTERM with a 10s grace window -golem start # foreground start (the daemon uses this under systemd/launchd) -golem update # pull the latest published version (placeholder — manual `npm i -g` for now) -``` - -**Configuration lives at:** -- `~/.local/share/golem/` on Linux (or `$XDG_DATA_HOME/golem`) -- `~/Library/Application Support/golem/` on macOS -- Override with `GOLEM_DATA_DIR` - -`golem status` prints the resolved path, and the data directory is chmod'd to `700` so secrets aren't world-readable. - -**Logs live at:** -- Linux: `journalctl --user -u golem` (or `golem logs`) -- macOS: `~/Library/Logs/com.golem.agent.log` (or `golem logs`) - -## Philosophy - -- **Agents act, they don't chat.** Every agent has tools, schedules, webhooks, and the agency to use them. Conversation is one input among many. -- **One bot per job.** Specialized agents beat one mega-prompt. Spin up a research agent, a code agent, a personal assistant — each with its own bot, its own boundaries. -- **Telegram-native, not Telegram-bolted-on.** Your agents live where you already are. Voice notes in, voice replies out. Group chats, media, buttons, identity. -- **You own the stack.** Your machine, your SQLite, your API keys, your bot tokens. Portable. Forkable. No cloud account required. -- **Configuration is data.** No YAML to edit by hand. The web UI writes SQLite; everything is hot-reloadable. - -## How it works - -``` -You ──Telegram──▶ Bot ──▶ Agent (Mastra) - │ - ├─▶ Tools (read/write workspace, web search, code agent, ...) - ├─▶ Skills (declarative SKILL.md capabilities) - ├─▶ Sub-agents (delegate specialised work) - ├─▶ MCP servers (any tool you can speak MCP to) - ├─▶ Working memory (your coffee order) - └─▶ Recall (semantic + recency) +ssh -L 3015:localhost:3015 you@your-vps +# then open http://localhost:3015 in your browser ``` -The web UI doesn't reach Mastra directly. It reads/writes SQLite tables (`agents.db`, `settings.db`, `crons.db`, `feed.db`) and the platform reacts. That's why everything is hot-reloadable: change a persona, the next message uses the new one. +**Even faster — one click:** the "Deploy on Vultr" badge above provisions a Vultr instance with Golem pre-installed via a first-boot startup script. SSH in, open the tunnel, walk the wizard. -![Skills](screenshots/skills.png) - -## Skills - -A skill is a `SKILL.md` file with YAML frontmatter and human-readable instructions. The agent decides when to invoke one based on `description`. Two bundled skills out of the box: web-search and time-zone-aware scheduling. Add your own under `~/.local/share/golem/skills/` or set `GOLEM_SKILLS_DIR`. - -## Requirements - -- **OS:** Linux (x86_64 or arm64) or macOS. Windows isn't supported yet. -- **Node:** 20 or newer. -- **An OpenRouter API key** ([get one](https://openrouter.ai/keys)). -- **A Telegram bot token** ([@BotFather](https://t.me/BotFather)). -- **Optional:** Groq API key (free Whisper tier), ElevenLabs (TTS), a Claude Code login (delegated coding). - -Environment overrides: - -| Variable | Description | -|---|---| -| `OPENROUTER_API_KEY` | LLM access (set by the wizard or in your `.env`) | -| `GOLEM_DATA_DIR` | Override the data directory | -| `GOLEM_SKILLS_DIR` | Override the skills directory | -| `GROQ_API_KEY` | Voice transcription | -| `ELEVENLABS_API_KEY` | Voice replies | - -## Development +**Local development:** ```bash git clone https://github.com/AvivK5498/Golem.git cd Golem && npm install cp .env.example .env -npm start # runs platform + Next.js dev UI together on :3015 -``` - -`npm start` uses `concurrently` to bring up both processes. `bun test` runs the unit suite. `npm run test:agent "your prompt"` exercises an agent end-to-end against real LLMs without Telegram. - -```bash -npm run test:agent "Hello, what can you do?" -npm run test:agent -- --verbose "Run ls /tmp" -npm run test:agent -- --image /path/to/image.jpg "What do you see?" -npm run test:agent -- --mount vault:/path/to/dir:rw "Read and write files under /mnt/vault" +npm start # http://localhost:3015 ``` -To publish a release: `npm run build && npm publish` (prepublishOnly runs typecheck + tests + build automatically). +For full install options, see **[docs/INSTALL.md](./docs/INSTALL.md)**. For the CLI reference, **[docs/CLI.md](./docs/CLI.md)**. For common questions, **[docs/FAQ.md](./docs/FAQ.md)**. -## FAQ +## What your agent can do -**Why SSH tunnels instead of a public web UI with a password?** -Exposing a self-hosted admin panel to the internet — even with auth — is one of the most reliable ways to get owned. Locking the UI to `127.0.0.1` removes that whole class of vulnerability. The trade-off is one extra `ssh -L` flag, which we think is worth it. OpenClaw and similar tools land on the same answer. - -**Can I run it without the daemon, just for testing?** -Yes — `git clone` + `npm start` runs both the platform and the dev UI without installing anything system-wide. The daemon is only needed when you want Golem to run unattended. - -**Does it need ffmpeg?** -No. Voice transcription sends the OGG/Opus blob straight to the Whisper API — no transcoding needed. +![Dashboard](screenshots/dashboard.png) -**What about Docker?** -Not officially supported in v1. Per-agent filesystem mounts (Obsidian vaults at `/mnt/`) get awkward in Docker because every new mount needs a compose-file edit and a container restart. +Each agent runs in its own Telegram bot with a custom persona, working memory, schedules, and a toolset you pick. Out of the box: -**How do I back up my install?** -Stop the daemon, tar the data directory, copy it somewhere safe. The data directory is the install — everything else is reproducible from `npm install`. +- **AI-generated personas** — describe the job in a sentence, Golem writes the prompt. +- **Working memory** — agents remember things between conversations (your coffee order on Monday, used on Friday). +- **Skills & MCP** — drop a `SKILL.md` or wire an MCP server; the agent learns a new trick. +- **Filesystem mounts** — mount an Obsidian vault at `/mnt/`, agents read and write. +- **Schedules & webhooks** — cron-driven check-ins, GitHub/Strava/CI webhook handlers. +- **Voice in, voice out** — Whisper transcription, ElevenLabs TTS replies. +- **Group chats, handled** — LLM classifier decides when to chime in; identity tagging keeps multi-bot rooms sane. +- **Sub-agent delegation** — parent agents hand specialised jobs to specialist children. +- **Code agent** — delegate coding tasks to Claude Code with live progress. +- **Tool approval** — destructive operations ping you on Telegram with Approve/Deny buttons. +- **Phoenix observability** — OpenTelemetry traces for every turn. -```bash -golem stop -tar czf golem-backup-$(date +%F).tgz -C ~/.local/share golem -``` +![Skills](screenshots/skills.png) -**How do I uninstall?** -`golem uninstall-daemon` removes the systemd unit or launchd plist and stops the daemon. `npm uninstall -g golem-agent` removes the package itself. Your data directory stays put; remove it manually if you want a clean slate. +## Philosophy -**Which models does it use?** -You pick three OpenRouter model IDs during onboarding — one each for the low, medium, and high tiers. Agents reference a tier (or override with a specific model). Cheap thinking on small queries, expensive thinking when it matters. +- **Agents act, they don't chat.** Every agent has tools, schedules, webhooks, and the agency to use them. Conversation is one input among many. +- **One bot per job.** Specialized agents beat one mega-prompt. Spin up a research agent, a code agent, a personal assistant — each with its own bot. +- **Telegram-native, not Telegram-bolted-on.** Your agents live where you already are. Voice notes in, voice replies out, group chats, media, buttons. +- **You own the stack.** Your machine, your SQLite, your API keys, your bot tokens. Portable. Forkable. No cloud account required. +- **Configuration is data.** No YAML to edit by hand. The web UI writes SQLite; everything is hot-reloadable. ## Tech stack diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..719e37e --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,112 @@ +# Golem CLI reference + +The `golem` command is a thin ops wrapper. All content management — agents, schedules, settings — lives in the web UI or Telegram. The CLI exists to run, stop, inspect, and diagnose the daemon. + +``` +golem [command] [args] +``` + +Running `golem` with no arguments is equivalent to `golem start`. + +## Commands + +### `golem start` + +Start the platform in the foreground. Refuses to launch a second instance if a live PID file is present. + +Used by systemd / launchd via `install-daemon`. You normally don't call it directly — `systemctl --user start golem` or the launchd plist runs it under the hood. + +When run interactively (e.g. SSH'd in, foreground for debugging), it prints the first-run banner including a copy-paste SSH tunnel command derived from `$SSH_CONNECTION`. + +### `golem stop` + +Send `SIGTERM` to the running daemon, wait up to 10 seconds for graceful shutdown, then `SIGKILL` if it didn't comply. + +Does the right thing whether the daemon was started by `golem start` or by systemd/launchd. For systemd you can also use `systemctl --user stop golem`. + +### `golem status` + +Report whether the daemon is running and where its data lives. + +``` +data dir: /home/you/.local/share/golem (default) +status: running (pid 12345, up 2h 17m) +``` + +Resolution source (`env` / `cwd` / `default`) shows which branch of the data-dir lookup won: +- `env` — `GOLEM_DATA_DIR` was set +- `cwd` — there's a `./data/` directory in your current working dir (dev workflow) +- `default` — OS-native default (`~/.local/share/golem` on Linux, `~/Library/Application Support/golem` on macOS) + +Exit code `0` if running, `3` if not (LSB convention). + +### `golem logs [-n N] [-f]` + +Tail the daemon's logs. Detection order: + +1. Linux with a systemd user unit named `golem` → `journalctl --user -u golem -f` +2. macOS with `com.golem.agent.plist` installed → `tail -F ~/Library/Logs/com.golem.agent.log` +3. Fallback → `tail -F` on the most recent `*.log` under the data dir's `logs/` folder + +Flags: +- `-f`, `--follow` — follow (default) +- `--no-follow` — print recent lines and exit +- `-n N`, `--lines N` — show N most recent lines (default 100) + +### `golem doctor` + +Run health checks. Each check is OK / WARN / FAIL. + +- **Node version** — must be ≥ 20 +- **Data dir writable** — does the data dir exist and is it writable? +- **Disk space** — warn under 1 GiB free, fail under 100 MiB +- **OpenRouter key** — `OPENROUTER_API_KEY` set, and accepted by `/api/v1/key` +- **Telegram tokens** — for each agent in `agents.db`, validates the bot token via Telegram `getMe` (resolves `${VAR}` refs from the data-dir `.env`) +- **Daemon running** — PID file present, process alive +- **Logs dir** — exists and readable + +Exit code `0` if everything is OK or WARN; `1` if any FAIL. + +Notably absent: ffmpeg is **not** checked. Voice transcription sends OGG/Opus directly to Whisper — no transcoding needed. + +### `golem version` + +Print the installed package version. + +### `golem update` + +(Stub.) Will eventually run `npm install -g golem-agent@latest && systemctl --user restart golem`. For now, run those commands manually. + +### `golem install-daemon [--force] [--dry-run]` + +Write a user-level systemd unit (Linux) or launchd plist (macOS) and start the daemon. + +- Linux: `~/.config/systemd/user/golem.service`, then `systemctl --user daemon-reload && enable --now golem.service`. Reminds you to `loginctl enable-linger $USER` if it's not on. +- macOS: `~/Library/LaunchAgents/com.golem.agent.plist`, then `launchctl bootstrap` + `kickstart`. Refuses to overwrite an existing repo-local plist (the dev install pattern) without `--force` and prints the exact migration steps. + +Flags: +- `--dry-run` (`-n`) — print what would be written, don't touch anything +- `--force` (`-f`) — overwrite an existing unit/plist even if it differs + +### `golem uninstall-daemon [--dry-run]` + +Stop the daemon, remove the systemd unit or launchd plist, reload the supervisor. Leaves your data directory alone — to remove that, do it manually. + +## Environment + +| Variable | Default | What it does | +|---|---|---| +| `GOLEM_DATA_DIR` | OS-native (`~/.local/share/golem` on Linux, `~/Library/Application Support/golem` on macOS) | Override where the daemon stores everything. `golem status` shows the resolved path. | +| `GOLEM_SKILLS_DIR` | `./skills` (relative to cwd) | Override where skills are loaded from. | + +The CLI loads `.env` from **both** cwd and the data directory, in that order — so the wizard-written `.env` (in the data dir) is picked up by `golem doctor` when you run it from your shell, and your repo-root `.env` works for `npm start` in a dev clone. + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Generic failure | +| 3 | `golem status` only — daemon not running (LSB convention) | +| 64 | Unknown subcommand (`EX_USAGE`) | +| 75 | (Internal) `golem start` requesting restart after wizard finishes | diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..79553d1 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,79 @@ +# Golem FAQ + +## Why SSH tunnels instead of a public web UI with a password? + +Exposing a self-hosted admin panel to the internet — even with auth — is one of the most reliable ways to get owned. Locking the UI to `127.0.0.1` removes that whole class of vulnerability. The trade-off is one extra `ssh -L` flag, which we think is worth it. OpenClaw and similar tools land on the same answer. + +If you want public access, the recommended approach is **Tailscale Serve** — put the VPS on your tailnet and Tailscale handles the secure remote access without exposing anything to the public internet. + +## Can I run it without the daemon, just for testing? + +Yes. `git clone` + `npm install` + `npm start` runs both the platform and the dev UI without installing anything system-wide. The daemon is only needed when you want Golem to run unattended. + +## Does it need ffmpeg? + +No. Voice transcription sends the OGG/Opus blob straight to the Whisper API — no transcoding needed. + +## What about Docker? + +Not officially supported in v1. Per-agent filesystem mounts (Obsidian vaults at `/mnt/`) get awkward in Docker because every new mount needs a compose-file edit and a container restart. The npm install path is the supported route. + +## How do I back up my install? + +Stop the daemon, tar the data directory, copy it somewhere safe. The data directory **is** the install — everything else is reproducible from `npm install`. + +```bash +golem stop +tar czf golem-backup-$(date +%F).tgz -C ~/.local/share golem +``` + +To restore on a new machine: install Golem, untar over the data directory, start the daemon. + +## How do I uninstall? + +```bash +golem uninstall-daemon # removes systemd unit / launchd plist, stops daemon +npm uninstall -g golem-agent # removes the package +rm -rf ~/.local/share/golem # removes your data (optional) +``` + +## Which models does it use? + +You pick three OpenRouter model IDs during onboarding — one each for the **low**, **medium**, and **high** tiers. Agents reference a tier (e.g. "use the high tier for hard reasoning") or can override with a specific model ID. Cheap thinking on small queries, expensive thinking when it matters. + +The model registry is global; tier definitions live in your data dir and can be edited any time via the UI. + +## Why doesn't `golem update` work yet? + +Because Golem isn't published to npm yet, there's nothing to update *to*. Run `npm install -g golem-agent@latest` manually for now. The subcommand will be wired up once the package is on the registry. + +## What happens if I run `npm i -g golem-agent` on a box where it's already installed? + +It's idempotent. The new version replaces the old. After installing, restart the daemon: + +```bash +systemctl --user restart golem # Linux +launchctl kickstart -k gui/$(id -u)/com.golem.agent # macOS +``` + +Your data directory, agents, conversation history — all of it stays. Schema migrations run automatically on first start. + +## Can I run multiple Golem instances on one machine? + +Yes, with separate `GOLEM_DATA_DIR` env vars. Each instance gets its own data dir, port, and bot tokens. You'd need separate systemd units though — `golem install-daemon` doesn't currently support a `--label` flag for parallel installs (file a feature request if you need this). + +## On a VPS, does the UI run all the time? + +Yes — `golem start` spawns the Next.js standalone server as a child process. Both the platform (`:3847`) and the UI (`:3015`) listen on `127.0.0.1`, so neither is reachable without a tunnel/tailnet. RAM cost is about 100-200 MB for the UI process; if you want to reclaim it on a tight box, see `src/cli/ui-server.ts` for the spawn logic (file an issue if a `--no-ui` flag would help you). + +## The first-run banner mentions an SSH tunnel command. Where does that come from? + +When you SSH into a box, `$SSH_CONNECTION` is set to ` `. The banner parses that to print a copy-paste-ready `ssh -L 3015:localhost:3015 user@server-ip` command. If you're running the daemon under systemd (no SSH context), the banner falls back to "open http://localhost:3015" — you'll see the tunnel-aware version in `journalctl` only if the daemon was started from an SSH session. + +## How do I see what the agent is actually thinking? + +The web UI has a per-agent **Traces** view showing every LLM call: prompt, response, tool invocations, token counts, latency. Backed by Phoenix (OpenTelemetry) — point any OTel-compatible viewer at the daemon to see the same data in your own tooling. + +## Where do I report bugs / get help? + +[github.com/AvivK5498/Golem/issues](https://github.com/AvivK5498/Golem/issues). Include the output of `golem doctor` and `golem version`. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..a2c6704 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,179 @@ +# Installing Golem + +Three paths. Pick the one that fits your situation. + +| | When to use | Time | +|---|---|---| +| [Deploy on Vultr](#deploy-on-vultr-one-click) | You want a VPS and don't have one yet | 3 min | +| [Manual VPS install](#manual-vps-install) | You have a server already (any provider) | 5 min | +| [Local development](#local-development) | You want to hack on Golem itself | 5 min | + +--- + +## Deploy on Vultr (one-click) + +The [Deploy on Vultr badge in the README](../README.md) links to a pre-filled Vultr deploy page. The first-boot startup script (registered in the Vultr account that owns the badge) installs Node, runs `npm install -g golem-agent`, and runs `golem install-daemon` automatically. By the time you can SSH in, the daemon is already running. + +After the instance boots (~2 minutes): + +```bash +# 1. SSH to confirm the bootstrap finished +ssh root@ +# (the MOTD will tell you Golem is running and what to do next) + +# 2. From your laptop in a separate terminal, open the tunnel +ssh -L 3015:localhost:3015 root@ + +# 3. Open http://localhost:3015 in your browser and walk the wizard +``` + +The startup script source lives in [`scripts/vultr-startup.sh`](../scripts/vultr-startup.sh) — feel free to inspect it or fork it. + +--- + +## Manual VPS install + +Works on any Linux VPS provider (Hetzner, DigitalOcean, AWS, Hetzner, etc.) running Ubuntu 22.04+, Debian 12+, or Fedora 40+. + +### 1. Provision a box + +Specs: **1GB RAM minimum** (2GB comfortable), 20GB disk, Ubuntu 24.04 LTS. Any x86_64 or arm64 will do. + +### 2. SSH in and install Node 20+ + +```bash +# Ubuntu/Debian — install Node 24 via NodeSource +curl -fsSL https://deb.nodesource.com/setup_24.x | bash - +apt-get install -y nodejs + +# Verify +node -v # should print v24.x.x +npm -v +``` + +### 3. Install Golem + +```bash +npm install -g golem-agent +golem install-daemon +``` + +`install-daemon` writes a user-level systemd unit at `~/.config/systemd/user/golem.service` and starts it. The daemon serves the platform API on `127.0.0.1:3847` and the web UI on `127.0.0.1:3015` — both loopback only, never exposed. + +### 4. Enable lingering (so the daemon survives logout) + +```bash +loginctl enable-linger $USER +``` + +Without this, systemd kills your user services when you SSH out. `install-daemon` reminds you about this if it's missing — but the one-liner above gets it done. + +### 5. Configure via SSH tunnel + +From your **laptop**, in a separate terminal: + +```bash +ssh -L 3015:localhost:3015 you@your-vps +# (port 3015 already taken on your laptop? use any free port: +# ssh -L 3030:localhost:3015 you@your-vps → open http://localhost:3030) +``` + +Then open in your browser. The 5-step onboarding wizard collects: + +1. **OpenRouter API key** ([get one](https://openrouter.ai/keys)) +2. **Model tiers** — pick three OpenRouter model IDs for low/medium/high tiers +3. **Telegram bot token** — get one from [@BotFather](https://t.me/BotFather); the wizard validates it via `getMe` before advancing +4. **Owner Telegram ID** — leave blank, it'll be auto-detected when you DM the bot +5. **First agent persona** + +After the wizard, the daemon restarts and you can message your bot. + +### 6. Verify + +```bash +golem status # should show pid + uptime +golem doctor # should be all green +``` + +--- + +## Local development + +```bash +git clone https://github.com/AvivK5498/Golem.git +cd Golem && npm install +cp .env.example .env # add your OPENROUTER_API_KEY +npm start # runs platform + Next.js dev UI on :3015 +``` + +The dev `npm start` uses `concurrently` to run both the platform daemon and the Next.js dev server. Use this when you want hot reload on the UI side. + +```bash +bun test # unit tests +npm run typecheck # tsc +npm run test:agent "your prompt" # end-to-end agent test (real LLM, no Telegram) +``` + +To build the publishable bundle: + +```bash +npm run build # tsc + Next.js standalone build +npm pack # creates golem-agent-x.y.z.tgz (no publish) +npm publish # actually publish (prepublishOnly runs typecheck + tests + build) +``` + +--- + +## Updating + +```bash +npm install -g golem-agent@latest +systemctl --user restart golem # Linux +# or +launchctl kickstart -k gui/$(id -u)/com.golem.agent # macOS +``` + +`golem update` will eventually do both steps automatically — currently a stub. + +## Uninstalling + +```bash +golem uninstall-daemon # stops the daemon, removes the systemd unit +npm uninstall -g golem-agent # removes the package +# Your data directory stays put. Remove manually if you want a clean slate: +rm -rf ~/.local/share/golem # Linux +rm -rf "~/Library/Application Support/golem" # macOS +``` + +## Backing up + +The data directory **is** the install. Tar it. + +```bash +golem stop +tar czf golem-backup-$(date +%F).tgz -C ~/.local/share golem +golem start # or: systemctl --user start golem +``` + +Move that tarball to another machine, install Golem there, untar over the data directory, and you've moved your entire install — agents, conversation history, working memory, scheduled tasks, all of it. + +## Requirements + +- **OS:** Linux x86_64 or arm64 (Ubuntu 22.04+, Debian 12+, Fedora 40+), or macOS. Windows isn't supported. +- **Node:** 20 or newer. +- **An OpenRouter API key** ([get one](https://openrouter.ai/keys)). +- **A Telegram bot token** ([@BotFather](https://t.me/BotFather)). + +Optional: Groq API key (free Whisper tier), ElevenLabs (TTS), a Claude Code login (delegated coding). + +## Environment variables + +| Variable | Description | +|---|---| +| `OPENROUTER_API_KEY` | LLM access (set by the wizard or in `.env`) | +| `GOLEM_DATA_DIR` | Override the data directory | +| `GOLEM_SKILLS_DIR` | Override the skills directory | +| `GROQ_API_KEY` | Voice transcription | +| `ELEVENLABS_API_KEY` | Voice replies | + +The wizard writes secrets to `/.env`. `golem start` and `golem doctor` both load that file at startup. From 9d38a222b6ac25ac0a3955da9b43acbf2260c62b Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 18:51:36 +0300 Subject: [PATCH 12/15] docs: drop FAQ.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aviv's call — nobody reads them. --- README.md | 2 +- docs/FAQ.md | 79 ----------------------------------------------------- 2 files changed, 1 insertion(+), 80 deletions(-) delete mode 100644 docs/FAQ.md diff --git a/README.md b/README.md index eaeca06..86d4eee 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ cp .env.example .env npm start # http://localhost:3015 ``` -For full install options, see **[docs/INSTALL.md](./docs/INSTALL.md)**. For the CLI reference, **[docs/CLI.md](./docs/CLI.md)**. For common questions, **[docs/FAQ.md](./docs/FAQ.md)**. +For full install options, see **[docs/INSTALL.md](./docs/INSTALL.md)**. For the CLI reference, **[docs/CLI.md](./docs/CLI.md)**. ## What your agent can do diff --git a/docs/FAQ.md b/docs/FAQ.md deleted file mode 100644 index 79553d1..0000000 --- a/docs/FAQ.md +++ /dev/null @@ -1,79 +0,0 @@ -# Golem FAQ - -## Why SSH tunnels instead of a public web UI with a password? - -Exposing a self-hosted admin panel to the internet — even with auth — is one of the most reliable ways to get owned. Locking the UI to `127.0.0.1` removes that whole class of vulnerability. The trade-off is one extra `ssh -L` flag, which we think is worth it. OpenClaw and similar tools land on the same answer. - -If you want public access, the recommended approach is **Tailscale Serve** — put the VPS on your tailnet and Tailscale handles the secure remote access without exposing anything to the public internet. - -## Can I run it without the daemon, just for testing? - -Yes. `git clone` + `npm install` + `npm start` runs both the platform and the dev UI without installing anything system-wide. The daemon is only needed when you want Golem to run unattended. - -## Does it need ffmpeg? - -No. Voice transcription sends the OGG/Opus blob straight to the Whisper API — no transcoding needed. - -## What about Docker? - -Not officially supported in v1. Per-agent filesystem mounts (Obsidian vaults at `/mnt/`) get awkward in Docker because every new mount needs a compose-file edit and a container restart. The npm install path is the supported route. - -## How do I back up my install? - -Stop the daemon, tar the data directory, copy it somewhere safe. The data directory **is** the install — everything else is reproducible from `npm install`. - -```bash -golem stop -tar czf golem-backup-$(date +%F).tgz -C ~/.local/share golem -``` - -To restore on a new machine: install Golem, untar over the data directory, start the daemon. - -## How do I uninstall? - -```bash -golem uninstall-daemon # removes systemd unit / launchd plist, stops daemon -npm uninstall -g golem-agent # removes the package -rm -rf ~/.local/share/golem # removes your data (optional) -``` - -## Which models does it use? - -You pick three OpenRouter model IDs during onboarding — one each for the **low**, **medium**, and **high** tiers. Agents reference a tier (e.g. "use the high tier for hard reasoning") or can override with a specific model ID. Cheap thinking on small queries, expensive thinking when it matters. - -The model registry is global; tier definitions live in your data dir and can be edited any time via the UI. - -## Why doesn't `golem update` work yet? - -Because Golem isn't published to npm yet, there's nothing to update *to*. Run `npm install -g golem-agent@latest` manually for now. The subcommand will be wired up once the package is on the registry. - -## What happens if I run `npm i -g golem-agent` on a box where it's already installed? - -It's idempotent. The new version replaces the old. After installing, restart the daemon: - -```bash -systemctl --user restart golem # Linux -launchctl kickstart -k gui/$(id -u)/com.golem.agent # macOS -``` - -Your data directory, agents, conversation history — all of it stays. Schema migrations run automatically on first start. - -## Can I run multiple Golem instances on one machine? - -Yes, with separate `GOLEM_DATA_DIR` env vars. Each instance gets its own data dir, port, and bot tokens. You'd need separate systemd units though — `golem install-daemon` doesn't currently support a `--label` flag for parallel installs (file a feature request if you need this). - -## On a VPS, does the UI run all the time? - -Yes — `golem start` spawns the Next.js standalone server as a child process. Both the platform (`:3847`) and the UI (`:3015`) listen on `127.0.0.1`, so neither is reachable without a tunnel/tailnet. RAM cost is about 100-200 MB for the UI process; if you want to reclaim it on a tight box, see `src/cli/ui-server.ts` for the spawn logic (file an issue if a `--no-ui` flag would help you). - -## The first-run banner mentions an SSH tunnel command. Where does that come from? - -When you SSH into a box, `$SSH_CONNECTION` is set to ` `. The banner parses that to print a copy-paste-ready `ssh -L 3015:localhost:3015 user@server-ip` command. If you're running the daemon under systemd (no SSH context), the banner falls back to "open http://localhost:3015" — you'll see the tunnel-aware version in `journalctl` only if the daemon was started from an SSH session. - -## How do I see what the agent is actually thinking? - -The web UI has a per-agent **Traces** view showing every LLM call: prompt, response, tool invocations, token counts, latency. Backed by Phoenix (OpenTelemetry) — point any OTel-compatible viewer at the daemon to see the same data in your own tooling. - -## Where do I report bugs / get help? - -[github.com/AvivK5498/Golem/issues](https://github.com/AvivK5498/Golem/issues). Include the output of `golem doctor` and `golem version`. From 7a558e7138efa34977a668c6fe8e88ed3f7c4122 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 18:54:03 +0300 Subject: [PATCH 13/15] docs(readme): wire real Vultr script ID + affiliate code into deploy badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 86d4eee..e16c8c5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

🤖 Multi-agent  •  💬 Telegram-native  •  🧠 Working memory  •  🔧 Skills & MCP  •  ⏰ Schedules & webhooks

- Deploy on Vultr + Deploy on Vultr Built on Mastra OpenRouter MIT License From 1b51a72eca6ddfb36947f436207af3e397606bc4 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 18:57:04 +0300 Subject: [PATCH 14/15] fix(cli): load .env from data dir too (missed in d99ada2) The previous commit message described this change but the file wasn't staged. Without it, the doctor-from-shell flow doesn't see the wizard- written .env at $DATA/.env (it's only read by the daemon, which has cwd=$DATA). Adds dotenv.config() for cwd .env, then a second call with override:false for the data-dir .env so cwd wins on conflict (dev). --- src/cli.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 870a060..b1eccec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,17 @@ * * Running with no subcommand is equivalent to `start`. */ -import "dotenv/config"; +// .env loading: support both layouts. +// - Dev clone: .env lives in repo root (cwd when running `npm start`). +// - Installed: .env lives in the data dir, written by the onboarding wizard +// (the daemon's WorkingDirectory is the data dir, but `golem +// doctor` from an interactive shell has cwd=$HOME and won't +// find it). Loading both — cwd first so dev wins on conflict. +import dotenv from "dotenv"; +import path from "node:path"; +import { getDataDir } from "./utils/paths.js"; +dotenv.config(); // cwd-relative .env +dotenv.config({ path: path.join(getDataDir(), ".env"), override: false }); type Subcommand = (args: string[]) => Promise; From 7267c4794ed02e88f79fb942e5ac711952d10ae4 Mon Sep 17 00:00:00 2001 From: AvivK5498 Date: Fri, 15 May 2026 18:59:14 +0300 Subject: [PATCH 15/15] ci(release): install bun so prepublishOnly's `bun test` works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow ran 'npm publish' which triggers prepublishOnly, which runs 'bun test'. Bun wasn't installed in the runner — exit 127. Adds oven-sh/setup-bun@v2 before the install step. --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab06fc0..68fb0a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,10 @@ jobs: node-version: 24 cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm install --ignore-scripts --no-audit --no-fund - run: npm run lint