From 029ce743bb7cc1e48f62eba0e567188c27503a3c Mon Sep 17 00:00:00 2001 From: Emil Date: Sat, 1 Aug 2026 00:18:47 +0300 Subject: [PATCH] feat: /agent opens a subagent's full chat (OpenCode-style), /back returns - /agent [filter] lists the run's agents (glyphs, elapsed, task) via the native selector and switchSession()es to the agent's own session file - run is restored in the agent session by deriving the run id from the session file path (/runs//sessions/...) - main session file recorded in the run manifest so /back works even after a reload while viewing an agent chat - widget re-attach timer guard on session switch --- CHANGELOG.md | 12 ++++++ src/index.ts | 2 + src/run-store.ts | 10 +++++ src/supervisor.ts | 92 ++++++++++++++++++++++++++++++++++++++-- tests/agent-chat.test.ts | 79 ++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 tests/agent-chat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a11b6b8..63879a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- New `/agent [filter]` command opens a subagent's full chat session in the TUI + (OpenCode-style `Subagent Actions → Open`): a selector lists the run's agents + with status glyphs, elapsed time, and task; picking one switches the session + to the agent's own conversation so it can be read (and continued once idle) + in the normal pi view. `/back` returns to the main session. The run is + re-linked automatically: an agent chat session carries no RUN_ENTRY, so the + run id is derived from the session file path + (`/runs//sessions/…`) and the run is restored instead of a + new one being created; the main session file is recorded in the run manifest + so `/back` works even after a reload while viewing an agent chat. +- The live widget is re-attached safely on every session switch (timer guard). + - Fixed the research loop and agent tree getting permanently stuck after `research_control stop`: `start` now restarts a stopped/completed run in the same session (`AgentTree.restart` revives the run, archives old branches, and diff --git a/src/index.ts b/src/index.ts index 546a057..853da2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ export default function hypothesisMachine(pi: ExtensionAPI): void { pi.on("session_shutdown", async () => { await supervisor.shutdown(); }); pi.registerCommand("team", { description: "Show the recursive research team", handler: async (_args, ctx) => { ctx.ui.notify(supervisor.team(), "info"); } }); + pi.registerCommand("agent", { description: "Open a subagent's full chat (like OpenCode)", handler: async (args, ctx) => { await supervisor.showAgentChat(ctx, args.trim() || undefined); } }); + pi.registerCommand("back", { description: "Return to the main session from an agent chat", handler: async (_args, ctx) => { const main = supervisor.mainSessionFile; if (!main) { ctx.ui.notify("Main session is not recorded", "warning"); return; } if (ctx.sessionManager.getSessionFile() === main) { ctx.ui.notify("Already on the main session", "info"); return; } await ctx.switchSession(main); } }); pi.registerCommand("research", { description: "Start a bounded research run", handler: async (args, ctx) => { const goal = args.trim(); if (!goal) { ctx.ui.notify("Usage: /research ", "warning"); return; } const state = supervisor.loop; if (!state || !supervisor.tree) throw new Error("Not initialized"); supervisor.tree.setGoal(goal); state.setGoal(goal); state.start(); const prompt = `Research goal: ${goal}\nUse research_control and the recursive agent tools. Create specialized children only when useful. Record each iteration and stop on the coded conditions. Report important progress without flooding the chat.`; if (ctx.isIdle()) pi.sendUserMessage(prompt); else pi.sendUserMessage(prompt, { deliverAs: "followUp" }); } }); pi.registerCommand("research-status", { description: "Show research loop state", handler: async (_args, ctx) => { ctx.ui.notify(JSON.stringify(supervisor.loop?.snapshot() ?? {}, null, 2), "info"); } }); pi.registerCommand("research-pause", { description: "Pause spawning and the loop", handler: async (_args, ctx) => { if (supervisor.loop?.snapshot().status !== "running") { ctx.ui.notify("Cannot pause a non-running loop", "warning"); return; } supervisor.loop?.pause(); supervisor.tree?.pause(); ctx.ui.notify("Research paused", "info"); } }); diff --git a/src/run-store.ts b/src/run-store.ts index f673e96..fcd860e 100644 --- a/src/run-store.ts +++ b/src/run-store.ts @@ -11,6 +11,8 @@ export interface RunManifest { createdAt: string; updatedAt: string; agents: Record; + /** Session file of the main chat, used by `/back` to return from an agent chat. */ + mainSessionFile?: string; } export class RunStore { @@ -33,6 +35,14 @@ export class RunStore { return data; } + mainSessionFileOf(runId: string): string | undefined { return this.load(runId).mainSessionFile; } + + setMainSessionFile(runId: string, sessionFile: string): void { + const manifest = this.load(runId); + manifest.mainSessionFile = sessionFile; + this.save(manifest); + } + exists(runId: string): boolean { return existsSync(this.manifestPath(runId)); } save(manifest: RunManifest): void { diff --git a/src/supervisor.ts b/src/supervisor.ts index 3070b3b..c88c17c 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -1,5 +1,5 @@ -import { resolve } from "node:path"; -import { defineTool, ModelRuntime, type ExtensionAPI, type ExtensionContext, type ModelRegistry, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { resolve, sep } from "node:path"; +import { defineTool, ModelRuntime, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ModelRegistry, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Text, truncateToWidth } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; @@ -12,10 +12,51 @@ import { RunStore } from "./run-store.js"; import { ExperimentRunner } from "./tools/experiment.js"; import { createResearchTools } from "./tools/index.js"; import { WebGateway } from "./tools/web.js"; +import type { AgentRecord, AgentStatus } from "./types.js"; const RUN_ENTRY = "hypothesis-machine-run"; const toolText = (value: unknown) => ({ content: [{ type: "text" as const, text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], details: {} }); +/** One-character status glyphs for the agent-chat selector (OpenCode-style checklist markers). */ +const AGENT_STATUS_GLYPH: Record = { + created: "·", running: "•", waiting: "·", completed: "✓", failed: "✖", cancelled: "·", interrupted: "!", archived: "·", +}; + +function shortAgentName(id: string): string { return id.replace(/-[a-f0-9]{8}$/, ""); } + +/** + * Derive the run id from an agent session file path (`/runs//sessions/…`). + * Used when the current session is an agent chat opened via `/agent` (it has no + * RUN_ENTRY in its branch) so the run is restored instead of creating a new one. + */ +export function runIdFromSessionPath(sessionFile: string | undefined, stateDir: string): string | undefined { + if (!sessionFile) return undefined; + const prefix = `${resolve(stateDir, "runs")}${sep}`; + if (!sessionFile.startsWith(prefix)) return undefined; + const rest = sessionFile.slice(prefix.length); + const runId = rest.slice(0, rest.indexOf(sep)); + return runId && /^[a-zA-Z0-9-]+$/.test(runId) ? runId : undefined; +} + +function agentElapsed(record: AgentRecord, now: number): string { + if (!record.startedAt) return ""; + const start = Date.parse(record.startedAt); + const end = record.finishedAt ? Date.parse(record.finishedAt) : (record.status === "running" || record.status === "waiting") ? now : undefined; + if (!end) return ""; + const span = Math.max(0, Math.round((end - start) / 1000)); + return `${String(Math.floor(span / 60)).padStart(2, "0")}:${String(span % 60).padStart(2, "0")}`; +} + +/** Build one-line selector options for agents: `✓ short-name [status] mm:ss — task`. */ +export function buildAgentChatOptions(agents: AgentRecord[], now: number): string[] { + return agents.map((agent) => { + const time = agentElapsed(agent, now); + const label = `${AGENT_STATUS_GLYPH[agent.status]} ${shortAgentName(agent.id)} [${agent.status}]${time ? ` ${time}` : ""}`; + const task = agent.task.replace(/\s+/g, " ").trim(); + return task ? `${label} — ${truncateToWidth(task, 120)}` : label; + }); +} + export class SupervisorIntegration { config: HypothesisMachineConfig | undefined; tree: AgentTree | undefined; memory: ResearchMemory | undefined; web: WebGateway | undefined; experiments: ExperimentRunner | undefined; loop: ResearchLoop | undefined; private modelRuntime: ModelRuntime | undefined; @@ -23,6 +64,8 @@ export class SupervisorIntegration { private lastScheduledIteration = 0; private widgetTimer: NodeJS.Timeout | undefined; private requestAgentRender: (() => void) | undefined; + /** Session file of the main chat; used by `/back` to return from an agent chat. */ + mainSessionFile: string | undefined; constructor(private readonly pi: ExtensionAPI) {} async start(ctx: ExtensionContext): Promise { @@ -32,11 +75,21 @@ export class SupervisorIntegration { if (typeof Runtime?.create === "function") this.modelRuntime = await Runtime.create(); else this.modelRegistry = ctx.modelRegistry; const previous = [...ctx.sessionManager.getBranch()].reverse().find((entry) => entry.type === "custom" && entry.customType === RUN_ENTRY); - const runId = previous && previous.type === "custom" ? (previous.data as { runId?: string } | undefined)?.runId : undefined; + const runIdFromEntry = previous && previous.type === "custom" ? (previous.data as { runId?: string } | undefined)?.runId : undefined; + // Agent chats opened via `/agent` carry no RUN_ENTRY in their branch; the run + // id is derived from the session file location instead so the run is restored. + const sessionFile = ctx.sessionManager.getSessionFile(); + const runId = runIdFromEntry ?? runIdFromSessionPath(sessionFile, stateDir); + const isMainSession = !runIdFromEntry && !runId; const runtimeFactory = new PiAgentRuntimeFactory({ cwd: ctx.cwd, config: this.config, store, memory: this.memory, web: this.web, experiments: this.experiments, ...(this.modelRuntime ? { modelRuntime: this.modelRuntime } : {}), ...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}), ...(ctx.model ? { model: ctx.model } : {}), ...(ctx.thinkingLevel ? { thinkingLevel: ctx.thinkingLevel } : {}) }); const onRootMessage = (fromId: string, message: string) => this.pi.sendMessage({ customType: "hypothesis-machine-agent-update", content: `Agent ${fromId} reports:\n\n${message}`, display: true, details: { fromId, runId: this.tree?.runId } }, { triggerTurn: false, deliverAs: "nextTurn" }); this.tree = runId && store.exists(runId) ? AgentTree.restore(store, runtimeFactory, this.config, runId, onRootMessage) : new AgentTree(store, runtimeFactory, this.config, { goal: "Research requested in the current Supervisor session", inherited: { model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "inherit", thinkingLevel: ctx.thinkingLevel ?? "inherit" }, onRootMessage }); runtimeFactory.attachTree(this.tree); if (!runId) this.pi.appendEntry(RUN_ENTRY, { runId: this.tree.runId }); + // Remember which session file is the main chat so `/back` can return to it + // after an agent chat. Only the main session (RUN_ENTRY holder or the brand + // new session) is recorded; agent chats never overwrite it. + if (sessionFile && isMainSession) store.setMainSessionFile(this.tree.runId, sessionFile); + this.mainSessionFile = store.mainSessionFileOf(this.tree.runId); this.loop = new ResearchLoop(stateDir, this.tree.runId, this.tree.inspect(this.tree.rootId).task, this.config); this.lastScheduledIteration = this.loop.snapshot().iteration; const tools = createResearchTools({ tree: this.tree, parentId: this.tree.rootId, memory: this.memory, web: this.web, experiments: this.experiments, cwd: ctx.cwd, ...(this.config.subagent_model ? { subagentModel: this.config.subagent_model } : {}) }); @@ -63,8 +116,41 @@ export class SupervisorIntegration { team(): string { return this.required().tree.render(); } findings(kind?: string): unknown { return this.required().memory.list(kind); } + /** + * OpenCode-style subagent switcher: let the user pick an agent and open its + * full chat session in the TUI (like `Subagent Actions → Open` in OpenCode). + * `/back` returns to the main session. + */ + async showAgentChat(ctx: ExtensionCommandContext, filter?: string): Promise { + const { tree } = this.required(); + const all = tree.list().filter((agent) => agent.id !== tree.rootId); + const query = (filter ?? "").trim().toLowerCase(); + const agents = query + ? all.filter((agent) => agent.id.toLowerCase().includes(query) || agent.task.toLowerCase().includes(query)) + : all; + if (agents.length === 0) { ctx.ui.notify(query ? `No agents match \"${filter}\"` : "No agents in this run yet", "info"); return; } + const options = buildAgentChatOptions(agents, Date.now()); + const picked = await ctx.ui.select("Open agent chat (Esc cancels)", options); + if (!picked) return; + const index = options.indexOf(picked); + if (index < 0) { ctx.ui.notify("Selection was lost", "warning"); return; } + const agent = agents[index]!; + if (!agent.sessionFile) { ctx.ui.notify(`${shortAgentName(agent.id)} has no session file yet`, "warning"); return; } + const running = agent.status === "running" || agent.status === "waiting"; + ctx.ui.notify(`Opening ${shortAgentName(agent.id)}'s chat…`, "info"); + await ctx.switchSession(agent.sessionFile, { + withSession: async (replaced) => { + // The run is re-linked automatically: the new session's file lives under + // /runs//sessions/ and `start()` derives the run id from + // the path, so the run is restored instead of a new one being created. + replaced.ui.notify(`Agent chat: ${shortAgentName(agent.id)}${running ? " — agent still running, view is a snapshot" : ""}. /back returns to the main session`, "info"); + }, + }); + } + /** Live subagent dashboard widget above the editor, refreshed on a light timer. */ private installAgentWidget(ctx: ExtensionContext): void { + if (this.widgetTimer) { clearInterval(this.widgetTimer); this.widgetTimer = undefined; } ctx.ui.setWidget("hm-agents", (tui, theme) => { this.requestAgentRender = () => tui.requestRender(); return { diff --git a/tests/agent-chat.test.ts b/tests/agent-chat.test.ts new file mode 100644 index 0000000..ff8820d --- /dev/null +++ b/tests/agent-chat.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { RunStore } from "../src/run-store.js"; +import { buildAgentChatOptions, runIdFromSessionPath } from "../src/supervisor.js"; +import type { AgentRecord } from "../src/types.js"; + +const base: Omit = { + runId: "run-test", parentId: "root", children: [], lineage: [], depth: 1, + task: "x", taskFingerprint: "x", expectedOutput: "", completionCriteria: "", specPath: "", + createdAt: new Date(Date.now() - 60_000).toISOString(), +}; +const mk = (id: string, status: AgentRecord["status"], startedMin: number, finishedMin?: number): AgentRecord => { + const record: AgentRecord = { + ...base, id, status, + startedAt: new Date(Date.now() - startedMin * 60_000).toISOString(), + }; + if (finishedMin !== undefined) record.finishedAt = new Date(Date.now() - finishedMin * 60_000).toISOString(); + return record; +}; + +describe("runIdFromSessionPath", () => { + const stateDir = "/proj/.hypothesis-machine"; + it("derives the run id from an agent session file under the state dir", () => { + const sessionFile = resolve(stateDir, "runs/run-132965dd/sessions/agent-abc123.jsonl"); + expect(runIdFromSessionPath(sessionFile, stateDir)).toBe("run-132965dd"); + }); + it("returns undefined for session files outside the state dir", () => { + expect(runIdFromSessionPath("/proj/.hypothesis-machine/other/file.jsonl", stateDir)).toBeUndefined(); + expect(runIdFromSessionPath(resolve(stateDir, "runs/x.jsonl"), stateDir)).toBeUndefined(); + expect(runIdFromSessionPath(undefined, stateDir)).toBeUndefined(); + expect(runIdFromSessionPath("/home/emil/.pi/agent/sessions/main.jsonl", stateDir)).toBeUndefined(); + }); + it("rejects malformed run ids", () => { + expect(runIdFromSessionPath(resolve(stateDir, "runs/../evil/sessions/a.jsonl"), stateDir)).toBeUndefined(); + }); +}); + +describe("buildAgentChatOptions", () => { + it("formats status glyphs, short names, elapsed time and task", () => { + const now = Date.now(); + const options = buildAgentChatOptions([ + mk("market-competitors-bb389dda", "running", 3), + mk("science-education-niches-293d399a", "completed", 55, 12), + mk("demand-b2b-b2g-57637d25", "failed", 40, 9), + ], now); + expect(options).toHaveLength(3); + expect(options[0]).toContain("• market-competitors [running] 03:00"); + expect(options[0]).toContain("x"); + expect(options[1]).toContain("✓ science-education-niches [completed] 43:00"); + expect(options[2]).toContain("✖ demand-b2b-b2g [failed] 31:00"); + }); + it("collapses whitespace in the task", () => { + const agent = mk("a-bb1", "waiting", 1); + agent.task = " Multi\nline task "; + expect(buildAgentChatOptions([agent], Date.now())[0]).toContain("— Multi line task"); + }); +}); + +describe("RunStore mainSessionFile", () => { + it("persists and reads back the main session file", () => { + const dir = mkdtempSync(join(tmpdir(), "hm-runstore-")); + try { + const store = new RunStore(dir); + const runId = "run-main"; + const root: AgentRecord = { ...base, id: "root", parentId: null, depth: 0, runId, status: "completed" }; + store.create(runId, "goal", root); + expect(store.mainSessionFileOf(runId)).toBeUndefined(); + store.setMainSessionFile(runId, "/main/session.jsonl"); + expect(store.mainSessionFileOf(runId)).toBe("/main/session.jsonl"); + // survives reload + const reloaded = new RunStore(dir); + expect(reloaded.mainSessionFileOf(runId)).toBe("/main/session.jsonl"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});