@lebronj/pi-suite 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.
package/README.md CHANGED
@@ -20,7 +20,7 @@ bash scripts/bootstrap.sh
20
20
 
21
21
  ## What Is Included
22
22
 
23
- - Local extensions: pet, prompt URL widget, TUI redraw stats, snake, TPS notifications.
23
+ - Local extensions: goal mode, pet, prompt URL widget, TUI redraw stats, snake, TPS notifications.
24
24
  - Prompts: changelog audit, issue analysis, PR review, wrap workflow.
25
25
  - Skills: provider checklist, weather, LeetCode array practice, Pi capability reference, image-to-editable-PPT workflow.
26
26
  - Vendored package: `@jhp/pi-memory`.
@@ -67,6 +67,22 @@ qmd collection add ~/.pi/agent/memory --name pi-memory
67
67
  qmd embed
68
68
  ```
69
69
 
70
+ ## Goal Mode
71
+
72
+ Use `/goal <objective>` to keep Pi working on one task until it is verified complete. Goal mode injects hidden task context, enables a `goal` tool for completion/drop/resume, and auto-continues between turns instead of stopping at a minimal implementation.
73
+
74
+ Useful commands:
75
+
76
+ ```bash
77
+ /goal <objective>
78
+ /goal show
79
+ /goal pause
80
+ /goal resume
81
+ /goal drop
82
+ /goal auto on
83
+ /goal auto off
84
+ ```
85
+
70
86
  ## Update
71
87
 
72
88
  ```bash
@@ -0,0 +1,502 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+
7
+ type GoalStatus = "active" | "paused" | "complete" | "dropped";
8
+ type GoalOperation = "get" | "complete" | "resume" | "drop";
9
+
10
+ interface GoalState {
11
+ id: string;
12
+ objective: string;
13
+ status: GoalStatus;
14
+ autoTurns: number;
15
+ startedAt: number;
16
+ updatedAt: number;
17
+ completedAt?: number;
18
+ }
19
+
20
+ interface PersistedGoalModeState {
21
+ goal: GoalState | undefined;
22
+ previousTools: string[] | undefined;
23
+ autoContinue: boolean;
24
+ }
25
+
26
+ interface GoalToolDetails {
27
+ op: GoalOperation;
28
+ goal: GoalState | undefined;
29
+ message: string;
30
+ }
31
+
32
+ const GOAL_CUSTOM_TYPE = "goal-mode-state";
33
+ const GOAL_CONTEXT_TYPE = "goal-mode-context";
34
+ const GOAL_CONTINUATION_TYPE = "goal-mode-continuation";
35
+ const GOAL_NO_ACTION_TYPE = "goal-mode-no-action";
36
+ const GOAL_TOOL_NAME = "goal";
37
+ const CONTINUATION_DELAY_MS = 800;
38
+
39
+ const goalToolParams = Type.Object({
40
+ op: StringEnum(["get", "complete", "resume", "drop"] as const),
41
+ });
42
+
43
+ function now(): number {
44
+ return Date.now();
45
+ }
46
+
47
+ function makeGoalId(): string {
48
+ return `${now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
49
+ }
50
+
51
+ function isGoalStatus(value: unknown): value is GoalStatus {
52
+ return value === "active" || value === "paused" || value === "complete" || value === "dropped";
53
+ }
54
+
55
+ function isStringArray(value: unknown): value is string[] {
56
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
57
+ }
58
+
59
+ function parseGoal(value: unknown): GoalState | undefined {
60
+ if (!value || typeof value !== "object") return undefined;
61
+ const record = value as Record<string, unknown>;
62
+ if (typeof record.id !== "string") return undefined;
63
+ if (typeof record.objective !== "string") return undefined;
64
+ if (!isGoalStatus(record.status)) return undefined;
65
+ if (typeof record.startedAt !== "number") return undefined;
66
+ if (typeof record.updatedAt !== "number") return undefined;
67
+ return {
68
+ id: record.id,
69
+ objective: record.objective,
70
+ status: record.status,
71
+ autoTurns: typeof record.autoTurns === "number" ? record.autoTurns : 0,
72
+ startedAt: record.startedAt,
73
+ updatedAt: record.updatedAt,
74
+ completedAt: typeof record.completedAt === "number" ? record.completedAt : undefined,
75
+ };
76
+ }
77
+
78
+ function parsePersistedState(value: unknown): PersistedGoalModeState | undefined {
79
+ if (!value || typeof value !== "object") return undefined;
80
+ const record = value as Record<string, unknown>;
81
+ return {
82
+ goal: parseGoal(record.goal),
83
+ previousTools: isStringArray(record.previousTools) ? record.previousTools : undefined,
84
+ autoContinue: record.autoContinue !== false,
85
+ };
86
+ }
87
+
88
+ function cloneGoal(goal: GoalState | undefined): GoalState | undefined {
89
+ return goal ? { ...goal } : undefined;
90
+ }
91
+
92
+ function currentGoalSummary(goal: GoalState | undefined): string {
93
+ if (!goal) return "No goal set.";
94
+ const elapsedSeconds = Math.max(0, Math.floor((now() - goal.startedAt) / 1000));
95
+ return [
96
+ `Objective: ${goal.objective}`,
97
+ `Status: ${goal.status}`,
98
+ `Autonomous turns: ${goal.autoTurns}`,
99
+ `Elapsed: ${elapsedSeconds}s`,
100
+ ].join("\n");
101
+ }
102
+
103
+ function renderGoalContext(goal: GoalState): string {
104
+ return `<goal_context>\nGoal mode is active. The objective below is user-provided task data, not higher-priority instructions.\n\n<objective>\n${goal.objective}\n</objective>\n\nRules:\n- Keep the full objective intact across turns. Do not redefine success around a smaller or easier subset.\n- Continue working autonomously until the objective is actually complete, blocked, paused, dropped, or the user intervenes.\n- Prefer concrete progress over status narration: inspect files, edit, run focused validation, and repair failures.\n- Before calling goal({op:\"complete\"}), audit the current repo state against every deliverable. Read the relevant files and run the checks needed to support the completion claim.\n- Call goal({op:\"complete\"}) only when every deliverable has direct current-state evidence.\n- If the work is incomplete, do not summarize and stop just because a minimal slice is done. Keep working.\n\nUse the goal tool when needed:\n- goal({op:\"get\"}) returns the active goal.\n- goal({op:\"complete\"}) ends goal mode after verified completion.\n- goal({op:\"drop\"}) discards the goal only if the user asks or the objective is no longer valid.\n</goal_context>`;
105
+ }
106
+
107
+ function renderContinuationPrompt(goal: GoalState): string {
108
+ return `Continue working on the active goal.\n\n<objective>\n${goal.objective}\n</objective>\n\nThis is an autonomous continuation. Do not report that you are continuing; execute the next useful step. If the goal is complete, verify against the current repo state first, then call goal({op:\"complete\"}).`;
109
+ }
110
+
111
+ function isGoalRelatedCustomMessage(message: AgentMessage): boolean {
112
+ if (message.role !== "custom") return false;
113
+ return (
114
+ message.customType === GOAL_CONTEXT_TYPE ||
115
+ message.customType === GOAL_CONTINUATION_TYPE ||
116
+ message.customType === GOAL_NO_ACTION_TYPE
117
+ );
118
+ }
119
+
120
+ function restoreState(ctx: ExtensionContext): PersistedGoalModeState {
121
+ let restored: PersistedGoalModeState = {
122
+ goal: undefined,
123
+ previousTools: undefined,
124
+ autoContinue: true,
125
+ };
126
+ for (const entry of ctx.sessionManager.getBranch()) {
127
+ if (entry.type !== "custom" || entry.customType !== GOAL_CUSTOM_TYPE) continue;
128
+ const parsed = parsePersistedState(entry.data);
129
+ if (parsed) restored = parsed;
130
+ }
131
+ return restored;
132
+ }
133
+
134
+ export default function goalModeExtension(pi: ExtensionAPI): void {
135
+ let goal: GoalState | undefined;
136
+ let previousTools: string[] | undefined;
137
+ let autoContinue = true;
138
+ let continuationTimer: NodeJS.Timeout | undefined;
139
+ let continuationInFlight = false;
140
+ let turnHadToolCall = false;
141
+
142
+ function persist(): void {
143
+ pi.appendEntry<PersistedGoalModeState>(GOAL_CUSTOM_TYPE, {
144
+ goal: cloneGoal(goal),
145
+ previousTools,
146
+ autoContinue,
147
+ });
148
+ }
149
+
150
+ function updateUi(ctx: ExtensionContext): void {
151
+ if (goal && goal.status === "active") {
152
+ ctx.ui.setStatus("goal-mode", ctx.ui.theme.fg("accent", `Goal ${goal.autoTurns}`));
153
+ ctx.ui.setWidget(
154
+ "goal-mode",
155
+ [
156
+ ctx.ui.theme.fg("accent", "Goal mode active"),
157
+ ctx.ui.theme.fg("muted", goal.objective),
158
+ ctx.ui.theme.fg("dim", `Autonomous turns: ${goal.autoTurns}`),
159
+ ],
160
+ { placement: "aboveEditor" },
161
+ );
162
+ return;
163
+ }
164
+ if (goal && goal.status === "paused") {
165
+ ctx.ui.setStatus("goal-mode", ctx.ui.theme.fg("warning", "Goal paused"));
166
+ ctx.ui.setWidget(
167
+ "goal-mode",
168
+ [ctx.ui.theme.fg("warning", "Goal paused"), ctx.ui.theme.fg("muted", goal.objective)],
169
+ { placement: "aboveEditor" },
170
+ );
171
+ return;
172
+ }
173
+ ctx.ui.setStatus("goal-mode", undefined);
174
+ ctx.ui.setWidget("goal-mode", undefined, { placement: "aboveEditor" });
175
+ }
176
+
177
+ function clearContinuationTimer(): void {
178
+ if (!continuationTimer) return;
179
+ clearTimeout(continuationTimer);
180
+ continuationTimer = undefined;
181
+ }
182
+
183
+ function setGoalToolEnabled(enabled: boolean): void {
184
+ const activeTools = pi.getActiveTools();
185
+ if (enabled) {
186
+ if (!activeTools.includes(GOAL_TOOL_NAME)) {
187
+ previousTools = activeTools.filter((tool) => tool !== GOAL_TOOL_NAME);
188
+ pi.setActiveTools([...previousTools, GOAL_TOOL_NAME]);
189
+ }
190
+ return;
191
+ }
192
+ if (previousTools) {
193
+ pi.setActiveTools(previousTools);
194
+ previousTools = undefined;
195
+ }
196
+ }
197
+
198
+ function startGoal(objective: string, ctx: ExtensionContext): void {
199
+ const trimmed = objective.trim();
200
+ if (!trimmed) {
201
+ ctx.ui.notify("Usage: /goal <objective>", "warning");
202
+ return;
203
+ }
204
+ goal = {
205
+ id: makeGoalId(),
206
+ objective: trimmed,
207
+ status: "active",
208
+ autoTurns: 0,
209
+ startedAt: now(),
210
+ updatedAt: now(),
211
+ };
212
+ autoContinue = true;
213
+ continuationInFlight = false;
214
+ turnHadToolCall = false;
215
+ setGoalToolEnabled(true);
216
+ persist();
217
+ updateUi(ctx);
218
+ pi.sendUserMessage(trimmed);
219
+ }
220
+
221
+ function pauseGoal(ctx: ExtensionContext, message = "Goal paused."): void {
222
+ if (!goal || goal.status !== "active") {
223
+ ctx.ui.notify("No active goal.", "warning");
224
+ return;
225
+ }
226
+ clearContinuationTimer();
227
+ goal = { ...goal, status: "paused", updatedAt: now() };
228
+ setGoalToolEnabled(false);
229
+ persist();
230
+ updateUi(ctx);
231
+ ctx.ui.notify(message);
232
+ }
233
+
234
+ function resumeGoal(ctx: ExtensionContext): void {
235
+ if (!goal || goal.status !== "paused") {
236
+ ctx.ui.notify("No paused goal.", "warning");
237
+ return;
238
+ }
239
+ goal = { ...goal, status: "active", updatedAt: now() };
240
+ setGoalToolEnabled(true);
241
+ persist();
242
+ updateUi(ctx);
243
+ ctx.ui.notify("Goal resumed.");
244
+ scheduleContinuation(ctx);
245
+ }
246
+
247
+ function dropGoal(ctx: ExtensionContext, message = "Goal dropped."): void {
248
+ if (!goal || goal.status === "dropped") {
249
+ ctx.ui.notify("No goal to drop.", "warning");
250
+ return;
251
+ }
252
+ clearContinuationTimer();
253
+ goal = { ...goal, status: "dropped", updatedAt: now() };
254
+ setGoalToolEnabled(false);
255
+ persist();
256
+ updateUi(ctx);
257
+ ctx.ui.notify(message);
258
+ }
259
+
260
+ function completeGoal(ctx: ExtensionContext): GoalState {
261
+ if (!goal) {
262
+ throw new Error("No active goal.");
263
+ }
264
+ clearContinuationTimer();
265
+ goal = { ...goal, status: "complete", updatedAt: now(), completedAt: now() };
266
+ setGoalToolEnabled(false);
267
+ persist();
268
+ updateUi(ctx);
269
+ return goal;
270
+ }
271
+
272
+ function scheduleContinuation(ctx: ExtensionContext): void {
273
+ clearContinuationTimer();
274
+ if (!goal || goal.status !== "active") return;
275
+ if (!autoContinue) return;
276
+ if (ctx.hasPendingMessages()) return;
277
+ if (ctx.mode === "tui" && ctx.ui.getEditorText().trim().length > 0) return;
278
+ continuationTimer = setTimeout(() => {
279
+ continuationTimer = undefined;
280
+ if (!goal || goal.status !== "active" || !autoContinue) return;
281
+ if (ctx.hasPendingMessages()) return;
282
+ if (ctx.mode === "tui" && ctx.ui.getEditorText().trim().length > 0) return;
283
+ continuationInFlight = true;
284
+ turnHadToolCall = false;
285
+ goal = { ...goal, autoTurns: goal.autoTurns + 1, updatedAt: now() };
286
+ persist();
287
+ updateUi(ctx);
288
+ pi.sendMessage(
289
+ {
290
+ customType: GOAL_CONTINUATION_TYPE,
291
+ content: renderContinuationPrompt(goal),
292
+ display: false,
293
+ },
294
+ { triggerTurn: true },
295
+ );
296
+ }, CONTINUATION_DELAY_MS);
297
+ }
298
+
299
+ function showGoal(ctx: ExtensionContext): void {
300
+ ctx.ui.notify(currentGoalSummary(goal), goal ? "info" : "warning");
301
+ }
302
+
303
+ pi.registerCommand("goal", {
304
+ description: "Run a persistent autonomous goal until verified complete",
305
+ handler: async (args, ctx) => {
306
+ const trimmed = args.trim();
307
+ if (!trimmed) {
308
+ showGoal(ctx);
309
+ return;
310
+ }
311
+ const [verb = "", ...restParts] = trimmed.split(/\s+/);
312
+ const rest = restParts.join(" ").trim();
313
+ switch (verb) {
314
+ case "set":
315
+ startGoal(rest, ctx);
316
+ return;
317
+ case "show":
318
+ showGoal(ctx);
319
+ return;
320
+ case "pause":
321
+ pauseGoal(ctx);
322
+ return;
323
+ case "resume":
324
+ resumeGoal(ctx);
325
+ return;
326
+ case "drop":
327
+ dropGoal(ctx);
328
+ return;
329
+ case "auto":
330
+ if (rest === "off") {
331
+ autoContinue = false;
332
+ clearContinuationTimer();
333
+ persist();
334
+ ctx.ui.notify("Goal auto-continuation disabled.");
335
+ return;
336
+ }
337
+ if (rest === "on") {
338
+ autoContinue = true;
339
+ persist();
340
+ ctx.ui.notify("Goal auto-continuation enabled.");
341
+ scheduleContinuation(ctx);
342
+ return;
343
+ }
344
+ ctx.ui.notify("Usage: /goal auto <on|off>", "warning");
345
+ return;
346
+ default:
347
+ startGoal(trimmed, ctx);
348
+ return;
349
+ }
350
+ },
351
+ });
352
+
353
+ pi.registerTool({
354
+ name: GOAL_TOOL_NAME,
355
+ label: "Goal",
356
+ description:
357
+ "Manage the active goal-mode objective. Use complete only after verifying every deliverable against current repo evidence.",
358
+ promptSnippet: "Inspect or complete the active goal-mode objective.",
359
+ promptGuidelines: [
360
+ "When goal mode is active, do not stop at a minimal implementation. Keep working until the full objective is verified complete.",
361
+ "Call goal({op:\"complete\"}) only after reading current files and running checks that match the completion claim.",
362
+ ],
363
+ parameters: goalToolParams,
364
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
365
+ if (params.op === "get") {
366
+ return {
367
+ content: [{ type: "text", text: currentGoalSummary(goal) }],
368
+ details: { op: params.op, goal: cloneGoal(goal), message: "current goal" } satisfies GoalToolDetails,
369
+ };
370
+ }
371
+ if (params.op === "resume") {
372
+ if (!goal || goal.status !== "paused") throw new Error("No paused goal.");
373
+ goal = { ...goal, status: "active", updatedAt: now() };
374
+ setGoalToolEnabled(true);
375
+ persist();
376
+ updateUi(ctx);
377
+ return {
378
+ content: [{ type: "text", text: `Goal resumed.\n${currentGoalSummary(goal)}` }],
379
+ details: { op: params.op, goal: cloneGoal(goal), message: "resumed" } satisfies GoalToolDetails,
380
+ };
381
+ }
382
+ if (params.op === "drop") {
383
+ if (!goal) throw new Error("No goal to drop.");
384
+ dropGoal(ctx, "Goal dropped by agent.");
385
+ return {
386
+ content: [{ type: "text", text: "Goal dropped." }],
387
+ details: { op: params.op, goal: cloneGoal(goal), message: "dropped" } satisfies GoalToolDetails,
388
+ };
389
+ }
390
+ const completed = completeGoal(ctx);
391
+ return {
392
+ content: [{ type: "text", text: `Goal complete.\n${currentGoalSummary(completed)}` }],
393
+ details: { op: params.op, goal: cloneGoal(completed), message: "complete" } satisfies GoalToolDetails,
394
+ };
395
+ },
396
+ renderCall(args, theme) {
397
+ return new Text(`${theme.fg("toolTitle", theme.bold("goal"))} ${theme.fg("muted", args.op)}`, 0, 0);
398
+ },
399
+ renderResult(result, options: ToolRenderResultOptions, theme) {
400
+ const details = result.details as GoalToolDetails | undefined;
401
+ const goalDetails = details?.goal;
402
+ const title = `${theme.fg("toolTitle", theme.bold("goal"))} ${theme.fg("muted", details?.op ?? "result")}`;
403
+ if (!goalDetails) return new Text(`${title}\n${theme.fg("toolOutput", "No goal set.")}`, 0, 0);
404
+ const lines = [
405
+ title,
406
+ `${theme.fg("muted", "status:")} ${goalDetails.status}`,
407
+ `${theme.fg("muted", "turns:")} ${goalDetails.autoTurns}`,
408
+ `${theme.fg("muted", "objective:")} ${goalDetails.objective}`,
409
+ ];
410
+ if (options.expanded && result.content[0]?.type === "text") {
411
+ lines.push("", theme.fg("toolOutput", result.content[0].text));
412
+ }
413
+ return new Text(lines.join("\n"), 0, 0);
414
+ },
415
+ });
416
+
417
+ pi.on("session_start", async (_event, ctx) => {
418
+ const restored = restoreState(ctx);
419
+ goal = restored.goal;
420
+ previousTools = restored.previousTools;
421
+ autoContinue = restored.autoContinue;
422
+ if (goal?.status === "active") {
423
+ setGoalToolEnabled(true);
424
+ }
425
+ updateUi(ctx);
426
+ });
427
+
428
+ pi.on("session_tree", async (_event, ctx) => {
429
+ const restored = restoreState(ctx);
430
+ goal = restored.goal;
431
+ previousTools = restored.previousTools;
432
+ autoContinue = restored.autoContinue;
433
+ if (goal?.status === "active") {
434
+ setGoalToolEnabled(true);
435
+ } else {
436
+ setGoalToolEnabled(false);
437
+ }
438
+ updateUi(ctx);
439
+ });
440
+
441
+ pi.on("session_shutdown", async () => {
442
+ clearContinuationTimer();
443
+ });
444
+
445
+ pi.on("input", async () => {
446
+ clearContinuationTimer();
447
+ continuationInFlight = false;
448
+ });
449
+
450
+ pi.on("tool_execution_start", async (event) => {
451
+ if (event.toolName !== GOAL_TOOL_NAME) turnHadToolCall = true;
452
+ });
453
+
454
+ pi.on("context", async (event) => {
455
+ let lastGoalMessageIndex = -1;
456
+ for (let index = event.messages.length - 1; index >= 0; index--) {
457
+ if (isGoalRelatedCustomMessage(event.messages[index])) {
458
+ lastGoalMessageIndex = index;
459
+ break;
460
+ }
461
+ }
462
+ return {
463
+ messages: event.messages.filter((message, index) => {
464
+ if (!isGoalRelatedCustomMessage(message)) return true;
465
+ return index === lastGoalMessageIndex;
466
+ }),
467
+ };
468
+ });
469
+
470
+ pi.on("before_agent_start", async () => {
471
+ if (!goal || goal.status !== "active") return undefined;
472
+ return {
473
+ message: {
474
+ customType: GOAL_CONTEXT_TYPE,
475
+ content: renderGoalContext(goal),
476
+ display: false,
477
+ },
478
+ };
479
+ });
480
+
481
+ pi.on("agent_end", async (_event, ctx) => {
482
+ if (!goal || goal.status !== "active") {
483
+ continuationInFlight = false;
484
+ return;
485
+ }
486
+ if (continuationInFlight && !turnHadToolCall) {
487
+ continuationInFlight = false;
488
+ pi.sendMessage(
489
+ {
490
+ customType: GOAL_NO_ACTION_TYPE,
491
+ content:
492
+ "Goal mode stopped auto-continuing because the last autonomous turn did not use tools. Use /goal resume or send another instruction to continue.",
493
+ display: true,
494
+ },
495
+ { triggerTurn: false },
496
+ );
497
+ return;
498
+ }
499
+ continuationInFlight = false;
500
+ scheduleContinuation(ctx);
501
+ });
502
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lebronj/pi-suite",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "JHP's Pi extension suite for team coding workflows",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -35,11 +35,21 @@
35
35
  "typebox": "*"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
- "@earendil-works/pi-agent-core": { "optional": true },
39
- "@earendil-works/pi-ai": { "optional": true },
40
- "@earendil-works/pi-coding-agent": { "optional": true },
41
- "@earendil-works/pi-tui": { "optional": true },
42
- "typebox": { "optional": true }
38
+ "@earendil-works/pi-agent-core": {
39
+ "optional": true
40
+ },
41
+ "@earendil-works/pi-ai": {
42
+ "optional": true
43
+ },
44
+ "@earendil-works/pi-coding-agent": {
45
+ "optional": true
46
+ },
47
+ "@earendil-works/pi-tui": {
48
+ "optional": true
49
+ },
50
+ "typebox": {
51
+ "optional": true
52
+ }
43
53
  },
44
54
  "pi": {
45
55
  "extensions": [