@fyeeme/pi-goal 1.0.0

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/index.ts ADDED
@@ -0,0 +1,651 @@
1
+ /**
2
+ * pi-goal — oh-my-pi goal mode migrated to a pi extension.
3
+ *
4
+ * Source: oh-my-pi (github.com/can1357/oh-my-pi, a fork of badlogic/pi-mono)
5
+ * - packages/coding-agent/src/goals/state.ts → src/state.ts
6
+ * - packages/coding-agent/src/goals/runtime.ts → src/runtime.ts
7
+ * - packages/coding-agent/src/goals/tools/goal-tool.ts → src/tool.ts (+src/render.ts)
8
+ * - packages/coding-agent/src/prompts/tools/goal.md → src/prompts/goal.md
9
+ * - packages/coding-agent/src/prompts/goals/*.md → src/prompts/*.md
10
+ * - packages/coding-agent/src/session/agent-session.ts → index.ts host wiring
11
+ * - packages/coding-agent/src/modes/interactive-mode.ts → index.ts continuation
12
+ * loop + src/commands.ts (/goal, /guided-goal)
13
+ *
14
+ * Adaptations for pi's extension API (each maps an omp-internal surface to
15
+ * the public extension boundary):
16
+ *
17
+ * omp surface → pi adaptation
18
+ * ─────────────────────────────────────────────────────────────────────────
19
+ * session getGoalModeState/setGoalModeState
20
+ * → closure state + goal-state
21
+ * custom entries (full snapshots)
22
+ * appendModeChange goal/goal_paused/none → "goal-state" / "goal-cleared"
23
+ * entries (see src/restore.ts)
24
+ * appendCustomEntry goal-completed → "goal-completed" entry
25
+ * session stats getCurrentUsage → sessionManager entries scan
26
+ * (same sums as getSessionStats)
27
+ * goal_updated session event → pi.events.emit("goal_updated")
28
+ * sendHiddenMessage (budget steer) → pi.sendMessage display:false
29
+ * prompt-time prependMessages goal context
30
+ * → before_agent_start message
31
+ * injection (hidden custom message)
32
+ * #scheduleGoalContinuation 800ms TUI timer
33
+ * → followUp delivery + triggerTurn
34
+ * (editor-empty guard dropped: pi
35
+ * extensions cannot read the editor)
36
+ * setActiveToolsByName → pi.setActiveTools
37
+ * status-line segment → ctx.ui.setStatus("goal", ...)
38
+ * settings goal.enabled / continuationModes / statusInFooter
39
+ * → dropped (extension is opt-in:
40
+ * installing it enables goal mode)
41
+ *
42
+ * omp operation semantics are kept verbatim: one live goal per session,
43
+ * budget accounting includes cache writes but not cache reads, budget limit
44
+ * steers exactly once per goal, interrupt pauses (never completes) the goal,
45
+ * resume pauses active goals again, and completion requires a verified
46
+ * `goal({op:"complete"})` call.
47
+ *
48
+ * Integration contract for other extensions:
49
+ * - `pi.events.emit("goal_updated", { goal, state })` after every runtime
50
+ * transition (goal may be null; state undefined after drop)
51
+ * - reads `todo_updated` events + "todo-phases" entries from pi-todo to
52
+ * render the goal todo context (works without pi-todo installed)
53
+ */
54
+
55
+ // Prompt lives in a static .md asset next to this module (published with the
56
+ // package). Loaded at runtime: pi loads extensions through jiti, which does
57
+ // not support bundler-style text imports (pi-todo precedent).
58
+ import { readFileSync } from "node:fs";
59
+ import * as path from "node:path";
60
+ import { fileURLToPath } from "node:url";
61
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
62
+ import { Text } from "@earendil-works/pi-tui";
63
+ import {
64
+ createGoalCommand,
65
+ createGuidedGoalCommand,
66
+ type GoalCommandDeps,
67
+ goalArgumentCompletions,
68
+ renderGuidedGoalKickoff,
69
+ } from "./src/commands.ts";
70
+ import { formatDuration, formatNumber } from "./src/format.ts";
71
+ import {
72
+ GOAL_CLEARED_ENTRY_TYPE,
73
+ GOAL_COMPLETED_ENTRY_TYPE,
74
+ GOAL_STATE_ENTRY_TYPE,
75
+ restoreGoalFromEntries,
76
+ } from "./src/restore.ts";
77
+ import { GoalRuntime } from "./src/runtime.ts";
78
+ import type { Goal, GoalModeState, GoalTokenUsage } from "./src/state.ts";
79
+ import { cloneGoal } from "./src/state.ts";
80
+ import { renderTemplate } from "./src/template.ts";
81
+ import { buildTodoContext, restoreTodoPhases, type TodoPhase } from "./src/todo-bridge.ts";
82
+ import { runGoalEvaluator } from "./src/evaluator.ts";
83
+ import { createGoalTool, type GoalToolDeps } from "./src/tool.ts";
84
+
85
+ const goalModeContextPrompt = readFileSync(
86
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "src", "prompts", "goal-mode-context.md"),
87
+ "utf8",
88
+ );
89
+
90
+ interface EntryMessageLike {
91
+ role?: string;
92
+ stopReason?: string;
93
+ usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number } | undefined;
94
+ }
95
+
96
+ interface EntryUsageLike {
97
+ usage?: EntryMessageLike["usage"];
98
+ }
99
+
100
+ export default function piGoalExtension(pi: ExtensionAPI): void {
101
+ // ------------------------------------------------------------------
102
+ // Closure state (omp session fields)
103
+ // ------------------------------------------------------------------
104
+ let goalState: GoalModeState | undefined;
105
+ /** Active tool set saved when goal mode took over (omp #goalModePreviousTools). */
106
+ let savedTools: string[] | undefined;
107
+ let turnCounter = 0;
108
+ /** A continuation submitted by this extension is being processed. */
109
+ let continuationInFlight = false;
110
+ /** Last continuation turn produced no tool calls: stop auto-continuing. */
111
+ let suppressNextContinuation = false;
112
+ /** Any tool ran during the current agent run (suppression heuristic). */
113
+ let runHadToolCalls = false;
114
+ /** pi-todo phases for the goal todo context (empty without pi-todo). */
115
+ let todoPhases: TodoPhase[] = [];
116
+ /** Latest event context: gives the runtime synchronous entry access. */
117
+ let currentCtx: ExtensionContext | undefined;
118
+ /** A blocking ctx.ui dialog is open (ui_prompt_start/end). While open, no
119
+ * continuation may fire: the modal would hide a turn starting behind it. */
120
+ let uiPromptOpen = false;
121
+
122
+ // ------------------------------------------------------------------
123
+ // Usage accounting (mirror of pi getSessionStats().tokens sums)
124
+ // ------------------------------------------------------------------
125
+ function currentUsage(ctx: ExtensionContext | undefined): GoalTokenUsage {
126
+ const totals: GoalTokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
127
+ if (!ctx) return totals;
128
+ const add = (usage: EntryMessageLike["usage"]): void => {
129
+ if (!usage) return;
130
+ totals.input += usage.input ?? 0;
131
+ totals.output += usage.output ?? 0;
132
+ totals.cacheRead += usage.cacheRead ?? 0;
133
+ totals.cacheWrite += usage.cacheWrite ?? 0;
134
+ };
135
+ for (const entry of ctx.sessionManager.getEntries()) {
136
+ const typed = entry as { type?: string; message?: EntryMessageLike } & EntryUsageLike;
137
+ if (typed.type === "branch_summary" || typed.type === "compaction") {
138
+ add(typed.usage);
139
+ continue;
140
+ }
141
+ if (typed.type !== "message") continue;
142
+ const message = typed.message;
143
+ if (!message) continue;
144
+ if (message.role === "toolResult" || message.role === "assistant") {
145
+ add(message.usage);
146
+ }
147
+ }
148
+ return totals;
149
+ }
150
+
151
+ // ------------------------------------------------------------------
152
+ // UI surfaces
153
+ // ------------------------------------------------------------------
154
+ /** omp status-line footer segment icons (unicode symbol set). */
155
+ function goalStatusIcon(status: Goal["status"]): string {
156
+ switch (status) {
157
+ case "paused":
158
+ return "⏸";
159
+ case "complete":
160
+ return "✔";
161
+ case "budget-limited":
162
+ return "⚠";
163
+ case "dropped":
164
+ return "⏹";
165
+ default:
166
+ return "🎯";
167
+ }
168
+ }
169
+
170
+ /** omp status-line/segments.ts renderGoalMode: segment visible only while
171
+ * enabled or paused; text "<icon> Goal used[/budget]" (goal.statusInFooter
172
+ * defaults to true, so usage always renders). */
173
+ function updateStatus(): void {
174
+ const ctx = currentCtx;
175
+ if (!ctx) return;
176
+ const state = goalState;
177
+ if (!state || !(state.enabled || state.goal.status === "paused")) {
178
+ ctx.ui.setStatus("goal", undefined);
179
+ return;
180
+ }
181
+ const goal = state.goal;
182
+ const used = formatNumber(goal.tokensUsed);
183
+ const budget = goal.tokenBudget !== undefined ? `/${formatNumber(goal.tokenBudget)}` : "";
184
+ ctx.ui.setStatus("goal", `${goalStatusIcon(goal.status)} Goal ${used}${budget}`);
185
+ }
186
+
187
+ function notify(text: string, severity: "info" | "warning" | "error" = "info"): void {
188
+ const ctx = currentCtx;
189
+ if (!ctx) return;
190
+ if (ctx.hasUI) {
191
+ ctx.ui.notify(text, severity);
192
+ return;
193
+ }
194
+ console.error(text);
195
+ }
196
+
197
+ // ------------------------------------------------------------------
198
+ // Goal toolset management (omp setActiveToolsByName dance)
199
+ // ------------------------------------------------------------------
200
+ function ensureGoalToolActive(): void {
201
+ const active = pi.getActiveTools().filter((name) => name !== "goal");
202
+ savedTools = active;
203
+ pi.setActiveTools([...new Set([...active, "goal"])]);
204
+ }
205
+
206
+ function restoreSavedTools(): void {
207
+ if (savedTools !== undefined) {
208
+ pi.setActiveTools(savedTools);
209
+ savedTools = undefined;
210
+ }
211
+ }
212
+
213
+ // ------------------------------------------------------------------
214
+ // Exit (omp #exitGoalMode)
215
+ // ------------------------------------------------------------------
216
+ async function exitGoalMode(options?: { reason?: "completed" | "dropped" | "paused" }): Promise<void> {
217
+ const currentState = goalState;
218
+ if (options?.reason === "completed") {
219
+ goalState = undefined;
220
+ pi.appendEntry(GOAL_CLEARED_ENTRY_TYPE, { clearedAt: Date.now() });
221
+ pi.appendEntry(GOAL_COMPLETED_ENTRY_TYPE, {
222
+ objective: currentState?.goal?.objective,
223
+ tokensUsed: currentState?.goal?.tokensUsed,
224
+ tokenBudget: currentState?.goal?.tokenBudget,
225
+ timeUsedSeconds: currentState?.goal?.timeUsedSeconds,
226
+ });
227
+ }
228
+ restoreSavedTools();
229
+ continuationInFlight = false;
230
+ updateStatus();
231
+ if (options?.reason === "completed") notify("Goal mode completed.");
232
+ else if (options?.reason === "dropped") notify("Goal dropped.");
233
+ else if (options?.reason === "paused") notify("Goal mode paused.");
234
+ }
235
+
236
+ // ------------------------------------------------------------------
237
+ // goal_updated handling (omp #handleGoalSessionEvent)
238
+ // ------------------------------------------------------------------
239
+ function handleGoalUpdated(goal: Goal | null, state: GoalModeState | undefined): void {
240
+ pi.events.emit("goal_updated", { goal, state });
241
+ if (state?.goal?.status === "dropped") {
242
+ // omp runtime.dropGoal commits state undefined (persist "none"), so the
243
+ // dropped goal must vanish from session state entirely.
244
+ goalState = undefined;
245
+ void exitGoalMode({ reason: "dropped" });
246
+ return;
247
+ }
248
+ goalState = state;
249
+ if (!state?.enabled) {
250
+ continuationInFlight = false;
251
+ }
252
+ updateStatus();
253
+ }
254
+
255
+ // ------------------------------------------------------------------
256
+ // Runtime host (omp AgentSession GoalRuntimeHost)
257
+ // ------------------------------------------------------------------
258
+ const runtime = new GoalRuntime({
259
+ getState: () => goalState,
260
+ setState: (state) => {
261
+ goalState = state;
262
+ },
263
+ getCurrentUsage: () => currentUsage(currentCtx),
264
+ emit: (event) => {
265
+ if (event.type === "goal_updated") {
266
+ handleGoalUpdated(event.goal, event.state);
267
+ }
268
+ },
269
+ persist: (mode, state) => {
270
+ if (mode === "none") {
271
+ pi.appendEntry(GOAL_CLEARED_ENTRY_TYPE, { clearedAt: Date.now() });
272
+ } else if (state) {
273
+ pi.appendEntry(GOAL_STATE_ENTRY_TYPE, { enabled: state.enabled, goal: cloneGoal(state.goal) });
274
+ }
275
+ },
276
+ sendHiddenMessage: async (message) => {
277
+ pi.sendMessage(
278
+ { customType: message.customType, content: message.content, display: false },
279
+ { deliverAs: message.deliverAs ?? "steer" },
280
+ );
281
+ },
282
+ });
283
+
284
+ // ------------------------------------------------------------------
285
+ // Enter/replace/resume (omp #enterGoalMode and friends)
286
+ // ------------------------------------------------------------------
287
+ async function startGoal(objective: string): Promise<void> {
288
+ if (goalState?.enabled) return;
289
+ ensureGoalToolActive();
290
+ const state = await runtime.createGoal({ objective });
291
+ goalState = state;
292
+ suppressNextContinuation = false;
293
+ updateStatus();
294
+ if (currentCtx && !currentCtx.isIdle()) {
295
+ await sendGoalModeContext("steer");
296
+ }
297
+ }
298
+
299
+ async function replaceGoal(objective: string): Promise<void> {
300
+ const state = await runtime.replaceGoal({ objective });
301
+ goalState = state;
302
+ ensureGoalToolActive();
303
+ suppressNextContinuation = false;
304
+ updateStatus();
305
+ if (currentCtx && !currentCtx.isIdle()) {
306
+ await sendGoalModeContext("steer");
307
+ }
308
+ }
309
+
310
+ async function resumeGoal(): Promise<void> {
311
+ // omp #resumeGoalAction guard: exactly a persisted paused goal resumes.
312
+ if (!goalState || goalState.enabled || goalState.goal.status !== "paused") {
313
+ notify("No paused goal to resume.", "warning");
314
+ return;
315
+ }
316
+ ensureGoalToolActive();
317
+ const state = await runtime.resumeGoal();
318
+ goalState = state;
319
+ suppressNextContinuation = false;
320
+ updateStatus();
321
+ notify("Goal mode resumed.");
322
+ }
323
+
324
+ async function pauseGoal(): Promise<void> {
325
+ if (!goalState?.enabled) {
326
+ notify("No active goal to pause.", "warning");
327
+ return;
328
+ }
329
+ await runtime.pauseGoal();
330
+ await exitGoalMode({ reason: "paused" });
331
+ }
332
+
333
+ async function dropGoal(): Promise<void> {
334
+ if (!goalState) {
335
+ notify("No goal to drop.", "warning");
336
+ return;
337
+ }
338
+ // runtime.dropGoal emits goal_updated (handled above: state → undefined,
339
+ // exit notification) then commits undefined — no local state to reassign.
340
+ await runtime.dropGoal();
341
+ }
342
+
343
+ /** omp #startGoalFromObjective: the objective text kicks off the work. */
344
+ async function submitObjective(objective: string): Promise<void> {
345
+ const streaming = currentCtx !== undefined && !currentCtx.isIdle();
346
+ pi.sendUserMessage(objective, streaming ? { deliverAs: "steer" } : undefined);
347
+ }
348
+
349
+ async function setBudget(raw: string): Promise<void> {
350
+ if (!goalState?.enabled) {
351
+ notify("No active goal.", "warning");
352
+ return;
353
+ }
354
+ if (goalState.goal.status === "complete") {
355
+ notify("Goal is already complete.");
356
+ return;
357
+ }
358
+ const trimmed = raw.trim().toLowerCase();
359
+ let nextBudget: number | undefined;
360
+ if (trimmed !== "off") {
361
+ const parsed = Number.parseInt(trimmed, 10);
362
+ if (!Number.isInteger(parsed) || parsed <= 0) {
363
+ notify("Goal budget must be a positive integer or `off`.", "error");
364
+ return;
365
+ }
366
+ nextBudget = parsed;
367
+ }
368
+ await runtime.onBudgetMutated(nextBudget);
369
+ suppressNextContinuation = false;
370
+ scheduleContinuation();
371
+ notify(nextBudget === undefined ? "Goal budget cleared." : `Goal budget set to ${nextBudget}.`);
372
+ }
373
+
374
+ // ------------------------------------------------------------------
375
+ // Goal context injection + continuation loop
376
+ // ------------------------------------------------------------------
377
+ async function sendGoalModeContext(deliverAs: "steer" | "followUp" | "nextTurn"): Promise<void> {
378
+ const message = buildGoalModeMessage();
379
+ if (!message) return;
380
+ pi.sendMessage({ customType: "goal-mode-context", content: message, display: false }, { deliverAs });
381
+ }
382
+
383
+ function buildGoalModeMessage(): string | undefined {
384
+ const content = runtime.buildActivePrompt();
385
+ if (!content) return undefined;
386
+ const todoToolActive = pi.getActiveTools().includes("todo");
387
+ const todoContext = buildTodoContext(todoPhases, todoToolActive);
388
+ return renderTemplate(goalModeContextPrompt, { goalContext: content, todoContext });
389
+ }
390
+
391
+ /** omp #scheduleGoalContinuation (TUI timer replaced by followUp delivery). */
392
+ function scheduleContinuation(): void {
393
+ if (!goalState?.enabled || goalState.goal.status !== "active") return;
394
+ if (suppressNextContinuation) return;
395
+ if (uiPromptOpen) return; // modal open: never start a turn behind it
396
+ if (currentCtx?.hasPendingMessages()) return;
397
+ const prompt = runtime.buildContinuationPrompt();
398
+ if (!prompt) return;
399
+ continuationInFlight = true;
400
+ pi.sendMessage(
401
+ { customType: "goal-continuation", content: prompt, display: false },
402
+ { triggerTurn: true, deliverAs: "followUp" },
403
+ );
404
+ }
405
+
406
+ // ------------------------------------------------------------------
407
+ // Tool + command registration
408
+ // ------------------------------------------------------------------
409
+ const goalToolDeps: GoalToolDeps = {
410
+ getRuntime: () => runtime,
411
+ getState: () => goalState,
412
+ // Grounded independent evaluator: a fresh `pi -p` subprocess in the
413
+ // session cwd that re-verifies completion/impossibility claims itself
414
+ // (src/evaluator.ts). currentCtx is undefined during registration, so
415
+ // fall back to the process cwd.
416
+ runEvaluator: (request, opts) =>
417
+ runGoalEvaluator(request, { cwd: opts.cwd ?? currentCtx?.cwd ?? process.cwd(), signal: opts.signal }),
418
+ };
419
+ pi.registerTool(createGoalTool(goalToolDeps));
420
+
421
+ const commandDeps: GoalCommandDeps = {
422
+ getState: () => (goalState ? { enabled: goalState.enabled, goal: goalState.goal } : undefined),
423
+ hasPausedGoal: () => goalState !== undefined && !goalState.enabled,
424
+ // Command-driven start/replace mirror omp: create the goal, then submit
425
+ // the objective text as the working prompt (steered when streaming).
426
+ startGoal: async (objective) => {
427
+ await startGoal(objective);
428
+ await submitObjective(objective);
429
+ },
430
+ replaceGoal: async (objective) => {
431
+ await replaceGoal(objective);
432
+ await submitObjective(objective);
433
+ },
434
+ resumeGoal,
435
+ pauseGoal,
436
+ dropGoal,
437
+ setBudget,
438
+ startGuidedInterview: async (initial) => {
439
+ // Expose the goal tool for the interview so the agent can finish by
440
+ // calling `goal create` (omp handleGuidedGoalCommand). The kickoff
441
+ // rides in as a hidden message; the interview is normal conversation.
442
+ // omp queues behind an in-flight run (followUp) instead of steering it.
443
+ ensureGoalToolActive();
444
+ const kickoff = renderGuidedGoalKickoff(initial);
445
+ pi.sendMessage(
446
+ { customType: "guided-goal", content: kickoff, display: false },
447
+ { triggerTurn: true, deliverAs: "followUp" },
448
+ );
449
+ },
450
+ };
451
+ pi.registerCommand("goal", {
452
+ description: "Toggle goal mode (persistent autonomous objective for this session)",
453
+ getArgumentCompletions: goalArgumentCompletions,
454
+ handler: createGoalCommand(commandDeps),
455
+ });
456
+ pi.registerCommand("guided-goal", {
457
+ description: "Have the agent interview you in chat, then set up goal mode",
458
+ handler: createGuidedGoalCommand(commandDeps),
459
+ });
460
+
461
+ // Persistent transcript summary for completed goals (docs: appendEntry
462
+ // entries "can render inside the chat transcript when paired with
463
+ // pi.registerEntryRenderer()"). Invisible in the live flow (the goal tool
464
+ // result already reports completion); renders on reload/history review.
465
+ pi.registerEntryRenderer<{
466
+ objective?: string;
467
+ tokensUsed?: number;
468
+ tokenBudget?: number;
469
+ timeUsedSeconds?: number;
470
+ }>(GOAL_COMPLETED_ENTRY_TYPE, (entry, _options, theme) => {
471
+ const data = entry.data;
472
+ const objective = typeof data?.objective === "string" ? data.objective : "";
473
+ const used = typeof data?.tokensUsed === "number" ? formatNumber(data.tokensUsed) : "0";
474
+ const budget = typeof data?.tokenBudget === "number" ? ` / ${formatNumber(data.tokenBudget)}` : " (no budget)";
475
+ const seconds = typeof data?.timeUsedSeconds === "number" ? data.timeUsedSeconds : 0;
476
+ const text = theme.fg(
477
+ "success",
478
+ `● Goal completed${objective ? `: ${objective}` : ""} · ${used}${budget} tokens · ${formatDuration(seconds * 1000)}`,
479
+ );
480
+ return new Text(text, 0, 0);
481
+ });
482
+
483
+ // ------------------------------------------------------------------
484
+ // Lifecycle events (omp agent-session + interactive-mode handlers)
485
+ // ------------------------------------------------------------------
486
+ pi.on("session_start", async (_event, ctx) => {
487
+ currentCtx = ctx;
488
+ // Branch-aware restore: latest valid snapshot on the current branch wins.
489
+ const branch = ctx.sessionManager.getBranch();
490
+ todoPhases = restoreTodoPhases(branch);
491
+ const restored = restoreGoalFromEntries(branch);
492
+ if (!restored) {
493
+ goalState = undefined;
494
+ // omp sdk.ts excludes the goal tool from the initial set; mirror that
495
+ // when this session has no goal to manage.
496
+ const active = pi.getActiveTools();
497
+ if (active.includes("goal")) {
498
+ pi.setActiveTools(active.filter((name) => name !== "goal"));
499
+ }
500
+ updateStatus();
501
+ return;
502
+ }
503
+ goalState = restored;
504
+ ensureGoalToolActive();
505
+ // omp onThreadResumed: a persisted ACTIVE goal is paused again on cold
506
+ // resume (the run that owned it is gone); paused/budget-limited goals
507
+ // keep their state and re-arm accounting.
508
+ const resumed = await runtime.onThreadResumed();
509
+ goalState = resumed;
510
+ updateStatus();
511
+ });
512
+
513
+ pi.on("agent_start", () => {
514
+ runHadToolCalls = false;
515
+ });
516
+
517
+ pi.on("turn_start", (_event, ctx) => {
518
+ currentCtx = ctx;
519
+ runtime.onTurnStart(`turn-${++turnCounter}`, currentUsage(ctx));
520
+ });
521
+
522
+ pi.on("tool_execution_start", (_event, _ctx) => {
523
+ runHadToolCalls = true;
524
+ if (!continuationInFlight) {
525
+ suppressNextContinuation = false;
526
+ }
527
+ });
528
+
529
+ pi.on("tool_execution_end", async (event, ctx) => {
530
+ currentCtx = ctx;
531
+ if (event.toolName === "goal") {
532
+ await runtime.onGoalToolCompleted();
533
+ } else {
534
+ await runtime.onToolCompleted(event.toolName);
535
+ }
536
+ });
537
+
538
+ pi.on("message_start", (event) => {
539
+ // A real user message re-arms the continuation loop (omp watches
540
+ // non-synthetic user message_start; goal-continuation messages are
541
+ // custom role and never match).
542
+ if (event.message.role === "user") {
543
+ suppressNextContinuation = false;
544
+ }
545
+ });
546
+
547
+ pi.on("before_agent_start", (_event, ctx) => {
548
+ currentCtx = ctx;
549
+ const content = buildGoalModeMessage();
550
+ if (!content) return undefined;
551
+ return {
552
+ message: { customType: "goal-mode-context", content, display: false },
553
+ };
554
+ });
555
+
556
+ pi.on("agent_end", async (event, ctx) => {
557
+ currentCtx = ctx;
558
+ // omp separates onAgentEnd (session) from continuation scheduling
559
+ // (interactive-mode); both subscribe to the same end-of-run moment.
560
+ const aborted = lastAssistantStopReason(event.messages) === "aborted";
561
+ if (aborted) {
562
+ await runtime.onTaskAborted({ reason: "interrupted" });
563
+ } else {
564
+ await runtime.onAgentEnd({ currentUsage: currentUsage(ctx) });
565
+ }
566
+
567
+ if (continuationInFlight) {
568
+ suppressNextContinuation = !runHadToolCalls;
569
+ continuationInFlight = false;
570
+ }
571
+ if (goalState?.mode === "exiting") {
572
+ await exitGoalMode({ reason: "completed" });
573
+ return;
574
+ }
575
+ updateStatus();
576
+ scheduleContinuation();
577
+ });
578
+
579
+ pi.on("turn_end", (_event, ctx) => {
580
+ currentCtx = ctx;
581
+ updateStatus();
582
+ });
583
+
584
+ // Blocking ctx.ui dialogs (drop confirm, objective editor, /goal menu,
585
+ // budget input — plus other extensions' ask_user). While one is open the
586
+ // agent is not running, and a continuation must not start behind the modal.
587
+ pi.on("ui_prompt_start", (_event, ctx) => {
588
+ currentCtx = ctx;
589
+ uiPromptOpen = true;
590
+ updateStatus();
591
+ });
592
+
593
+ pi.on("ui_prompt_end", (_event, ctx) => {
594
+ currentCtx = ctx;
595
+ uiPromptOpen = false;
596
+ updateStatus();
597
+ // Recover a continuation withheld while the dialog was open. Only when
598
+ // idle: if the agent is still streaming (e.g. another extension's
599
+ // ask_user just closed), its own agent_end schedules the continuation.
600
+ if (ctx.isIdle()) {
601
+ scheduleContinuation();
602
+ }
603
+ });
604
+
605
+ // Docs: "Use agent_settled for status integrations that need to know Pi
606
+ // will not continue running automatically" — final usage is visible here.
607
+ pi.on("agent_settled", (_event, ctx) => {
608
+ currentCtx = ctx;
609
+ updateStatus();
610
+ });
611
+
612
+ pi.on("session_tree", async (_event, ctx) => {
613
+ currentCtx = ctx;
614
+ // Tree navigation: omp reconciles with preserveActiveGoal — an active goal
615
+ // stays live (the run continues), but accounting re-anchors to the
616
+ // restored branch state.
617
+ const branch = ctx.sessionManager.getBranch();
618
+ todoPhases = restoreTodoPhases(branch);
619
+ const restored = restoreGoalFromEntries(branch);
620
+ if (!restored) {
621
+ goalState = undefined;
622
+ updateStatus();
623
+ return;
624
+ }
625
+ goalState = restored;
626
+ ensureGoalToolActive();
627
+ const resumed = await runtime.onThreadResumed({ preserveActiveGoal: true });
628
+ goalState = resumed;
629
+ updateStatus();
630
+ });
631
+
632
+ pi.on("session_shutdown", () => {
633
+ // Runtime replacement (quit/reload/session switch): drop closure state so
634
+ // a stale instance never acts after teardown.
635
+ goalState = undefined;
636
+ currentCtx = undefined;
637
+ });
638
+ }
639
+
640
+ function lastAssistantStopReason(messages: unknown[]): string | undefined {
641
+ for (let i = messages.length - 1; i >= 0; i--) {
642
+ const message = messages[i] as EntryMessageLike | undefined;
643
+ if (message?.role === "assistant") return message.stopReason;
644
+ }
645
+ return undefined;
646
+ }
647
+
648
+ export type { GoalRuntimeHost } from "./src/runtime.ts";
649
+ // Re-exported for consumers that compose the pieces directly (tests, tools).
650
+ export { GoalRuntime } from "./src/runtime.ts";
651
+ export type { Goal, GoalModeState, GoalRuntimeEvent, GoalStatus, GoalToolDetails } from "./src/state.ts";