From 9bc2e6101758f075a8241844cce452f3ee959407 Mon Sep 17 00:00:00 2001 From: hua03 Date: Sat, 10 Oct 2026 17:41:05 +0800 Subject: [PATCH] fix(pi): make a paused goal loop visible and shrink the status widget A pause was silent: the runtime persisted `autoResume: false` when a `before_agent_start` prompt was not its own injected task body and cancelled the pending timer without telling the owner or recording why, so a stalled goal loop looked healthy while LoopX kept answering run_now. The pause now notifies the owner, and the binding keeps the reason (`pauseReason: "user_prompt"`, or `"aborted"` after Escape) until `/loopx resume` clears it. The reason is diagnostic only: `autoResume` stays the single continuation authority and the pause keeps the existing generation fence. The pause decision itself is unchanged: only the loop's own injected task body keeps auto-resume armed. An earlier revision of this branch also exempted the prompt that follows a compaction by pairing `session_compact` with the next `before_agent_start`. That inference is gone, because Pi compacts before it emits `before_agent_start` for a submitted prompt, so the pair does not prove where the prompt came from and owner input after a manual /compact stopped pausing. Bare /loopx dumped the whole bootstrap-command-pack preview into the editor widget, where the fixed-height area chopped it mid-sentence and covered the working indicator. It now renders a summarized status (goal id, connection state, next step) below the editor, keeps the full text in the `loopx-packet` session entry, and clears the widget on session shutdown and goal start. Refs #6107 -- the pause is now visible and durable; the disarm reported there is not produced by a compaction event in the current Pi extension API. Fixes #6108 Signed-off-by: hua03 --- loopx/pi_goal_mode/README.md | 9 ++++ loopx/pi_goal_mode/loopx-goal.ts | 55 ++++++++++++++++++--- loopx/pi_goal_mode/pi-goal-loop-runtime.mjs | 36 ++++++++++++-- tests/pi_goal_loop_runtime.test.mjs | 29 +++++++++++ tests/test_pi_goal_mode.py | 42 ++++++++++++++++ 5 files changed, 162 insertions(+), 9 deletions(-) diff --git a/loopx/pi_goal_mode/README.md b/loopx/pi_goal_mode/README.md index c6b752b8d5..2535d15f1e 100644 --- a/loopx/pi_goal_mode/README.md +++ b/loopx/pi_goal_mode/README.md @@ -39,6 +39,15 @@ into a LoopX-governed visible goal loop. and fences an in-flight quota probe. For persistent sessions, the loop stays paused across Pi restarts until `/loopx resume` or a fresh goal activation explicitly re-arms it. +- **Pause reasons** — every pause records why it happened in the binding: + `pauseReason: "user_prompt"` when a `before_agent_start` prompt was not the + loop's own injected task body, `"aborted"` after Escape, and `""` again once + `/loopx resume` re-arms the loop. Both pause paths report the pause in the UI + and the recorded reason is diagnostic only: it never decides continuation, and + the canonical `autoResume` flag stays the single authority. Prompt origin is not + inferred from another event: Pi compacts before it emits `before_agent_start` + for a submitted prompt, so a preceding `session_compact` does not prove that + the next prompt came from the host. ## Install / uninstall diff --git a/loopx/pi_goal_mode/loopx-goal.ts b/loopx/pi_goal_mode/loopx-goal.ts index 7e5abc6b28..6a3fbd389b 100644 --- a/loopx/pi_goal_mode/loopx-goal.ts +++ b/loopx/pi_goal_mode/loopx-goal.ts @@ -86,7 +86,8 @@ type StartGoalPacket = { ok?: boolean; goal_id?: unknown; project?: unknown; - project_connection?: { registry?: unknown }; + project_connection?: { registry?: unknown; connection_state?: unknown }; + recommended_next_step?: { kind?: unknown; summary?: unknown }; host_loop_activation?: { activation_allowed?: unknown; agent_id?: unknown; @@ -118,6 +119,36 @@ function parseJsonObject(stdout: string): StartGoalPacket | null { } } +// A widget is a fixed-height area near the editor, so a long packet preview has +// to be summarized into a few clamped lines; the full text stays in the +// `loopx-packet` session entry. +const STATUS_WIDGET_LINE_MAX = 96; + +function widgetLine(text: string): string { + const value = String(text || "").replace(/\s+/g, " ").trim(); + return value.length > STATUS_WIDGET_LINE_MAX + ? `${value.slice(0, STATUS_WIDGET_LINE_MAX - 1)}…` + : value; +} + +function buildLoopxStatusWidget(packet: StartGoalPacket | null): string[] | undefined { + if (!packet) return undefined; + const connection = packet.project_connection || {}; + const next = packet.recommended_next_step || {}; + const project = typeof packet.project === "string" ? packet.project : ""; + const goalId = typeof packet.goal_id === "string" ? packet.goal_id : ""; + const state = typeof connection.connection_state === "string" ? connection.connection_state : ""; + const nextText = + typeof next.summary === "string" ? next.summary : typeof next.kind === "string" ? next.kind : ""; + return [ + widgetLine(`LoopX · ${goalId || "(no goal id)"}`), + widgetLine( + `state: ${state || "unknown"}${project ? ` · ${project.split("/").filter(Boolean).pop()}` : ""}`, + ), + widgetLine(`next: ${nextText || "-"}`), + ]; +} + function buildPiSessionAuthority(packet: StartGoalPacket): PiSessionAuthority { const commandPack = packet.command_pack || {}; const activation = packet.host_loop_activation || commandPack.host_loop_activation || {}; @@ -295,8 +326,13 @@ export default function (pi: ExtensionAPI) { } } const display = typeof packet?.message === "string" ? packet.message : stdout; - ctx.ui.setWidget("loopx", display.split("\n").slice(0, 24)); - ctx.ui.notify("LoopX packet ready (widget above the editor).", "info"); + // The packet preview is a long multi-section document. A widget is a + // fixed-height area near the editor, so dumping the preview into it gets + // chopped mid-sentence and covers the working indicator. Show a compact + // status summary and keep the full text in the session entry instead. + const status = buildLoopxStatusWidget(packet); + if (status) ctx.ui.setWidget("loopx", status, { placement: "belowEditor" }); + ctx.ui.notify("LoopX packet ready (status widget below the editor).", "info"); pi.appendEntry("loopx-packet", { text: display }); } catch (error) { ctx.ui.notify( @@ -312,6 +348,9 @@ export default function (pi: ExtensionAPI) { store: ReturnType, ctx: ExtensionContext, ) => { + // A status widget from an earlier /loopx preview would otherwise keep + // floating while the guided start packet renders its own summary. + ctx.ui.setWidget("loopx", undefined); try { const stdout = await runLoopxCli( [ @@ -577,8 +616,11 @@ export default function (pi: ExtensionAPI) { await loop.interrupt(key); }); - // User-driven prompts pause the auto loop; only prompts we injected for the - // same goal keep auto-resume armed. + // Every prompt that is not this loop's own injected task body is the owner + // taking the wheel and pauses the loop; the pause itself carries the reason. + // Pi compacts before it emits before_agent_start for a submitted prompt, so a + // preceding compaction says nothing about who submitted that prompt and is not + // used to exempt one. pi.on("before_agent_start", async (event, ctx) => { const { key } = bindContext(ctx); await loop.userPrompt(key, String(event.prompt || "")); @@ -588,7 +630,8 @@ export default function (pi: ExtensionAPI) { // extension instance. Every timer is cancelled and an in-flight quota probe // returns to the runtime's disposed guard, so the old session cannot send a // follow-up or reschedule past this boundary. - pi.on("session_shutdown", async (_event, _ctx) => { + pi.on("session_shutdown", async (_event, ctx) => { + ctx.ui.setWidget("loopx", undefined); loop.dispose(); }); } diff --git a/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs b/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs index 04c49480f7..d4373d15c8 100644 --- a/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs +++ b/loopx/pi_goal_mode/pi-goal-loop-runtime.mjs @@ -121,6 +121,9 @@ function bindingDefaults(directory) { taskBody: "", autoResume: true, terminal: false, + // Why the loop stopped auto-resuming: "" (armed), "user_prompt", or + // "aborted". A later /loopx resume clears it again. + pauseReason: "", generation: 0, schedulerToken: "", unchangedPolls: 0, @@ -1082,6 +1085,12 @@ export function createGoalLoop(options) { if (!binding || binding.terminal || binding.autoResume === false) return await evaluateIdle(key) }, + // Every prompt that is not this loop's own injected task body is treated as + // the owner taking the wheel, and the pause records why it happened. The + // loop's own turns are recognized through lastInjectedPrompt; no other event + // is used to infer a prompt's origin, because Pi compacts before it emits + // before_agent_start for a submitted prompt, so a preceding session_compact + // says nothing about who submitted that prompt. async userPrompt(key, prompt) { if (disposed) return const instanceEpoch = epoch @@ -1097,10 +1106,22 @@ export function createGoalLoop(options) { if (!binding || binding.terminal) return if (String(prompt || "") !== binding.lastInjectedPrompt) { const expected = { generation: binding.generation || 0, goalId: binding.goalId } - const committed = await services.store.write(key, { autoResume: false, generation: (binding.generation || 0) + 1 }, expected) + const committed = await services.store.write( + key, + { + autoResume: false, + generation: (binding.generation || 0) + 1, + pauseReason: "user_prompt", + }, + expected, + ) if (!committed) return if (disposed || epoch !== instanceEpoch) return cancelScheduled(key) + services.notify( + `LoopX goal ${committed.goalId} auto-continuation paused by user input; run /loopx resume to continue.`, + "info", + ) } }, async interrupt(key) { @@ -1117,7 +1138,11 @@ export function createGoalLoop(options) { if (disposed || epoch !== instanceEpoch) return null if (!binding || binding.terminal) return binding const expected = { generation: binding.generation || 0, goalId: binding.goalId } - const committed = await services.store.write(key, { autoResume: false, generation: (binding.generation || 0) + 1 }, expected) + const committed = await services.store.write( + key, + { autoResume: false, generation: (binding.generation || 0) + 1, pauseReason: "aborted" }, + expected, + ) if (!committed || disposed || epoch !== instanceEpoch) return committed cancelScheduled(key) services.notify( @@ -1133,7 +1158,12 @@ export function createGoalLoop(options) { const current = await services.store.read(key) if (!current || disposed) return null const binding = await services.store.write(key, - { autoResume: true, terminal: false, generation: (current.generation || 0) + 1 }, + { + autoResume: true, + terminal: false, + generation: (current.generation || 0) + 1, + pauseReason: "", + }, { generation: current.generation || 0, goalId: current.goalId }) if (!binding) return null if (disposed) return null diff --git a/tests/pi_goal_loop_runtime.test.mjs b/tests/pi_goal_loop_runtime.test.mjs index eaec94bdfd..c6056243d7 100644 --- a/tests/pi_goal_loop_runtime.test.mjs +++ b/tests/pi_goal_loop_runtime.test.mjs @@ -333,12 +333,15 @@ test("a user-driven prompt pauses auto-resume and cancels the scheduled timer", await fixture.activate("session-pause") await fixture.loop.settle("session-pause") assert.equal(fixture.scheduled.length, 1) + const notifyBefore = fixture.calls.notify await fixture.loop.userPrompt("session-pause", "change direction") const binding = await fixture.store.read("session-pause") assert.equal(binding.autoResume, false) + assert.equal(binding.pauseReason, "user_prompt") assert.equal(fixture.scheduled[0].cleared, true) + assert.equal(fixture.calls.notify, notifyBefore + 1) const probesBefore = fixture.calls.quota await fixture.loop.settle("session-pause") @@ -357,6 +360,7 @@ test("an aborted Pi run pauses durably and cancels the scheduled timer", async ( const binding = await fixture.store.read("session-abort") assert.equal(binding.autoResume, false) + assert.equal(binding.pauseReason, "aborted") assert.equal(fixture.scheduled[0].cleared, true) const probesBefore = fixture.calls.quota @@ -365,6 +369,7 @@ test("an aborted Pi run pauses durably and cancels the scheduled timer", async ( fixture.setDecision({ should_run: true, scheduler_hint: { action: "run_now" } }) await fixture.loop.resume("session-abort") + assert.equal((await fixture.store.read("session-abort")).pauseReason, "") await fixture.loop.settle("session-abort") assert.equal(fixture.calls.quota, probesBefore + 1) assert.equal(fixture.calls.send, 1) @@ -419,6 +424,30 @@ test("an injected follow-up prompt keeps auto-resume armed", async () => { }) +test("no caller flag exempts a foreign prompt from pausing auto-resume", async () => { + const fixture = harness(backoffDecision()) + fixture.loop.bind("session-foreign-prompt", fixture.services) + await fixture.activate("session-foreign-prompt") + await fixture.loop.settle("session-foreign-prompt") + assert.equal(fixture.scheduled.length, 1) + const notifyBefore = fixture.calls.notify + + // Pi compacts before it emits before_agent_start for a submitted prompt, so a + // prompt that follows a compaction is owner input, not host housekeeping. An + // option claiming a host origin must not turn it back into an armed + // continuation, and the pause must stay visible. + await fixture.loop.userPrompt("session-foreign-prompt", "## Goal\n- continue after compaction", { + hostGenerated: true, + }) + + const binding = await fixture.store.read("session-foreign-prompt") + assert.equal(binding.autoResume, false) + assert.equal(binding.pauseReason, "user_prompt") + assert.equal(fixture.scheduled[0].cleared, true) + assert.equal(fixture.calls.notify, notifyBefore + 1) +}) + + test("session_shutdown during an in-flight probe stops the old session", async () => { let releaseDecision const pendingDecision = new Promise((resolve) => { diff --git a/tests/test_pi_goal_mode.py b/tests/test_pi_goal_mode.py index f1ae1b89b1..c2181d2deb 100644 --- a/tests/test_pi_goal_mode.py +++ b/tests/test_pi_goal_mode.py @@ -163,6 +163,48 @@ def test_pi_extension_binding_state_stays_private_and_scoped() -> None: assert "0o700" in runtime +def test_pi_extension_never_infers_prompt_origin_from_compaction() -> None: + adapter = extension_source() + runtime = runtime_source() + # Pi compacts before it emits before_agent_start for a submitted prompt, so a + # preceding session_compact says nothing about who submitted the next prompt. + # The adapter must not exempt a prompt from the pause on that basis, and the + # runtime must accept no caller flag that skips the pause. + assert "session_compact" not in adapter + assert "hostGenerated" not in adapter + assert "hostGenerated" not in runtime + # Every pause keeps a durable reason, and both pause paths report it: the + # previous silent user-prompt pause is what made a stalled loop look healthy. + assert 'pauseReason: "user_prompt"' in runtime + assert 'pauseReason: "aborted"' in runtime + assert 'pauseReason: ""' in runtime + assert "auto-continuation paused by user input" in runtime + + +def test_pi_extension_declares_the_packet_fields_it_reads() -> None: + adapter = extension_source() + # buildLoopxStatusWidget reads the bootstrap-command-pack top-level + # project_connection / recommended_next_step fields, so the declared packet + # type has to carry them there too; declaring them only under command_pack + # makes the widget fail typecheck. + top_level = adapter.split("command_pack?: {", 1)[0] + assert "project_connection?: { registry?: unknown; connection_state?: unknown };" in top_level + assert "recommended_next_step?: { kind?: unknown; summary?: unknown };" in top_level + + +def test_pi_extension_status_widget_stays_compact() -> None: + adapter = extension_source() + # A widget is a fixed-height area near the editor: the full packet preview + # must not be dumped into it (it was truncated mid-sentence and covered the + # working indicator). Keep a summarized status instead and store the full + # text in the session entry. + assert "buildLoopxStatusWidget" in adapter + assert 'placement: "belowEditor"' in adapter + assert "setWidget(\"loopx\", undefined)" in adapter + assert 'pi.appendEntry("loopx-packet"' in adapter + assert 'setWidget("loopx", display.split' not in adapter + + def test_pi_goal_loop_runtime_contract() -> None: node = shutil.which("node") if not node: