pi-goal 0.1.0 → 0.1.1

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,12 @@ 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;
20
24
  let activeTurnStartedAt: number | null = null;
21
25
  let continuationQueued = false;
26
+ let pendingControlPrompt: string | null = null;
22
27
 
23
28
  function parseTokenBudget(input: string): { objective: string; tokenBudget: number | null; error?: string } {
24
29
  const match = input.match(/(?:^|\s)--tokens(?:=|\s+)([0-9]+(?:\.[0-9]+)?\s*[kKmM]?)(?:\s|$)/);
@@ -61,6 +66,58 @@ function statusLine(state: GoalState | null): string | undefined {
61
66
  return `Goal achieved${budget}`;
62
67
  }
63
68
 
69
+ function goalUsage(state: GoalState): string {
70
+ if (state.tokenBudget != null) return `${formatTokens(state.tokensUsed)} / ${formatTokens(state.tokenBudget)} tokens`;
71
+ return formatElapsed(state.timeUsedSeconds);
72
+ }
73
+
74
+ function tokenDeltaFromUsage(usage: any): number {
75
+ if (!usage) return 0;
76
+ if (typeof usage.totalTokens === "number") return Math.max(0, usage.totalTokens);
77
+ return Math.max(0, (Number(usage.input) || 0) + (Number(usage.output) || 0));
78
+ }
79
+
80
+ function truncateObjective(objective: string, max = 96): string {
81
+ const singleLine = objective.replace(/\s+/g, " ").trim();
82
+ return singleLine.length > max ? `${singleLine.slice(0, max - 1)}…` : singleLine;
83
+ }
84
+
85
+ function goalEventStatus(kind: GoalEventKind): string {
86
+ const labels: Record<GoalEventKind, string> = {
87
+ active: "active",
88
+ continuation: "continuing",
89
+ paused: "paused",
90
+ resumed: "resumed",
91
+ cleared: "cleared",
92
+ budget_limited: "budget reached",
93
+ complete: "achieved",
94
+ };
95
+ return labels[kind];
96
+ }
97
+
98
+ function emitGoalEvent(
99
+ pi: ExtensionAPI,
100
+ kind: GoalEventKind,
101
+ state: GoalState | null,
102
+ content?: string,
103
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
104
+ ) {
105
+ const text = content ?? `Goal ${goalEventStatus(kind)} (ctrl+o to expand)`;
106
+ pi.sendMessage(
107
+ {
108
+ customType: EVENT_TYPE,
109
+ content: text,
110
+ display: true,
111
+ details: {
112
+ kind,
113
+ goal: state,
114
+ timestamp: Date.now(),
115
+ },
116
+ },
117
+ options,
118
+ );
119
+ }
120
+
64
121
  function latestGoalFromSession(ctx: ExtensionContext): GoalState | null {
65
122
  const entries = ctx.sessionManager.getBranch?.() ?? ctx.sessionManager.getEntries();
66
123
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -131,25 +188,49 @@ The system has marked the goal as budget_limited, so do not start new substantiv
131
188
  Do not call update_goal unless the goal is actually complete.`;
132
189
  }
133
190
 
134
- function maybeQueueContinuation(pi: ExtensionAPI, state: GoalState) {
191
+ function queueContinuation(pi: ExtensionAPI, state: GoalState) {
135
192
  if (continuationQueued || state.status !== "active") return;
136
193
  continuationQueued = true;
137
194
  queueMicrotask(() => {
138
195
  continuationQueued = false;
139
196
  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
- );
197
+ pendingControlPrompt = continuationPrompt(goal);
198
+ emitGoalEvent(pi, "continuation", goal, undefined, { triggerTurn: true, deliverAs: "followUp" });
149
199
  });
150
200
  }
151
201
 
152
202
  export default function piGoal(pi: ExtensionAPI) {
203
+ pi.registerMessageRenderer(EVENT_TYPE, (message, { expanded }, theme) => {
204
+ const details = message.details as { kind?: GoalEventKind; goal?: GoalState | null; timestamp?: number } | undefined;
205
+ const kind = details?.kind ?? "continuation";
206
+ const state = details?.goal ?? null;
207
+ const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
208
+ box.addChild(new Text(theme.fg("customMessageLabel", theme.bold("Goal")), 0, 0));
209
+ box.addChild(new Spacer(1));
210
+ if (!expanded) {
211
+ box.addChild(new Text(`${theme.fg("customMessageText", goalEventStatus(kind))} ${theme.fg("dim", "(ctrl+o to expand)")}`, 0, 0));
212
+ return box;
213
+ }
214
+ const lines = [
215
+ `${theme.fg("dim", "Status: ")}${theme.fg("customMessageText", goalEventStatus(kind))}`,
216
+ ];
217
+ if (state) {
218
+ lines.push(`${theme.fg("dim", "Goal: ")}${theme.fg("customMessageText", state.objective)}`);
219
+ lines.push(`${theme.fg("dim", "Usage: ")}${theme.fg("customMessageText", goalUsage(state))}`);
220
+ }
221
+ box.addChild(new Text(lines.join("\n"), 0, 0));
222
+ return box;
223
+ });
224
+
225
+ pi.on("before_agent_start", (event) => {
226
+ const prompt = pendingControlPrompt;
227
+ pendingControlPrompt = null;
228
+ if (!prompt) return;
229
+ return {
230
+ systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
231
+ };
232
+ });
233
+
153
234
  pi.registerTool({
154
235
  name: "get_goal",
155
236
  label: "Get Goal",
@@ -196,6 +277,7 @@ export default function piGoal(pi: ExtensionAPI) {
196
277
  const now = Date.now();
197
278
  const next: GoalState = { ...goal, status: "complete", updatedAt: now };
198
279
  persist(pi, ctx, next);
280
+ emitGoalEvent(pi, "complete", next);
199
281
  return {
200
282
  content: [{ type: "text", text: JSON.stringify({ goal: next, remainingTokens: next.tokenBudget == null ? null : Math.max(0, next.tokenBudget - next.tokensUsed) }, null, 2) }],
201
283
  details: { goal: next },
@@ -221,8 +303,9 @@ export default function piGoal(pi: ExtensionAPI) {
221
303
  }
222
304
 
223
305
  if (trimmed === "clear") {
306
+ const previous = goal;
224
307
  persist(pi, ctx, null);
225
- ctx.ui.notify("Goal cleared", "info");
308
+ emitGoalEvent(pi, "cleared", previous);
226
309
  return;
227
310
  }
228
311
 
@@ -234,8 +317,8 @@ export default function piGoal(pi: ExtensionAPI) {
234
317
  const status: GoalStatus = trimmed === "pause" ? "paused" : "active";
235
318
  const next = { ...goal, status, updatedAt: now };
236
319
  persist(pi, ctx, next);
237
- ctx.ui.notify(statusLine(next) ?? "Goal updated", "info");
238
- if (status === "active" && ctx.isIdle()) maybeQueueContinuation(pi, next);
320
+ emitGoalEvent(pi, status === "active" ? "resumed" : "paused", next);
321
+ if (status === "active" && ctx.isIdle()) queueContinuation(pi, next);
239
322
  return;
240
323
  }
241
324
 
@@ -264,15 +347,40 @@ export default function piGoal(pi: ExtensionAPI) {
264
347
  updatedAt: now,
265
348
  };
266
349
  persist(pi, ctx, next);
267
- ctx.ui.notify(`Goal active: ${parsed.objective}`, "success");
268
- if (ctx.isIdle()) maybeQueueContinuation(pi, next);
350
+ if (ctx.isIdle()) {
351
+ pendingControlPrompt = continuationPrompt(next);
352
+ emitGoalEvent(pi, "active", next, undefined, { triggerTurn: true });
353
+ } else {
354
+ emitGoalEvent(pi, "active", next);
355
+ }
269
356
  },
270
357
  });
271
358
 
272
- pi.on("session_start", (_event, ctx) => {
359
+ pi.on("session_start", (event, ctx) => {
273
360
  goal = latestGoalFromSession(ctx);
361
+ pendingControlPrompt = null;
362
+ continuationQueued = false;
363
+ activeTurnStartedAt = null;
364
+ if (goal?.status === "active" && event.reason === "reload") {
365
+ goal = { ...goal, status: "paused", updatedAt: Date.now() };
366
+ persist(pi, ctx, goal);
367
+ emitGoalEvent(
368
+ pi,
369
+ "paused",
370
+ goal,
371
+ `Ⅱ goal paused after reload: ${truncateObjective(goal.objective)}\nUse /goal resume to continue, or /goal clear to stop.`,
372
+ );
373
+ return;
374
+ }
274
375
  ctx.ui.setStatus(CUSTOM_TYPE, statusLine(goal) ?? "");
275
- if (goal?.status === "active" && ctx.isIdle()) maybeQueueContinuation(pi, goal);
376
+ if (goal?.status === "active") {
377
+ emitGoalEvent(
378
+ pi,
379
+ "active",
380
+ goal,
381
+ `⚑ goal restored: ${truncateObjective(goal.objective)}\nUse /goal pause to stop continuation, or /goal clear to remove it.`,
382
+ );
383
+ }
276
384
  });
277
385
 
278
386
  pi.on("turn_start", (_event, _ctx) => {
@@ -283,8 +391,7 @@ export default function piGoal(pi: ExtensionAPI) {
283
391
  if (!goal || goal.status !== "active") return;
284
392
  const elapsed = activeTurnStartedAt ? Math.max(0, Math.round((Date.now() - activeTurnStartedAt) / 1000)) : 0;
285
393
  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);
394
+ const tokenDelta = tokenDeltaFromUsage((event.message as any)?.usage);
288
395
  let next: GoalState = {
289
396
  ...goal,
290
397
  tokensUsed: goal.tokensUsed + tokenDelta,
@@ -296,16 +403,17 @@ export default function piGoal(pi: ExtensionAPI) {
296
403
  }
297
404
  persist(pi, ctx, next);
298
405
  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
- );
406
+ pendingControlPrompt = budgetLimitPrompt(next);
407
+ emitGoalEvent(pi, "budget_limited", next, undefined, { triggerTurn: true, deliverAs: "followUp" });
303
408
  }
304
409
  });
305
410
 
306
411
  pi.on("agent_end", (_event, ctx) => {
307
- if (goal?.status === "active" && ctx.isIdle() && !ctx.hasPendingMessages()) {
308
- maybeQueueContinuation(pi, goal);
309
- }
412
+ const currentGoal = goal;
413
+ if (!currentGoal || currentGoal.status !== "active" || ctx.hasPendingMessages()) return;
414
+ setTimeout(() => {
415
+ if (!goal || goal.id !== currentGoal.id || goal.status !== "active") return;
416
+ queueContinuation(pi, goal);
417
+ }, 0);
310
418
  });
311
419
  }
package/README.md CHANGED
@@ -27,7 +27,9 @@ pi install git:github.com/Michaelliv/pi-goal
27
27
  /goal clear
28
28
  ```
29
29
 
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.
30
+ 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.
31
+
32
+ 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
33
 
32
34
  ## What it adds
33
35
 
@@ -45,8 +47,8 @@ When a goal is active, the extension injects a hidden continuation prompt after
45
47
  ```text
46
48
  /goal <objective>
47
49
  -> persist goal in the current Pi session
48
- -> show footer status
49
- -> inject hidden continuation message
50
+ -> show compact Goal marker and footer status
51
+ -> inject hidden continuation instructions into the next system prompt
50
52
  -> trigger an agent turn
51
53
  -> account time/tokens on turn_end
52
54
  -> 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.1",
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__",