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 (<stateDir>/runs/<runId>/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
This commit is contained in:
Emil
2026-08-01 00:18:47 +03:00
parent e722900290
commit 029ce743bb
5 changed files with 192 additions and 3 deletions
+12
View File
@@ -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
(`<stateDir>/runs/<runId>/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
+2
View File
@@ -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 <goal>", "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"); } });
+10
View File
@@ -11,6 +11,8 @@ export interface RunManifest {
createdAt: string;
updatedAt: string;
agents: Record<string, AgentRecord>;
/** 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 {
+89 -3
View File
@@ -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<AgentStatus, string> = {
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 (`<stateDir>/runs/<runId>/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<void> {
@@ -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<void> {
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
// <stateDir>/runs/<runId>/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 {
+79
View File
@@ -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<AgentRecord, "id" | "status" | "startedAt" | "finishedAt"> = {
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 });
}
});
});