From 949bd4ae9b2db5d1e426ae143889c3b19ddd7127 Mon Sep 17 00:00:00 2001 From: BGamboa13 Date: Wed, 30 Sep 2026 12:46:14 -0400 Subject: [PATCH 1/2] fix(agents): wake an idle parent through prompt() so the harness survives An idle pi.sendMessage(..., { triggerTurn: true }) starts the turn through _runAgentPrompt without before_agent_start (earendil-works/pi#5581, still open), so the woken turn runs without the Gentle harness: claude-bridge refuses it and other providers silently drop ODD for that turn (#1528). The three triggerTurn wake-ups in gentle-agents (incoming orchestrator message, background completion, subagent notification/query) now go through createIdleWakeSender. When the host is idle it queues the message as nextTurn and starts the turn with sendUserMessage(" "), the normal prompt() path; while a run is active the original sendMessage call is kept. Idleness is read from ctx.isIdle(), the flag sendCustomMessage routes by. Counting agent_start/agent_end is not enough: the host emits that pair per inner prompt()/continue(), so a counter reads 0 while the run continues and the message is stranded in nextTurn. A missing or stale ctx falls back to the host path. --- extensions/gentle-agents.ts | 10 +++-- lib/idle-wake.ts | 54 ++++++++++++++++++++++++++ tests/gentle-agents.test.ts | 25 +++++++++++- tests/idle-wake.test.ts | 77 +++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 lib/idle-wake.ts create mode 100644 tests/idle-wake.test.ts diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 4528cd2fb..c371b9c20 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -19,6 +19,7 @@ import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts"; import { resolveVisualSettings } from "../lib/visual-customization-policy.ts"; import { createCompletionQueue } from "../lib/agents-completion-delivery.ts"; import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts"; +import { createIdleWakeSender } from "../lib/idle-wake.ts"; import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts"; import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts"; import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts"; @@ -315,6 +316,9 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as } export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial = {}): void { + // Every triggerTurn wake-up goes through this sender so an idle parent is + // started via prompt() and keeps its before_agent_start harness (#1528). + const wakeSend = createIdleWakeSender(pi); const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined)); if (env.GENTLE_PI_AGENTS_CHILD === "1") { // A stale managed-SDD child must never inherit unrestricted ordinary tools. @@ -479,7 +483,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = listener = sessionTransport.createListener(registry, sessionId, async (notification) => { const active = activeSessionTransport; if (!active || active.generation !== generation || active.sessionManager !== sessionManager || active.sessionId !== sessionId || sessions !== sessionManager || activeSessionId() !== sessionId) throw new Error("stale session transport"); - pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true }); + wakeSend({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true }); }); client = sessionTransport.createClient(registry, sessionId); if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) { @@ -588,7 +592,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // branch, so a parent that keeps calling tools would see the completion // only when the whole run ends — the original #867 delay. When the parent // is idle, triggerTurn runs the prompt immediately, preserving wake-up. - pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true }); + wakeSend({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true }); }; // A stale completion must not re-enter the LLM conversation, so it is @@ -601,7 +605,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = const deliverMessage = (msg: PendingAgentMessage) => { if (activeSessionId() !== msg.parentSessionId) return; - pi.sendMessage( + wakeSend( { customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details }, { deliverAs: "steer", triggerTurn: true }, ); diff --git a/lib/idle-wake.ts b/lib/idle-wake.ts new file mode 100644 index 000000000..6bc1822c6 --- /dev/null +++ b/lib/idle-wake.ts @@ -0,0 +1,54 @@ +// Idle wake-up that keeps the harness (issue #1528). +// +// Pi starts an idle `pi.sendMessage(..., { triggerTurn: true })` turn through +// `_runAgentPrompt` directly, without `before_agent_start` (earendil-works/pi#5581, +// still open). Everything an extension adds to the system prompt in +// `before_agent_start` — the whole Gentle harness — is missing on that turn: +// claude-bridge refuses to run it (prompt-capture miss), and other providers +// silently run a turn without the harness. +// +// When the host is idle, the message is queued with `deliverAs: "nextTurn"` and +// the turn is started through `pi.sendUserMessage(" ")`, i.e. the normal +// `prompt()` path: `before_agent_start` runs and the queued message is injected +// alongside that prompt. When the host is running, the original +// `sendMessage(message, options)` is kept unchanged. +// +// Idleness is read from the host (`ctx.isIdle()`, the same run flag +// `sendCustomMessage` routes by). Counting `agent_start`/`agent_end` does not +// work: the host emits that pair once per inner `prompt()`/`continue()` call and +// chains several of them in one run, so a counter reads 0 in the gap before the +// next `continue()` — exactly where completions are flushed on `agent_end`. +// Taking the idle path there queues the message as `nextTurn` while +// `sendUserMessage(" ")` throws "Agent is already processing", stranding the +// message until the next user prompt. + +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +type WakeMessage = Parameters[0]; +export type WakeOptions = { deliverAs: "steer" | "followUp"; triggerTurn: true }; +export type WakeHost = Pick; + +export function createIdleWakeSender(pi: WakeHost): (message: WakeMessage, options: WakeOptions) => void { + let latest: Pick | undefined; + const track = (_event: unknown, ctx: ExtensionContext) => { latest = ctx; }; + pi.on("session_start", track); + pi.on("agent_start", track); + pi.on("turn_end", track); + pi.on("agent_end", track); + + // A missing or stale ctx (after session replacement or reload) is not proof of + // idleness: fall back to the host's own delivery path, never to "nextTurn", + // which could strand the message. + const hostIdle = (): boolean => { + try { return latest?.isIdle() === true; } catch { return false; } + }; + + return (message, options) => { + if (hostIdle()) { + pi.sendMessage(message, { deliverAs: "nextTurn" }); + pi.sendUserMessage(" "); + return; + } + pi.sendMessage(message, options); + }; +} diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index db71a9754..6dac835a7 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -110,6 +110,7 @@ function fakePi() { const shortcuts = new Map }>(); const commands = new Map }>(); const sent: Array<{ message: Record; options: Record }> = []; + const userMessages: unknown[] = []; const renderers = new Map { render(width: number): string[] }>(); const entryRenderers = new Map { render(width: number): string[] }>(); const entries: Array<{ type: string; customType: string; data: unknown }> = []; @@ -125,6 +126,7 @@ function fakePi() { }, }, sendMessage: (message: Record, options: Record) => sent.push({ message, options }), + sendUserMessage: (content: unknown) => userMessages.push(content), registerMessageRenderer: (type: string, renderer: (message: unknown, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => renderers.set(type, renderer), registerEntryRenderer: (type: string, renderer: (entry: { type: string; customType: string; data: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] }) => entryRenderers.set(type, renderer), on: (event: string, handler: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), handler]), @@ -153,7 +155,7 @@ function fakePi() { } } }; - return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners }; + return { pi, tools, shortcuts, commands, fire, sent, userMessages, renderers, entryRenderers, entries, events, listeners }; } function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (title: string, message: string) => Promise = async () => true, inputResult: (title: string, placeholder: string | undefined) => Promise = async () => undefined, overlayTui: { terminal: { rows: number }; requestRender(): void } = { terminal: { rows: 30 }, requestRender() {} }, selectResult: (title: string, options: string[]) => Promise = async (_title, options) => options[0]) { @@ -4331,3 +4333,24 @@ test("children receive the child-context extension, and a missing file is omitte } } }); + +test("a completion for an idle parent starts the turn through prompt(), not a bare triggerTurn (#1528)", async () => { + // pi#5581: an idle sendMessage(..., { triggerTurn: true }) skips + // before_agent_start, so the woken turn would run without the harness. The + // completion is queued as nextTurn and the turn is started with a prompt. + const { pi, tools, fire, sent, userMessages } = fakePi(); + const harness = deps(); + gentleAgents(pi, {}, harness.deps); + const { ctx } = fakeContext(); + const idleCtx = { ...ctx, isIdle: () => true } as unknown as ExtensionContext; + await fire("session_start", idleCtx); + await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Idle job", mode: "background" }, undefined, undefined, idleCtx); + await tick(); + harness.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done while idle." }] }] }); + harness.children[0].emit({ type: "agent_settled" }); + await tick(); + assert.equal(sent.length, 1, "the completion is delivered once"); + assert.equal(sent[0].message.customType, "gentle-agents.result"); + assert.deepEqual(sent[0].options, { deliverAs: "nextTurn" }); + assert.deepEqual(userMessages, [" "], "the idle parent is started through the prompt path"); +}); diff --git a/tests/idle-wake.test.ts b/tests/idle-wake.test.ts new file mode 100644 index 000000000..f3f31f1f2 --- /dev/null +++ b/tests/idle-wake.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createIdleWakeSender, type WakeHost } from "../lib/idle-wake.ts"; + +// An idle wake-up must go through prompt() so before_agent_start runs +// (issue #1528, earendil-works/pi#5581); a running host keeps the original +// sendMessage call. + +type Call = [string, unknown]; + +function host() { + const handlers = new Map void>>(); + const calls: Call[] = []; + const pi = { + on: (event: string, handler: (event: unknown, ctx: unknown) => void) => { handlers.set(event, [...(handlers.get(event) ?? []), handler]); }, + sendMessage: (_message: unknown, options: unknown) => { calls.push(["sendMessage", options]); }, + sendUserMessage: (content: unknown) => { calls.push(["sendUserMessage", content]); }, + } as unknown as WakeHost; + const emit = (event: string, ctx: unknown) => { for (const handler of handlers.get(event) ?? []) handler({ type: event }, ctx); }; + return { send: createIdleWakeSender(pi), emit, calls }; +} + +const ctx = (idle: boolean) => ({ isIdle: () => idle }); +const message = { customType: "gentle-agents.result", content: "done", display: true }; + +test("an idle host is woken through prompt() so before_agent_start runs", () => { + const h = host(); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "nextTurn" }], ["sendUserMessage", " "]]); +}); + +test("a running host keeps the original steer/followUp delivery", () => { + const h = host(); + h.emit("session_start", ctx(false)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + h.send(message, { deliverAs: "followUp", triggerTurn: true }); + assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }], ["sendMessage", { deliverAs: "followUp", triggerTurn: true }]]); +}); + +test("a run that is still active after an inner agent_end is not treated as idle", () => { + // The host emits agent_start/agent_end per inner prompt()/continue(); a + // counter would read 0 here and strand the message as nextTurn. + const h = host(); + const running = ctx(false); + h.emit("session_start", running); + h.emit("agent_start", running); + h.emit("turn_end", running); + h.emit("agent_end", running); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }]]); +}); + +test("idleness is read live, not frozen at the last event", () => { + const h = host(); + let idle = false; + h.emit("agent_end", { isIdle: () => idle }); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + idle = true; + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [ + ["sendMessage", { deliverAs: "steer", triggerTurn: true }], + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ]); +}); + +test("a missing or stale ctx falls back to the host path, never to nextTurn", () => { + const noCtx = host(); + noCtx.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(noCtx.calls, [["sendMessage", { deliverAs: "steer", triggerTurn: true }]]); + + const stale = host(); + stale.emit("session_start", { isIdle: () => { throw new Error("This extension ctx is stale after session replacement or reload."); } }); + stale.send(message, { deliverAs: "followUp", triggerTurn: true }); + assert.deepEqual(stale.calls, [["sendMessage", { deliverAs: "followUp", triggerTurn: true }]]); +}); From d62bc901f69c4bd2e6dc77d81a372a4e6f30e3fb Mon Sep 17 00:00:00 2001 From: BGamboa13 Date: Wed, 30 Sep 2026 12:57:32 -0400 Subject: [PATCH 2/2] fix(agents): submit one wake prompt while an idle wake is being prepared prompt() awaits input handlers, auth, compaction and before_agent_start before it marks the run active, so the host still reads idle while a wake prompt is prepared. A second wake in that window submitted another prompt; Agent.prompt() rejected it and its settlement cleared the active-run flag under the first run, and a nextTurn queue it had taken was lost. The first wake now reserves the turn synchronously; later idle messages are only queued as nextTurn and ride the pending prompt, which takes that queue and marks the run active in one synchronous step. The reservation ends on agent_start or session_start, and expires after WAKE_RESERVATION_MS because a failed extension prompt is only reported to the host's error channel. --- lib/idle-wake.ts | 35 +++++++++++++++++----- tests/idle-wake.test.ts | 65 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/lib/idle-wake.ts b/lib/idle-wake.ts index 6bc1822c6..894e2f120 100644 --- a/lib/idle-wake.ts +++ b/lib/idle-wake.ts @@ -21,6 +21,20 @@ // Taking the idle path there queues the message as `nextTurn` while // `sendUserMessage(" ")` throws "Agent is already processing", stranding the // message until the next user prompt. +// +// One wake at a time. `prompt()` awaits input handlers, auth, compaction and +// `before_agent_start` before it marks the run active, so the host still reads +// idle while a wake prompt is being prepared. A second `sendUserMessage(" ")` in +// that window would reach `Agent.prompt()` while the first runs and be rejected, +// and its settlement would clear the active-run flag under the first run. The +// first wake therefore reserves the turn synchronously; later idle messages are +// only queued as `nextTurn`. They are carried by the pending prompt: the host +// takes the `nextTurn` queue and marks the run active in one synchronous step, +// so while the host still reads idle, the pending prompt has not taken it yet. +// The reservation ends when the run starts or the session is replaced. The host +// reports a failed extension prompt only to its error channel, so a reservation +// also expires after WAKE_RESERVATION_MS; the queued messages stay in `nextTurn` +// and the next wake (or user prompt) carries them. import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; @@ -28,11 +42,16 @@ type WakeMessage = Parameters[0]; export type WakeOptions = { deliverAs: "steer" | "followUp"; triggerTurn: true }; export type WakeHost = Pick; -export function createIdleWakeSender(pi: WakeHost): (message: WakeMessage, options: WakeOptions) => void { +/** How long a submitted wake prompt may stay unstarted before another wake may submit a new one. */ +export const WAKE_RESERVATION_MS = 60_000; + +export function createIdleWakeSender(pi: WakeHost, now: () => number = Date.now): (message: WakeMessage, options: WakeOptions) => void { let latest: Pick | undefined; + let wakeReservedAt: number | undefined; const track = (_event: unknown, ctx: ExtensionContext) => { latest = ctx; }; - pi.on("session_start", track); - pi.on("agent_start", track); + const trackAndRelease = (_event: unknown, ctx: ExtensionContext) => { latest = ctx; wakeReservedAt = undefined; }; + pi.on("session_start", trackAndRelease); + pi.on("agent_start", trackAndRelease); pi.on("turn_end", track); pi.on("agent_end", track); @@ -44,11 +63,13 @@ export function createIdleWakeSender(pi: WakeHost): (message: WakeMessage, optio }; return (message, options) => { - if (hostIdle()) { - pi.sendMessage(message, { deliverAs: "nextTurn" }); - pi.sendUserMessage(" "); + if (!hostIdle()) { + pi.sendMessage(message, options); return; } - pi.sendMessage(message, options); + pi.sendMessage(message, { deliverAs: "nextTurn" }); + if (wakeReservedAt !== undefined && now() - wakeReservedAt < WAKE_RESERVATION_MS) return; + wakeReservedAt = now(); + pi.sendUserMessage(" "); }; } diff --git a/tests/idle-wake.test.ts b/tests/idle-wake.test.ts index f3f31f1f2..e3ff71a00 100644 --- a/tests/idle-wake.test.ts +++ b/tests/idle-wake.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createIdleWakeSender, type WakeHost } from "../lib/idle-wake.ts"; +import { createIdleWakeSender, WAKE_RESERVATION_MS, type WakeHost } from "../lib/idle-wake.ts"; // An idle wake-up must go through prompt() so before_agent_start runs // (issue #1528, earendil-works/pi#5581); a running host keeps the original @@ -8,7 +8,7 @@ import { createIdleWakeSender, type WakeHost } from "../lib/idle-wake.ts"; type Call = [string, unknown]; -function host() { +function host(now: () => number = () => 0) { const handlers = new Map void>>(); const calls: Call[] = []; const pi = { @@ -17,7 +17,7 @@ function host() { sendUserMessage: (content: unknown) => { calls.push(["sendUserMessage", content]); }, } as unknown as WakeHost; const emit = (event: string, ctx: unknown) => { for (const handler of handlers.get(event) ?? []) handler({ type: event }, ctx); }; - return { send: createIdleWakeSender(pi), emit, calls }; + return { send: createIdleWakeSender(pi, now), emit, calls }; } const ctx = (idle: boolean) => ({ isIdle: () => idle }); @@ -65,6 +65,65 @@ test("idleness is read live, not frozen at the last event", () => { ]); }); +test("two wakes while the first prompt is still being prepared submit one prompt", () => { + // prompt() awaits input and before_agent_start handlers before the run is + // active, so the host keeps reading idle; a second prompt would be rejected + // and its settlement would clear the active-run flag under the first run. + const h = host(); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + h.send(message, { deliverAs: "followUp", triggerTurn: true }); + assert.deepEqual(h.calls, [ + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ["sendMessage", { deliverAs: "nextTurn" }], + ], "the second message rides the pending prompt's nextTurn queue"); +}); + +test("the reservation ends when the woken run starts", () => { + const h = host(); + let idle = true; + const live = { isIdle: () => idle }; + h.emit("session_start", live); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + idle = false; + h.emit("agent_start", live); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + idle = true; + h.emit("agent_end", live); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls, [ + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ["sendMessage", { deliverAs: "steer", triggerTurn: true }], + ["sendMessage", { deliverAs: "nextTurn" }], + ["sendUserMessage", " "], + ]); +}); + +test("session replacement releases a pending reservation", () => { + const h = host(); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls.filter(([name]) => name === "sendUserMessage").length, 2); +}); + +test("a wake prompt that never starts stops blocking after WAKE_RESERVATION_MS", () => { + // The host reports a failed extension prompt only to its error channel; the + // queued messages stay in nextTurn and the next wake submits a new prompt. + let clock = 0; + const h = host(() => clock); + h.emit("session_start", ctx(true)); + h.send(message, { deliverAs: "steer", triggerTurn: true }); + clock = WAKE_RESERVATION_MS - 1; + h.send(message, { deliverAs: "steer", triggerTurn: true }); + clock = WAKE_RESERVATION_MS; + h.send(message, { deliverAs: "steer", triggerTurn: true }); + assert.deepEqual(h.calls.map(([name]) => name), ["sendMessage", "sendUserMessage", "sendMessage", "sendMessage", "sendUserMessage"]); +}); + test("a missing or stale ctx falls back to the host path, never to nextTurn", () => { const noCtx = host(); noCtx.send(message, { deliverAs: "steer", triggerTurn: true });