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:
@@ -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
|
||||
|
||||
@@ -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"); } });
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user