pi-better-goal 0.1.22 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.1.22",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
3
  import { Type } from "typebox";
3
4
 
4
5
  import {
@@ -59,6 +60,19 @@ const MAX_NO_PROGRESS_RETRIES = parseRetryLimit(
59
60
  DEFAULT_MAX_NO_PROGRESS_RETRIES,
60
61
  );
61
62
 
63
+ const GOAL_ACTIONS: readonly AutocompleteItem[] = [
64
+ { value: "pause", label: "pause", description: "Pause the active goal" },
65
+ { value: "resume", label: "resume", description: "Resume the paused goal" },
66
+ { value: "clear", label: "clear", description: "Remove the current goal" },
67
+ { value: "complete", label: "complete", description: "Mark the current goal complete" },
68
+ ];
69
+
70
+ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
71
+ const prefix = argumentPrefix.trimStart().toLowerCase();
72
+ const matches = GOAL_ACTIONS.filter((action) => action.value.startsWith(prefix));
73
+ return matches.length > 0 ? [...matches] : null;
74
+ }
75
+
62
76
  function parseDurationEnv(raw: string | undefined, fallback: number): number {
63
77
  if (raw === undefined || raw.trim() === "") {
64
78
  return fallback;
@@ -148,6 +162,7 @@ function formatSnapshot(snapshot: ActivitySnapshot): string {
148
162
 
149
163
  export default function (pi: ExtensionAPI): void {
150
164
  const providers = new Map<string, BackgroundActivityProvider>();
165
+ const providerUnsubscribers = new Map<string, () => void>();
151
166
  providers.set("subagents", {
152
167
  id: "subagents",
153
168
  label: "Subagents",
@@ -158,6 +173,7 @@ export default function (pi: ExtensionAPI): void {
158
173
  let foregroundRunning = false;
159
174
  let pollTimer: ReturnType<typeof setInterval> | undefined;
160
175
  let collecting = false;
176
+ let collectionPending = false;
161
177
  let latestSnapshot: ActivitySnapshot | null = null;
162
178
  let backgroundDrainTracker: BackgroundDrainTracker | null = null;
163
179
  let lastWakeSignature = "";
@@ -216,6 +232,7 @@ export default function (pi: ExtensionAPI): void {
216
232
  continuationQueuedFor = null;
217
233
  backgroundDrainTracker = null;
218
234
  clearIdleContinuation();
235
+ syncPollingState();
219
236
  // Force a full redraw when the dock height changes (absent ↔ visible clock).
220
237
  refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
221
238
  };
@@ -227,6 +244,7 @@ export default function (pi: ExtensionAPI): void {
227
244
  continuationQueuedFor = null;
228
245
  backgroundDrainTracker = null;
229
246
  clearIdleContinuation();
247
+ syncPollingState();
230
248
  refreshGoalWidget?.(wasVisible);
231
249
  };
232
250
 
@@ -395,12 +413,22 @@ export default function (pi: ExtensionAPI): void {
395
413
 
396
414
  const collectIfPossible = (): void => {
397
415
  const ctx = currentCtx;
398
- if (!ctx || collecting) {
416
+ if (!ctx) {
417
+ return;
418
+ }
419
+ if (collecting) {
420
+ collectionPending = true;
399
421
  return;
400
422
  }
401
423
  collecting = true;
402
424
  void publishSnapshot(ctx).finally(() => {
403
425
  collecting = false;
426
+ if (collectionPending) {
427
+ collectionPending = false;
428
+ collectIfPossible();
429
+ } else {
430
+ syncPollingState();
431
+ }
404
432
  });
405
433
  };
406
434
 
@@ -410,7 +438,6 @@ export default function (pi: ExtensionAPI): void {
410
438
  }
411
439
  pollTimer = setInterval(collectIfPossible, POLL_INTERVAL_MS);
412
440
  pollTimer.unref?.();
413
- collectIfPossible();
414
441
  };
415
442
 
416
443
  const stopPolling = (): void => {
@@ -421,6 +448,16 @@ export default function (pi: ExtensionAPI): void {
421
448
  pollTimer = undefined;
422
449
  };
423
450
 
451
+ const syncPollingState = (): void => {
452
+ const ctx = currentCtx;
453
+ const goal = ctx ? currentGoalSnapshot(ctx) : null;
454
+ if (ctx && (isPokeable(goal) || latestSnapshot?.backgroundRunning === true)) {
455
+ startPolling();
456
+ } else {
457
+ stopPolling();
458
+ }
459
+ };
460
+
424
461
  const installGoalWidget = (ctx: ExtensionContext): void => {
425
462
  if (!ctx.hasUI) {
426
463
  return;
@@ -480,12 +517,19 @@ export default function (pi: ExtensionAPI): void {
480
517
  if (!candidate || typeof candidate.id !== "string" || typeof candidate.getActivity !== "function") {
481
518
  return;
482
519
  }
483
- providers.set(candidate.id, candidate as BackgroundActivityProvider);
484
- collectIfPossible();
520
+ const accepted = candidate as BackgroundActivityProvider;
521
+ providerUnsubscribers.get(accepted.id)?.();
522
+ providerUnsubscribers.delete(accepted.id);
523
+ providers.set(accepted.id, accepted);
524
+ if (typeof accepted.onActivityChanged === "function") {
525
+ providerUnsubscribers.set(accepted.id, accepted.onActivityChanged(collectIfPossible));
526
+ }
527
+ if (latestSnapshot) collectIfPossible();
485
528
  });
486
529
 
487
530
  pi.registerCommand("goal", {
488
531
  description: "Create, inspect, pause, resume, clear, or complete the active goal",
532
+ getArgumentCompletions: goalArgumentCompletions,
489
533
  handler: async (args, ctx) => {
490
534
  currentCtx = ctx;
491
535
  const trimmed = args.trim();
@@ -646,7 +690,8 @@ export default function (pi: ExtensionAPI): void {
646
690
  foregroundRunning = !ctx.isIdle();
647
691
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
648
692
  installGoalWidget(ctx);
649
- startPolling();
693
+ latestSnapshot = await publishSnapshot(ctx);
694
+ syncPollingState();
650
695
  });
651
696
 
652
697
  pi.on("input", async (event, ctx) => {
@@ -747,6 +792,8 @@ export default function (pi: ExtensionAPI): void {
747
792
  foregroundRunning = false;
748
793
  clearIdleContinuation();
749
794
  currentCtx = undefined;
795
+ latestSnapshot = null;
796
+ collectionPending = false;
750
797
  try {
751
798
  ctx.ui.setStatus(EXTENSION_NAME, undefined);
752
799
  ctx.ui.setWidget(EXTENSION_NAME, undefined);
package/src/subagents.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -82,6 +82,46 @@ export function readSubagentMetas(baseDir = defaultSubagentBaseDir()): SubagentR
82
82
  return metas.sort((left, right) => right.startedAt - left.startedAt);
83
83
  }
84
84
 
85
+ export function readParentSubagentMetas(
86
+ baseDir = defaultSubagentBaseDir(),
87
+ parentPid = process.pid,
88
+ ): SubagentRunMeta[] {
89
+ const directory = join(baseDir, "by-parent", String(parentPid));
90
+ ensureParentIndex(baseDir, directory, parentPid);
91
+ let ids: string[];
92
+ try {
93
+ ids = readdirSync(directory).filter((id) => id !== ".initialized");
94
+ } catch {
95
+ return [];
96
+ }
97
+ return ids
98
+ .map((id) => readSubagentMeta(baseDir, id))
99
+ .filter((meta): meta is SubagentRunMeta => meta !== undefined && meta.spawnPid === parentPid)
100
+ .sort((left, right) => right.startedAt - left.startedAt);
101
+ }
102
+
103
+ function readSubagentMeta(baseDir: string, id: string): SubagentRunMeta | undefined {
104
+ try {
105
+ const meta = JSON.parse(readFileSync(join(baseDir, "runs", id, "meta.json"), "utf8")) as SubagentRunMeta;
106
+ return typeof meta.id === "string" && typeof meta.status === "string" ? meta : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ function ensureParentIndex(baseDir: string, directory: string, parentPid: number): void {
113
+ try {
114
+ readFileSync(join(directory, ".initialized"));
115
+ return;
116
+ } catch {
117
+ // Existing registries are backfilled once for this parent process.
118
+ }
119
+ const owned = readSubagentMetas(baseDir).filter((meta) => meta.spawnPid === parentPid);
120
+ mkdirSync(directory, { recursive: true });
121
+ for (const meta of owned) writeFileSync(join(directory, meta.id), "");
122
+ writeFileSync(join(directory, ".initialized"), "1");
123
+ }
124
+
85
125
  export function subagentStatusToItem(
86
126
  meta: SubagentRunMeta,
87
127
  status: SubagentEffectiveStatus,
@@ -122,9 +162,7 @@ export function collectSubagentActivity(
122
162
  ): BackgroundProviderSnapshot {
123
163
  const parentPid = options.parentPid ?? process.pid;
124
164
  const processExists = options.processExists ?? defaultProcessExists;
125
- const metas = readSubagentMetas(options.baseDir).filter((meta) =>
126
- isCurrentParentSubagent(meta, parentPid),
127
- );
165
+ const metas = readParentSubagentMetas(options.baseDir, parentPid);
128
166
 
129
167
  return {
130
168
  providerId: "subagents",
package/src/types.ts CHANGED
@@ -47,6 +47,7 @@ export interface BackgroundActivityProvider {
47
47
  id: string;
48
48
  label?: string;
49
49
  getActivity(ctx: ExtensionContext): BackgroundProviderSnapshot | Promise<BackgroundProviderSnapshot>;
50
+ onActivityChanged?(notify: () => void): () => void;
50
51
  }
51
52
 
52
53
  export interface ActivitySnapshot {