@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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/index.ts +651 -0
- package/package.json +65 -0
- package/src/commands.ts +325 -0
- package/src/evaluator.ts +218 -0
- package/src/format.ts +35 -0
- package/src/prompts/evaluator-complete.md +26 -0
- package/src/prompts/evaluator-impossible.md +20 -0
- package/src/prompts/goal-budget-limit.md +15 -0
- package/src/prompts/goal-continuation.md +30 -0
- package/src/prompts/goal-mode-active.md +23 -0
- package/src/prompts/goal-mode-context.md +4 -0
- package/src/prompts/goal-todo-context.md +12 -0
- package/src/prompts/goal.md +11 -0
- package/src/prompts/guided-goal-interview.md +37 -0
- package/src/restore.ts +92 -0
- package/src/runtime.ts +565 -0
- package/src/state.ts +124 -0
- package/src/template.ts +154 -0
- package/src/todo-bridge.ts +136 -0
- package/src/tool.ts +557 -0
- package/test/evaluator.test.ts +186 -0
- package/test/index.test.ts +557 -0
- package/test/omp-alignment.test.ts +975 -0
- package/test/runtime.test.ts +471 -0
- package/test/template.test.ts +89 -0
- package/test/tool.test.ts +528 -0
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";
|