Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions loopx/pi_goal_mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 49 additions & 6 deletions loopx/pi_goal_mode/loopx-goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 || {};
Expand Down Expand Up @@ -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(
Expand All @@ -312,6 +348,9 @@ export default function (pi: ExtensionAPI) {
store: ReturnType<typeof createBindingStore>,
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(
[
Expand Down Expand Up @@ -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 || ""));
Expand All @@ -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();
});
}
36 changes: 33 additions & 3 deletions loopx/pi_goal_mode/pi-goal-loop-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions tests/pi_goal_loop_runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down
42 changes: 42 additions & 0 deletions tests/test_pi_goal_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading