pi-goal 0.1.0 → 0.1.2

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.
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { Box, Spacer, Text } from "@mariozechner/pi-tui";
2
3
 
3
4
  const CUSTOM_TYPE = "pi-goal";
5
+ const EVENT_TYPE = "pi-goal-event";
4
6
 
5
7
  type GoalStatus = "active" | "paused" | "budget_limited" | "complete";
6
8
 
@@ -16,9 +18,13 @@ type GoalState = {
16
18
  updatedAt: number;
17
19
  };
18
20
 
21
+ type GoalEventKind = "active" | "continuation" | "paused" | "resumed" | "cleared" | "budget_limited" | "complete";
22
+
19
23
  let goal: GoalState | null = null;
24
+ let statusBarEnabled = true;
20
25
  let activeTurnStartedAt: number | null = null;
21
26
  let continuationQueued = false;
27
+ let pendingControlPrompt: string | null = null;
22
28
 
23
29
  function parseTokenBudget(input: string): { objective: string; tokenBudget: number | null; error?: string } {
24
30
  const match = input.match(/(?:^|\s)--tokens(?:=|\s+)([0-9]+(?:\.[0-9]+)?\s*[kKmM]?)(?:\s|$)/);
@@ -61,22 +67,85 @@ function statusLine(state: GoalState | null): string | undefined {
61
67
  return `Goal achieved${budget}`;
62
68
  }
63
69
 
64
- function latestGoalFromSession(ctx: ExtensionContext): GoalState | null {
70
+ function goalUsage(state: GoalState): string {
71
+ if (state.tokenBudget != null) return `${formatTokens(state.tokensUsed)} / ${formatTokens(state.tokenBudget)} tokens`;
72
+ return formatElapsed(state.timeUsedSeconds);
73
+ }
74
+
75
+ function tokenDeltaFromUsage(usage: any): number {
76
+ if (!usage) return 0;
77
+ if (typeof usage.totalTokens === "number") return Math.max(0, usage.totalTokens);
78
+ return Math.max(0, (Number(usage.input) || 0) + (Number(usage.output) || 0));
79
+ }
80
+
81
+ function truncateObjective(objective: string, max = 96): string {
82
+ const singleLine = objective.replace(/\s+/g, " ").trim();
83
+ return singleLine.length > max ? `${singleLine.slice(0, max - 1)}…` : singleLine;
84
+ }
85
+
86
+ function goalEventStatus(kind: GoalEventKind): string {
87
+ const labels: Record<GoalEventKind, string> = {
88
+ active: "active",
89
+ continuation: "continuing",
90
+ paused: "paused",
91
+ resumed: "resumed",
92
+ cleared: "cleared",
93
+ budget_limited: "budget reached",
94
+ complete: "achieved",
95
+ };
96
+ return labels[kind];
97
+ }
98
+
99
+ function emitGoalEvent(
100
+ pi: ExtensionAPI,
101
+ kind: GoalEventKind,
102
+ state: GoalState | null,
103
+ content?: string,
104
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
105
+ ) {
106
+ const text = content ?? `Goal ${goalEventStatus(kind)} (ctrl+o to expand)`;
107
+ pi.sendMessage(
108
+ {
109
+ customType: EVENT_TYPE,
110
+ content: text,
111
+ display: true,
112
+ details: {
113
+ kind,
114
+ goal: state,
115
+ timestamp: Date.now(),
116
+ },
117
+ },
118
+ options,
119
+ );
120
+ }
121
+
122
+ function latestStateFromSession(ctx: ExtensionContext): { goal: GoalState | null; statusBarEnabled: boolean } {
65
123
  const entries = ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries();
66
124
  for (let i = entries.length - 1; i >= 0; i--) {
67
125
  const entry = entries[i] as any;
68
126
  if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) {
69
- return entry.data?.goal ?? null;
127
+ return {
128
+ goal: entry.data?.goal ?? null,
129
+ statusBarEnabled: entry.data?.statusBarEnabled ?? true,
130
+ };
70
131
  }
71
132
  }
72
- return null;
133
+ return { goal: null, statusBarEnabled: true };
134
+ }
135
+
136
+ function updateStatusBar(ctx: ExtensionContext) {
137
+ ctx.ui.setStatus(CUSTOM_TYPE, statusBarEnabled ? statusLine(goal) ?? "" : "");
73
138
  }
74
139
 
75
140
  function persist(pi: ExtensionAPI, ctx: ExtensionContext, next: GoalState | null) {
76
141
  goal = next;
77
- pi.appendEntry(CUSTOM_TYPE, { goal: next });
78
- const line = statusLine(next);
79
- ctx.ui.setStatus(CUSTOM_TYPE, line ?? "");
142
+ pi.appendEntry(CUSTOM_TYPE, { goal: next, statusBarEnabled });
143
+ updateStatusBar(ctx);
144
+ }
145
+
146
+ function persistSettings(pi: ExtensionAPI, ctx: ExtensionContext) {
147
+ pi.appendEntry(CUSTOM_TYPE, { goal, statusBarEnabled });
148
+ updateStatusBar(ctx);
80
149
  }
81
150
 
82
151
  function continuationPrompt(state: GoalState): string {
@@ -131,25 +200,49 @@ The system has marked the goal as budget_limited, so do not start new substantiv
131
200
  Do not call update_goal unless the goal is actually complete.`;
132
201
  }
133
202
 
134
- function maybeQueueContinuation(pi: ExtensionAPI, state: GoalState) {
203
+ function queueContinuation(pi: ExtensionAPI, state: GoalState) {
135
204
  if (continuationQueued || state.status !== "active") return;
136
205
  continuationQueued = true;
137
206
  queueMicrotask(() => {
138
207
  continuationQueued = false;
139
208
  if (!goal || goal.id !== state.id || goal.status !== "active") return;
140
- pi.sendMessage(
141
- {
142
- customType: CUSTOM_TYPE,
143
- content: continuationPrompt(goal),
144
- display: false,
145
- details: { kind: "goal-continuation", goalId: goal.id },
146
- },
147
- { triggerTurn: true, deliverAs: "followUp" },
148
- );
209
+ pendingControlPrompt = continuationPrompt(goal);
210
+ emitGoalEvent(pi, "continuation", goal, undefined, { triggerTurn: true, deliverAs: "followUp" });
149
211
  });
150
212
  }
151
213
 
152
214
  export default function piGoal(pi: ExtensionAPI) {
215
+ pi.registerMessageRenderer(EVENT_TYPE, (message, { expanded }, theme) => {
216
+ const details = message.details as { kind?: GoalEventKind; goal?: GoalState | null; timestamp?: number } | undefined;
217
+ const kind = details?.kind ?? "continuation";
218
+ const state = details?.goal ?? null;
219
+ const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
220
+ box.addChild(new Text(theme.fg("customMessageLabel", theme.bold("Goal")), 0, 0));
221
+ box.addChild(new Spacer(1));
222
+ if (!expanded) {
223
+ box.addChild(new Text(`${theme.fg("customMessageText", goalEventStatus(kind))} ${theme.fg("dim", "(ctrl+o to expand)")}`, 0, 0));
224
+ return box;
225
+ }
226
+ const lines = [
227
+ `${theme.fg("dim", "Status: ")}${theme.fg("customMessageText", goalEventStatus(kind))}`,
228
+ ];
229
+ if (state) {
230
+ lines.push(`${theme.fg("dim", "Goal: ")}${theme.fg("customMessageText", state.objective)}`);
231
+ lines.push(`${theme.fg("dim", "Usage: ")}${theme.fg("customMessageText", goalUsage(state))}`);
232
+ }
233
+ box.addChild(new Text(lines.join("\n"), 0, 0));
234
+ return box;
235
+ });
236
+
237
+ pi.on("before_agent_start", (event) => {
238
+ const prompt = pendingControlPrompt;
239
+ pendingControlPrompt = null;
240
+ if (!prompt) return;
241
+ return {
242
+ systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
243
+ };
244
+ });
245
+
153
246
  pi.registerTool({
154
247
  name: "get_goal",
155
248
  label: "Get Goal",
@@ -196,6 +289,7 @@ export default function piGoal(pi: ExtensionAPI) {
196
289
  const now = Date.now();
197
290
  const next: GoalState = { ...goal, status: "complete", updatedAt: now };
198
291
  persist(pi, ctx, next);
292
+ emitGoalEvent(pi, "complete", next);
199
293
  return {
200
294
  content: [{ type: "text", text: JSON.stringify({ goal: next, remainingTokens: next.tokenBudget == null ? null : Math.max(0, next.tokenBudget - next.tokensUsed) }, null, 2) }],
201
295
  details: { goal: next },
@@ -204,9 +298,9 @@ export default function piGoal(pi: ExtensionAPI) {
204
298
  });
205
299
 
206
300
  pi.registerCommand("goal", {
207
- description: "Set, view, pause, resume, or clear a long-running goal",
301
+ description: "Set, view, pause, resume, clear, or configure a long-running goal",
208
302
  getArgumentCompletions: (prefix) => {
209
- const values = ["pause", "resume", "clear", "status"];
303
+ const values = ["pause", "resume", "clear", "status", "statusbar", "statusbar on", "statusbar off"];
210
304
  const filtered = values.filter((value) => value.startsWith(prefix));
211
305
  return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
212
306
  },
@@ -216,13 +310,22 @@ export default function piGoal(pi: ExtensionAPI) {
216
310
 
217
311
  if (!trimmed || trimmed === "status") {
218
312
  if (!goal) ctx.ui.notify("Usage: /goal [--tokens 50k] <objective>", "info");
219
- else ctx.ui.notify(`${statusLine(goal)}\nObjective: ${goal.objective}`, "info");
313
+ else ctx.ui.notify(`${statusLine(goal)}\nObjective: ${goal.objective}\nStatus bar: ${statusBarEnabled ? "on" : "off"}`, "info");
314
+ return;
315
+ }
316
+
317
+ if (trimmed === "statusbar" || trimmed === "statusbar toggle" || trimmed === "statusbar on" || trimmed === "statusbar off") {
318
+ const [, value] = trimmed.split(/\s+/, 2);
319
+ statusBarEnabled = value === "on" ? true : value === "off" ? false : !statusBarEnabled;
320
+ persistSettings(pi, ctx);
321
+ ctx.ui.notify(`Goal status bar ${statusBarEnabled ? "enabled" : "disabled"}.`, "info");
220
322
  return;
221
323
  }
222
324
 
223
325
  if (trimmed === "clear") {
326
+ const previous = goal;
224
327
  persist(pi, ctx, null);
225
- ctx.ui.notify("Goal cleared", "info");
328
+ emitGoalEvent(pi, "cleared", previous);
226
329
  return;
227
330
  }
228
331
 
@@ -234,8 +337,8 @@ export default function piGoal(pi: ExtensionAPI) {
234
337
  const status: GoalStatus = trimmed === "pause" ? "paused" : "active";
235
338
  const next = { ...goal, status, updatedAt: now };
236
339
  persist(pi, ctx, next);
237
- ctx.ui.notify(statusLine(next) ?? "Goal updated", "info");
238
- if (status === "active" && ctx.isIdle()) maybeQueueContinuation(pi, next);
340
+ emitGoalEvent(pi, status === "active" ? "resumed" : "paused", next);
341
+ if (status === "active" && ctx.isIdle()) queueContinuation(pi, next);
239
342
  return;
240
343
  }
241
344
 
@@ -264,15 +367,42 @@ export default function piGoal(pi: ExtensionAPI) {
264
367
  updatedAt: now,
265
368
  };
266
369
  persist(pi, ctx, next);
267
- ctx.ui.notify(`Goal active: ${parsed.objective}`, "success");
268
- if (ctx.isIdle()) maybeQueueContinuation(pi, next);
370
+ if (ctx.isIdle()) {
371
+ pendingControlPrompt = continuationPrompt(next);
372
+ emitGoalEvent(pi, "active", next, undefined, { triggerTurn: true });
373
+ } else {
374
+ emitGoalEvent(pi, "active", next);
375
+ }
269
376
  },
270
377
  });
271
378
 
272
- pi.on("session_start", (_event, ctx) => {
273
- goal = latestGoalFromSession(ctx);
274
- ctx.ui.setStatus(CUSTOM_TYPE, statusLine(goal) ?? "");
275
- if (goal?.status === "active" && ctx.isIdle()) maybeQueueContinuation(pi, goal);
379
+ pi.on("session_start", (event, ctx) => {
380
+ const restored = latestStateFromSession(ctx);
381
+ goal = restored.goal;
382
+ statusBarEnabled = restored.statusBarEnabled;
383
+ pendingControlPrompt = null;
384
+ continuationQueued = false;
385
+ activeTurnStartedAt = null;
386
+ if (goal?.status === "active" && event.reason === "reload") {
387
+ goal = { ...goal, status: "paused", updatedAt: Date.now() };
388
+ persist(pi, ctx, goal);
389
+ emitGoalEvent(
390
+ pi,
391
+ "paused",
392
+ goal,
393
+ `Ⅱ goal paused after reload: ${truncateObjective(goal.objective)}\nUse /goal resume to continue, or /goal clear to stop.`,
394
+ );
395
+ return;
396
+ }
397
+ updateStatusBar(ctx);
398
+ if (goal?.status === "active") {
399
+ emitGoalEvent(
400
+ pi,
401
+ "active",
402
+ goal,
403
+ `⚑ goal restored: ${truncateObjective(goal.objective)}\nUse /goal pause to stop continuation, or /goal clear to remove it.`,
404
+ );
405
+ }
276
406
  });
277
407
 
278
408
  pi.on("turn_start", (_event, _ctx) => {
@@ -283,8 +413,7 @@ export default function piGoal(pi: ExtensionAPI) {
283
413
  if (!goal || goal.status !== "active") return;
284
414
  const elapsed = activeTurnStartedAt ? Math.max(0, Math.round((Date.now() - activeTurnStartedAt) / 1000)) : 0;
285
415
  activeTurnStartedAt = null;
286
- const usage = (event.message as any)?.usage;
287
- const tokenDelta = Math.max(0, Number(usage?.totalTokens ?? usage?.input + usage?.output ?? 0) || 0);
416
+ const tokenDelta = tokenDeltaFromUsage((event.message as any)?.usage);
288
417
  let next: GoalState = {
289
418
  ...goal,
290
419
  tokensUsed: goal.tokensUsed + tokenDelta,
@@ -296,16 +425,17 @@ export default function piGoal(pi: ExtensionAPI) {
296
425
  }
297
426
  persist(pi, ctx, next);
298
427
  if (next.status === "budget_limited") {
299
- pi.sendMessage(
300
- { customType: CUSTOM_TYPE, content: budgetLimitPrompt(next), display: false, details: { kind: "goal-budget-limit", goalId: next.id } },
301
- { triggerTurn: true, deliverAs: "followUp" },
302
- );
428
+ pendingControlPrompt = budgetLimitPrompt(next);
429
+ emitGoalEvent(pi, "budget_limited", next, undefined, { triggerTurn: true, deliverAs: "followUp" });
303
430
  }
304
431
  });
305
432
 
306
433
  pi.on("agent_end", (_event, ctx) => {
307
- if (goal?.status === "active" && ctx.isIdle() && !ctx.hasPendingMessages()) {
308
- maybeQueueContinuation(pi, goal);
309
- }
434
+ const currentGoal = goal;
435
+ if (!currentGoal || currentGoal.status !== "active" || ctx.hasPendingMessages()) return;
436
+ setTimeout(() => {
437
+ if (!goal || goal.id !== currentGoal.id || goal.status !== "active") return;
438
+ queueContinuation(pi, goal);
439
+ }, 0);
310
440
  });
311
441
  }
package/README.md CHANGED
@@ -25,9 +25,12 @@ pi install git:github.com/Michaelliv/pi-goal
25
25
  /goal pause
26
26
  /goal resume
27
27
  /goal clear
28
+ /goal statusbar off
28
29
  ```
29
30
 
30
- When a goal is active, the extension injects a hidden continuation prompt after the agent finishes. The same Pi agent keeps running normal turns in the same session context until it calls `update_goal({ status: "complete" })`, the user pauses/clears it, or the token budget is reached.
31
+ When a goal is active, the extension shows compact visible lifecycle markers like `Goal active` and `Goal continuing`; expand them with `ctrl+o` to inspect the objective and usage. The actual continuation instructions are injected into the next turn's system prompt, so the full prompt does not clutter the transcript.
32
+
33
+ The same Pi agent keeps running normal turns in the same session context until it calls `update_goal({ status: "complete" })`, the user pauses/clears it, or the token budget is reached. Reloading Pi pauses an active goal instead of silently resuming it; use `/goal resume` to continue.
31
34
 
32
35
  ## What it adds
33
36
 
@@ -36,6 +39,7 @@ When a goal is active, the extension injects a hidden continuation prompt after
36
39
  - `/goal pause`: stop autonomous continuation without deleting the goal
37
40
  - `/goal resume`: reactivate a paused goal
38
41
  - `/goal clear`: remove the goal
42
+ - `/goal statusbar on|off`: show or hide the footer status line
39
43
  - `get_goal` tool: read current goal state
40
44
  - `update_goal` tool: model can only mark the goal `complete`
41
45
  - footer status: `Pursuing goal`, `Goal paused`, `Goal achieved`, or `Goal unmet`
@@ -45,8 +49,8 @@ When a goal is active, the extension injects a hidden continuation prompt after
45
49
  ```text
46
50
  /goal <objective>
47
51
  -> persist goal in the current Pi session
48
- -> show footer status
49
- -> inject hidden continuation message
52
+ -> show compact Goal marker and footer status
53
+ -> inject hidden continuation instructions into the next system prompt
50
54
  -> trigger an agent turn
51
55
  -> account time/tokens on turn_end
52
56
  -> queue another continuation on agent_end while active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Persistent autonomous goals for pi — /goal loops until complete, paused, or budget-limited",
5
5
  "type": "commonjs",
6
6
  "keywords": [
@@ -21,7 +21,8 @@
21
21
  ]
22
22
  },
23
23
  "peerDependencies": {
24
- "@mariozechner/pi-coding-agent": "*"
24
+ "@mariozechner/pi-coding-agent": "*",
25
+ "@mariozechner/pi-tui": "*"
25
26
  },
26
27
  "scripts": {
27
28
  "check": "pi --no-extensions -e ./.pi/extensions/pi-goal/index.ts --list-models __pi_goal_load_check__",