Files
oh-my-pi/packages/coding-agent/src/tools/vibe.ts
T
roboomp 4bc68b45e2 fix(coding-agent): persisted vibe sessions across restarts
Vibe worker roster lived only in a process-local Map, so a resumed parent
session started with an empty registry and vibe_send failed with
"Unknown vibe session". Persist a versioned, parent-scoped lifecycle
journal (spawn/turn/tombstone events), rehydrate validated idle workers
through the persisted-subagent reviver on resume, and gate the flow with
generation/CAS protection so stale finalizers cannot clobber a
replacement worker. Killed transcripts stay readable but non-revivable;
mode-exit commits tombstones atomically with the mode change and rolls
back cleanly on storage failure.

Ported from @mastertyko's fork branch fix/vibe-session-persistence.

Fixes #5303
2026-07-14 17:58:14 +00:00

608 lines
23 KiB
TypeScript

/**
* Vibe mode tools — the director's entire non-read surface.
*
* Five thin tools over {@link VibeSessionRegistry}: spawn/send/wait/kill/list
* persistent worker sessions ("fast"/"good" CLIs). Spawns and sends return
* immediately; turn results self-deliver through the async job manager.
*
* The TUI renderers lean into the "you are driving little CLIs" fiction:
* spawn/send draw a mini composer (a message typed into a tiny Claude-Code-like
* terminal), and wait/list draw the "TV wall" — one live screen per worker,
* stacked, each showing its tool calls and streamed text as it works.
*/
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { prompt } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer";
import type { Theme } from "../modes/theme/theme";
import vibeKillDescription from "../prompts/tools/vibe-kill.md" with { type: "text" };
import vibeListDescription from "../prompts/tools/vibe-list.md" with { type: "text" };
import vibeSendDescription from "../prompts/tools/vibe-send.md" with { type: "text" };
import vibeSpawnDescription from "../prompts/tools/vibe-spawn.md" with { type: "text" };
import vibeWaitDescription from "../prompts/tools/vibe-wait.md" with { type: "text" };
import { oneLineLabel } from "../task/types";
import { renderStatusLine } from "../tui";
import {
type VibeCli,
type VibeKillOutcome,
type VibeScreenSnapshot,
type VibeSendOutcome,
VibeSessionRegistry,
type VibeSessionState,
type VibeWaitOutcome,
} from "../vibe/runtime";
import type { Tool, ToolSession } from "./index";
import {
Ellipsis,
formatBadge,
formatDuration,
formatStatusIcon,
replaceTabs,
type ToolUIColor,
type ToolUIStatus,
truncateToWidth,
} from "./render-utils";
export const VIBE_TOOL_NAMES = ["vibe_spawn", "vibe_send", "vibe_wait", "vibe_kill", "vibe_list"] as const;
const vibeSpawnSchema = type({
cli: type("'fast' | 'good'").describe(
"worker flavor: fast = low-latency model for mechanical work; good = strong model for hard work",
),
"name?": type("string <= 48").describe("optional session name; generated when omitted"),
prompt: type("string > 0").describe("first instruction; the worker starts with no other context"),
});
const vibeSendSchema = type({
session: type("string > 0").describe("session id from vibe_spawn / vibe_list"),
message: type("string > 0").describe("message for the session; steers mid-turn, else runs as its next turn"),
});
const vibeWaitSchema = type({
"sessions?": type("string[]").describe("session ids to watch; omit to watch every session with a turn in flight"),
"timeout?": type("number > 0").describe("max seconds to wait (default 30)"),
});
const vibeKillSchema = type({
session: type("string > 0").describe("session id to terminate"),
});
const vibeListSchema = type({});
type VibeOp = "spawn" | "send" | "wait" | "kill" | "list";
/** Details payload shared by every vibe tool for TUI rendering. */
export interface VibeToolDetails {
op: VibeOp;
/** Live TV-wall snapshot of the owner's worker sessions at (or during) the call. */
screens: VibeScreenSnapshot[];
spawned?: { id: string; cli: VibeCli; jobId: string };
send?: VibeSendOutcome;
wait?: {
settled: Array<{ id: string; jobId: string; status: "completed" | "failed" | "cancelled" }>;
stillRunning: string[];
timedOut: boolean;
/** True on interim progress emissions while the wait is still blocking. */
waiting?: boolean;
};
killed?: VibeKillOutcome;
}
function screensOf(session: ToolSession, ids?: string[]): VibeScreenSnapshot[] {
return VibeSessionRegistry.global().screens(session, ids);
}
function textResult(text: string, details: VibeToolDetails): AgentToolResult<VibeToolDetails> {
return { content: [{ type: "text", text }], details };
}
export class VibeSpawnTool implements AgentTool<typeof vibeSpawnSchema, VibeToolDetails> {
readonly name = "vibe_spawn";
readonly approval = "exec" as const;
readonly label = "Vibe Spawn";
readonly summary = "Start a persistent fast/good worker session";
readonly description: string;
readonly parameters = vibeSpawnSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(vibeSpawnDescription);
}
async execute(_toolCallId: string, params: typeof vibeSpawnSchema.infer): Promise<AgentToolResult<VibeToolDetails>> {
const { id, jobId } = await VibeSessionRegistry.global().spawn(this.session, params);
return textResult(
`Spawned ${params.cli} session \`${id}\` (turn job \`${jobId}\`). The turn result will be delivered when it finishes — keep directing other sessions meanwhile. Continue this one with vibe_send \`${id}\`.`,
{ op: "spawn", screens: screensOf(this.session), spawned: { id, cli: params.cli, jobId } },
);
}
}
export class VibeSendTool implements AgentTool<typeof vibeSendSchema, VibeToolDetails> {
readonly name = "vibe_send";
readonly approval = "exec" as const;
readonly label = "Vibe Send";
readonly summary = "Message a worker session (steer or next turn)";
readonly description: string;
readonly parameters = vibeSendSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(vibeSendDescription);
}
async execute(_toolCallId: string, params: typeof vibeSendSchema.infer): Promise<AgentToolResult<VibeToolDetails>> {
const outcome = await VibeSessionRegistry.global().send(this.session, params);
const ack =
outcome.mode === "turn"
? `Started a new turn on \`${outcome.id}\` (job \`${outcome.jobId}\`). Its result will be delivered when the turn finishes.`
: outcome.mode === "steered"
? `Steered \`${outcome.id}\` mid-turn — the running turn sees your message at its next step.`
: `\`${outcome.id}\` is mid-turn; your message is queued and runs automatically as the next turn.`;
return textResult(ack, { op: "send", screens: screensOf(this.session), send: outcome });
}
}
const WAIT_PROGRESS_INTERVAL_MS = 500;
export class VibeWaitTool implements AgentTool<typeof vibeWaitSchema, VibeToolDetails> {
readonly name = "vibe_wait";
readonly approval = "read" as const;
readonly label = "Vibe Wait";
readonly summary = "Block until a worker session finishes its turn";
readonly description: string;
readonly parameters = vibeWaitSchema;
readonly strict = true;
readonly interruptible = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(vibeWaitDescription);
}
async execute(
_toolCallId: string,
params: typeof vibeWaitSchema.infer,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<VibeToolDetails>,
): Promise<AgentToolResult<VibeToolDetails>> {
const registry = VibeSessionRegistry.global();
// Live TV-wall frames while the wait blocks: each tick re-snapshots the
// watched workers so their tool calls and streamed text play in place.
const emitProgress = (): void => {
onUpdate?.({
content: [{ type: "text", text: "" }],
details: {
op: "wait",
screens: screensOf(this.session, params.sessions),
wait: { settled: [], stillRunning: [], timedOut: false, waiting: true },
},
});
};
const progressTimer = onUpdate ? setInterval(emitProgress, WAIT_PROGRESS_INTERVAL_MS) : undefined;
emitProgress();
let outcome: VibeWaitOutcome;
try {
outcome = await registry.wait(this.session, {
sessions: params.sessions,
timeoutMs: params.timeout !== undefined ? params.timeout * 1000 : undefined,
signal,
});
} finally {
clearInterval(progressTimer);
}
const details: VibeToolDetails = {
op: "wait",
screens: screensOf(this.session, params.sessions),
wait: {
settled: outcome.settled.map(({ id, jobId, status }) => ({ id, jobId, status })),
stillRunning: outcome.stillRunning,
timedOut: outcome.timedOut,
},
};
if (outcome.settled.length === 0 && outcome.stillRunning.length === 0) {
return { ...textResult("No turns in flight to wait for.", details), useless: true };
}
const lines: string[] = [];
for (const entry of outcome.settled) {
lines.push(`## \`${entry.id}\` — ${entry.status}`, entry.resultText, "");
}
if (outcome.stillRunning.length > 0) {
lines.push(`Still running: ${outcome.stillRunning.map(id => `\`${id}\``).join(", ")}.`);
}
if (outcome.timedOut) {
lines.push("Wait window elapsed before any turn settled — re-issue vibe_wait to keep waiting.");
}
const result = textResult(lines.join("\n").trimEnd(), details);
// A pure "still waiting" frame is noise once a newer wait exists.
return outcome.settled.length === 0 ? { ...result, useless: true } : result;
}
}
export class VibeKillTool implements AgentTool<typeof vibeKillSchema, VibeToolDetails> {
readonly name = "vibe_kill";
readonly approval = "read" as const;
readonly label = "Vibe Kill";
readonly summary = "Terminate a worker session";
readonly description: string;
readonly parameters = vibeKillSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(vibeKillDescription);
}
async execute(_toolCallId: string, params: typeof vibeKillSchema.infer): Promise<AgentToolResult<VibeToolDetails>> {
const outcome = await VibeSessionRegistry.global().kill(this.session, params.session);
const cancelNote = outcome.cancelledTurn ? " Its in-flight turn was cancelled." : "";
return textResult(
`Killed session \`${outcome.id}\`.${cancelNote} Transcript remains at history://${outcome.id}.`,
{
op: "kill",
screens: screensOf(this.session),
killed: outcome,
},
);
}
}
export class VibeListTool implements AgentTool<typeof vibeListSchema, VibeToolDetails> {
readonly name = "vibe_list";
readonly approval = "read" as const;
readonly label = "Vibe List";
readonly summary = "List worker sessions and their states";
readonly description: string;
readonly parameters = vibeListSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(vibeListDescription);
}
async execute(): Promise<AgentToolResult<VibeToolDetails>> {
const screens = screensOf(this.session);
const details: VibeToolDetails = { op: "list", screens };
if (screens.length === 0) {
return textResult("No vibe sessions. Spawn one with vibe_spawn.", details);
}
const lines = screens.map(screen => {
const parts = [
`- \`${screen.id}\` [${screen.cli}] ${screen.state}`,
`${screen.turns} turn${screen.turns === 1 ? "" : "s"}`,
];
if (screen.queued > 0) parts.push(`${screen.queued} queued`);
if (screen.model) parts.push(screen.model);
if (screen.lastActivity) parts.push(`last: ${screen.lastActivity}`);
return parts.join(" · ");
});
return textResult(lines.join("\n"), details);
}
}
/** Creates the ephemeral tools installed while `/vibe` mode is active. */
export function createVibeTools(session: ToolSession): Tool[] {
return [
new VibeSpawnTool(session),
new VibeSendTool(session),
new VibeWaitTool(session),
new VibeKillTool(session),
new VibeListTool(session),
];
}
// =============================================================================
// TUI Renderer — mini composer (spawn/send) + TV wall (wait/list)
// =============================================================================
const COMPOSER_LINE_MAX = 96;
const TV_LINE_MAX = 110;
const TV_TRACE_COLLAPSED = 2;
const TV_TRACE_EXPANDED = 6;
const TV_OUTPUT_COLLAPSED = 1;
const TV_OUTPUT_EXPANDED = 3;
const CURSOR_GLYPH = "▌";
function stateToIcon(state: VibeSessionState): ToolUIStatus {
switch (state) {
case "running":
return "running";
case "starting":
return "pending";
case "idle":
return "done";
case "dead":
return "aborted";
}
}
function stateToColor(state: VibeSessionState): ToolUIColor {
switch (state) {
case "running":
return "accent";
case "starting":
return "accent";
case "idle":
return "success";
case "dead":
return "muted";
}
}
interface VibeRenderArgs {
cli?: VibeCli;
prompt?: string;
name?: string;
session?: string;
message?: string;
sessions?: string[];
}
/** One-line, escape-stripped fragment for embedding in a frame row. */
function frameText(text: string, max: number): string {
return oneLineLabel(replaceTabs(text), max);
}
/**
* Draw a left-railed mini terminal:
* ```
* ╭─ <header>
* │ <body…>
* ╰─ <footer>
* ```
*/
function miniFrame(uiTheme: Theme, header: string, body: string[], footer?: string): string[] {
const box = uiTheme.boxRound;
const rail = (glyph: string) => uiTheme.fg("dim", glyph);
const lines = [`${rail(`${box.topLeft}${box.horizontal}`)} ${header}`];
for (const row of body) {
lines.push(`${rail(box.vertical)} ${row}`);
}
lines.push(
footer ? `${rail(`${box.bottomLeft}${box.horizontal}`)} ${footer}` : rail(`${box.bottomLeft}${box.horizontal}`),
);
return lines;
}
/** The `>` composer rows of the mini CLI: the director's message being typed in. */
function composerRows(uiTheme: Theme, message: string, options: { cursor: boolean; expanded: boolean }): string[] {
const promptGlyph = uiTheme.fg("accent", ">");
const rawLines = message.split(/\r?\n/).filter(line => line.trim().length > 0);
const maxRows = options.expanded ? 6 : 2;
const visible = rawLines.slice(0, maxRows).map(line => frameText(line, COMPOSER_LINE_MAX));
if (visible.length === 0) visible.push("");
if (rawLines.length > maxRows) {
visible[visible.length - 1] = `${visible[visible.length - 1]} …`;
} else if (options.cursor) {
visible[visible.length - 1] = `${visible[visible.length - 1]}${uiTheme.fg("accent", CURSOR_GLYPH)}`;
}
return visible.map((line, index) =>
index === 0 ? `${promptGlyph} ${uiTheme.fg("toolOutput", line)}` : ` ${uiTheme.fg("toolOutput", line)}`,
);
}
/** Render one worker "TV": header + live tool calls + streamed text tail. */
function tvScreen(
uiTheme: Theme,
screen: VibeScreenSnapshot,
options: RenderResultOptions,
settledStatus?: "completed" | "failed" | "cancelled",
): string[] {
const live = screen.state === "running" || screen.state === "starting";
const spinnerFrame = live ? options.spinnerFrame : undefined;
const icon = formatStatusIcon(
settledStatus === "failed" ? "error" : settledStatus === "cancelled" ? "aborted" : stateToIcon(screen.state),
uiTheme,
spinnerFrame,
);
const badge = formatBadge(screen.cli, stateToColor(screen.state), uiTheme);
const idText =
live && options.spinnerFrame !== undefined && shimmerEnabled()
? shimmerText(screen.id, uiTheme)
: uiTheme.fg(live ? "accent" : "toolOutput", screen.id);
const headParts = [icon, badge, idText, uiTheme.fg("dim", settledStatus ?? screen.state)];
const turnsLabel = `${screen.turns}t${screen.queued > 0 ? `+${screen.queued}q` : ""}`;
headParts.push(uiTheme.fg("muted", turnsLabel));
if (screen.turnStartedAt !== undefined) {
headParts.push(uiTheme.fg("dim", formatDuration(Date.now() - screen.turnStartedAt)));
}
if (screen.model) headParts.push(uiTheme.fg("muted", frameText(screen.model, 40)));
const body: string[] = [];
const hook = uiTheme.tree.hook;
if (live) {
if (screen.turnMessage) {
body.push(`${uiTheme.fg("accent", ">")} ${uiTheme.fg("dim", frameText(screen.turnMessage, TV_LINE_MAX))}`);
}
const traceCap = options.expanded ? TV_TRACE_EXPANDED : TV_TRACE_COLLAPSED;
for (const line of screen.trace.slice(-traceCap)) {
body.push(`${uiTheme.fg("dim", hook)} ${uiTheme.fg("dim", frameText(line, TV_LINE_MAX))}`);
}
if (screen.currentTool) {
const detail = screen.lastIntent ?? screen.currentToolArgs;
const label = `${screen.currentTool}${detail ? `: ${detail}` : ""}`;
const painted =
options.spinnerFrame !== undefined && shimmerEnabled()
? shimmerText(frameText(label, TV_LINE_MAX), uiTheme)
: uiTheme.fg("muted", frameText(label, TV_LINE_MAX));
body.push(`${uiTheme.fg("accent", hook)} ${painted}`);
} else if (screen.lastIntent) {
body.push(`${uiTheme.fg("accent", hook)} ${uiTheme.fg("muted", frameText(screen.lastIntent, TV_LINE_MAX))}`);
}
const outputCap = options.expanded ? TV_OUTPUT_EXPANDED : TV_OUTPUT_COLLAPSED;
for (const line of screen.outputTail.slice(-outputCap)) {
if (line.trim().length === 0) continue;
body.push(` ${uiTheme.fg("muted", frameText(line, TV_LINE_MAX))}`);
}
} else if (screen.lastActivity) {
body.push(`${uiTheme.fg("dim", hook)} ${uiTheme.fg("muted", frameText(screen.lastActivity, TV_LINE_MAX))}`);
}
const footer = settledStatus
? uiTheme.fg(
settledStatus === "completed" ? "success" : settledStatus === "failed" ? "error" : "warning",
`turn ${settledStatus} — result delivered`,
)
: undefined;
return miniFrame(uiTheme, headParts.join(" "), body, footer);
}
/**
* Width-aware component over prebuilt lines, or — given a builder — lines
* recomputed on every paint. Spinner ticks repaint the tool block WITHOUT
* re-invoking renderCall/renderResult, so time-based content (shimmer sweep,
* spinner glyph, cursor blink, elapsed turn duration) must be produced inside
* a builder that reads the shared mutable `options` at paint time; prebuilt
* arrays are for static frames only.
*/
function linesComponent(lines: string[] | (() => string[])): Component {
return {
render(width: number): readonly string[] {
const rows = typeof lines === "function" ? lines() : lines;
return rows.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
},
invalidate() {},
};
}
function describeCall(op: VibeOp, args: VibeRenderArgs | undefined): string {
switch (op) {
case "spawn":
return `spawn ${args?.cli ?? "?"}${args?.name ? ` · ${frameText(args.name, 40)}` : ""}`;
case "send":
return `send → ${args?.session ? frameText(args.session, 40) : "?"}`;
case "wait":
return args?.sessions?.length
? `wait on ${frameText(args.sessions.join(", "), 60)}`
: "wait on running sessions";
case "kill":
return `kill ${args?.session ? frameText(args.session, 40) : "?"}`;
case "list":
return "sessions";
}
}
/** Build the shared vibe renderer for one tool name. */
export function createVibeToolRenderer(op: VibeOp) {
const composerOp = op === "spawn" || op === "send";
return {
inline: true,
mergeCallAndResult: true,
animatedPendingPreview: composerOp,
animatedPartialResult: op === "wait",
renderCall(args: VibeRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
const title = uiTheme.fg("muted", `vibe ${describeCall(op, args)}`);
if (composerOp) {
const message = op === "spawn" ? (args?.prompt ?? "") : (args?.message ?? "");
return linesComponent(() => {
const cursorOn = ((options.spinnerFrame ?? 0) & 1) === 0;
return miniFrame(
uiTheme,
title,
composerRows(uiTheme, message, { cursor: cursorOn, expanded: options.expanded }),
uiTheme.fg("dim", op === "spawn" ? "booting CLI…" : "delivering…"),
);
});
}
return new Text(renderStatusLine({ icon: "pending", title: `vibe ${describeCall(op, args)}` }, uiTheme), 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: VibeToolDetails; isError?: boolean },
options: RenderResultOptions,
uiTheme: Theme,
args?: VibeRenderArgs,
): Component {
const details = result.details;
if (!details || result.isError) {
const fallback = result.content.find(part => part.type === "text")?.text ?? "";
const header = renderStatusLine(
{ icon: result.isError ? "error" : "done", title: `vibe ${describeCall(op, args)}` },
uiTheme,
);
const body = fallback
? `\n ${uiTheme.fg(result.isError ? "error" : "dim", frameText(fallback, TV_LINE_MAX))}`
: "";
return new Text(`${header}${body}`, 0, 0);
}
if (composerOp) {
const message = op === "spawn" ? (args?.prompt ?? "") : (args?.message ?? "");
const target =
op === "spawn"
? `${uiTheme.fg("muted", "vibe spawn")} ${formatBadge(details.spawned?.cli ?? args?.cli ?? "?", "accent", uiTheme)} ${uiTheme.fg("accent", frameText(details.spawned?.id ?? args?.name ?? "", 40))}`
: `${uiTheme.fg("muted", "vibe send →")} ${uiTheme.fg("accent", frameText(args?.session ?? "?", 40))}`;
const ack =
op === "spawn"
? uiTheme.fg("success", `turn started${details.spawned ? ` (job ${details.spawned.jobId})` : ""}`)
: details.send?.mode === "steered"
? uiTheme.fg("success", "steered into the running turn")
: details.send?.mode === "queued"
? uiTheme.fg("warning", "mid-turn — queued as the next turn")
: uiTheme.fg(
"success",
`turn started${details.send?.jobId ? ` (job ${details.send.jobId})` : ""}`,
);
const lines = miniFrame(
uiTheme,
target,
composerRows(uiTheme, message, { cursor: false, expanded: options.expanded }),
ack,
);
return linesComponent(lines);
}
if (op === "kill") {
const killedNote = details.killed?.cancelledTurn ? " (in-flight turn cancelled)" : "";
const header = renderStatusLine(
{
icon: "done",
title: `vibe kill ${frameText(details.killed?.id ?? args?.session ?? "?", 40)}${killedNote}`,
},
uiTheme,
);
return new Text(header, 0, 0);
}
// wait/list: the TV wall.
const screens = details.screens;
if (screens.length === 0) {
const fallback = result.content.find(part => part.type === "text")?.text ?? "no sessions";
return new Text(
renderStatusLine(
{ icon: "warning", title: `vibe ${op}`, meta: [uiTheme.fg("dim", frameText(fallback, 60))] },
uiTheme,
),
0,
0,
);
}
const waiting = details.wait?.waiting === true;
const settledById = new Map(details.wait?.settled.map(entry => [entry.id, entry.status] as const) ?? []);
return linesComponent(() => {
const running = screens.filter(screen => screen.state === "running" || screen.state === "starting").length;
const meta: string[] = [];
if (running > 0) meta.push(uiTheme.fg("accent", `${running} on air`));
if (settledById.size > 0) meta.push(uiTheme.fg("success", `${settledById.size} settled`));
if (details.wait?.timedOut) meta.push(uiTheme.fg("warning", "timed out"));
const title =
op === "wait"
? waiting
? "vibe wait — watching the wall"
: "vibe wait"
: `vibe sessions (${screens.length})`;
const header = renderStatusLine(
{
icon: details.wait?.timedOut ? "warning" : running > 0 ? "info" : "done",
spinnerFrame: running > 0 ? options.spinnerFrame : undefined,
title,
meta,
},
uiTheme,
);
const lines = [header];
for (const screen of screens) {
lines.push(...tvScreen(uiTheme, screen, options, settledById.get(screen.id)));
}
return lines;
});
},
};
}