Merge PR #6820: fix(coding-agent): preserve parent todos in vibe mode (@Iron-Ham)

This commit is contained in:
can1357
2026-07-28 10:59:37 +02:00
7 changed files with 179 additions and 31 deletions
+9 -6
View File
@@ -2,9 +2,10 @@
Vibe mode turns the session into a **director** that drives persistent background
worker sessions instead of editing code itself. In vibe mode your own toolset is
stripped down to `read` plus five worker-control tools; the workers do the
grepping, editing, running, and building, and you verify their work by reading
the files they touch.
stripped down to `read`, optional parent-owned `todo`, plus five worker-control
tools; the workers do the grepping, editing, running, and building, and you
verify their work by reading the files they touch. When available, `todo` is
only the director's parent-session bookkeeping tool; workers do not own it.
## Enabling and disabling
@@ -16,8 +17,9 @@ Toggle it with the `/vibe` slash command:
/vibe # run again to exit
```
- Entering installs the vibe tools, reduces the active toolset to `read` + the
vibe tools, and injects the director instructions for the turn.
- Entering installs the vibe tools, reduces the active toolset to `read`,
optional parent-owned `todo`, and the vibe tools, and injects the director
instructions for the turn.
- An inline prompt (`/vibe <prompt>`) enters the mode and submits that prompt as
the first directive.
- Exiting restores the previous toolset and **kills every worker session** — a
@@ -62,7 +64,8 @@ concurrently is the normal shape.
3. Keep directing other workers while turns are in flight; `vibe_wait` only when
blocked.
4. When a turn result arrives, `read` the touched files to verify claims before
building on them, then `vibe_send` the next step.
building on them. When the optional parent-owned `todo` tool is available,
reconcile verified work through it, then `vibe_send` the next step.
5. Route by difficulty: draft with `fast`, escalate to `good` when `fast` stalls
or the problem needs judgment.
6. `vibe_kill` finished or stuck workers; `vibe_list` to recover the roster.
+1
View File
@@ -160,6 +160,7 @@
- Fixed `@czottmann/pi-automode` failing legacy extension validation because the pi-ai compatibility shim omitted `clampThinkingLevel`, then failing every classified tool call because `ctx.modelRegistry` omitted `getApiKeyAndHeaders`. ([#6648](https://github.com/can1357/oh-my-pi/issues/6648))
- Fixed hide-secrets placeholders conflicting with hashline edit headers by replacing hash-delimited tokens with the unambiguous `$$HASH$$` format ([#6631](https://github.com/can1357/oh-my-pi/issues/6631)).
- Fixed the advisor silently swallowing its own quarantined turns: when an advisor called an ungranted tool (e.g. `bash`) its whole turn was discarded before dispatch, so its advice never reached the primary agent and the failure surfaced only in advisor diagnostics — every other non-recovering failure branch notifies the host UI, but quarantine re-primed silently with no bound. A persistently-quarantining advisor now surfaces a `notifyFailure` warning in the main UI (deduped, cleared on the next successful turn) and stops the unbounded silent re-prime loop ([#6661](https://github.com/can1357/oh-my-pi/issues/6661)).
- Fixed `/vibe` mode preventing the director from completing parent todos after verifying worker results: a registered built-in `todo` tool now remains active alongside `read` and Vibe controls, while normal worker sessions still do not receive it.
- Fixed the Docker `natives-builder` stage failing to build releases ≥ 17.1.1: the native audio stack added bindgen (miniaudio needs libclang) and a bundled-opus CMake build (needs cmake + make), none of which were installed in the slim builder image.
- Fixed a configured `modelRoles.default` naming an extension-registered model (listed in `enabledModels`) silently running on a different in-scope provider's model. The startup model scope is resolved before extensions call `registerProvider()`, so the default role dropped out of scope and `buildSessionOptions` pinned `options.model` to the first scoped model — which marked the model "explicit" and suppressed the post-extension default-role re-resolution. A configured default that can't be found in the startup scope is now deferred so it re-resolves against the fully registered, still `enabledModels`-scoped catalog once extensions load ([#6694](https://github.com/can1357/oh-my-pi/issues/6694)).
- Fixed Parakeet speech-to-text failing to load `sherpa-onnx-node` from Windows source workspaces when Bun installed the wrapper under `packages/coding-agent/node_modules` but hoisted its native platform package to the repository root ([#6690](https://github.com/can1357/oh-my-pi/issues/6690)).
@@ -3277,9 +3277,10 @@ export class InteractiveMode implements InteractiveModeContext {
/**
* `/vibe` toggle. Entering installs the ephemeral vibe tools, strips the
* active toolset down to `read` plus those tools, and injects the director
* context. Exiting unregisters them, restores the previous toolset, and kills
* every worker session so workers cannot outlive the mode that directs them.
* active toolset down to `read`, optional parent-owned `todo`, plus those
* tools, and injects the director context. Exiting unregisters them, restores
* the previous toolset, and kills every worker session so workers cannot
* outlive the mode that directs them.
*/
async handleVibeModeCommand(initialPrompt?: string): Promise<void> {
if (this.vibeModeEnabled) {
@@ -3317,7 +3318,9 @@ export class InteractiveMode implements InteractiveModeContext {
const ownerScope = vibeRegistry.ownerScope(this.#vibeParentSession());
vibeRegistry.activateScope(ownerScope);
const previousTools = this.session.getEnabledToolNames();
await this.session.activateVibeTools(["read"]);
const vibeBaseTools = ["read"];
if (this.session.hasBuiltInTool("todo")) vibeBaseTools.push("todo");
await this.session.activateVibeTools(vibeBaseTools);
this.#vibeModePreviousTools = previousTools;
this.#vibeModeOwnerScope = ownerScope;
this.vibeModeEnabled = true;
@@ -3330,7 +3333,9 @@ export class InteractiveMode implements InteractiveModeContext {
}
this.#updateVibeModeStatus();
if (options?.persistModeChange !== false) this.sessionManager.appendModeChange("vibe");
this.showStatus("Vibe mode enabled. You direct fast/good worker sessions; toolset is read + vibe tools.");
this.showStatus(
"Vibe mode enabled. You direct fast/good worker sessions; toolset is read + optional parent Todo + vibe tools.",
);
}
async #exitVibeMode(): Promise<void> {
@@ -1,7 +1,7 @@
<vibe-mode>
Vibe mode is ON. You are the DIRECTOR. You do not edit, run, grep, or build anything yourself — your hands are off the keyboard. You drive two kinds of worker CLIs, each a full coding agent with every normal tool, and you verify their work by reading files.
Your entire toolset: `read`, `vibe_spawn`, `vibe_send`, `vibe_wait`, `vibe_kill`, `vibe_list`.
Your entire toolset: `read`{{#if todoAvailable}}, `todo`{{/if}}, `vibe_spawn`, `vibe_send`, `vibe_wait`, `vibe_kill`, `vibe_list`.
# The two CLIs you drive
@@ -16,6 +16,9 @@ Sessions are persistent conversations, like terminals you keep open. A session r
2. `vibe_spawn` with a complete, self-contained brief: files, constraints, acceptance criteria. Workers start blank — they never see this conversation.
3. Sends and spawns return immediately; results arrive on their own when a worker finishes its turn. Keep directing other sessions meanwhile; call `vibe_wait` only when you cannot proceed without a result.
4. When a turn result arrives, judge it: `read` the touched files to verify claims before building on them. Follow up with `vibe_send` — corrections, next step, or a review request.
{{#if todoAvailable}}
After reading and verifying a worker result, use `todo` to maintain the parent session's list. Workers do not own this bookkeeping.
{{/if}}
5. Route by difficulty: draft with `fast`, escalate to `good` when `fast` stalls or the problem needs judgment; have `good` design and `fast` execute the mechanical parts.
6. `vibe_kill` a session that is stuck or whose workstream is done; `vibe_list` when you lose track of the roster.
@@ -4587,7 +4587,9 @@ export class AgentSession {
return {
role: "custom",
customType: "vibe-mode-context",
content: prompt.render(vibeModeActivePrompt),
content: prompt.render(vibeModeActivePrompt, {
todoAvailable: this.getActiveToolNames().includes("todo"),
}),
display: false,
attribution: "agent",
timestamp: Date.now(),
@@ -2,7 +2,8 @@
* Contracts: /vibe mode toggle on InteractiveMode.
*
* 1. Vibe tools do not exist in the session registry before the mode is entered.
* 2. Entering registers and activates exactly `read` plus the vibe tools.
* 2. Entering registers and activates exactly `read`, parent-owned `todo`, plus
* the vibe tools.
* 3. Exiting unregisters the vibe tools and restores the pre-vibe active toolset
* exactly, including the legitimate empty set.
*/
@@ -15,6 +16,7 @@ import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mod
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { normalizeCustomMessagePayload } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { FileSessionStorage, type WriteTextAtomicOptions } from "@oh-my-pi/pi-coding-agent/session/session-storage";
import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe";
@@ -85,6 +87,7 @@ describe("InteractiveMode vibe mode toggle", () => {
let authStorage: AuthStorage;
let session: AgentSession;
let mode: InteractiveMode;
let modelRegistry: ModelRegistry;
let storage: ExitFaultStorage;
beforeAll(async () => {
@@ -97,12 +100,11 @@ describe("InteractiveMode vibe mode toggle", () => {
tempDir = TempDir.createSync("@pi-vibe-toggle-");
await Settings.init({ inMemory: true, cwd: tempDir.path() });
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage);
modelRegistry = new ModelRegistry(authStorage);
const model = modelRegistry.find("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry");
const registryTools = [stubTool("read")];
const registryTools = [stubTool("read"), stubTool("todo")];
storage = new ExitFaultStorage();
session = new AgentSession({
agent: new Agent({
@@ -117,6 +119,7 @@ describe("InteractiveMode vibe mode toggle", () => {
settings: Settings.isolated({}),
modelRegistry,
toolRegistry: new Map(registryTools.map(tool => [tool.name, tool])),
builtInToolNames: registryTools.map(tool => tool.name),
createVibeTools: () => VIBE_TOOL_NAMES.map(stubTool),
});
mode = new InteractiveMode(session, "test", undefined, undefined, undefined, undefined, new EventBus());
@@ -132,29 +135,88 @@ describe("InteractiveMode vibe mode toggle", () => {
resetSettingsForTest();
});
it("restores the exact pre-vibe toolset on exit, including an empty one", async () => {
expect(session.getAllToolNames()).toEqual(["read"]);
it("preserves the parent Todo tool and restores the exact pre-vibe toolset on exit", async () => {
expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo"]);
expect(session.getActiveToolNames()).toEqual([]);
await mode.handleVibeModeCommand();
expect(mode.vibeModeEnabled).toBe(true);
const inMode = session.getActiveToolNames();
expect(inMode).toContain("read");
expect(inMode).toContain("todo");
for (const name of VIBE_TOOL_NAMES) {
expect(inMode).toContain(name);
}
expect(inMode.toSorted()).toEqual(["read", ...VIBE_TOOL_NAMES].toSorted());
expect(session.getAllToolNames().toSorted()).toEqual(["read", ...VIBE_TOOL_NAMES].toSorted());
expect(inMode.toSorted()).toEqual(["read", "todo", ...VIBE_TOOL_NAMES].toSorted());
expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo", ...VIBE_TOOL_NAMES].toSorted());
// Toggle off: the empty previous toolset must come back — vibe tools
// must not leak past the mode.
const sendCustomMessage = vi.spyOn(session, "sendCustomMessage");
await session.sendVibeModeContext({ deliverAs: "steer" });
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(message.customType).toBe("vibe-mode-context");
expect(content).toContain("`todo`");
expect(content).toContain("parent session's list");
expect(content).toContain("Workers do not own this bookkeeping.");
// Toggle off: the empty previous toolset must come back — only the
// ephemeral vibe tools must leave the registry.
await mode.handleVibeModeCommand();
expect(mode.vibeModeEnabled).toBe(false);
expect(session.getActiveToolNames()).toEqual([]);
expect(session.getAllToolNames()).toEqual(["read"]);
expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo"]);
});
it("preserves workers and mode metadata on a same-session reload", async () => {
it("keeps a same-named non-built-in Todo tool unavailable in Vibe mode", async () => {
const model = session.model;
if (!model) throw new Error("Expected active model");
const foreignTodoSession = new AgentSession({
agent: new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
}),
sessionManager: SessionManager.create(tempDir.path(), tempDir.path()),
settings: Settings.isolated({}),
modelRegistry,
toolRegistry: new Map(["read", "todo"].map(name => [name, stubTool(name)])),
builtInToolNames: ["read"],
createVibeTools: () => VIBE_TOOL_NAMES.map(stubTool),
});
const foreignTodoMode = new InteractiveMode(
foreignTodoSession,
"test",
undefined,
undefined,
undefined,
undefined,
new EventBus(),
);
try {
await foreignTodoMode.handleVibeModeCommand();
expect(foreignTodoSession.getActiveToolNames().toSorted()).toEqual(["read", ...VIBE_TOOL_NAMES].toSorted());
const sendCustomMessage = vi.spyOn(foreignTodoSession, "sendCustomMessage");
await foreignTodoSession.sendVibeModeContext({ deliverAs: "steer" });
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(content).not.toContain("`todo`");
expect(content).not.toContain("parent session's list");
await foreignTodoMode.handleVibeModeCommand();
expect(foreignTodoSession.getActiveToolNames()).toEqual([]);
expect(foreignTodoSession.getAllToolNames().toSorted()).toEqual(["read", "todo"]);
} finally {
foreignTodoMode.stop();
await foreignTodoSession.dispose();
}
});
it("preserves workers, Todo access, and mode metadata on a same-session reload", async () => {
await mode.init({ suppressWelcomeIntro: true });
await mode.handleVibeModeCommand();
await session.sessionManager.ensureOnDisk();
@@ -173,6 +235,13 @@ describe("InteractiveMode vibe mode toggle", () => {
expect(await switching).toBe(true);
expect(mode.vibeModeEnabled).toBe(true);
expect(session.getActiveToolNames()).toEqual(expect.arrayContaining(["read", "todo", ...VIBE_TOOL_NAMES]));
const sendCustomMessage = vi.spyOn(session, "sendCustomMessage");
await session.sendVibeModeContext({ deliverAs: "steer" });
const message = normalizeCustomMessagePayload(sendCustomMessage.mock.calls[0]?.[0]);
const content = typeof message.content === "string" ? message.content : "";
expect(content).toContain("`todo`");
expect(content).toContain("parent session's list");
expect(suspend).toHaveBeenCalledTimes(1);
expect(terminate).not.toHaveBeenCalled();
expect(vibeModeEntryCount(session.sessionManager)).toBe(1);
@@ -230,7 +299,7 @@ describe("InteractiveMode vibe mode toggle", () => {
it("does not clobber the target's active tools with the source snapshot when switching out of vibe", async () => {
await mode.init({ suppressWelcomeIntro: true });
// Pre-vibe snapshot on the source session is empty; entering vibe activates
// read + the vibe tools.
// read, parent-owned todo, and the vibe tools.
await mode.handleVibeModeCommand();
expect(mode.vibeModeEnabled).toBe(true);
expect(session.getActiveToolNames()).toContain("read");
@@ -246,9 +315,10 @@ describe("InteractiveMode vibe mode toggle", () => {
expect(await session.switchSession(targetFile)).toBe(true);
expect(mode.vibeModeEnabled).toBe(false);
// The transient vibe tools are gone, but the genuinely-active `read` tool
// must survive — the source's empty pre-vibe snapshot must not wipe it.
expect(session.getActiveToolNames()).toEqual(["read"]);
// The transient vibe tools are gone, but the genuinely-active `read` and
// parent-owned `todo` tools must survive — the source's empty pre-vibe
// snapshot must not wipe them.
expect(session.getActiveToolNames()).toEqual(["read", "todo"]);
for (const name of VIBE_TOOL_NAMES) {
expect(session.getActiveToolNames()).not.toContain(name);
}
@@ -305,7 +305,7 @@ describe("createAgentSession defaultInactive tool activation", () => {
await session.dispose();
}
});
it("registers vibe tools only during explicit vibe activation", async () => {
it("registers vibe tools only during explicit vibe activation and exposes parent Todo bookkeeping", async () => {
const tempDir = makeTempDir();
const { session } = await createAgentSession(baseOptions(tempDir));
const previousActiveToolNames = session.getActiveToolNames();
@@ -315,12 +315,27 @@ describe("createAgentSession defaultInactive tool activation", () => {
expect(session.getToolByName(name)).toBeUndefined();
}
await session.activateVibeTools(["read"]);
await session.activateVibeTools(["read", "todo"]);
const todo = session.getToolByName("todo");
if (!todo) throw new Error("Expected real Todo tool");
expect(session.getActiveToolNames()).toContain("todo");
for (const name of VIBE_TOOL_NAMES) {
expect(session.getToolByName(name)).toBeDefined();
expect(session.getActiveToolNames()).toContain(name);
}
await todo.execute("vibe-todo-init", {
op: "init",
list: [{ phase: "Work", items: ["Worker change"] }],
});
await todo.execute("vibe-todo-done", { op: "done", task: "Worker change" });
expect(session.getTodoPhases()).toMatchObject([
{
name: "Work",
tasks: [{ content: "Worker change", status: "completed" }],
},
]);
await session.deactivateVibeTools(previousActiveToolNames);
for (const name of VIBE_TOOL_NAMES) {
expect(session.getToolByName(name)).toBeUndefined();
@@ -331,6 +346,55 @@ describe("createAgentSession defaultInactive tool activation", () => {
}
});
it("rehydrates completed parent Todo work from persisted session history", async () => {
const tempDir = makeTempDir();
const sessionManager = SessionManager.create(tempDir, tempDir);
const { session } = await createAgentSession({
...baseOptions(tempDir),
sessionManager,
});
try {
await session.activateVibeTools(["read", "todo"]);
const todo = session.getToolByName("todo");
if (!todo) throw new Error("Expected real Todo tool");
const init = await todo.execute("vibe-todo-init", {
op: "init",
list: [{ phase: "Worker flow", items: ["Reconcile worker result"] }],
});
const done = await todo.execute("vibe-todo-done", { op: "done", task: "Reconcile worker result" });
for (const [toolCallId, result] of [
["vibe-todo-init", init],
["vibe-todo-done", done],
] as const) {
sessionManager.appendMessage({
role: "toolResult",
toolCallId,
toolName: "todo",
content: result.content,
details: result.details,
isError: result.isError === true,
timestamp: Date.now(),
});
}
await sessionManager.ensureOnDisk();
const sessionFile = session.sessionFile;
if (!sessionFile) throw new Error("Expected persisted session file");
session.setTodoPhases([]);
expect(session.getTodoPhases()).toEqual([]);
expect(await session.switchSession(sessionFile)).toBe(true);
expect(session.getTodoPhases()).toMatchObject([
{
name: "Worker flow",
tasks: [{ content: "Reconcile worker result", status: "completed" }],
},
]);
} finally {
await session.dispose();
}
});
it("does not register the xAI TTS tool unless enabled", async () => {
const tempDir = makeTempDir();