diff --git a/package.json b/package.json index 8bcd955..a62a4e0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "test": "vitest run --config vitest.config.mjs", - "test:js": "vitest run --config vitest.config.mjs pi/extensions/heartbeat.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs test/broker-bridge.integration.test.mjs test/integrity-status-check.test.mjs", + "test:js": "vitest run --config vitest.config.mjs pi/extensions/heartbeat.test.mjs pi/extensions/agent-spawn.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs test/broker-bridge.integration.test.mjs test/integrity-status-check.test.mjs", "test:shell": "vitest run --config vitest.config.mjs test/shell-scripts.test.mjs test/security-audit.test.mjs", "test:coverage": "vitest run --config vitest.config.mjs --coverage pi/extensions/heartbeat.test.mjs pi/extensions/memory.test.mjs test/legacy-node-tests.test.mjs", "lint": "npm run lint:js && npm run lint:shell", diff --git a/pi/extensions/agent-spawn.test.mjs b/pi/extensions/agent-spawn.test.mjs new file mode 100644 index 0000000..217fb2f --- /dev/null +++ b/pi/extensions/agent-spawn.test.mjs @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import agentSpawnExtension from "./agent-spawn.ts"; + +const CONTROL_DIR_ENV = "PI_SESSION_CONTROL_DIR"; +const ORIGINAL_CONTROL_DIR = process.env[CONTROL_DIR_ENV]; + +function randomId() { + return Math.random().toString(16).slice(2, 10); +} + +function createExtensionHarness(execImpl) { + let registeredTool = null; + const pi = { + registerTool(tool) { + registeredTool = tool; + }, + exec: execImpl, + }; + agentSpawnExtension(pi); + if (!registeredTool) throw new Error("agent_spawn tool was not registered"); + return registeredTool; +} + +function startUnixSocketServer(socketPath) { + return new Promise((resolve, reject) => { + const server = net.createServer((client) => { + client.end(); + }); + + const onError = (err) => { + server.close(); + reject(err); + }; + + server.once("error", onError); + server.listen(socketPath, () => { + server.off("error", onError); + resolve(server); + }); + }); +} + +describe("agent_spawn extension tool", () => { + const tempDirs = []; + const servers = []; + const cleanupPaths = []; + + afterEach(async () => { + for (const server of servers) { + await new Promise((resolve) => server.close(() => resolve(undefined))); + } + servers.length = 0; + + for (const p of cleanupPaths) { + try { + if (existsSync(p)) unlinkSync(p); + } catch { + // Ignore cleanup failures. + } + } + cleanupPaths.length = 0; + + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + + if (ORIGINAL_CONTROL_DIR === undefined) { + delete process.env[CONTROL_DIR_ENV]; + } else { + process.env[CONTROL_DIR_ENV] = ORIGINAL_CONTROL_DIR; + } + }); + + it("spawns and reports ready when alias/socket becomes available", async () => { + const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-")); + tempDirs.push(root); + const worktree = path.join(root, "worktree"); + const skillPath = path.join(root, "dev-skill"); + const controlDir = path.join(root, "session-control"); + process.env[CONTROL_DIR_ENV] = controlDir; + mkdirSync(worktree, { recursive: true }); + mkdirSync(skillPath, { recursive: true }); + mkdirSync(controlDir, { recursive: true }); + + const sessionName = `dev-agent-test-${randomId()}`; + const aliasPath = path.join(controlDir, `${sessionName}.alias`); + const socketPath = path.join(controlDir, `${sessionName}.sock`); + cleanupPaths.push(aliasPath, socketPath); + + const execSpy = vi.fn(async (command, args) => { + expect(command).toBe("tmux"); + expect(args.slice(0, 4)).toEqual(["new-session", "-d", "-s", sessionName]); + expect(args[4]).toContain(`export PI_SESSION_NAME='${sessionName}'`); + expect(args[4]).toContain("--session-control"); + expect(args[4]).toContain(`--skill '${skillPath}'`); + expect(args[4]).toContain("--model 'anthropic/claude-opus-4-6'"); + + const server = await startUnixSocketServer(socketPath); + servers.push(server); + symlinkSync(path.basename(socketPath), aliasPath); + return { stdout: "", stderr: "", code: 0, killed: false }; + }); + + const tool = createExtensionHarness(execSpy); + const result = await tool.execute( + "tool-call-id", + { + session_name: sessionName, + cwd: worktree, + skill_path: skillPath, + model: "anthropic/claude-opus-4-6", + ready_timeout_sec: 5, + }, + undefined, + undefined, + {}, + ); + + expect(result.isError).not.toBe(true); + expect(result.details.spawned).toBe(true); + expect(result.details.ready).toBe(true); + expect(result.details.session_name).toBe(sessionName); + expect(result.details.ready_alias).toBe(sessionName); + expect(result.details.alias_path).toBe(aliasPath); + expect(result.details.socket_path).toBe(socketPath); + expect(execSpy).toHaveBeenCalledTimes(1); + }); + + it("returns readiness timeout and does not issue cleanup commands", async () => { + const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-")); + tempDirs.push(root); + const worktree = path.join(root, "worktree"); + const skillPath = path.join(root, "dev-skill"); + const controlDir = path.join(root, "session-control"); + process.env[CONTROL_DIR_ENV] = controlDir; + mkdirSync(worktree, { recursive: true }); + mkdirSync(skillPath, { recursive: true }); + mkdirSync(controlDir, { recursive: true }); + + const sessionName = `dev-agent-timeout-${randomId()}`; + const calls = []; + const execSpy = vi.fn(async (command, args) => { + calls.push([command, args]); + return { stdout: "", stderr: "", code: 0, killed: false }; + }); + + const tool = createExtensionHarness(execSpy); + const result = await tool.execute( + "tool-call-id", + { + session_name: sessionName, + cwd: worktree, + skill_path: skillPath, + model: "anthropic/claude-opus-4-6", + ready_timeout_sec: 1, + }, + undefined, + undefined, + {}, + ); + + expect(result.isError).toBe(true); + expect(result.details.spawned).toBe(true); + expect(result.details.ready).toBe(false); + expect(result.details.error).toBe("readiness_timeout"); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe("tmux"); + expect(String(result.content[0].text)).toContain("left intact"); + }); + + it("rejects invalid session_name before executing tmux", async () => { + const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-")); + tempDirs.push(root); + const worktree = path.join(root, "worktree"); + const skillPath = path.join(root, "dev-skill"); + const controlDir = path.join(root, "session-control"); + process.env[CONTROL_DIR_ENV] = controlDir; + mkdirSync(worktree, { recursive: true }); + mkdirSync(skillPath, { recursive: true }); + mkdirSync(controlDir, { recursive: true }); + + const execSpy = vi.fn(async () => ({ stdout: "", stderr: "", code: 0, killed: false })); + const tool = createExtensionHarness(execSpy); + const result = await tool.execute( + "tool-call-id", + { + session_name: "bad name", + cwd: worktree, + skill_path: skillPath, + model: "anthropic/claude-opus-4-6", + }, + undefined, + undefined, + {}, + ); + + expect(result.isError).toBe(true); + expect(String(result.content[0].text)).toContain("Invalid session_name"); + expect(execSpy).not.toHaveBeenCalled(); + }); + + it("honors abort signal while waiting for readiness", async () => { + const root = mkdtempSync(path.join(tmpdir(), "agent-spawn-test-")); + tempDirs.push(root); + const worktree = path.join(root, "worktree"); + const skillPath = path.join(root, "dev-skill"); + const controlDir = path.join(root, "session-control"); + process.env[CONTROL_DIR_ENV] = controlDir; + mkdirSync(worktree, { recursive: true }); + mkdirSync(skillPath, { recursive: true }); + mkdirSync(controlDir, { recursive: true }); + + const sessionName = `dev-agent-abort-${randomId()}`; + const execSpy = vi.fn(async () => ({ stdout: "", stderr: "", code: 0, killed: false })); + const tool = createExtensionHarness(execSpy); + + const controller = new AbortController(); + const abortTimer = setTimeout(() => controller.abort(), 25); + const startedAt = Date.now(); + const result = await tool.execute( + "tool-call-id", + { + session_name: sessionName, + cwd: worktree, + skill_path: skillPath, + model: "anthropic/claude-opus-4-6", + ready_timeout_sec: 60, + }, + controller.signal, + undefined, + {}, + ); + clearTimeout(abortTimer); + + expect(result.isError).toBe(true); + expect(result.details.error).toBe("readiness_aborted"); + expect(result.details.aborted).toBe(true); + expect(Date.now() - startedAt).toBeLessThan(1000); + }); +}); diff --git a/pi/extensions/agent-spawn.ts b/pi/extensions/agent-spawn.ts new file mode 100644 index 0000000..bd3eb54 --- /dev/null +++ b/pi/extensions/agent-spawn.ts @@ -0,0 +1,361 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { existsSync, mkdirSync, readlinkSync, statSync } from "node:fs"; +import net from "node:net"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const SAFE_NAME_RE = /^[a-zA-Z0-9._-]+$/; +const SESSION_CONTROL_DIR_ENV = "PI_SESSION_CONTROL_DIR"; + +const MIN_READY_TIMEOUT_SEC = 1; +const MAX_READY_TIMEOUT_SEC = 60; +const DEFAULT_READY_TIMEOUT_SEC = 10; +const READINESS_POLL_MS = 200; +const SOCKET_PROBE_TIMEOUT_MS = 300; +const TMUX_SPAWN_TIMEOUT_MS = 15_000; + +type SpawnStage = "spawn" | "wait_alias" | "wait_socket" | "probe" | "aborted"; + +type ReadinessResult = { + ready: boolean; + aborted: boolean; + stage: SpawnStage; + aliasPath: string; + socketPath: string | null; + readyAfterMs: number; +}; + +function controlDir(): string { + const configured = process.env[SESSION_CONTROL_DIR_ENV]?.trim(); + if (configured) return resolve(expandHomePath(configured)); + return join(homedir(), ".pi", "session-control"); +} + +function expandHomePath(value: string): string { + const trimmed = value.trim(); + if (trimmed === "~") return homedir(); + if (trimmed.startsWith("~/")) return join(homedir(), trimmed.slice(2)); + return trimmed; +} + +function absolutePath(value: string): string { + return resolve(expandHomePath(value)); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolveSleep) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + resolveSleep(); + }; + const onAbort = () => finish(); + const timer = setTimeout(finish, ms); + if (signal) { + if (signal.aborted) { + finish(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +function isSafeName(value: string): boolean { + return SAFE_NAME_RE.test(value); +} + +function clampReadyTimeout(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_READY_TIMEOUT_SEC; + return Math.min(MAX_READY_TIMEOUT_SEC, Math.max(MIN_READY_TIMEOUT_SEC, Math.round(value))); +} + +function resolveSocketPathFromAlias(aliasPath: string, socketDir: string): string | null { + try { + const target = readlinkSync(aliasPath); + const resolved = resolve(socketDir, target); + if (!resolved.endsWith(".sock")) return null; + return resolved; + } catch { + return null; + } +} + +async function isSocketAlive(socketPath: string, timeoutMs: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return false; + return await new Promise((resolveAlive) => { + let settled = false; + const client = net.createConnection(socketPath); + + const finish = (value: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (signal) signal.removeEventListener("abort", onAbort); + client.removeAllListeners(); + client.destroy(); + resolveAlive(value); + }; + + const timeout = setTimeout(() => { + finish(false); + }, timeoutMs); + + client.once("connect", () => finish(true)); + client.once("error", () => finish(false)); + + const onAbort = () => finish(false); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function waitForSessionReadiness( + readyAlias: string, + timeoutSec: number, + signal?: AbortSignal, +): Promise { + const socketDir = controlDir(); + const aliasPath = join(socketDir, `${readyAlias}.alias`); + const startedAt = Date.now(); + const deadline = startedAt + timeoutSec * 1000; + let stage: SpawnStage = "wait_alias"; + let socketPath: string | null = null; + + while (true) { + if (signal?.aborted) { + return { + ready: false, + aborted: true, + stage: "aborted", + aliasPath, + socketPath, + readyAfterMs: Date.now() - startedAt, + }; + } + if (Date.now() > deadline) break; + + if (existsSync(aliasPath)) { + socketPath = resolveSocketPathFromAlias(aliasPath, socketDir); + if (socketPath && existsSync(socketPath)) { + stage = "probe"; + const remainingProbeMs = deadline - Date.now(); + if (remainingProbeMs <= 0) break; + const probeTimeoutMs = Math.min(SOCKET_PROBE_TIMEOUT_MS, remainingProbeMs); + if (await isSocketAlive(socketPath, probeTimeoutMs, signal)) { + return { + ready: true, + aborted: false, + stage, + aliasPath, + socketPath, + readyAfterMs: Date.now() - startedAt, + }; + } + } else { + stage = "wait_socket"; + } + } else { + stage = "wait_alias"; + } + const remainingPollMs = deadline - Date.now(); + if (remainingPollMs <= 0) break; + await sleep(Math.min(READINESS_POLL_MS, remainingPollMs), signal); + } + + return { + ready: false, + aborted: false, + stage, + aliasPath, + socketPath, + readyAfterMs: Date.now() - startedAt, + }; +} + +type AgentSpawnInput = { + session_name: string; + cwd: string; + skill_path: string; + model: string; + ready_alias?: string; + ready_timeout_sec?: number; + log_path?: string; +}; + +export default function agentSpawnExtension(pi: ExtensionAPI): void { + pi.registerTool({ + name: "agent_spawn", + label: "Agent Spawn", + description: + "Spawn a pi session in tmux and verify readiness through session-control alias/socket with a bounded timeout.", + parameters: Type.Object({ + session_name: Type.String({ description: "Target session name (also PI_SESSION_NAME)" }), + cwd: Type.String({ description: "Working directory for the new session" }), + skill_path: Type.String({ description: "Absolute or ~/ path to skill file/directory" }), + model: Type.String({ description: "Model ID for the spawned session" }), + ready_alias: Type.Optional(Type.String({ description: "Alias to verify for readiness (default: session_name)" })), + ready_timeout_sec: Type.Optional(Type.Number({ description: "Readiness timeout in seconds (default 10, max 60)" })), + log_path: Type.Optional(Type.String({ description: "Log file path (default ~/.pi/agent/logs/spawn-.log)" })), + }), + async execute(_toolCallId, params, signal) { + const input = params as AgentSpawnInput; + const sessionName = input.session_name?.trim(); + const readyAlias = (input.ready_alias ?? sessionName)?.trim(); + const cwdPath = absolutePath(input.cwd ?? ""); + const skillPath = absolutePath(input.skill_path ?? ""); + const model = input.model?.trim(); + const readyTimeoutSec = clampReadyTimeout(input.ready_timeout_sec); + + if (!sessionName || !isSafeName(sessionName)) { + return { + content: [{ type: "text", text: "Invalid session_name. Use only letters, numbers, '.', '_', and '-'." }], + isError: true, + details: { error: "invalid_session_name" }, + }; + } + if (!readyAlias || !isSafeName(readyAlias)) { + return { + content: [{ type: "text", text: "Invalid ready_alias. Use only letters, numbers, '.', '_', and '-'." }], + isError: true, + details: { error: "invalid_ready_alias" }, + }; + } + if (!model) { + return { + content: [{ type: "text", text: "Missing model." }], + isError: true, + details: { error: "missing_model" }, + }; + } + if (!existsSync(cwdPath) || !statSync(cwdPath).isDirectory()) { + return { + content: [{ type: "text", text: `Working directory not found: ${cwdPath}` }], + isError: true, + details: { error: "cwd_not_found", cwd: cwdPath }, + }; + } + if (!existsSync(skillPath)) { + return { + content: [{ type: "text", text: `Skill path not found: ${skillPath}` }], + isError: true, + details: { error: "skill_path_not_found", skill_path: skillPath }, + }; + } + + const logPath = input.log_path?.trim() + ? absolutePath(input.log_path) + : join(homedir(), ".pi", "agent", "logs", `spawn-${sessionName}.log`); + try { + mkdirSync(dirname(logPath), { recursive: true }); + } catch (error) { + return { + content: [{ type: "text", text: `Failed to prepare log path: ${logPath}` }], + isError: true, + details: { + spawned: false, + ready: false, + stage: "spawn", + error: "log_path_prepare_failed", + session_name: sessionName, + ready_alias: readyAlias, + log_path: logPath, + reason: error instanceof Error ? error.message : String(error), + }, + }; + } + + const tmuxCommand = [ + `cd ${shellQuote(cwdPath)}`, + 'export PATH="$HOME/.varlock/bin:$HOME/opt/node/bin:$PATH"', + `export PI_SESSION_NAME=${shellQuote(sessionName)}`, + `exec varlock run --path "$HOME/.config/" -- pi --session-control --skill ${shellQuote(skillPath)} --model ${shellQuote(model)} > ${shellQuote(logPath)} 2>&1`, + ].join(" && "); + + const spawnResult = await pi.exec( + "tmux", + ["new-session", "-d", "-s", sessionName, tmuxCommand], + { + timeout: TMUX_SPAWN_TIMEOUT_MS, + signal, + }, + ); + + if (spawnResult.code !== 0) { + return { + content: [{ type: "text", text: `Failed to spawn tmux session ${sessionName}.` }], + isError: true, + details: { + spawned: false, + ready: false, + stage: "spawn", + error: "spawn_failed", + session_name: sessionName, + ready_alias: readyAlias, + log_path: logPath, + stdout: spawnResult.stdout, + stderr: spawnResult.stderr, + exit_code: spawnResult.code, + }, + }; + } + + const readiness = await waitForSessionReadiness(readyAlias, readyTimeoutSec, signal); + const details = { + spawned: true, + ready: readiness.ready, + aborted: readiness.aborted, + session_name: sessionName, + ready_alias: readyAlias, + alias_path: readiness.aliasPath, + socket_path: readiness.socketPath, + log_path: logPath, + ready_after_ms: readiness.readyAfterMs, + stage: readiness.stage, + error: readiness.ready ? null : readiness.aborted ? "readiness_aborted" : "readiness_timeout", + }; + + if (readiness.aborted) { + return { + content: [{ + type: "text", + text: `Spawned ${sessionName}, but readiness check was cancelled. Session/logs were left intact at ${logPath}.`, + }], + isError: true, + details, + }; + } + + if (!readiness.ready) { + return { + content: [{ + type: "text", + text: + `Spawned ${sessionName}, but readiness check timed out after ${readyTimeoutSec}s ` + + `(stage: ${readiness.stage}). Session/logs were left intact at ${logPath}.`, + }], + isError: true, + details, + }; + } + + return { + content: [{ + type: "text", + text: + `Spawned ${sessionName} and verified readiness via alias ${readyAlias} ` + + `in ${readiness.readyAfterMs}ms.`, + }], + details, + }; + }, + }); +} diff --git a/pi/extensions/memory.test.mjs b/pi/extensions/memory.test.mjs index b944d9c..17ab07d 100644 --- a/pi/extensions/memory.test.mjs +++ b/pi/extensions/memory.test.mjs @@ -300,6 +300,41 @@ describe("memory: skill file integration", () => { ); }); + it("control-agent SKILL.md references agent_spawn", () => { + assert.ok( + controlSkill.includes("agent_spawn"), + "control-agent runbook should use agent_spawn for worker launches" + ); + }); + + it("control-agent SKILL.md forbids pi session spawn", () => { + assert.ok( + controlSkill.includes("`pi session spawn`"), + "control-agent runbook should explicitly forbid pi session spawn" + ); + }); + + it("control-agent SKILL.md states send_to_session is a tool call", () => { + assert.ok( + controlSkill.includes("`send_to_session` is a tool call, not a shell command."), + "control-agent runbook should prevent shell-style send_to_session usage" + ); + }); + + it("control-agent SKILL.md uses agent_spawn for sentry-agent startup", () => { + assert.ok( + controlSkill.includes("session_name: sentry-agent"), + "control-agent runbook should define sentry-agent startup via agent_spawn arguments" + ); + }); + + it("control-agent SKILL.md does not use raw tmux sentry-agent spawn", () => { + assert.ok( + !controlSkill.includes("tmux new-session -d -s sentry-agent"), + "control-agent runbook should avoid raw tmux spawn commands for sentry-agent startup" + ); + }); + it("dev-agent SKILL.md has Memory section", () => { assert.ok(devSkill.includes("## Memory"), "should have Memory section"); }); diff --git a/pi/skills/control-agent/SKILL.md b/pi/skills/control-agent/SKILL.md index af8f4c9..b5d2529 100644 --- a/pi/skills/control-agent/SKILL.md +++ b/pi/skills/control-agent/SKILL.md @@ -197,22 +197,21 @@ cd $REPO_PATH git fetch origin git worktree add ~/workspace/worktrees/$BRANCH -b $BRANCH origin/main -# 2. Launch the agent IN the worktree -tmux new-session -d -s $SESSION_NAME \ - "cd ~/workspace/worktrees/$BRANCH && \ - export PATH=\$HOME/.varlock/bin:\$HOME/opt/node/bin:\$PATH && \ - export PI_SESSION_NAME=$SESSION_NAME && \ - exec varlock run --path ~/.config/ -- pi --session-control --skill ~/.pi/agent/skills/dev-agent --model " +# 2. Spawn via agent_spawn tool (preferred and required) +# Call the tool with: +# session_name: $SESSION_NAME +# cwd: ~/workspace/worktrees/$BRANCH +# skill_path: ~/.pi/agent/skills/dev-agent +# model: +# ready_alias: $SESSION_NAME +# ready_timeout_sec: 10 ``` **Important notes:** -- `cd` into the worktree BEFORE launching pi — this ensures pi discovers project context from the repo's CWD -- Use `exec` so the tmux session exits when pi exits -- Use `varlock run --path ~/.config/` to validate and inject env vars -- Set `PI_SESSION_NAME` so the auto-name extension registers it -- Include `--session-control` for `send_to_session` / `list_sessions` -- Wait **~10 seconds** after spawning before sending messages (agent needs time to initialize) -- Do NOT use `--name` (not a real pi CLI flag) +- `agent_spawn` performs a bounded readiness check against `~/.pi/session-control/.alias` before returning. +- Do not use ad-hoc shell spawn commands or pipes for readiness checks (`tail -5`, socket polling loops, etc.). +- `send_to_session` is a tool call, not a shell command. +- `pi session spawn` and `--name` are not valid in this runtime. **Model note**: Dev agents use the top-tier model from the table above. For cheaper tasks (e.g. read-only analysis), use the cheap model from the sentry-agent table instead. @@ -309,8 +308,8 @@ This removes stale `.sock` files, cleans dead aliases, and restarts the Slack br - [ ] Find or create sentry-agent: 1. Use `list_sessions` to look for a session named `sentry-agent` 2. If found, use that session - 3. If not found, launch with tmux (see Sentry Agent section) - 4. Wait ~8 seconds, then send role assignment + 3. If not found, launch with `agent_spawn` (see Sentry Agent section) + 4. If launched, continue after `agent_spawn` reports readiness - [ ] Send role assignment to the `sentry-agent` session - [ ] Clean up any stale dev-agent worktrees/tmux sessions from previous runs @@ -330,10 +329,18 @@ The sentry-agent triages Sentry alerts and investigates critical issues via the | `OPENCODE_ZEN_API_KEY` | `opencode-zen/claude-haiku-4-5` | ```bash -tmux new-session -d -s sentry-agent "export PATH=\$HOME/.varlock/bin:\$HOME/opt/node/bin:\$PATH && export PI_SESSION_NAME=sentry-agent && varlock run --path ~/.config/ -- pi --session-control --skill ~/.pi/agent/skills/sentry-agent --model " +# Spawn via agent_spawn tool +# Call the tool with: +# session_name: sentry-agent +# cwd: ~ +# skill_path: ~/.pi/agent/skills/sentry-agent +# model: +# ready_alias: sentry-agent +# ready_timeout_sec: 10 ``` **Model note**: `github-copilot/*` models reject Personal Access Tokens and will fail in non-interactive sessions. +**Spawn note**: Do not use raw `tmux new-session` shell commands for sentry-agent startup; use `agent_spawn`. The sentry-agent operates in **on-demand mode** — it does NOT poll. Sentry alerts arrive via the Slack bridge in real-time and are forwarded by you. The sentry-agent uses `sentry_monitor get ` to investigate when asked. @@ -361,7 +368,7 @@ Health checks run automatically every ~10 minutes via the `heartbeat.ts` extensi If you need to check manually, use `heartbeat trigger` to run all checks immediately. When the heartbeat reports a failure, take the appropriate action: -1. **Missing sentry-agent**: Respawn with tmux and re-send role assignment. +1. **Missing sentry-agent**: Respawn with `agent_spawn` and re-send role assignment. 2. **Orphaned dev-agents**: Kill tmux session and remove worktree. 3. **Bridge down**: Restart via `startup-pi.sh`, then check `~/.pi/agent/logs/slack-bridge.log`. 4. **Stale worktrees**: `git worktree remove --force` + `rmdir` empty parents. diff --git a/vitest.config.mjs b/vitest.config.mjs index 8c21acb..e018c38 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -4,6 +4,7 @@ export default defineConfig({ test: { include: [ "pi/extensions/heartbeat.test.mjs", + "pi/extensions/agent-spawn.test.mjs", "pi/extensions/memory.test.mjs", "test/legacy-node-tests.test.mjs", "test/broker-bridge.integration.test.mjs",