@narumitw/pi-goal 0.1.11 → 0.1.13

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.
Files changed (3) hide show
  1. package/README.md +38 -9
  2. package/package.json +1 -1
  3. package/src/goal.ts +425 -53
package/README.md CHANGED
@@ -2,17 +2,22 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-goal)](https://www.npmjs.com/package/@narumitw/pi-goal) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-goal` is a native [Pi coding agent](https://pi.dev) extension that adds `/goal <goal_to_complete>` and a `goal_complete` tool for autonomous, verifiable task completion.
5
+ `@narumitw/pi-goal` is a native [Pi coding agent](https://pi.dev) extension that adds durable `/goal` commands and a `goal_complete` tool for autonomous, verifiable task completion.
6
6
 
7
- Goal mode keeps sending automatic follow-up messages until the agent calls `goal_complete`, so tasks such as `/goal implement snake game` continue past planning, partial progress, and intermediate tool calls.
7
+ Goal mode keeps sending guarded automatic follow-up messages until the agent calls `goal_complete`, the user pauses or clears the goal, or an optional token budget is reached.
8
8
 
9
9
  ## ✨ Features
10
10
 
11
11
  - Adds `/goal <goal_to_complete>` to start goal mode.
12
- - Adds `/goal-status` to show the active goal.
13
- - Adds `/goal-stop` to cancel the active goal loop.
12
+ - Bare `/goal` shows the current goal summary.
13
+ - Adds `/goal pause`, `/goal resume`, `/goal clear`, and `/goal edit <goal_to_complete>`.
14
+ - Keeps `/goal-status` and `/goal-stop` as compatibility aliases.
15
+ - Supports optional token budgets such as `/goal --tokens 100k <goal>`.
16
+ - Tracks `active`, `paused`, `budget_limited`, and `complete` states.
17
+ - Persists in-progress goal state per working directory under the Pi agent config directory.
14
18
  - Registers a `goal_complete` tool for explicit completion.
15
- - Automatically prompts the agent to continue if a turn ends early.
19
+ - Automatically prompts the agent to continue if an active turn ends early.
20
+ - Guards auto-follow-ups so replaced, paused, cleared, completed, or budget-limited goals are not continued.
16
21
  - Encourages verification before the goal is marked complete.
17
22
 
18
23
  ## 📦 Install
@@ -36,20 +41,44 @@ pi -e ./extensions/pi-goal
36
41
  ## 🚀 Commands
37
42
 
38
43
  ```text
44
+ /goal
39
45
  /goal implement snake game
46
+ /goal --tokens 100k fix the failing test and verify it
47
+ /goal pause
48
+ /goal resume
49
+ /goal clear
50
+ /goal edit ship the smaller fix first
40
51
  /goal-status
41
52
  /goal-stop
42
53
  ```
43
54
 
44
- - `/goal <goal_to_complete>` starts goal mode and asks the agent to keep working until complete.
45
- - `/goal-status` shows the active goal.
46
- - `/goal-stop` cancels the active goal loop.
55
+ - `/goal` shows the current goal, status, iteration count, elapsed time, and token usage.
56
+ - `/goal <goal_to_complete>` starts goal mode when no other goal is active.
57
+ - `/goal --tokens 100k <goal_to_complete>` starts goal mode with a token budget. `k` and `m` suffixes are accepted, for example `100k` or `1.5m`.
58
+ - `/goal pause` stops prompt injection and auto-continuation without forgetting the goal.
59
+ - `/goal resume` resumes a paused or budget-limited goal.
60
+ - `/goal clear` cancels the current goal and clears persisted state.
61
+ - `/goal edit <goal_to_complete>` replaces the current goal with a new active goal.
62
+ - `/goal-status` is a compatibility alias for `/goal`.
63
+ - `/goal-stop` is a compatibility alias for `/goal clear`.
64
+
65
+ Goal objectives are limited to 4,000 characters. Put longer instructions in a file and reference the file path from `/goal`.
66
+
67
+ ## 📊 Statusline states
68
+
69
+ `pi-goal` writes compact status strings for statusline extensions:
70
+
71
+ - `goal: active 3m` — an active goal without a token budget.
72
+ - `goal: active 18k/100k` — an active goal with token usage and budget.
73
+ - `goal: paused` — auto-continuation is paused.
74
+ - `goal: budget 100k/100k` — the token budget was reached; auto-continuation stops.
75
+ - `goal: complete` — shown briefly after `goal_complete` succeeds.
47
76
 
48
77
  ## ✅ How completion works
49
78
 
50
79
  The extension registers a `goal_complete` tool. While a goal is active, the system prompt tells the agent to keep working, verify the result, and call `goal_complete` only when the goal is fully done.
51
80
 
52
- If an agent turn ends before `goal_complete` is called, the extension automatically sends a follow-up prompt to continue the same goal.
81
+ If an agent turn ends before `goal_complete` is called, the extension records elapsed time and token usage, checks the budget, verifies that the same goal id is still active, then sends a follow-up prompt to continue the same goal.
53
82
 
54
83
  ## 🧠 Use cases
55
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-goal",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Pi extension that keeps working on a /goal until the agent marks it complete.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/goal.ts CHANGED
@@ -1,10 +1,23 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import process from "node:process";
4
+ import { randomUUID } from "node:crypto";
1
5
  import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
6
  import { Type } from "typebox";
3
7
 
8
+ type GoalStatus = "active" | "paused" | "budget_limited" | "complete";
9
+
4
10
  interface ActiveGoal {
11
+ id: string;
5
12
  text: string;
13
+ status: GoalStatus;
6
14
  startedAt: number;
15
+ updatedAt: number;
7
16
  iteration: number;
17
+ tokenBudget?: number;
18
+ tokensUsed: number;
19
+ timeUsedSeconds: number;
20
+ baselineTokens: number;
8
21
  }
9
22
 
10
23
  interface GoalCompleteDetails {
@@ -12,7 +25,31 @@ interface GoalCompleteDetails {
12
25
  summary: string;
13
26
  }
14
27
 
28
+ interface CommandResult {
29
+ kind: "show" | "start" | "pause" | "resume" | "clear" | "edit";
30
+ objective?: string;
31
+ tokenBudget?: number;
32
+ }
33
+
34
+ interface StatusContext {
35
+ cwd: string;
36
+ ui: {
37
+ notify: (message: string, level?: "info" | "warning" | "error") => void;
38
+ setStatus: (key: string, value: string | undefined) => void;
39
+ };
40
+ isIdle?: () => boolean;
41
+ sessionManager?: unknown;
42
+ }
43
+
44
+ const STATUS_KEY = "goal";
45
+ const MAX_OBJECTIVE_LENGTH = 4_000;
46
+ const STATE_FILE = join(
47
+ process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? ".", ".pi", "agent"),
48
+ "pi-goal-state.json",
49
+ );
50
+
15
51
  let activeGoal: ActiveGoal | undefined;
52
+ let completionStatusTimer: NodeJS.Timeout | undefined;
16
53
 
17
54
  const goalCompleteTool = defineTool({
18
55
  name: "goal_complete",
@@ -31,12 +68,18 @@ const goalCompleteTool = defineTool({
31
68
  }),
32
69
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
33
70
  const completedGoal = activeGoal;
34
- activeGoal = undefined;
35
- ctx.ui.setStatus("goal", undefined);
71
+ if (completedGoal) {
72
+ activeGoal = transitionGoal(completedGoal, "complete");
73
+ updateGoalUsage(activeGoal, ctx);
74
+ persistGoal(ctx.cwd, activeGoal);
75
+ }
36
76
 
37
77
  const goal = completedGoal?.text ?? "unknown goal";
38
78
  const summary = params.summary.trim();
39
79
 
80
+ ctx.ui.setStatus(STATUS_KEY, completedGoal ? formatStatus(activeGoal) : undefined);
81
+ clearActiveGoal(ctx);
82
+ showCompletionStatus(ctx);
40
83
  ctx.ui.notify(`Goal complete: ${goal}`, "info");
41
84
 
42
85
  return {
@@ -51,93 +94,422 @@ export default function goal(pi: ExtensionAPI) {
51
94
  pi.registerTool(goalCompleteTool);
52
95
 
53
96
  pi.registerCommand("goal", {
54
- description: "Run a goal to completion: /goal <goal_to_complete>",
97
+ description: "Run a goal to completion: /goal [--tokens 100k] <goal_to_complete>",
55
98
  handler: async (args, ctx) => {
56
- const goalText = args.trim();
57
- if (!goalText) {
58
- ctx.ui.notify("Usage: /goal <goal_to_complete>", "warning");
99
+ const result = parseCommand(args);
100
+ if (typeof result === "string") {
101
+ ctx.ui.notify(result, "warning");
59
102
  return;
60
103
  }
61
104
 
62
- activeGoal = {
63
- text: goalText,
64
- startedAt: Date.now(),
65
- iteration: 0,
66
- };
67
-
68
- ctx.ui.setStatus("goal", `goal: ${goalText}`);
69
- ctx.ui.notify(`Goal started: ${goalText}`, "info");
70
-
71
- const prompt = buildGoalPrompt(goalText);
72
- if (ctx.isIdle()) {
73
- pi.sendUserMessage(prompt);
74
- } else {
75
- pi.sendUserMessage(prompt, { deliverAs: "followUp" });
105
+ switch (result.kind) {
106
+ case "show":
107
+ showGoal(ctx);
108
+ return;
109
+ case "pause":
110
+ pauseGoal(ctx);
111
+ return;
112
+ case "resume":
113
+ resumeGoal(ctx);
114
+ return;
115
+ case "clear":
116
+ clearGoal(ctx);
117
+ return;
118
+ case "edit":
119
+ editGoal(result.objective ?? "", result.tokenBudget, ctx);
120
+ return;
121
+ case "start":
122
+ startGoal(result.objective ?? "", result.tokenBudget, pi, ctx);
123
+ return;
76
124
  }
77
125
  },
78
126
  });
79
127
 
80
128
  pi.registerCommand("goal-stop", {
81
- description: "Stop the active /goal loop",
82
- handler: async (_args, ctx) => {
83
- if (!activeGoal) {
84
- ctx.ui.notify("No active goal.", "info");
85
- return;
86
- }
87
-
88
- const stoppedGoal = activeGoal.text;
89
- activeGoal = undefined;
90
- ctx.ui.setStatus("goal", undefined);
91
- ctx.ui.notify(`Goal stopped: ${stoppedGoal}`, "warning");
92
- },
129
+ description: "Compatibility alias for /goal clear",
130
+ handler: async (_args, ctx) => clearGoal(ctx),
93
131
  });
94
132
 
95
133
  pi.registerCommand("goal-status", {
96
- description: "Show the active /goal status",
97
- handler: async (_args, ctx) => {
98
- if (!activeGoal) {
99
- ctx.ui.notify("No active goal.", "info");
100
- return;
101
- }
102
-
103
- ctx.ui.notify(`Active goal: ${activeGoal.text} (iteration ${activeGoal.iteration})`, "info");
104
- },
134
+ description: "Compatibility alias for bare /goal",
135
+ handler: async (_args, ctx) => showGoal(ctx),
105
136
  });
106
137
 
107
138
  pi.on("session_start", (_event, ctx) => {
108
- if (activeGoal) ctx.ui.setStatus("goal", `goal: ${activeGoal.text}`);
139
+ activeGoal = loadGoal(ctx.cwd);
140
+ if (activeGoal) updateStatus(ctx, activeGoal);
141
+ else ctx.ui.setStatus(STATUS_KEY, undefined);
109
142
  });
110
143
 
111
144
  pi.on("session_shutdown", (_event, ctx) => {
112
- ctx.ui.setStatus("goal", undefined);
145
+ if (activeGoal) persistGoal(ctx.cwd, activeGoal);
146
+ ctx.ui.setStatus(STATUS_KEY, undefined);
147
+ if (completionStatusTimer) clearTimeout(completionStatusTimer);
113
148
  });
114
149
 
115
150
  pi.on("before_agent_start", (event) => {
116
- if (!activeGoal) return;
151
+ if (!activeGoal || activeGoal.status !== "active") return;
117
152
 
118
153
  return {
119
- systemPrompt: `${event.systemPrompt}\n\n${buildGoalSystemPrompt(activeGoal.text)}`,
154
+ systemPrompt: `${event.systemPrompt}\n\n${buildGoalSystemPrompt(activeGoal)}`,
120
155
  };
121
156
  });
122
157
 
123
158
  pi.on("agent_end", (_event, ctx) => {
124
- if (!activeGoal) return;
159
+ if (!activeGoal || activeGoal.status !== "active") return;
160
+
161
+ const goalId = activeGoal.id;
162
+ activeGoal = incrementGoal(activeGoal);
163
+ updateGoalUsage(activeGoal, ctx);
164
+
165
+ if (activeGoal.tokenBudget !== undefined && activeGoal.tokensUsed >= activeGoal.tokenBudget) {
166
+ activeGoal = transitionGoal(activeGoal, "budget_limited");
167
+ persistGoal(ctx.cwd, activeGoal);
168
+ updateStatus(ctx, activeGoal);
169
+ ctx.ui.notify(`Goal token budget reached: ${formatBudget(activeGoal)}`, "warning");
170
+ return;
171
+ }
125
172
 
126
- activeGoal.iteration += 1;
127
- ctx.ui.setStatus("goal", `goal: ${activeGoal.text} (${activeGoal.iteration})`);
173
+ persistGoal(ctx.cwd, activeGoal);
174
+ updateStatus(ctx, activeGoal);
128
175
 
129
- pi.sendUserMessage(buildContinuePrompt(activeGoal), { deliverAs: "followUp" });
176
+ const currentGoal = activeGoal;
177
+ if (!currentGoal || currentGoal.id !== goalId || currentGoal.status !== "active") return;
178
+ pi.sendUserMessage(buildContinuePrompt(currentGoal), { deliverAs: "followUp" });
130
179
  });
131
180
  }
132
181
 
133
- function buildGoalPrompt(goalText: string) {
134
- return `Goal mode is active. Complete this goal fully:\n\n${goalText}\n\nKeep working until the goal is done. Do not stop after planning or partial progress. When the goal is fully complete and verified, call the goal_complete tool with a concise completion summary.`;
182
+ function startGoal(objective: string, tokenBudget: number | undefined, pi: ExtensionAPI, ctx: StatusContext) {
183
+ const validationError = validateObjective(objective);
184
+ if (validationError) {
185
+ ctx.ui.notify(validationError, "warning");
186
+ return;
187
+ }
188
+
189
+ if (activeGoal && activeGoal.status !== "complete") {
190
+ ctx.ui.notify(
191
+ `A goal is already ${activeGoal.status}. Use /goal edit <objective> to replace it, /goal clear to stop it, or /goal to inspect it.`,
192
+ "warning",
193
+ );
194
+ return;
195
+ }
196
+
197
+ activeGoal = createGoal(objective, tokenBudget, currentTokenTotal(ctx));
198
+ persistGoal(ctx.cwd, activeGoal);
199
+ updateStatus(ctx, activeGoal);
200
+ ctx.ui.notify(`Goal started: ${objective}`, "info");
201
+ sendGoalPrompt(pi, ctx, activeGoal);
135
202
  }
136
203
 
137
- function buildGoalSystemPrompt(goalText: string) {
138
- return `Active /goal: ${goalText}\n\nGoal-mode rules:\n- Continue making concrete progress until the active goal is fully complete.\n- Do not end your response with only a plan, TODO list, or partial progress.\n- Verify the result when possible using appropriate checks.\n- If the goal is not complete at the end of a turn, expect an automatic follow-up and continue from where you left off.\n- Only call the goal_complete tool after the goal is fully complete and verified.`;
204
+ function pauseGoal(ctx: StatusContext) {
205
+ if (!activeGoal) {
206
+ ctx.ui.notify("No active goal.", "info");
207
+ return;
208
+ }
209
+ if (activeGoal.status !== "active") {
210
+ ctx.ui.notify(`Goal is ${activeGoal.status}; only active goals can be paused.`, "warning");
211
+ return;
212
+ }
213
+ activeGoal = transitionGoal(activeGoal, "paused");
214
+ persistGoal(ctx.cwd, activeGoal);
215
+ updateStatus(ctx, activeGoal);
216
+ ctx.ui.notify(`Goal paused: ${activeGoal.text}`, "info");
217
+ }
218
+
219
+ function resumeGoal(ctx: StatusContext) {
220
+ if (!activeGoal) {
221
+ ctx.ui.notify("No active goal.", "info");
222
+ return;
223
+ }
224
+ if (activeGoal.status !== "paused" && activeGoal.status !== "budget_limited") {
225
+ ctx.ui.notify(`Goal is ${activeGoal.status}; only paused or budget-limited goals can be resumed.`, "warning");
226
+ return;
227
+ }
228
+ activeGoal = transitionGoal(activeGoal, "active");
229
+ persistGoal(ctx.cwd, activeGoal);
230
+ updateStatus(ctx, activeGoal);
231
+ ctx.ui.notify(`Goal resumed: ${activeGoal.text}`, "info");
232
+ }
233
+
234
+ function clearGoal(ctx: StatusContext) {
235
+ if (!activeGoal) {
236
+ ctx.ui.notify("No active goal.", "info");
237
+ clearPersistedGoal(ctx.cwd);
238
+ ctx.ui.setStatus(STATUS_KEY, undefined);
239
+ return;
240
+ }
241
+
242
+ const stoppedGoal = activeGoal.text;
243
+ clearActiveGoal(ctx);
244
+ ctx.ui.notify(`Goal cleared: ${stoppedGoal}`, "warning");
245
+ }
246
+
247
+ function editGoal(objective: string, tokenBudget: number | undefined, ctx: StatusContext) {
248
+ const validationError = validateObjective(objective);
249
+ if (validationError) {
250
+ ctx.ui.notify(validationError, "warning");
251
+ return;
252
+ }
253
+
254
+ activeGoal = createGoal(objective, tokenBudget, currentTokenTotal(ctx));
255
+ persistGoal(ctx.cwd, activeGoal);
256
+ updateStatus(ctx, activeGoal);
257
+ ctx.ui.notify(`Goal updated: ${objective}`, "info");
258
+ }
259
+
260
+ function showGoal(ctx: StatusContext) {
261
+ if (!activeGoal) {
262
+ ctx.ui.notify("No active goal.", "info");
263
+ ctx.ui.setStatus(STATUS_KEY, undefined);
264
+ return;
265
+ }
266
+ updateGoalUsage(activeGoal, ctx);
267
+ persistGoal(ctx.cwd, activeGoal);
268
+ updateStatus(ctx, activeGoal);
269
+ ctx.ui.notify(goalSummary(activeGoal), "info");
270
+ }
271
+
272
+ function createGoal(text: string, tokenBudget: number | undefined, baselineTokens: number): ActiveGoal {
273
+ const now = Date.now();
274
+ return {
275
+ id: randomUUID(),
276
+ text,
277
+ status: "active",
278
+ startedAt: now,
279
+ updatedAt: now,
280
+ iteration: 0,
281
+ tokenBudget,
282
+ tokensUsed: 0,
283
+ timeUsedSeconds: 0,
284
+ baselineTokens,
285
+ };
286
+ }
287
+
288
+ function transitionGoal(goal: ActiveGoal, status: GoalStatus): ActiveGoal {
289
+ return { ...goal, status, updatedAt: Date.now() };
290
+ }
291
+
292
+ function incrementGoal(goal: ActiveGoal): ActiveGoal {
293
+ return { ...goal, iteration: goal.iteration + 1, updatedAt: Date.now() };
294
+ }
295
+
296
+ function updateGoalUsage(goal: ActiveGoal, ctx: StatusContext) {
297
+ goal.tokensUsed = Math.max(0, currentTokenTotal(ctx) - goal.baselineTokens);
298
+ goal.timeUsedSeconds = Math.max(0, Math.floor((Date.now() - goal.startedAt) / 1000));
299
+ goal.updatedAt = Date.now();
300
+ }
301
+
302
+ function parseCommand(args: string): CommandResult | string {
303
+ const tokens = tokenize(args.trim());
304
+ if (tokens.length === 0) return { kind: "show" };
305
+
306
+ const [first, ...rest] = tokens;
307
+ if (first === "pause") return rest.length === 0 ? { kind: "pause" } : "Usage: /goal pause";
308
+ if (first === "resume") return rest.length === 0 ? { kind: "resume" } : "Usage: /goal resume";
309
+ if (first === "clear" || first === "stop") return rest.length === 0 ? { kind: "clear" } : "Usage: /goal clear";
310
+ if (first === "status") return rest.length === 0 ? { kind: "show" } : "Usage: /goal status";
311
+ if (first === "edit") return parseObjective("edit", rest);
312
+ return parseObjective("start", tokens);
313
+ }
314
+
315
+ function parseObjective(kind: "start" | "edit", tokens: string[]): CommandResult | string {
316
+ let tokenBudget: number | undefined;
317
+ const objectiveTokens = [...tokens];
318
+
319
+ if (objectiveTokens[0] === "--tokens") {
320
+ const rawBudget = objectiveTokens[1];
321
+ if (!rawBudget) return "Usage: /goal --tokens 100k <goal_to_complete>";
322
+ const parsedBudget = parseTokenBudget(rawBudget);
323
+ if (parsedBudget === undefined) return `Invalid token budget: ${rawBudget}`;
324
+ tokenBudget = parsedBudget;
325
+ objectiveTokens.splice(0, 2);
326
+ }
327
+
328
+ if (objectiveTokens.length === 0) {
329
+ return kind === "edit" ? "Usage: /goal edit <goal_to_complete>" : "Usage: /goal <goal_to_complete>";
330
+ }
331
+
332
+ return { kind, objective: objectiveTokens.join(" "), tokenBudget };
333
+ }
334
+
335
+ function tokenize(input: string): string[] {
336
+ const tokens: string[] = [];
337
+ let current = "";
338
+ let quote: '"' | "'" | undefined;
339
+
340
+ for (const char of input) {
341
+ if (quote) {
342
+ if (char === quote) quote = undefined;
343
+ else current += char;
344
+ continue;
345
+ }
346
+ if (char === '"' || char === "'") {
347
+ quote = char;
348
+ continue;
349
+ }
350
+ if (/\s/.test(char)) {
351
+ if (current) tokens.push(current);
352
+ current = "";
353
+ continue;
354
+ }
355
+ current += char;
356
+ }
357
+ if (current) tokens.push(current);
358
+ return tokens;
359
+ }
360
+
361
+ function parseTokenBudget(value: string): number | undefined {
362
+ const match = /^(\d+(?:\.\d+)?)([km])?$/iu.exec(value.trim());
363
+ if (!match) return undefined;
364
+ const amount = Number(match[1]);
365
+ if (!Number.isFinite(amount) || amount <= 0) return undefined;
366
+ const multiplier = match[2]?.toLowerCase() === "m" ? 1_000_000 : match[2]?.toLowerCase() === "k" ? 1_000 : 1;
367
+ return Math.floor(amount * multiplier);
368
+ }
369
+
370
+ function validateObjective(objective: string): string | undefined {
371
+ const trimmed = objective.trim();
372
+ if (!trimmed) return "Usage: /goal <goal_to_complete>";
373
+ if (trimmed.length > MAX_OBJECTIVE_LENGTH) {
374
+ return `Goal objective is too long (${trimmed.length}/${MAX_OBJECTIVE_LENGTH} characters). Put long instructions in a file and reference it from /goal instead.`;
375
+ }
376
+ return undefined;
377
+ }
378
+
379
+ function sendGoalPrompt(pi: ExtensionAPI, ctx: StatusContext, goal: ActiveGoal) {
380
+ const prompt = buildGoalPrompt(goal);
381
+ if (ctx.isIdle?.()) pi.sendUserMessage(prompt);
382
+ else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
383
+ }
384
+
385
+ function updateStatus(ctx: StatusContext, goal: ActiveGoal) {
386
+ ctx.ui.setStatus(STATUS_KEY, formatStatus(goal));
387
+ }
388
+
389
+ function formatStatus(goal: ActiveGoal | undefined) {
390
+ if (!goal) return undefined;
391
+ if (goal.status === "complete") return "goal: complete";
392
+ if (goal.status === "paused") return "goal: paused";
393
+ if (goal.status === "budget_limited") return `goal: budget ${formatBudget(goal)}`;
394
+ if (goal.tokenBudget !== undefined) return `goal: active ${formatBudget(goal)}`;
395
+ return `goal: active ${formatDuration(goal.timeUsedSeconds)}`;
396
+ }
397
+
398
+ function formatBudget(goal: ActiveGoal) {
399
+ return `${formatTokenCount(goal.tokensUsed)}/${formatTokenCount(goal.tokenBudget ?? 0)}`;
400
+ }
401
+
402
+ function goalSummary(goal: ActiveGoal) {
403
+ return [
404
+ `Goal: ${goal.text}`,
405
+ `Status: ${goal.status}`,
406
+ `Iteration: ${goal.iteration}`,
407
+ `Elapsed: ${formatDuration(goal.timeUsedSeconds)}`,
408
+ `Tokens: ${goal.tokenBudget === undefined ? formatTokenCount(goal.tokensUsed) : formatBudget(goal)}`,
409
+ ].join("\n");
410
+ }
411
+
412
+ function formatDuration(seconds: number) {
413
+ if (seconds < 60) return `${seconds}s`;
414
+ const minutes = Math.floor(seconds / 60);
415
+ if (minutes < 60) return `${minutes}m`;
416
+ const hours = Math.floor(minutes / 60);
417
+ return `${hours}h${minutes % 60}m`;
418
+ }
419
+
420
+ function formatTokenCount(value: number) {
421
+ if (value < 1_000) return `${value}`;
422
+ if (value < 1_000_000) return `${Number.isInteger(value / 1_000) ? value / 1_000 : (value / 1_000).toFixed(1)}k`;
423
+ return `${Number.isInteger(value / 1_000_000) ? value / 1_000_000 : (value / 1_000_000).toFixed(1)}m`;
424
+ }
425
+
426
+ function buildGoalPrompt(goal: ActiveGoal) {
427
+ const budgetLine = goal.tokenBudget === undefined ? "" : `\nToken budget: ${formatTokenCount(goal.tokenBudget)}.`;
428
+ return `Goal mode is active. Complete this goal fully:\n\n${goal.text}${budgetLine}\n\nKeep working until the goal is done. Do not stop after planning or partial progress. When the goal is fully complete and verified, call the goal_complete tool with a concise completion summary.`;
429
+ }
430
+
431
+ function buildGoalSystemPrompt(goal: ActiveGoal) {
432
+ const budgetLine = goal.tokenBudget === undefined ? "" : `\n- Respect the goal token budget (${formatBudget(goal)} used).`;
433
+ return `Active /goal: ${goal.text}\n\nGoal-mode rules:\n- Continue making concrete progress until the active goal is fully complete.\n- Do not end your response with only a plan, TODO list, or partial progress.\n- Verify the result when possible using appropriate checks.\n- If the goal is not complete at the end of a turn, expect an automatic follow-up and continue from where you left off.\n- Only call the goal_complete tool after the goal is fully complete and verified.${budgetLine}`;
139
434
  }
140
435
 
141
436
  function buildContinuePrompt(goal: ActiveGoal) {
142
437
  return `Continue the active /goal until it is complete:\n\n${goal.text}\n\nThis is automatic continuation #${goal.iteration}. If the goal is not complete yet, keep working and verify progress. If it is fully complete and verified, call the goal_complete tool.`;
143
438
  }
439
+
440
+ function currentTokenTotal(ctx: StatusContext): number {
441
+ const sessionManager = ctx.sessionManager as
442
+ | { getBranch?: () => Array<{ type?: string; message?: { role?: string; usage?: unknown } }> }
443
+ | undefined;
444
+ const branch = sessionManager?.getBranch?.() ?? [];
445
+ let total = 0;
446
+ for (const entry of branch) {
447
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
448
+ const usage = entry.message.usage as { input?: number; output?: number } | undefined;
449
+ total += usage?.input ?? 0;
450
+ total += usage?.output ?? 0;
451
+ }
452
+ return total;
453
+ }
454
+
455
+ function persistGoal(cwd: string, goal: ActiveGoal) {
456
+ writeState(cwd, goal);
457
+ }
458
+
459
+ function clearPersistedGoal(cwd: string) {
460
+ writeState(cwd, undefined);
461
+ }
462
+
463
+ function loadGoal(cwd: string): ActiveGoal | undefined {
464
+ const goals = readState();
465
+ const stored = goals[cwd];
466
+ return isGoal(stored) && stored.status !== "complete" ? stored : undefined;
467
+ }
468
+
469
+ function clearActiveGoal(ctx: StatusContext) {
470
+ activeGoal = undefined;
471
+ clearPersistedGoal(ctx.cwd);
472
+ ctx.ui.setStatus(STATUS_KEY, undefined);
473
+ }
474
+
475
+ function showCompletionStatus(ctx: StatusContext) {
476
+ if (completionStatusTimer) clearTimeout(completionStatusTimer);
477
+ ctx.ui.setStatus(STATUS_KEY, "goal: complete");
478
+ completionStatusTimer = setTimeout(() => ctx.ui.setStatus(STATUS_KEY, undefined), 8_000);
479
+ }
480
+
481
+ function readState(): Record<string, unknown> {
482
+ if (!existsSync(STATE_FILE)) return {};
483
+ try {
484
+ const parsed = JSON.parse(readFileSync(STATE_FILE, "utf8")) as unknown;
485
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
486
+ ? (parsed as Record<string, unknown>)
487
+ : {};
488
+ } catch {
489
+ return {};
490
+ }
491
+ }
492
+
493
+ function writeState(cwd: string, goal: ActiveGoal | undefined) {
494
+ const goals = readState();
495
+ if (goal) goals[cwd] = goal;
496
+ else delete goals[cwd];
497
+ mkdirSync(dirname(STATE_FILE), { recursive: true });
498
+ writeFileSync(STATE_FILE, `${JSON.stringify(goals, null, 2)}\n`);
499
+ }
500
+
501
+ function isGoal(value: unknown): value is ActiveGoal {
502
+ if (!value || typeof value !== "object") return false;
503
+ const goal = value as Partial<ActiveGoal>;
504
+ return (
505
+ typeof goal.id === "string" &&
506
+ typeof goal.text === "string" &&
507
+ ["active", "paused", "budget_limited", "complete"].includes(String(goal.status)) &&
508
+ typeof goal.startedAt === "number" &&
509
+ typeof goal.updatedAt === "number" &&
510
+ typeof goal.iteration === "number" &&
511
+ typeof goal.tokensUsed === "number" &&
512
+ typeof goal.timeUsedSeconds === "number" &&
513
+ typeof goal.baselineTokens === "number"
514
+ );
515
+ }