pi-goal-pro 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +397 -0
  3. package/index.ts +1037 -0
  4. package/package.json +63 -0
package/index.ts ADDED
@@ -0,0 +1,1037 @@
1
+ /**
2
+ * pi-goal-pro — Persistent autonomous goals for Pi
3
+ *
4
+ * A production-quality /goal extension combining the best ideas from:
5
+ * - Michaelliv/pi-goal (clean event architecture, session persistence)
6
+ * - capyup/pi-goal (immutable objective, completion audit)
7
+ * - prevalentWare/opencode-goal-plugin (no-progress detection, evidence requirements)
8
+ *
9
+ * Features:
10
+ * - /goal <objective> [--tokens N] — set a long-running goal
11
+ * - /goal pause / resume / clear / status — manage goals
12
+ * - get_goal tool — agent reads current goal
13
+ * - update_goal tool — agent marks complete (requires evidence)
14
+ * - Auto-continuation with no-progress detection
15
+ * - Token budget tracking
16
+ * - Evidence/blocker requirements on completion
17
+ * - Status bar footer
18
+ * - Session entry persistence (survives compaction & reload)
19
+ *
20
+ * Install: cp to ~/.pi/agent/extensions/pi-goal-pro/index.ts, then /reload
21
+ */
22
+
23
+ import { StringEnum } from '@earendil-works/pi-ai';
24
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
25
+ import { matchesKey } from '@earendil-works/pi-tui';
26
+ import { Type } from 'typebox';
27
+
28
+ // ─── Types ───────────────────────────────────────────────────────────────
29
+
30
+ type GoalStatus = 'active' | 'paused' | 'complete' | 'unmet' | 'budget_limited';
31
+
32
+ interface GoalState {
33
+ version: 1;
34
+ id: string;
35
+ objective: string;
36
+ status: GoalStatus;
37
+ tokenBudget: number | null;
38
+ tokensUsed: number;
39
+ timeUsedMs: number;
40
+ createdAt: number;
41
+ updatedAt: number;
42
+ completionEvidence?: string;
43
+ blocker?: string;
44
+ /** How many consecutive low-output / no-progress turns we've seen */
45
+ noProgressCount: number;
46
+ /** Total auto-continue turns fired */
47
+ autoTurnCount: number;
48
+ /** Max auto-continue turns before forced pause */
49
+ maxAutoTurns: number;
50
+ }
51
+
52
+ interface GoalEvent {
53
+ kind: 'active' | 'continuation' | 'paused' | 'resumed' | 'cleared' | 'budget_limited' | 'complete' | 'unmet';
54
+ goal: GoalState;
55
+ timestamp: number;
56
+ }
57
+
58
+ interface GoalSnapshot {
59
+ action:
60
+ | 'set'
61
+ | 'update'
62
+ | 'clear'
63
+ | 'complete'
64
+ | 'unmet'
65
+ | 'pause'
66
+ | 'resume'
67
+ | 'budget_limited'
68
+ | 'no_progress_pause';
69
+ goals: GoalState[];
70
+ config: GoalConfig;
71
+ }
72
+
73
+ interface GoalConfig {
74
+ maxAutoTurns: number;
75
+ noProgressTokenThreshold: number;
76
+ maxNoProgressTurns: number;
77
+ minContinueIntervalMs: number;
78
+ }
79
+
80
+ // ─── Defaults ────────────────────────────────────────────────────────────
81
+
82
+ const GOAL_STORAGE_TYPE = 'pi-goal-pro';
83
+
84
+ const DEFAULTS: GoalConfig = {
85
+ maxAutoTurns: 25,
86
+ noProgressTokenThreshold: 50,
87
+ maxNoProgressTurns: 2,
88
+ minContinueIntervalMs: 3000,
89
+ };
90
+
91
+ const GOAL_FOOTER_ID = 'pi-goal-pro';
92
+
93
+ // ─── Helpers ─────────────────────────────────────────────────────────────
94
+
95
+ function formatTokens(v: number): string {
96
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(v >= 10_000_000 ? 0 : 1).replace(/\.0$/, '')}M`;
97
+ if (v >= 1_000) return `${(v / 1_000).toFixed(v >= 10_000 ? 0 : 1).replace(/\.0$/, '')}K`;
98
+ return String(v);
99
+ }
100
+
101
+ function formatDuration(ms: number): string {
102
+ const total = Math.max(0, Math.floor(ms / 1000));
103
+ const h = Math.floor(total / 3600);
104
+ const m = Math.floor((total % 3600) / 60);
105
+ const s = total % 60;
106
+ if (h > 0) return `${h}h${String(m).padStart(2, '0')}m${String(s).padStart(2, '0')}s`;
107
+ if (m > 0) return `${m}m${String(s).padStart(2, '0')}s`;
108
+ return `${s}s`;
109
+ }
110
+
111
+ function goalId(): string {
112
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
113
+ }
114
+
115
+ function parseTokenBudget(input: string): { objective: string; tokenBudget: number | null } {
116
+ const m = input.match(/(?:^|\s)--tokens(?:=|\s+)(\d+(?:\.\d+)?)\s*([kKmM])?(?:\s|$)/);
117
+ if (!m) return { objective: input.trim(), tokenBudget: null };
118
+ const num = Number(m[1]);
119
+ if (!Number.isFinite(num) || num <= 0) return { objective: input.trim(), tokenBudget: null };
120
+ const mult = m[2]?.toLowerCase() === 'm' ? 1_000_000 : m[2]?.toLowerCase() === 'k' ? 1_000 : 1;
121
+ const budget = Math.round(num * mult);
122
+ const idx = m.index!;
123
+ const objective = (input.slice(0, idx) + input.slice(idx + m[0].length)).trim();
124
+ return { objective, tokenBudget: budget };
125
+ }
126
+
127
+ function parseMaxAutoTurns(input: string): { rest: string; maxAutoTurns: number | null } {
128
+ const m = input.match(/(?:^|\s)--max-turns(?:=|\s+)(\d+)(?:\s|$)/);
129
+ if (!m) return { rest: input.trim(), maxAutoTurns: null };
130
+ const turns = Number.parseInt(m[1], 10);
131
+ if (!Number.isFinite(turns) || turns <= 0) return { rest: input.trim(), maxAutoTurns: null };
132
+ const idx = m.index!;
133
+ const rest = (input.slice(0, idx) + input.slice(idx + m[0].length)).trim();
134
+ return { rest, maxAutoTurns: turns };
135
+ }
136
+
137
+ function footerStatus(goal: GoalState | null, _config: GoalConfig, queueDepth: number): string | undefined {
138
+ if (!goal) return undefined;
139
+ const qs = queueDepth > 0 ? ` [+${queueDepth}]` : '';
140
+ const usage = goal.tokenBudget
141
+ ? `${formatTokens(goal.tokensUsed)}/${formatTokens(goal.tokenBudget)}`
142
+ : formatDuration(goal.timeUsedMs);
143
+ switch (goal.status) {
144
+ case 'active':
145
+ return `🎯 goal active (${usage})${qs}`;
146
+ case 'paused':
147
+ return `⏸ goal paused${qs}`;
148
+ case 'complete':
149
+ return `✅ goal achieved${qs}`;
150
+ case 'unmet':
151
+ return `🚫 goal unmet${qs}`;
152
+ case 'budget_limited':
153
+ return `💰 goal budget (${usage})${qs}`;
154
+ }
155
+ }
156
+
157
+ // ─── Token extraction ────────────────────────────────────────────────────
158
+
159
+ function extractOutputTokens(event: { message?: unknown }): number {
160
+ const msg = event.message as Record<string, unknown> | undefined;
161
+ const usage = msg?.usage as Record<string, unknown> | undefined;
162
+ if (!usage) return 0;
163
+ if (typeof usage.output === 'number')
164
+ return usage.output + (typeof usage.reasoning === 'number' ? usage.reasoning : 0);
165
+ if (typeof usage.totalTokens === 'number') {
166
+ const total = usage.totalTokens as number;
167
+ const cacheRead = typeof usage.cacheRead === 'number' ? (usage.cacheRead as number) : 0;
168
+ const inputTokens = typeof usage.input === 'number' ? (usage.input as number) : 0;
169
+ const delta = total - cacheRead - inputTokens;
170
+ return delta > 0 ? delta : 0;
171
+ }
172
+ return 0;
173
+ }
174
+
175
+ // ─── Extension ───────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Check if the last assistant message was aborted or errored.
179
+ */
180
+ function wasAgentAborted(event: { messages?: unknown[] }): boolean {
181
+ const messages = event.messages ?? [];
182
+ for (let i = messages.length - 1; i >= 0; i--) {
183
+ const m = messages[i] as { role?: string; stopReason?: string } | undefined;
184
+ if (m?.role === 'assistant' && (m.stopReason === 'aborted' || m.stopReason === 'error')) {
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ }
190
+
191
+ export default function piGoalPro(pi: ExtensionAPI) {
192
+ let goals: GoalState[] = [];
193
+ let config: GoalConfig = { ...DEFAULTS };
194
+
195
+ // Steering state
196
+ let goalDrivenInvocation = false;
197
+ let userSuspended = false;
198
+ let consecutiveContinuations = 0;
199
+
200
+ // Per-turn accounting
201
+ let turnStartedAt: number | null = null;
202
+ let turnGoalId: string | null = null;
203
+ let _turnOutputTokens = 0;
204
+
205
+ let continuationTimer: ReturnType<typeof setTimeout> | null = null;
206
+
207
+ // ── Helpers ────────────────────────────────────────────────────────
208
+
209
+ function activeGoal(): GoalState | null {
210
+ return goals.find((g) => g.status === 'active') ?? null;
211
+ }
212
+
213
+ function pausedGoals(): GoalState[] {
214
+ return goals.filter((g) => g.status === 'paused' || g.status === 'budget_limited');
215
+ }
216
+
217
+ function queueDepth(): number {
218
+ return goals.filter((g) => g.status === 'paused' || g.status === 'budget_limited').length;
219
+ }
220
+
221
+ function clearTimer() {
222
+ if (continuationTimer) {
223
+ clearTimeout(continuationTimer);
224
+ continuationTimer = null;
225
+ }
226
+ }
227
+
228
+ const GOAL_TOOLS = ['get_goal', 'update_goal'];
229
+
230
+ function syncTools() {
231
+ const want = !!activeGoal();
232
+ const active = new Set(pi.getActiveTools());
233
+ let changed = false;
234
+ for (const name of GOAL_TOOLS) {
235
+ if (want && !active.has(name)) {
236
+ active.add(name);
237
+ changed = true;
238
+ } else if (!want && active.has(name)) {
239
+ active.delete(name);
240
+ changed = true;
241
+ }
242
+ }
243
+ if (changed) pi.setActiveTools(Array.from(active));
244
+ }
245
+
246
+ function persist(action: GoalSnapshot['action']) {
247
+ pi.appendEntry<GoalSnapshot>(GOAL_STORAGE_TYPE, {
248
+ action,
249
+ goals: goals.map((g) => ({ ...g })),
250
+ config: { ...config },
251
+ });
252
+ }
253
+
254
+ function updateFooter(ctx: ExtensionContext) {
255
+ const a = activeGoal();
256
+ if (!a && goals.every((g) => g.status === 'complete' || g.status === 'unmet')) {
257
+ ctx.ui.setStatus(GOAL_FOOTER_ID, undefined);
258
+ return;
259
+ }
260
+ const status = footerStatus(a, config, queueDepth());
261
+ ctx.ui.setStatus(GOAL_FOOTER_ID, status ?? '');
262
+ }
263
+
264
+ function updateState(goalId: string, patch: Partial<GoalState>, ctx: ExtensionContext) {
265
+ const g = goals.find((x) => x.id === goalId);
266
+ if (!g) return;
267
+ Object.assign(g, patch, { updatedAt: Date.now() });
268
+ persist('update');
269
+ updateFooter(ctx);
270
+ syncTools();
271
+ }
272
+
273
+ // ── Continuation loop ──────────────────────────────────────────────
274
+
275
+ function buildContinuationPrompt(goal: GoalState, isFirst: boolean): string {
276
+ const budgetLine =
277
+ goal.tokenBudget != null
278
+ ? `- Token budget: ${formatTokens(goal.tokenBudget)} (${formatTokens(Math.max(0, goal.tokenBudget - goal.tokensUsed))} remaining)`
279
+ : '- No token budget set';
280
+ return `Continue working toward the active goal.
281
+
282
+ <goal_objective>
283
+ ${goal.objective}
284
+ </goal_objective>
285
+
286
+ Progress so far:
287
+ - Tokens used: ${formatTokens(goal.tokensUsed)}
288
+ ${budgetLine}
289
+ - Time spent: ${formatDuration(goal.timeUsedMs)}
290
+ - Auto-continuation turns: ${goal.autoTurnCount} / ${goal.maxAutoTurns}
291
+ ${isFirst ? '- This is the first continuation turn. Review what has been done so far and decide the next concrete step.' : ''}
292
+
293
+ Rules:
294
+ 1. Before marking the goal complete, perform a strict completion audit against real evidence:
295
+ - Inspect relevant files, command output, test results, PR state
296
+ - Verify every explicit requirement has been met
297
+ - Do not accept proxy signals or partial progress as completion
298
+ - If any requirement is missing, incomplete, or unverified, keep working
299
+ 2. Call update_goal({ status: "complete", evidence: "<summary>" }) ONLY when the objective is fully achieved.
300
+ 3. Call update_goal({ status: "unmet", blocker: "<reason>" }) if the goal cannot be achieved or is blocked.
301
+ 4. Do not mark complete merely because budget is nearly exhausted.
302
+ 5. Do not ask for permission to mark complete — just do the audit and call the tool.`;
303
+ }
304
+
305
+ function buildBudgetLimitPrompt(goal: GoalState): string {
306
+ return `The active goal has reached its token budget.
307
+
308
+ <goal_objective>
309
+ ${goal.objective}
310
+ </goal_objective>
311
+
312
+ Usage:
313
+ - Tokens used: ${formatTokens(goal.tokensUsed)}
314
+ - Token budget: ${goal.tokenBudget != null ? formatTokens(goal.tokenBudget) : 'none'}
315
+ - Time spent: ${formatDuration(goal.timeUsedMs)}
316
+
317
+ The system has marked the goal as budget_limited. Do not start new substantive work.
318
+ Wrap up: summarize progress, identify remaining work, and leave the user with a clear next step.
319
+ Do not call update_goal unless the goal is actually complete.`;
320
+ }
321
+
322
+ function sendContinuationNow(_ctx: ExtensionContext) {
323
+ const a = activeGoal();
324
+ if (!a) return;
325
+ if (userSuspended) return;
326
+ clearTimer();
327
+ queueMicrotask(() => {
328
+ const a2 = activeGoal();
329
+ if (!a2) return;
330
+ if (userSuspended) return;
331
+ goalDrivenInvocation = true;
332
+ pi.sendMessage(
333
+ {
334
+ customType: `${GOAL_STORAGE_TYPE}:continuation`,
335
+ content: buildContinuationPrompt(a2, consecutiveContinuations === 0),
336
+ display: false,
337
+ },
338
+ { triggerTurn: true, deliverAs: 'followUp' },
339
+ );
340
+ });
341
+ }
342
+
343
+ function scheduleContinuation(ctx: ExtensionContext) {
344
+ clearTimer();
345
+ if (userSuspended) return;
346
+ const a = activeGoal();
347
+ if (!a) return;
348
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
349
+
350
+ if (a.noProgressCount >= config.maxNoProgressTurns) {
351
+ // No-progress detection: pause the goal
352
+ updateState(a.id, { status: 'paused' as GoalStatus }, ctx);
353
+ const e: GoalEvent = { kind: 'paused', goal: { ...a, status: 'paused' }, timestamp: Date.now() };
354
+ pi.sendMessage(
355
+ {
356
+ customType: `${GOAL_STORAGE_TYPE}:event`,
357
+ content: `Goal paused: no progress detected after ${config.maxNoProgressTurns} turns (output < ${config.noProgressTokenThreshold} tokens each). Use /goal resume to continue.`,
358
+ display: true,
359
+ details: e,
360
+ },
361
+ { triggerTurn: false },
362
+ );
363
+ ctx.ui.notify(
364
+ `⏸ Goal paused (no progress for ${config.maxNoProgressTurns} turns). Use /goal resume to continue.`,
365
+ 'warning',
366
+ );
367
+ return;
368
+ }
369
+
370
+ if (a.autoTurnCount >= a.maxAutoTurns) {
371
+ // Max turns reached: pause the goal
372
+ updateState(a.id, { status: 'paused' as GoalStatus }, ctx);
373
+ const e: GoalEvent = { kind: 'paused', goal: { ...a, status: 'paused' }, timestamp: Date.now() };
374
+ pi.sendMessage(
375
+ {
376
+ customType: `${GOAL_STORAGE_TYPE}:event`,
377
+ content: `Goal paused: reached max auto-continue turns (${a.maxAutoTurns}). Use /goal resume to continue.`,
378
+ display: true,
379
+ details: e,
380
+ },
381
+ { triggerTurn: false },
382
+ );
383
+ return;
384
+ }
385
+
386
+ const goalId = a.id;
387
+ continuationTimer = setTimeout(() => {
388
+ continuationTimer = null;
389
+ const a2 = activeGoal();
390
+ if (!a2 || a2.id !== goalId) return;
391
+ if (userSuspended) return;
392
+ goalDrivenInvocation = true;
393
+ pi.sendMessage(
394
+ {
395
+ customType: `${GOAL_STORAGE_TYPE}:continuation`,
396
+ content: buildContinuationPrompt(a2, false),
397
+ display: false,
398
+ },
399
+ { triggerTurn: true, deliverAs: 'followUp' },
400
+ );
401
+ }, config.minContinueIntervalMs);
402
+ }
403
+
404
+ // ── Mutation helpers ───────────────────────────────────────────────
405
+
406
+ function setGoal(
407
+ objective: string,
408
+ opts: { tokenBudget?: number | null; maxAutoTurns?: number | null; replace?: boolean },
409
+ ctx: ExtensionContext,
410
+ ): GoalState {
411
+ const now = Date.now();
412
+ const existing = activeGoal();
413
+ if (existing) {
414
+ if (opts.replace) {
415
+ existing.status = 'unmet';
416
+ existing.blocker = 'Replaced by user';
417
+ existing.updatedAt = now;
418
+ }
419
+ }
420
+
421
+ const goal: GoalState = {
422
+ version: 1,
423
+ id: goalId(),
424
+ objective,
425
+ status: 'active',
426
+ tokenBudget: opts.tokenBudget ?? null,
427
+ tokensUsed: 0,
428
+ timeUsedMs: 0,
429
+ createdAt: now,
430
+ updatedAt: now,
431
+ noProgressCount: 0,
432
+ autoTurnCount: 0,
433
+ maxAutoTurns: opts.maxAutoTurns ?? config.maxAutoTurns,
434
+ };
435
+ goals.push(goal);
436
+
437
+ userSuspended = false;
438
+ consecutiveContinuations = 0;
439
+
440
+ persist('set');
441
+ updateFooter(ctx);
442
+ syncTools();
443
+
444
+ // Start first continuation immediately
445
+ sendContinuationNow(ctx);
446
+
447
+ return goal;
448
+ }
449
+
450
+ function clearGoal(ctx: ExtensionContext): boolean {
451
+ if (goals.length === 0) return false;
452
+ const a = activeGoal();
453
+ if (a) {
454
+ const e: GoalEvent = { kind: 'cleared', goal: { ...a }, timestamp: Date.now() };
455
+ pi.sendMessage(
456
+ {
457
+ customType: `${GOAL_STORAGE_TYPE}:event`,
458
+ content: `Goal cleared by user.\n\nObjective was: ${a.objective}`,
459
+ display: true,
460
+ details: e,
461
+ },
462
+ { triggerTurn: false },
463
+ );
464
+ }
465
+ clearTimer();
466
+ goals = [];
467
+ userSuspended = false;
468
+ consecutiveContinuations = 0;
469
+ goalDrivenInvocation = false;
470
+ persist('clear');
471
+ updateFooter(ctx);
472
+ syncTools();
473
+ return true;
474
+ }
475
+
476
+ function pauseGoal(ctx: ExtensionContext): boolean {
477
+ const a = activeGoal();
478
+ if (!a) return false;
479
+ updateState(a.id, { status: 'paused' }, ctx);
480
+ clearTimer();
481
+ userSuspended = true;
482
+ const e: GoalEvent = { kind: 'paused', goal: { ...a, status: 'paused' }, timestamp: Date.now() };
483
+ pi.sendMessage(
484
+ {
485
+ customType: `${GOAL_STORAGE_TYPE}:event`,
486
+ content: `Goal paused.\n\nObjective: ${a.objective}`,
487
+ display: true,
488
+ details: e,
489
+ },
490
+ { triggerTurn: false },
491
+ );
492
+ return true;
493
+ }
494
+
495
+ function resumeGoal(ctx: ExtensionContext): GoalState | null {
496
+ const paused = pausedGoals();
497
+ const target = paused.length > 0 ? paused[0] : null;
498
+ if (!target) return null;
499
+ userSuspended = false;
500
+ consecutiveContinuations = 0;
501
+ target.noProgressCount = 0;
502
+ updateState(target.id, { status: 'active' }, ctx);
503
+ const e: GoalEvent = { kind: 'resumed', goal: { ...target, status: 'active' }, timestamp: Date.now() };
504
+ pi.sendMessage(
505
+ {
506
+ customType: `${GOAL_STORAGE_TYPE}:event`,
507
+ content: `Goal resumed.\n\nObjective: ${target.objective}`,
508
+ display: true,
509
+ details: e,
510
+ },
511
+ { triggerTurn: false },
512
+ );
513
+ sendContinuationNow(ctx);
514
+ return target;
515
+ }
516
+
517
+ // ── State reconstruction ──────────────────────────────────────────
518
+
519
+ function reconstruct(ctx: ExtensionContext) {
520
+ goals = [];
521
+ config = { ...DEFAULTS };
522
+ turnStartedAt = null;
523
+ turnGoalId = null;
524
+ _turnOutputTokens = 0;
525
+ goalDrivenInvocation = false;
526
+ userSuspended = false;
527
+ consecutiveContinuations = 0;
528
+ clearTimer();
529
+
530
+ for (const entry of ctx.sessionManager.getBranch()) {
531
+ if (entry.type !== 'custom' || entry.customType !== GOAL_STORAGE_TYPE) continue;
532
+ const data = entry.data as GoalSnapshot | undefined;
533
+ if (!data) continue;
534
+ goals = data.goals.map((g) => ({ ...g }));
535
+ if (data.config) config = { ...data.config };
536
+ }
537
+ }
538
+
539
+ // ── Events ─────────────────────────────────────────────────────────
540
+
541
+ pi.on('session_start', async (_event, ctx) => {
542
+ reconstruct(ctx);
543
+ syncTools();
544
+
545
+ const a = activeGoal();
546
+ if (a) {
547
+ ctx.ui.notify(
548
+ `🎯 Goal restored: ${a.objective.replace(/\s+/g, ' ').slice(0, 80)}…\n/goal pause to stop, /goal clear to remove.`,
549
+ 'info',
550
+ );
551
+ }
552
+ updateFooter(ctx);
553
+ });
554
+
555
+ pi.on('session_tree', async (_event, ctx) => {
556
+ reconstruct(ctx);
557
+ syncTools();
558
+ updateFooter(ctx);
559
+ });
560
+
561
+ pi.on('session_shutdown', async () => {
562
+ clearTimer();
563
+ turnStartedAt = null;
564
+ turnGoalId = null;
565
+ });
566
+
567
+ // User input → suspend auto-continuation
568
+ pi.on('input', async (_event, _ctx) => {
569
+ clearTimer();
570
+ if (activeGoal()) {
571
+ userSuspended = true;
572
+ }
573
+ });
574
+
575
+ pi.on('turn_start', async (_event, _ctx) => {
576
+ if (goalDrivenInvocation) {
577
+ consecutiveContinuations += 1;
578
+ } else {
579
+ consecutiveContinuations = 0;
580
+ }
581
+
582
+ const a = activeGoal();
583
+ if (a) {
584
+ turnStartedAt = Date.now();
585
+ turnGoalId = a.id;
586
+ _turnOutputTokens = 0;
587
+ } else {
588
+ turnStartedAt = null;
589
+ turnGoalId = null;
590
+ _turnOutputTokens = 0;
591
+ }
592
+ });
593
+
594
+ pi.on('turn_end', async (event, ctx) => {
595
+ if (turnStartedAt === null || turnGoalId === null) return;
596
+
597
+ const charged = goals.find((g) => g.id === turnGoalId);
598
+ const elapsed = Date.now() - turnStartedAt;
599
+ const outputTokens = extractOutputTokens(event);
600
+ turnStartedAt = null;
601
+ turnGoalId = null;
602
+ _turnOutputTokens = 0;
603
+
604
+ if (!charged) return;
605
+
606
+ charged.timeUsedMs += elapsed;
607
+ charged.tokensUsed += outputTokens;
608
+
609
+ // No-progress tracking
610
+ if (outputTokens < config.noProgressTokenThreshold) {
611
+ charged.noProgressCount += 1;
612
+ } else {
613
+ charged.noProgressCount = 0;
614
+ }
615
+
616
+ // Auto-turn tracking
617
+ if (goalDrivenInvocation) {
618
+ charged.autoTurnCount += 1;
619
+ }
620
+
621
+ // Budget check
622
+ if (charged.status === 'active' && charged.tokenBudget !== null && charged.tokensUsed >= charged.tokenBudget) {
623
+ charged.status = 'budget_limited';
624
+ charged.updatedAt = Date.now();
625
+ persist('budget_limited');
626
+ updateFooter(ctx);
627
+ syncTools();
628
+ const e: GoalEvent = { kind: 'budget_limited', goal: { ...charged }, timestamp: Date.now() };
629
+ pi.sendMessage(
630
+ {
631
+ customType: `${GOAL_STORAGE_TYPE}:event`,
632
+ content: buildBudgetLimitPrompt(charged),
633
+ display: true,
634
+ details: e,
635
+ },
636
+ { triggerTurn: true, deliverAs: 'steer' },
637
+ );
638
+ return;
639
+ }
640
+
641
+ persist('update');
642
+ updateFooter(ctx);
643
+ });
644
+
645
+ pi.on('agent_end', async (event, ctx) => {
646
+ updateFooter(ctx);
647
+ const wasGoalDriven = goalDrivenInvocation;
648
+ goalDrivenInvocation = false;
649
+
650
+ if (wasAgentAborted(event)) {
651
+ userSuspended = true;
652
+ clearTimer();
653
+ ctx.ui.notify('⏸ Goal continuations suspended (interrupted). Use /goal resume to continue.', 'info');
654
+ return;
655
+ }
656
+
657
+ if (!wasGoalDriven) return;
658
+ if (userSuspended) return;
659
+ if (ctx.hasPendingMessages()) return;
660
+
661
+ scheduleContinuation(ctx);
662
+ });
663
+
664
+ // System prompt injection
665
+ pi.on('before_agent_start', async (event, _ctx) => {
666
+ const a = activeGoal();
667
+ if (!a) return;
668
+ if (!goalDrivenInvocation) return;
669
+
670
+ const lines = ['', '## Active Goal', `Objective: ${a.objective}`, `Status: ${a.status}`];
671
+ if (a.tokenBudget !== null) {
672
+ const remaining = Math.max(0, a.tokenBudget - a.tokensUsed);
673
+ lines.push(`Token budget: ${formatTokens(a.tokenBudget)} (${formatTokens(remaining)} remaining)`);
674
+ }
675
+ const qd = queueDepth();
676
+ if (qd > 0) lines.push(`${qd} paused goal(s) remaining.`);
677
+ lines.push('');
678
+ lines.push('Use update_goal({ status: "complete", evidence: "..." }) when the objective is fully achieved.');
679
+ lines.push('Use update_goal({ status: "unmet", blocker: "..." }) if the goal cannot be achieved.');
680
+
681
+ return {
682
+ systemPrompt: event.systemPrompt + lines.join('\n'),
683
+ message: !goalDrivenInvocation
684
+ ? undefined
685
+ : {
686
+ customType: `${GOAL_STORAGE_TYPE}:context`,
687
+ content: `Active goal context:\nObjective: ${a.objective}\nStatus: ${a.status}`,
688
+ display: false,
689
+ },
690
+ };
691
+ });
692
+
693
+ // ── Message renderer for goal events ───────────────────────────────
694
+
695
+ pi.registerMessageRenderer(`${GOAL_STORAGE_TYPE}:event`, (message, options, theme) => {
696
+ const details = message.details as GoalEvent | undefined;
697
+ const kind = details?.kind ?? 'continuation';
698
+ const state = details?.goal ?? null;
699
+
700
+ const box = new (class {
701
+ private children: import('@earendil-works/pi-tui').Component[] = [];
702
+
703
+ render(_width: number): string[] {
704
+ const lines: string[] = [];
705
+ const isExpanded = options.expanded;
706
+ const prefix = theme.fg('accent', theme.bold('Goal'));
707
+ const kindLabel = this.kindLabel(kind, theme);
708
+ const statusText = theme.fg('dim', isExpanded ? '' : '(ctrl+o to expand)');
709
+
710
+ lines.push(`${prefix} ${kindLabel} ${!isExpanded ? statusText : ''}`);
711
+
712
+ if (isExpanded && state) {
713
+ lines.push(`${theme.fg('dim', ' Status: ')}${theme.fg('text', kind)}`);
714
+ lines.push(`${theme.fg('dim', ' Goal: ')}${theme.fg('text', state.objective)}`);
715
+ if (state.completionEvidence) {
716
+ lines.push(`${theme.fg('dim', ' Evidence: ')}${theme.fg('success', state.completionEvidence)}`);
717
+ }
718
+ if (state.blocker) {
719
+ lines.push(`${theme.fg('dim', ' Blocker: ')}${theme.fg('warning', state.blocker)}`);
720
+ }
721
+ const usage = state.tokenBudget
722
+ ? `${formatTokens(state.tokensUsed)}/${formatTokens(state.tokenBudget)}`
723
+ : formatDuration(state.timeUsedMs);
724
+ lines.push(`${theme.fg('dim', ' Usage: ')}${theme.fg('text', usage)}`);
725
+ }
726
+
727
+ return lines;
728
+ }
729
+
730
+ invalidate(): void {}
731
+
732
+ handleInput?(data: string): void {
733
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) {
734
+ // no-op, let parent handle
735
+ }
736
+ }
737
+
738
+ private kindLabel(k: string, th: typeof theme): string {
739
+ const labels: Record<string, string> = {
740
+ active: th.fg('accent', 'active'),
741
+ continuation: th.fg('muted', 'continuing'),
742
+ paused: th.fg('warning', 'paused'),
743
+ resumed: th.fg('accent', 'resumed'),
744
+ cleared: th.fg('dim', 'cleared'),
745
+ budget_limited: th.fg('warning', 'budget'),
746
+ complete: th.fg('success', 'achieved'),
747
+ unmet: th.fg('error', 'unmet'),
748
+ };
749
+ return labels[k] ?? k;
750
+ }
751
+ })();
752
+
753
+ return box;
754
+ });
755
+
756
+ // ── Commands ───────────────────────────────────────────────────────
757
+
758
+ pi.registerCommand('goal', {
759
+ description: 'Set, view, pause, resume, clear, or configure a long-running goal',
760
+ getArgumentCompletions: (prefix) => {
761
+ const cmds = ['pause', 'resume', 'clear', 'status', 'help', 'config'];
762
+ return cmds.filter((c) => c.startsWith(prefix)).map((c) => ({ value: c, label: c }));
763
+ },
764
+ handler: async (args, ctx) => {
765
+ const trimmed = args.trim();
766
+
767
+ if (!trimmed || trimmed === 'status') {
768
+ if (goals.length === 0) {
769
+ ctx.ui.notify(
770
+ 'Usage: /goal <objective> [--tokens N] [--max-turns N]\n /goal pause|resume|clear|status|config',
771
+ 'info',
772
+ );
773
+ return;
774
+ }
775
+ const a = activeGoal();
776
+ const paused = pausedGoals();
777
+ const done = goals.filter((g) => g.status === 'complete' || g.status === 'unmet');
778
+ let msg = `Goals: ${goals.length} total`;
779
+ if (a) {
780
+ msg += `\nActive: ${a.objective.replace(/\s+/g, ' ').slice(0, 80)}`;
781
+ msg += `\n Status: ${a.status} | Tokens: ${formatTokens(a.tokensUsed)}${a.tokenBudget ? `/${formatTokens(a.tokenBudget)}` : ''} | Time: ${formatDuration(a.timeUsedMs)}`;
782
+ msg += `\n Turns: ${a.autoTurnCount}/${a.maxAutoTurns}`;
783
+ }
784
+ if (paused.length > 0) {
785
+ msg += `\nPaused: ${paused.length}`;
786
+ }
787
+ if (done.length > 0) {
788
+ msg += `\nDone: ${done.length}`;
789
+ }
790
+ msg += `\nContinuations: ${userSuspended ? 'suspended' : 'active'}`;
791
+ ctx.ui.notify(msg, 'info');
792
+ return;
793
+ }
794
+
795
+ if (trimmed === 'help') {
796
+ ctx.ui.notify(
797
+ `/goal <objective> [--tokens N] [--max-turns N] — set a goal
798
+ /goal status — show current goal
799
+ /goal pause — pause active goal
800
+ /goal resume — resume paused goal
801
+ /goal clear — clear all goals
802
+ /goal config — show configuration`,
803
+ 'info',
804
+ );
805
+ return;
806
+ }
807
+
808
+ if (trimmed === 'clear') {
809
+ if (goals.length === 0) {
810
+ ctx.ui.notify('No goals to clear.', 'info');
811
+ return;
812
+ }
813
+ clearGoal(ctx);
814
+ ctx.ui.notify('All goals cleared.', 'info');
815
+ return;
816
+ }
817
+
818
+ if (trimmed === 'pause') {
819
+ if (!pauseGoal(ctx)) {
820
+ ctx.ui.notify('No active goal to pause.', 'info');
821
+ } else {
822
+ ctx.ui.notify('Goal paused.', 'info');
823
+ }
824
+ return;
825
+ }
826
+
827
+ if (trimmed === 'resume') {
828
+ if (activeGoal()) {
829
+ ctx.ui.notify('A goal is already active.', 'info');
830
+ return;
831
+ }
832
+ const g = resumeGoal(ctx);
833
+ if (!g) {
834
+ ctx.ui.notify('No paused goal to resume.', 'info');
835
+ } else {
836
+ ctx.ui.notify(`Goal resumed: ${g.objective.replace(/\s+/g, ' ').slice(0, 60)}…`, 'info');
837
+ }
838
+ return;
839
+ }
840
+
841
+ if (trimmed === 'config') {
842
+ ctx.ui.notify(
843
+ `Configuration:
844
+ maxAutoTurns: ${config.maxAutoTurns}
845
+ noProgressTokenThreshold: ${config.noProgressTokenThreshold}
846
+ maxNoProgressTurns: ${config.maxNoProgressTurns}
847
+ minContinueIntervalMs: ${config.minContinueIntervalMs}`,
848
+ 'info',
849
+ );
850
+ return;
851
+ }
852
+
853
+ // /goal <objective> [--tokens N] [--max-turns N]
854
+ let rest = trimmed;
855
+
856
+ // Parse --max-turns
857
+ const turnsResult = parseMaxAutoTurns(rest);
858
+ rest = turnsResult.rest;
859
+
860
+ // Parse --tokens
861
+ const budgetResult = parseTokenBudget(rest);
862
+ const objective = budgetResult.objective;
863
+
864
+ if (!objective) {
865
+ ctx.ui.notify('Usage: /goal <objective> [--tokens N] [--max-turns N]', 'warning');
866
+ return;
867
+ }
868
+
869
+ const existing = activeGoal();
870
+ if (existing) {
871
+ const ok = await ctx.ui.confirm(
872
+ 'Replace active goal?',
873
+ `Current: ${existing.objective.replace(/\s+/g, ' ').slice(0, 80)}…\n\nNew: ${objective.slice(0, 80)}…`,
874
+ );
875
+ if (!ok) return;
876
+ }
877
+
878
+ const goal = setGoal(
879
+ objective,
880
+ {
881
+ tokenBudget: budgetResult.tokenBudget,
882
+ maxAutoTurns: turnsResult.maxAutoTurns,
883
+ replace: !!existing,
884
+ },
885
+ ctx,
886
+ );
887
+
888
+ ctx.ui.notify(`🎯 Goal set: ${goal.objective.replace(/\s+/g, ' ').slice(0, 80)}…`, 'info');
889
+ },
890
+ });
891
+
892
+ // ── Tools ──────────────────────────────────────────────────────────
893
+
894
+ pi.registerTool({
895
+ name: 'get_goal',
896
+ label: 'Get Goal',
897
+ description: 'Get the current active goal, its status, token usage, budget, and queue.',
898
+ promptSnippet: 'Read the current pi-goal-pro objective and remaining budget',
899
+ promptGuidelines: ['Use get_goal when you need the current objective or remaining budget.'],
900
+ parameters: Type.Object({}),
901
+ async execute() {
902
+ if (goals.length === 0) {
903
+ return { content: [{ type: 'text', text: 'No goal is currently set.' }], details: {} };
904
+ }
905
+ const a = activeGoal();
906
+ const info = {
907
+ active: a
908
+ ? {
909
+ id: a.id,
910
+ objective: a.objective,
911
+ status: a.status,
912
+ tokens_used: a.tokensUsed,
913
+ token_budget: a.tokenBudget,
914
+ remaining_tokens: a.tokenBudget !== null ? Math.max(0, a.tokenBudget - a.tokensUsed) : null,
915
+ time_used_seconds: Math.floor(a.timeUsedMs / 1000),
916
+ auto_turns: a.autoTurnCount,
917
+ max_auto_turns: a.maxAutoTurns,
918
+ }
919
+ : null,
920
+ paused: pausedGoals().map((g) => ({
921
+ id: g.id,
922
+ objective: g.objective,
923
+ status: g.status,
924
+ })),
925
+ completed: goals
926
+ .filter((g) => g.status === 'complete' || g.status === 'unmet')
927
+ .map((g) => ({
928
+ id: g.id,
929
+ objective: g.objective,
930
+ status: g.status,
931
+ })),
932
+ continuations_suspended: userSuspended,
933
+ };
934
+ return {
935
+ content: [{ type: 'text', text: JSON.stringify(info, null, 2) }],
936
+ details: info,
937
+ };
938
+ },
939
+ });
940
+
941
+ pi.registerTool({
942
+ name: 'update_goal',
943
+ label: 'Update Goal',
944
+ description: `Close the active goal. Use status "complete" with evidence when the objective is fully achieved and verified against real artifacts (files, tests, command output). Use status "unmet" with blocker when the goal cannot be achieved or is blocked. Do not close a goal merely because work is stopping or budget is exhausted.`,
945
+ promptSnippet: 'Mark the active goal complete or unmet after a strict completion audit',
946
+ promptGuidelines: [
947
+ 'Use update_goal only after a strict completion audit against real evidence.',
948
+ 'Provide concrete evidence for complete, or a concrete blocker for unmet.',
949
+ ],
950
+ parameters: Type.Object({
951
+ status: StringEnum(['complete', 'unmet'] as const),
952
+ evidence: Type.Optional(
953
+ Type.String({ description: 'Required for complete. Summarize concrete evidence verified.' }),
954
+ ),
955
+ blocker: Type.Optional(Type.String({ description: 'Required for unmet. Explain the concrete blocker.' })),
956
+ }),
957
+ async execute(_id, params, _signal, _onUpdate, ctx) {
958
+ const a = activeGoal();
959
+ if (!a) {
960
+ return { content: [{ type: 'text', text: 'No active goal to update.' }], details: {}, isError: true };
961
+ }
962
+
963
+ if (params.status === 'complete') {
964
+ if (!params.evidence) {
965
+ return {
966
+ content: [
967
+ {
968
+ type: 'text',
969
+ text: 'Evidence is required to mark a goal complete. Provide a summary of verification evidence.',
970
+ },
971
+ ],
972
+ details: {},
973
+ isError: true,
974
+ };
975
+ }
976
+ updateState(a.id, { status: 'complete', completionEvidence: params.evidence, noProgressCount: 0 }, ctx);
977
+ const e: GoalEvent = { kind: 'complete', goal: { ...a, status: 'complete' }, timestamp: Date.now() };
978
+ pi.sendMessage(
979
+ {
980
+ customType: `${GOAL_STORAGE_TYPE}:event`,
981
+ content: `Goal achieved!\n\nObjective: ${a.objective}\nEvidence: ${params.evidence}`,
982
+ display: true,
983
+ details: e,
984
+ },
985
+ { triggerTurn: false },
986
+ );
987
+ return {
988
+ content: [
989
+ {
990
+ type: 'text',
991
+ text: `Goal complete: ${a.objective}\nEvidence: ${params.evidence}\nTokens used: ${formatTokens(a.tokensUsed)}\nTime: ${formatDuration(a.timeUsedMs)}`,
992
+ },
993
+ ],
994
+ details: { goal: { ...a, status: 'complete', completionEvidence: params.evidence } },
995
+ };
996
+ }
997
+
998
+ if (params.status === 'unmet') {
999
+ if (!params.blocker) {
1000
+ return {
1001
+ content: [
1002
+ { type: 'text', text: 'Blocker is required to mark a goal unmet. Describe why it cannot be achieved.' },
1003
+ ],
1004
+ details: {},
1005
+ isError: true,
1006
+ };
1007
+ }
1008
+ updateState(a.id, { status: 'unmet', blocker: params.blocker, noProgressCount: 0 }, ctx);
1009
+ const e: GoalEvent = { kind: 'unmet', goal: { ...a, status: 'unmet' }, timestamp: Date.now() };
1010
+ pi.sendMessage(
1011
+ {
1012
+ customType: `${GOAL_STORAGE_TYPE}:event`,
1013
+ content: `Goal unmet.\n\nObjective: ${a.objective}\nBlocker: ${params.blocker}`,
1014
+ display: true,
1015
+ details: e,
1016
+ },
1017
+ { triggerTurn: false },
1018
+ );
1019
+ return {
1020
+ content: [
1021
+ {
1022
+ type: 'text',
1023
+ text: `Goal unmet: ${a.objective}\nBlocker: ${params.blocker}\nTokens used: ${formatTokens(a.tokensUsed)}\nTime: ${formatDuration(a.timeUsedMs)}`,
1024
+ },
1025
+ ],
1026
+ details: { goal: { ...a, status: 'unmet', blocker: params.blocker } },
1027
+ };
1028
+ }
1029
+
1030
+ return {
1031
+ content: [{ type: 'text', text: "Invalid status. Use 'complete' or 'unmet'." }],
1032
+ details: {},
1033
+ isError: true,
1034
+ };
1035
+ },
1036
+ });
1037
+ }