diff --git a/apps/local-agent-cli/esbuild.mjs b/apps/local-agent-cli/esbuild.mjs new file mode 100644 index 0000000..91f0245 --- /dev/null +++ b/apps/local-agent-cli/esbuild.mjs @@ -0,0 +1,56 @@ +// Bundles the headless local agent into the single executable the +// `tangent-local-agent` skill ships. Workspace packages and socket.io-client +// are inlined, so the skill folder needs no node_modules. +import { chmod, copyFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const cliDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(cliDir, "..", ".."); +const scriptsDir = path.join( + repoRoot, + ".cursor", + "skills", + "tangent-local-agent", + "scripts", +); +const outfile = path.join(scriptsDir, "tangent-local-agent.cjs"); + +await mkdir(scriptsDir, { recursive: true }); +await copyFile( + fileURLToPath( + import.meta.resolve("@tangent/local-agent-host/resources/proxyProvider.ts"), + ), + path.join(scriptsDir, "proxyProvider.ts"), +); +await copyFile( + fileURLToPath( + import.meta + .resolve("@tangent/local-agent-host/resources/sessionResources.ts"), + ), + path.join(scriptsDir, "sessionResources.ts"), +); +await build({ + entryPoints: [path.join(cliDir, "src", "main.ts")], + bundle: true, + platform: "node", + format: "cjs", + target: "node20", + outfile, + banner: { + js: [ + "#!/usr/bin/env node", + 'const __bundleUrl = require("node:url").pathToFileURL(__filename).href;', + ].join("\n"), + }, + define: { "import.meta.url": "__bundleUrl" }, + // The ws native add-ons are optional and loaded behind a try/catch. + external: ["bufferutil", "utf-8-validate"], + legalComments: "none", +}); +await chmod(outfile, 0o755); +console.log( + "[build:local-agent-cli] bundled to .cursor/skills/tangent-local-agent/scripts/", +); diff --git a/apps/local-agent-cli/eslint.config.js b/apps/local-agent-cli/eslint.config.js new file mode 100644 index 0000000..4c7e399 --- /dev/null +++ b/apps/local-agent-cli/eslint.config.js @@ -0,0 +1,3 @@ +import node from "@tangent/build/eslint/node"; + +export default [{ ignores: ["node_modules"] }, ...node]; diff --git a/apps/local-agent-cli/package.json b/apps/local-agent-cli/package.json new file mode 100644 index 0000000..fd560ac --- /dev/null +++ b/apps/local-agent-cli/package.json @@ -0,0 +1,26 @@ +{ + "name": "tangent-local-agent-cli", + "version": "0.0.1", + "description": "Headless Tangent local agent: host Tangent sub-agents locally with pi.", + "private": true, + "type": "module", + "prettier": "@tangent/build/prettier", + "scripts": { + "build": "node esbuild.mjs", + "lint": "eslint .", + "test": "tsx --test \"src/**/*.test.ts\"", + "typecheck": "tsc --noEmit", + "format": "prettier --write ." + }, + "dependencies": { + "@tangent/local-agent-host": "workspace:*" + }, + "devDependencies": { + "@tangent/build": "workspace:*", + "@types/node": "^25.9.1", + "esbuild": "0.28.0", + "eslint": "^10.4.0", + "tsx": "^4.22.3", + "typescript": "^6.0.3" + } +} diff --git a/apps/local-agent-cli/src/config.test.ts b/apps/local-agent-cli/src/config.test.ts new file mode 100644 index 0000000..d6b54e9 --- /dev/null +++ b/apps/local-agent-cli/src/config.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import { test } from "node:test"; + +import { parseCli } from "./config.ts"; + +const env = { REMOTE_ENV_TOKEN: "secret" }; + +function parse(argv: string[], extraEnv = {}) { + const config = parseCli(argv, { ...env, ...extraEnv }); + assert.notEqual(config, "help"); + return config as Exclude; +} + +test("a session URL sets the shell origin and scopes the host id", () => { + const config = parse([ + "--url", + "http://localhost:5173/sessions/abc?x=1", + "--host-id", + "cli-test", + ]); + + assert.equal(config.url, "http://localhost:5173"); + assert.equal(config.sessionId, "abc"); + assert.equal(config.selector, "sessionId:abc"); + assert.equal(config.environmentId, "abc:cli-test"); + assert.equal(config.token, "secret"); +}); + +test("an explicit session wins over the URL and accepts a pasted URL", () => { + const config = parse([ + "--url", + "http://h/sessions/from-url", + "--session", + "http://h/sessions/explicit", + "--host-id", + "h1", + ]); + + assert.equal(config.environmentId, "explicit:h1"); +}); + +test("without a session the host connects unscoped with a generated id", () => { + const config = parse(["--url", "https://shell.example/tangent/"]); + + assert.equal(config.url, "https://shell.example/tangent"); + assert.equal(config.sessionId, undefined); + assert.equal(config.selector, config.environmentId); + assert.match(config.environmentId, /^cli-[a-z0-9-]+-[0-9a-f]{8}$/); +}); + +test("flags fall back to environment variables", () => { + const config = parse([], { + TANGENT_SHELL_URL: "http://env-shell", + TANGENT_PI_BIN: "/opt/pi", + TANGENT_PI_MODEL: "openai/gpt-5", + }); + + assert.equal(config.url, "http://env-shell"); + assert.equal(config.launch.bin, "/opt/pi"); + assert.equal(config.launch.model, "openai/gpt-5"); +}); + +test("pi launch options resolve cwd and only pass the proxy key with a proxy", () => { + const plain = parse(["--url", "http://h", "--cwd", "some/dir"], { + PI_PROXY_API_KEY: "k", + }); + assert.equal(plain.launch.cwd, path.resolve("some/dir")); + assert.equal(plain.launch.bin, "pi"); + assert.equal(plain.launch.proxyApiKey, undefined); + + const proxied = parse(["--url", "http://h", "--pi-proxy-url", "https://p"], { + PI_PROXY_API_KEY: "k", + }); + assert.equal(proxied.launch.proxyUrl, "https://p"); + assert.equal(proxied.launch.proxyApiKey, "k"); + assert.match(proxied.launch.proxyExtensionPath, /proxyProvider\.ts$/); +}); + +test("missing URL or token fails clearly; --help short-circuits", () => { + assert.throws(() => parseCli([], env), /--url/); + assert.throws(() => parseCli(["--url", "http://h"], {}), /REMOTE_ENV_TOKEN/); + assert.throws(() => parseCli(["--bogus"], env)); + assert.equal(parseCli(["-h"], {}), "help"); +}); diff --git a/apps/local-agent-cli/src/config.ts b/apps/local-agent-cli/src/config.ts new file mode 100644 index 0000000..40d7f68 --- /dev/null +++ b/apps/local-agent-cli/src/config.ts @@ -0,0 +1,141 @@ +import { randomUUID } from "node:crypto"; +import { hostname } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import type { PiLaunchOptions } from "@tangent/local-agent-host/piAdapter.ts"; +import { + normalizeShellUrl, + parseSessionRef, + sessionIdFromUrl, +} from "@tangent/local-agent-host/shellUrl.ts"; + +const OPTIONS = { + url: { type: "string" }, + session: { type: "string" }, + cwd: { type: "string" }, + "host-id": { type: "string" }, + "pi-bin": { type: "string" }, + model: { type: "string" }, + "pi-proxy-url": { type: "string" }, + description: { type: "string" }, + help: { type: "boolean", short: "h" }, +} as const; + +/** pi loads this from disk, so the build copies it next to the bundle. */ +const PROXY_PROVIDER_PATH = fileURLToPath( + new URL("proxyProvider.ts", import.meta.url), +); + +/** Session-resources extension; copied next to the bundle like the proxy one. */ +const SESSION_RESOURCES_PATH = fileURLToPath( + new URL("sessionResources.ts", import.meta.url), +); + +type Env = Record; +type CliValues = ReturnType["values"]; + +export interface CliConfig { + url: string; + token: string; + sessionId?: string; + /** Routes this host to matching sessions, e.g. `sessionId:`. */ + selector: string; + environmentId: string; + /** Self-description Prime reads to tell this host apart and match work to it. */ + metadata: Record; + launch: PiLaunchOptions & { cwd: string }; +} + +function parseFlags(argv: string[]) { + return parseArgs({ args: argv, options: OPTIONS }); +} + +/** A flag, else its environment variable, trimmed; `undefined` when empty. */ +function setting( + flag: string | undefined, + env: Env, + envName: string, +): string | undefined { + return (flag ?? env[envName])?.trim() || undefined; +} + +function required(value: string | undefined, message: string): string { + if (!value) throw new Error(message); + return value; +} + +function defaultHostId(): string { + const host = hostname() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-"); + return `cli-${host}-${randomUUID().slice(0, 8)}`; +} + +function launchOptions(values: CliValues, env: Env): CliConfig["launch"] { + const proxyUrl = setting(values["pi-proxy-url"], env, "TANGENT_PI_PROXY_URL"); + return { + bin: setting(values["pi-bin"], env, "TANGENT_PI_BIN") ?? "pi", + cwd: path.resolve(values.cwd ?? "."), + model: setting(values.model, env, "TANGENT_PI_MODEL"), + proxyUrl, + proxyApiKey: proxyUrl ? env.PI_PROXY_API_KEY : undefined, + proxyExtensionPath: PROXY_PROVIDER_PATH, + sessionResourceExtensionPath: SESSION_RESOURCES_PATH, + }; +} + +/** + * The self-description this host advertises: what it is and what it can build, + * so Prime can tell it apart from other connected hosts and match work to it. + * `description` is overridable; the rest is derived from the host's environment. + */ +function buildMetadata( + cwd: string, + description: string | undefined, +): Record { + return { + editor: "tangent-local-agent", + workspacePath: cwd, + description: + description ?? `Tangent Local Agent on ${hostname()}, working in ${cwd}.`, + capabilities: + "Runs local pi sub-agents that read, edit, and run commands in this workspace.", + }; +} + +/** Parses argv and the environment; `"help"` when usage was requested. */ +export function parseCli(argv: string[], env: Env): CliConfig | "help" { + const { values } = parseFlags(argv); + if (values.help) return "help"; + const rawUrl = required( + setting(values.url, env, "TANGENT_SHELL_URL"), + "Pass --url or set TANGENT_SHELL_URL.", + ); + const token = required( + setting(undefined, env, "REMOTE_ENV_TOKEN"), + "Set REMOTE_ENV_TOKEN to the Shell's remote environment token.", + ); + const session = setting(values.session, env, "TANGENT_SESSION_ID"); + const sessionId = session + ? parseSessionRef(session) + : sessionIdFromUrl(rawUrl); + const hostId = + setting(values["host-id"], env, "TANGENT_HOST_ID") ?? defaultHostId(); + const launch = launchOptions(values, env); + const description = setting( + values.description, + env, + "TANGENT_HOST_DESCRIPTION", + ); + return { + url: normalizeShellUrl(rawUrl), + token, + sessionId, + selector: sessionId ? `sessionId:${sessionId}` : hostId, + environmentId: sessionId ? `${sessionId}:${hostId}` : hostId, + metadata: buildMetadata(launch.cwd, description), + launch, + }; +} diff --git a/apps/local-agent-cli/src/main.ts b/apps/local-agent-cli/src/main.ts new file mode 100644 index 0000000..e92389a --- /dev/null +++ b/apps/local-agent-cli/src/main.ts @@ -0,0 +1,71 @@ +import { statSync } from "node:fs"; + +import { LocalAgentBridge } from "@tangent/local-agent-host/bridge.ts"; +import { createFileTools } from "@tangent/local-agent-host/remoteFileTools.ts"; + +import { type CliConfig, parseCli } from "./config.ts"; +import { USAGE } from "./usage.ts"; + +function log(line: string): void { + console.log(`${new Date().toISOString()} ${line}`); +} + +function assertDirectory(dir: string): void { + if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) { + throw new Error(`Not a directory: ${dir}`); + } +} + +function statusLine(bridge: LocalAgentBridge): string { + return `status: ${bridge.state}, ${bridge.listAgents().length} sub-agent(s)`; +} + +function startBridge(config: CliConfig): LocalAgentBridge { + let lastStatus = ""; + const bridge: LocalAgentBridge = new LocalAgentBridge({ + url: config.url, + selector: config.selector, + metadata: config.metadata, + tools: createFileTools(() => config.launch.cwd), + piLaunchOptions: () => Promise.resolve(config.launch), + log, + onChange: () => { + const status = statusLine(bridge); + if (status === lastStatus) return; + lastStatus = status; + log(status); + }, + onSpawned: (agent) => + log(`sub-agent "${agent.name}" (${agent.agentId}) started`), + }); + bridge.connect(config.token, config.environmentId); + return bridge; +} + +function main(): void { + const config = parseCli(process.argv.slice(2), process.env); + if (config === "help") { + console.log(USAGE); + return; + } + assertDirectory(config.launch.cwd); + log( + `connecting to ${config.url} as ${config.environmentId}, working in ${config.launch.cwd}`, + ); + const bridge = startBridge(config); + const stop = (signal: NodeJS.Signals): void => { + log(`${signal}: stopping local sub-agents`); + bridge.dispose(); + process.exit(0); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); +} + +try { + main(); +} catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`tangent-local-agent: ${message}\nRun with --help for usage.`); + process.exitCode = 1; +} diff --git a/apps/local-agent-cli/src/usage.ts b/apps/local-agent-cli/src/usage.ts new file mode 100644 index 0000000..9f3cfe7 --- /dev/null +++ b/apps/local-agent-cli/src/usage.ts @@ -0,0 +1,18 @@ +export const USAGE = `Usage: tangent-local-agent --url [options] + +Hosts Tangent sub-agents as local pi processes and serves read-only file tools +from a local folder. Runs until interrupted. + +Options: + --url Shell URL. A session URL also scopes to that session. (TANGENT_SHELL_URL) + --session Session whose remote spawns and file tools this host serves. (TANGENT_SESSION_ID) + --cwd Folder sub-agents and file tools work in. Default: current directory. + --host-id Stable host id. Default: cli--. (TANGENT_HOST_ID) + --pi-bin pi executable. Default: pi. (TANGENT_PI_BIN) + --model Model for local sub-agents instead of Prime's choice. (TANGENT_PI_MODEL) + --pi-proxy-url LLM proxy for local pi; key from PI_PROXY_API_KEY. (TANGENT_PI_PROXY_URL) + --description How this host describes itself to Prime. (TANGENT_HOST_DESCRIPTION) + -h, --help Show this help. + +Environment: + REMOTE_ENV_TOKEN Required. The Shell's remote environment token.`; diff --git a/apps/local-agent-cli/tsconfig.json b/apps/local-agent-cli/tsconfig.json new file mode 100644 index 0000000..d0d3089 --- /dev/null +++ b/apps/local-agent-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tangent/build/tsconfig/node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60f2691..becce9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,31 @@ importers: specifier: ^2.9.0 version: 2.9.0 + apps/local-agent-cli: + dependencies: + '@tangent/local-agent-host': + specifier: workspace:* + version: link:../../packages/local-agent-host + devDependencies: + '@tangent/build': + specifier: workspace:* + version: link:../../packages/build + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + esbuild: + specifier: 0.28.0 + version: 0.28.0 + eslint: + specifier: ^10.4.0 + version: 10.4.0(jiti@2.7.0) + tsx: + specifier: ^4.22.3 + version: 4.22.3 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + apps/server: dependencies: '@a2a-js/sdk':