@fyeeme/pi-goal 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.0.5] - 2026-10-02
6
+
7
+ ### Changed
8
+
9
+ - Relaxed the `@earendil-works/pi-agent-core` peer dependency from exact `0.99.2` to `>=0.99.0`, matching the other peer floors.
10
+ - Dev toolchain pinned to `@earendil-works/pi-agent-core`/`pi-ai`/`pi-coding-agent`/`pi-tui` 1.0.0; typecheck and tests pass against 1.0.0 unchanged.
11
+
12
+ ## [1.0.4] - 2026-10-01
13
+
5
14
  ### Added
6
15
 
7
16
  - Objective length cap (`MAX_OBJECTIVE_CHARS`, 4,000 code points) enforced by the runtime on `create` and `replace` — the objective is re-injected into context on every continuation and passed to the evaluator subprocess, so an oversized one silently burns budget every turn; the error guides long instructions into a referenced file. Counting is code-point-based, not UTF-16 units. (Borrowed from mitsuhiko/agent-stuff `extensions/goal.ts`.)
@@ -16,6 +25,7 @@
16
25
 
17
26
  - Context hygiene: hidden goal messages no longer accumulate in the LLM's view. A `context` handler keeps only the newest `goal-mode-context` and `goal-budget-limit` messages plus the newest `goal-continuation` stamped for the currently active goal id (`details.goalId`); stale ones — including all continuations once no goal is active — are dropped from the model's view, not from the transcript. (Borrowed from mitsuhiko/agent-stuff `extensions/goal.ts`.)
18
27
  - Run-error handling: when a run ends with an assistant `stopReason: "error"`, the active goal now pauses (persisted) instead of letting the continuation loop fire into a likely retry loop, with a classified notice — provider usage/rate/quota/limit errors read differently from generic faults. Abort behavior is unchanged. (Borrowed from mitsuhiko/agent-stuff `extensions/goal.ts`.)
28
+ - Peer dependency floor raised to `@earendil-works/pi-coding-agent >= 0.99.0`; dev toolchain pinned to 0.99.2 (typecheck and tests pass against 0.99.2 unchanged).
19
29
 
20
30
  ## [1.0.2] - 2026-09-16
21
31
 
package/index.ts CHANGED
@@ -27,12 +27,18 @@
27
27
  * goal_updated session event → pi.events.emit("goal_updated")
28
28
  * sendHiddenMessage (budget steer) → pi.sendMessage display:false
29
29
  * prompt-time prependMessages goal context
30
- * → before_agent_start message
31
- * injection (hidden custom message)
30
+ * → context_with_system per-request
31
+ * injection (0.87.0): survives
32
+ * compaction, supersedes transcript
33
+ * copies instead of accumulating
32
34
  * #scheduleGoalContinuation 800ms TUI timer
33
- * → followUp delivery + triggerTurn
34
- * (editor-empty guard dropped: pi
35
- * extensions cannot read the editor)
35
+ * → agent_before_settle actionable
36
+ * boundary (0.87.0):
37
+ * { entries: [continuation draft],
38
+ * continue: true } — exactly one
39
+ * next provider request; sendMessage
40
+ * fallback kept for continuations
41
+ * withheld after settlement
36
42
  * setActiveToolsByName → pi.setActiveTools
37
43
  * status-line segment → ctx.ui.setStatus("goal", ...)
38
44
  * settings goal.enabled / continuationModes / statusInFooter
@@ -59,7 +65,12 @@
59
65
  import { readFileSync } from "node:fs";
60
66
  import * as path from "node:path";
61
67
  import { fileURLToPath } from "node:url";
62
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
68
+ import type {
69
+ ContextEditEntryDraft,
70
+ ExtensionAPI,
71
+ ExtensionContext,
72
+ SessionBoundaryDraft,
73
+ } from "@earendil-works/pi-coding-agent";
63
74
  import { Text } from "@earendil-works/pi-tui";
64
75
  import {
65
76
  createGoalCommand,
@@ -105,6 +116,34 @@ interface EntryUsageLike {
105
116
  usage?: EntryMessageLike["usage"];
106
117
  }
107
118
 
119
+ /** Budget slimming drafts (opt-in via PI_GOAL_SLIM_ON_BUDGET=1, attached once
120
+ * per goal at the budget-limited flip): omit toolResult entries that precede
121
+ * the newest real user message from future provider context via append-only
122
+ * context-edit drafts — superseded turns' work products. Raw history, usage
123
+ * and UI history stay untouched (context_edit contract). Pure: returns the
124
+ * drafts; the settle boundary attaches them. */
125
+ export function contextSlimDrafts(branch: unknown[]): ContextEditEntryDraft[] {
126
+ const drafts: ContextEditEntryDraft[] = [];
127
+ let keepFrom = -1;
128
+ for (let i = branch.length - 1; i >= 0; i--) {
129
+ const entry = branch[i] as { type?: string; message?: { role?: string } } | undefined;
130
+ if (entry?.type === "message" && entry.message?.role === "user") {
131
+ keepFrom = i;
132
+ break;
133
+ }
134
+ }
135
+ if (keepFrom <= 0) return drafts;
136
+ const seen = new Set<string>();
137
+ for (let i = 0; i < keepFrom; i++) {
138
+ const entry = branch[i] as { type?: string; id?: string; message?: { role?: string } } | undefined;
139
+ if (entry?.type !== "message" || entry.message?.role !== "toolResult") continue;
140
+ if (!entry.id || seen.has(entry.id)) continue;
141
+ seen.add(entry.id);
142
+ drafts.push({ type: "context_edit", targetId: entry.id, replacement: null });
143
+ }
144
+ return drafts;
145
+ }
146
+
108
147
  export default function piGoalExtension(pi: ExtensionAPI): void {
109
148
  // ------------------------------------------------------------------
110
149
  // Closure state (omp session fields)
@@ -126,6 +165,10 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
126
165
  /** A blocking ctx.ui dialog is open (ui_prompt_start/end). While open, no
127
166
  * continuation may fire: the modal would hide a turn starting behind it. */
128
167
  let uiPromptOpen = false;
168
+ /** Goal id whose budget-limited flip already armed context slimming. */
169
+ let slimmedBudgetGoalId: string | undefined;
170
+ /** Context-slimming drafts pending attachment at the next settle boundary. */
171
+ let pendingSlim = false;
129
172
 
130
173
  // ------------------------------------------------------------------
131
174
  // Usage accounting (mirror of pi getSessionStats().tokens sums)
@@ -257,6 +300,14 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
257
300
  return;
258
301
  }
259
302
  goalState = state;
303
+ if (
304
+ state?.goal.status === "budget-limited" &&
305
+ process.env.PI_GOAL_SLIM_ON_BUDGET === "1" &&
306
+ slimmedBudgetGoalId !== state.goal.id
307
+ ) {
308
+ slimmedBudgetGoalId = state.goal.id;
309
+ pendingSlim = true;
310
+ }
260
311
  if (!state?.enabled) {
261
312
  continuationInFlight = false;
262
313
  }
@@ -416,7 +467,9 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
416
467
  return renderTemplate(goalModeContextPrompt, { goalContext: content, todoContext });
417
468
  }
418
469
 
419
- /** omp #scheduleGoalContinuation (TUI timer replaced by followUp delivery). */
470
+ /** omp #scheduleGoalContinuation. Post-settlement fallback path only: the
471
+ * primary continuation decision lives in the agent_before_settle boundary
472
+ * below (0.87.0). */
420
473
  function scheduleContinuation(): void {
421
474
  if (!goalState?.enabled || goalState.goal.status !== "active") return;
422
475
  if (suppressNextContinuation) return;
@@ -433,6 +486,26 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
433
486
  );
434
487
  }
435
488
 
489
+ /** omp #scheduleGoalContinuation decision chain, settled into a draft:
490
+ * guards pass → one custom_message draft carrying the continuation
491
+ * prompt (details.goalId keys context pruning). */
492
+ function continuationDraft(ctx: ExtensionContext): SessionBoundaryDraft | undefined {
493
+ if (!goalState?.enabled || goalState.goal.status !== "active") return undefined;
494
+ if (suppressNextContinuation) return undefined;
495
+ if (uiPromptOpen) return undefined; // modal open: never start a turn behind it
496
+ if (ctx.hasPendingMessages()) return undefined;
497
+ const prompt = runtime.buildContinuationPrompt();
498
+ if (!prompt) return undefined;
499
+ continuationInFlight = true;
500
+ return {
501
+ type: "custom_message",
502
+ customType: "goal-continuation",
503
+ content: prompt,
504
+ display: false,
505
+ details: { goalId: goalState.goal.id },
506
+ };
507
+ }
508
+
436
509
  // ------------------------------------------------------------------
437
510
  // Tool + command registration
438
511
  // ------------------------------------------------------------------
@@ -584,13 +657,25 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
584
657
  }
585
658
  });
586
659
 
587
- pi.on("before_agent_start", (_event, ctx) => {
660
+ // Goal context injection (0.87.0 context_with_system): runs after `context`
661
+ // handlers on the full transcript including system messages; the result is
662
+ // sent verbatim. Per-request injection survives compaction (re-applied every
663
+ // call) and the fresh copy supersedes any steer-injected transcript copies.
664
+ pi.on("context_with_system", (event, ctx) => {
588
665
  currentCtx = ctx;
589
666
  const content = buildGoalModeMessage();
590
667
  if (!content) return undefined;
591
- return {
592
- message: { customType: "goal-mode-context", content, display: false },
593
- };
668
+ const messages = event.messages.filter((message) => {
669
+ const msg = message as { role?: string; customType?: string } | undefined;
670
+ return !(msg?.role === "custom" && msg.customType === "goal-mode-context");
671
+ });
672
+ messages.push({
673
+ role: "custom",
674
+ customType: "goal-mode-context",
675
+ content,
676
+ display: false,
677
+ } as (typeof event.messages)[number]);
678
+ return { messages };
594
679
  });
595
680
 
596
681
  // Context hygiene (borrowed from mitsuhiko/agent-stuff goal.ts): hidden goal
@@ -649,7 +734,31 @@ export default function piGoalExtension(pi: ExtensionAPI): void {
649
734
  return;
650
735
  }
651
736
  updateStatus();
652
- scheduleContinuation();
737
+ });
738
+
739
+ // omp #scheduleGoalContinuation migrated to the 0.87.0 actionable boundary:
740
+ // at settle time decide ONCE whether exactly one more provider request
741
+ // should run, attaching the continuation prompt as a custom_message draft.
742
+ // No timer, no followUp scheduling — pi owns the request. Guards mirror the
743
+ // old scheduleContinuation chain; continuations withheld after settlement
744
+ // (modal closed late, budget set while idle) still recover via the
745
+ // sendMessage fallback in scheduleContinuation().
746
+ pi.on("agent_before_settle", (_event, ctx) => {
747
+ currentCtx = ctx;
748
+ // Context slimming (opt-in): attach pending context_edit drafts first —
749
+ // they persist even when the continuation itself is withheld.
750
+ const drafts: SessionBoundaryDraft[] = [];
751
+ if (pendingSlim) {
752
+ pendingSlim = false;
753
+ drafts.push(...contextSlimDrafts(ctx.sessionManager.getBranch()));
754
+ }
755
+ const continuation = continuationDraft(ctx);
756
+ if (continuation) drafts.push(continuation);
757
+ if (drafts.length === 0) return undefined;
758
+ // Absent `continue` = no opinion on auto-continuation (budget-limited
759
+ // goals settle without one); `continue: true` guarantees exactly one
760
+ // next provider request.
761
+ return continuation !== undefined ? { entries: drafts, continue: true } : { entries: drafts };
653
762
  });
654
763
 
655
764
  pi.on("turn_end", (_event, ctx) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-goal",
3
- "version": "1.0.3",
4
- "description": "Goal mode for pi \u2014 one persistent autonomous objective looped until verified success: goal tool (create/get/complete/resume/drop), token/time budget accounting with budget-limit steering, automatic continuation turns, /goal and /guided-goal commands, and a goal_updated event bus for other extensions.",
3
+ "version": "1.0.5",
4
+ "description": "Goal mode for pi — one persistent autonomous objective looped until verified success: goal tool (create/get/complete/resume/drop), token/time budget accounting with budget-limit steering, automatic continuation turns, /goal and /guided-goal commands, and a goal_updated event bus for other extensions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "fyeeme",
@@ -47,17 +47,17 @@
47
47
  "typecheck": "tsc"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-ai": ">=0.84.4",
51
- "@earendil-works/pi-coding-agent": ">=0.84.4",
52
- "@earendil-works/pi-tui": ">=0.84.4",
50
+ "@earendil-works/pi-ai": ">=0.99.0",
51
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
52
+ "@earendil-works/pi-tui": ">=0.99.0",
53
53
  "typebox": ">=1.0.0",
54
- "@earendil-works/pi-agent-core": "0.84.4"
54
+ "@earendil-works/pi-agent-core": ">=0.99.0"
55
55
  },
56
56
  "devDependencies": {
57
- "@earendil-works/pi-agent-core": "0.84.4",
58
- "@earendil-works/pi-ai": "0.84.4",
59
- "@earendil-works/pi-coding-agent": "0.84.4",
60
- "@earendil-works/pi-tui": "0.84.4",
57
+ "@earendil-works/pi-agent-core": "1.0.0",
58
+ "@earendil-works/pi-ai": "1.0.0",
59
+ "@earendil-works/pi-coding-agent": "1.0.0",
60
+ "@earendil-works/pi-tui": "1.0.0",
61
61
  "@types/node": "22.19.19",
62
62
  "typescript": "5.9.3",
63
63
  "vitest": "3.2.7"
@@ -152,6 +152,19 @@ async function fire(host: FakeHost, event: string, payload: unknown, ctx?: unkno
152
152
  }
153
153
  }
154
154
 
155
+ /** Fire agent_before_settle and return its boundary result. */
156
+ function fireBeforeSettle(
157
+ host: FakeHost,
158
+ ctx?: unknown,
159
+ ): { continue?: boolean; entries?: Array<{ type: string; customType?: string; details?: unknown }> } | undefined {
160
+ const list = host.handlers.get("agent_before_settle") ?? [];
161
+ let result: unknown;
162
+ for (const handler of list) {
163
+ result = handler({ type: "agent_before_settle" }, ctx ?? createContext(host));
164
+ }
165
+ return result as { continue?: boolean; entries?: Array<{ type: string; customType?: string; details?: unknown }> } | undefined;
166
+ }
167
+
155
168
  function fireBus(host: FakeHost, channel: string, data: unknown): void {
156
169
  for (const handler of host.busHandlers.get(channel) ?? []) {
157
170
  handler(data);
@@ -268,30 +281,52 @@ describe("pi-goal extension wiring", () => {
268
281
  expect(host.statuses.get("goal")).toBe("🎯 Goal 0");
269
282
  });
270
283
 
271
- it("before_agent_start injects the hidden goal-mode-context message only while a goal is active", async () => {
284
+ it("context_with_system injects the goal-mode-context message only while a goal is active", async () => {
272
285
  const host = fakeHost();
273
286
  defaultExport(host.pi);
274
287
  await fire(host, "session_start", { type: "session_start", reason: "startup" });
275
288
 
276
- let result: unknown;
277
- const handler = host.handlers.get("before_agent_start")?.[0];
278
- result = await handler!({ prompt: "hi", systemPrompt: "" }, createContext(host));
289
+ const handler = host.handlers.get("context_with_system")?.[0]!;
290
+ const transcript = [{ role: "user", content: "hi" }];
291
+ let result = (await handler({ type: "context_with_system", messages: transcript }, createContext(host))) as
292
+ | { messages: Array<{ customType?: string; content?: string }> }
293
+ | undefined;
279
294
  expect(result).toBeUndefined();
280
295
 
281
296
  await host.commands.goal!.handler("Do the thing", createContext(host));
282
- result = await handler!({ prompt: "hi", systemPrompt: "" }, createContext(host));
283
- expect(result).toMatchObject({ message: { customType: "goal-mode-context", display: false } });
284
- const content = (result as { message: { content: string } }).message.content;
285
- expect(content).toContain("<goal_context>");
286
- expect(content).toContain("Do the thing");
297
+ result = (await handler({ type: "context_with_system", messages: transcript }, createContext(host))) as
298
+ | { messages: Array<{ customType?: string; content?: string }> }
299
+ | undefined;
300
+ const contexts = result!.messages.filter((m) => m.customType === "goal-mode-context");
301
+ expect(contexts).toHaveLength(1);
302
+ expect(contexts[0]?.content).toContain("<goal_context>");
303
+ expect(contexts[0]?.content).toContain("Do the thing");
287
304
  });
288
305
 
289
- it("agent_end schedules a continuation when the goal is still active", async () => {
306
+ it("context_with_system supersedes steer-injected transcript copies (single fresh context)", async () => {
307
+ const host = fakeHost();
308
+ defaultExport(host.pi);
309
+ await fire(host, "session_start", { type: "session_start", reason: "startup" });
310
+ await host.commands.goal!.handler("Do the thing", createContext(host));
311
+
312
+ const handler = host.handlers.get("context_with_system")?.[0]!;
313
+ const transcript = [
314
+ { role: "user", content: "hi" },
315
+ { role: "custom", customType: "goal-mode-context", content: "stale copy", display: false },
316
+ ];
317
+ const result = (await handler({ type: "context_with_system", messages: transcript }, createContext(host))) as {
318
+ messages: Array<{ customType?: string; content?: string }>;
319
+ };
320
+ const contexts = result.messages.filter((m) => m.customType === "goal-mode-context");
321
+ expect(contexts).toHaveLength(1);
322
+ expect(contexts[0]?.content).not.toBe("stale copy");
323
+ });
324
+
325
+ it("agent_before_settle continues exactly one request when the goal is still active", async () => {
290
326
  const host = fakeHost();
291
327
  defaultExport(host.pi);
292
328
  await fire(host, "session_start", { type: "session_start", reason: "startup" });
293
329
  await host.commands.goal!.handler("Do the thing", createContext(host));
294
- host.sentMessages.length = 0;
295
330
 
296
331
  await fire(host, "agent_start", { type: "agent_start" });
297
332
  await fire(host, "tool_execution_end", {
@@ -303,13 +338,16 @@ describe("pi-goal extension wiring", () => {
303
338
  });
304
339
  await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("toolUse")] });
305
340
 
306
- const continuation = host.sentMessages.find((m) => m.customType === "goal-continuation");
307
- expect(continuation).toBeDefined();
308
- expect(continuation?.display).toBe(false);
309
- expect(continuation?.options).toMatchObject({ triggerTurn: true, deliverAs: "followUp" });
341
+ const result = fireBeforeSettle(host);
342
+ expect(result?.continue).toBe(true);
343
+ // The continuation prompt rides as a custom_message draft.
344
+ const draft = result?.entries?.[0];
345
+ expect(draft).toMatchObject({ type: "custom_message", customType: "goal-continuation", display: false });
310
346
  // details.goalId keys the context-pruning handler.
311
347
  const goalId = (host.entries.find((e) => e.customType === GOAL_STATE_ENTRY_TYPE)?.data as { goal: Goal }).goal.id;
312
- expect(continuation?.details).toMatchObject({ goalId });
348
+ expect(draft?.details).toMatchObject({ goalId });
349
+ // The boundary path sends no followUp message.
350
+ expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
313
351
  });
314
352
 
315
353
  it("suppresses the next continuation when a continuation turn produced no tool calls", async () => {
@@ -317,18 +355,16 @@ describe("pi-goal extension wiring", () => {
317
355
  defaultExport(host.pi);
318
356
  await fire(host, "session_start", { type: "session_start", reason: "startup" });
319
357
  await host.commands.goal!.handler("Do the thing", createContext(host));
320
- host.sentMessages.length = 0;
321
358
 
322
359
  // Continuation turn: agent replies with no tool calls.
323
360
  await fire(host, "agent_start", { type: "agent_start" });
324
361
  await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
325
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(1);
362
+ expect(fireBeforeSettle(host)?.continue).toBe(true);
326
363
 
327
- // That turn's end marks suppression: the following end schedules nothing.
328
- host.sentMessages.length = 0;
364
+ // That turn's end marks suppression: the following settle continues nothing.
329
365
  await fire(host, "agent_start", { type: "agent_start" });
330
366
  await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
331
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
367
+ expect(fireBeforeSettle(host)?.continue).toBeUndefined();
332
368
 
333
369
  // A real user message re-arms the loop.
334
370
  await fire(host, "message_start", { type: "message_start", message: { role: "user" } });
@@ -340,9 +376,8 @@ describe("pi-goal extension wiring", () => {
340
376
  result: {},
341
377
  isError: false,
342
378
  });
343
- host.sentMessages.length = 0;
344
379
  await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
345
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(1);
380
+ expect(fireBeforeSettle(host)?.continue).toBe(true);
346
381
  });
347
382
 
348
383
  it("interrupt aborts pause the goal instead of continuing", async () => {
@@ -355,7 +390,7 @@ describe("pi-goal extension wiring", () => {
355
390
  await fire(host, "agent_start", { type: "agent_start" });
356
391
  await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("aborted")] });
357
392
 
358
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
393
+ expect(fireBeforeSettle(host)?.continue).toBeUndefined();
359
394
  // omp footer segment: pause icon + usage.
360
395
  expect(host.statuses.get("goal")).toBe("⏸ Goal 0");
361
396
  const pauseEntry = [...host.entries].reverse().find((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
@@ -380,7 +415,7 @@ describe("pi-goal extension wiring", () => {
380
415
  expect(host.entries.some((e) => e.customType === GOAL_CLEARED_ENTRY_TYPE)).toBe(true);
381
416
  expect(host.notifications).toContain("Goal mode completed.");
382
417
  expect(host.statuses.has("goal")).toBe(false);
383
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
418
+ expect(fireBeforeSettle(host)?.continue).toBeUndefined();
384
419
  });
385
420
 
386
421
  it("budget-limit steering sends one hidden steer message when usage crosses the budget", async () => {
@@ -443,6 +478,96 @@ describe("pi-goal extension wiring", () => {
443
478
  expect(host.sentMessages.filter((m) => m.customType === "goal-budget-limit")).toHaveLength(0);
444
479
  });
445
480
 
481
+ it("budget flip attaches context-slimming drafts when PI_GOAL_SLIM_ON_BUDGET=1", async () => {
482
+ const host = fakeHost();
483
+ defaultExport(host.pi);
484
+ await fire(host, "session_start", { type: "session_start", reason: "startup" });
485
+ await host.commands.goal!.handler("Do the thing", createContext(host));
486
+ await host.commands.goal!.handler("budget 10", createContext(host));
487
+
488
+ const entries: unknown[] = [
489
+ { type: "message", id: "t-old", message: { role: "toolResult" } },
490
+ { type: "message", id: "u1", message: { role: "user" } },
491
+ { type: "message", id: "t-new", message: { role: "toolResult" } },
492
+ ];
493
+ const ctx = createContext(host, { entries });
494
+
495
+ vi.stubEnv("PI_GOAL_SLIM_ON_BUDGET", "1");
496
+ await fire(host, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
497
+ entries.push({
498
+ type: "message",
499
+ id: "m1",
500
+ message: { role: "assistant", stopReason: "toolUse", usage: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 } },
501
+ });
502
+ await fire(host, "tool_execution_end", {
503
+ type: "tool_execution_end",
504
+ toolCallId: "t1",
505
+ toolName: "read",
506
+ result: {},
507
+ isError: false,
508
+ }, ctx);
509
+
510
+ // Budget flip arms slimming; the boundary attaches the drafts.
511
+ // Budget-limited goals stop auto-continuing (omp semantics), so the
512
+ // boundary persists the edits without a next request.
513
+ const result = fireBeforeSettle(host, ctx);
514
+ expect(result?.continue).toBeUndefined();
515
+ const edits = (result?.entries ?? []).filter((e) => e.type === "context_edit");
516
+ expect(edits).toEqual([{ type: "context_edit", targetId: "t-old", replacement: null }]);
517
+
518
+ // Once per goal: the next settle carries no further slimming drafts.
519
+ entries.push({
520
+ type: "message",
521
+ id: "m2",
522
+ message: { role: "assistant", stopReason: "toolUse", usage: { input: 50, output: 0, cacheRead: 0, cacheWrite: 0 } },
523
+ });
524
+ await fire(host, "tool_execution_end", {
525
+ type: "tool_execution_end",
526
+ toolCallId: "t2",
527
+ toolName: "read",
528
+ result: {},
529
+ isError: false,
530
+ }, ctx);
531
+ expect(fireBeforeSettle(host, ctx)).toBeUndefined();
532
+ vi.unstubAllEnvs();
533
+ });
534
+
535
+ it("context slimming stays off by default (opt-in via PI_GOAL_SLIM_ON_BUDGET)", async () => {
536
+ const host = fakeHost();
537
+ defaultExport(host.pi);
538
+ await fire(host, "session_start", { type: "session_start", reason: "startup" });
539
+ await host.commands.goal!.handler("Do the thing", createContext(host));
540
+ await host.commands.goal!.handler("budget 10", createContext(host));
541
+
542
+ const entries: unknown[] = [
543
+ { type: "message", id: "t-old", message: { role: "toolResult" } },
544
+ { type: "message", id: "u1", message: { role: "user" } },
545
+ { type: "message", id: "t-new", message: { role: "toolResult" } },
546
+ ];
547
+ const ctx = createContext(host, { entries });
548
+
549
+ // Explicitly opt out (order-independent against leaking stubs).
550
+ vi.stubEnv("PI_GOAL_SLIM_ON_BUDGET", "");
551
+ await fire(host, "turn_start", { type: "turn_start", turnIndex: 0, timestamp: 0 }, ctx);
552
+ entries.push({
553
+ type: "message",
554
+ id: "m1",
555
+ message: { role: "assistant", stopReason: "toolUse", usage: { input: 25, output: 0, cacheRead: 0, cacheWrite: 0 } },
556
+ });
557
+ await fire(host, "tool_execution_end", {
558
+ type: "tool_execution_end",
559
+ toolCallId: "t1",
560
+ toolName: "read",
561
+ result: {},
562
+ isError: false,
563
+ }, ctx);
564
+
565
+ // Budget flip steers but arms no slimming drafts; budget-limited goals
566
+ // do not auto-continue, so the boundary settles empty.
567
+ expect(fireBeforeSettle(host, ctx)).toBeUndefined();
568
+ vi.unstubAllEnvs();
569
+ });
570
+
446
571
  it("/guided-goal queues a hidden interview kickoff", async () => {
447
572
  const host = fakeHost();
448
573
  defaultExport(host.pi);
@@ -503,7 +628,8 @@ describe("pi-goal extension wiring", () => {
503
628
 
504
629
  await fire(host, "agent_start", { type: "agent_start" });
505
630
  await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
506
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
631
+ // Modal open at settle: the boundary withholds the continuation.
632
+ expect(fireBeforeSettle(host)?.continue).toBeUndefined();
507
633
 
508
634
  // Dialog closes while idle: the withheld continuation is scheduled now.
509
635
  const idleCtx = createContext(host, { idle: true });
@@ -532,7 +658,8 @@ describe("pi-goal extension wiring", () => {
532
658
  expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
533
659
 
534
660
  await fire(host, "agent_end", { type: "agent_end", messages: [assistantMessage("stop")] });
535
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(1);
661
+ // Modal already closed: the boundary itself continues the run.
662
+ expect(fireBeforeSettle(host)?.continue).toBe(true);
536
663
  });
537
664
 
538
665
  it("refreshes the status on agent_settled (status integrations per docs)", async () => {
@@ -667,7 +794,7 @@ describe("pi-goal extension wiring", () => {
667
794
  const pauseEntry = [...host.entries].reverse().find((e) => e.customType === GOAL_STATE_ENTRY_TYPE);
668
795
  expect(pauseEntry?.data).toMatchObject({ enabled: false, goal: { status: "paused" } });
669
796
  expect(host.notifications.some((n) => n.includes("rate limits"))).toBe(true);
670
- expect(host.sentMessages.filter((m) => m.customType === "goal-continuation")).toHaveLength(0);
797
+ expect(fireBeforeSettle(host)?.continue).toBeUndefined();
671
798
  });
672
799
 
673
800
  it("pauses with a generic notice on non-usage errors", async () => {