pi-better-goal 0.1.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.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # pi-better-goal
2
+
3
+ `pi-better-goal` is a Pi package that provides a `/goal` runtime with background-aware continuation for async subagents.
4
+
5
+ It ships one extension that:
6
+
7
+ - reads `pi-better-subagents` durable run metadata from the temp registry
8
+ - treats current-parent `running` and `orphaned` subagents as active background work
9
+ - owns `/goal` plus the `get_goal` and `update_goal` tools; only `/goal <objective>` can create a goal
10
+ - shows the current goal with active and elapsed clocks in a right-aligned widget above custom footers such as `pi-observability`
11
+ - publishes a typed activity snapshot on `pi.events`
12
+ - adds goal-aware prompt context while background work is active, so foreground idleness is not confused with goal completion
13
+ - sends a hidden follow-up when active background work drains to zero, including `callback:false` subagent runs
14
+ - provides a `/better-activity` command and `get_background_activity` tool for inspection
15
+
16
+ Goal state is stored as `pi-better-goal` custom entries in the Pi session. Existing `pi-codex-goal` entries are read for compatibility, but new state is written by this package.
17
+
18
+ ## Commands And Tools
19
+
20
+ ```text
21
+ /goal
22
+ /goal <objective>
23
+ /goal pause
24
+ /goal resume
25
+ /goal clear
26
+ /goal complete
27
+ /better-activity
28
+ ```
29
+
30
+ Model-callable tools:
31
+
32
+ - `get_goal`
33
+ - `update_goal`
34
+ - `get_background_activity`
35
+
36
+ ## Goal Clock
37
+
38
+ Only `/goal <objective>` can create or replace a goal; there is no model-callable
39
+ goal creation tool. The right-aligned widget below the editor shows the objective,
40
+ status, active time, and total elapsed time. Active time stops while paused. Both
41
+ clocks freeze when the goal is completed and remain visible until the goal is
42
+ cleared or replaced.
43
+
44
+ The widget does not replace Pi's footer, so custom footer extensions such as
45
+ `pi-observability` retain ownership of their layout and lifecycle.
46
+
47
+ Set `PI_BETTER_GOAL_DISABLE_WAKE=1` to disable hidden background-drain wakeups.
48
+
49
+ ## Install Locally
50
+
51
+ From this directory:
52
+
53
+ ```sh
54
+ npm install
55
+ pi install .
56
+ ```
57
+
58
+ For a one-off run:
59
+
60
+ ```sh
61
+ pi -e .
62
+ ```
63
+
64
+ ## Event Contract
65
+
66
+ The extension emits these events on `pi.events`:
67
+
68
+ - `pi-better-goal:ready` with `{ version }`
69
+ - `pi-better-goal:activity` with an `ActivitySnapshot`
70
+ - `pi-better-goal:terminal-attention` when terminal or unhealthy background work needs attention
71
+
72
+ Other extensions can register providers by emitting `pi-better-goal:register-provider` with:
73
+
74
+ ```ts
75
+ {
76
+ id: "provider-id",
77
+ label: "Provider label",
78
+ getActivity(ctx) {
79
+ return {
80
+ providerId: "provider-id",
81
+ label: "Provider label",
82
+ items: [
83
+ { id: "work-1", status: "running", active: true }
84
+ ]
85
+ };
86
+ }
87
+ }
88
+ ```
89
+
90
+ The built-in provider id is `subagents`.
91
+
92
+ ## Status Semantics
93
+
94
+ For `pi-better-subagents`, active background work is:
95
+
96
+ - `running`
97
+ - `orphaned`
98
+
99
+ `orphaned` is active but unhealthy. `completed`, `failed`, `killed`, `lost`, and `exited` are non-active terminal states. Batches are represented as normal per-run items; batch metadata is display-only.
100
+
101
+ ## Validation
102
+
103
+ ```sh
104
+ npm run verify
105
+ ```
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "pi-better-goal",
3
+ "version": "0.1.0",
4
+ "description": "Background activity bridge for Pi goal continuations and async subagents.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "pi-extension",
15
+ "goal",
16
+ "subagents"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/1aboveio/pi-better-harness.git",
21
+ "directory": "packages/pi-better-goal"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/1aboveio/pi-better-harness/issues"
25
+ },
26
+ "homepage": "https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-goal#readme",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ]
34
+ },
35
+ "scripts": {
36
+ "test": "node --import tsx --test test/*.test.ts",
37
+ "typecheck": "tsc --noEmit",
38
+ "verify": "npm run typecheck && npm test"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "*",
42
+ "@earendil-works/pi-tui": "*",
43
+ "typebox": "*"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@earendil-works/pi-coding-agent": {
47
+ "optional": true
48
+ },
49
+ "@earendil-works/pi-tui": {
50
+ "optional": true
51
+ },
52
+ "typebox": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@earendil-works/pi-coding-agent": "^0.82.0",
58
+ "@earendil-works/pi-tui": "^0.82.0",
59
+ "@types/node": "^25.0.0",
60
+ "tsx": "^4.22.3",
61
+ "typebox": "^1.1.39",
62
+ "typescript": "^6.0.0"
63
+ },
64
+ "engines": {
65
+ "node": ">=22.0.0"
66
+ }
67
+ }
@@ -0,0 +1,158 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type {
4
+ ActivityCategory,
5
+ ActivitySnapshot,
6
+ BackgroundActivityProvider,
7
+ BackgroundProviderSnapshot,
8
+ BackgroundWorkItem,
9
+ GoalSnapshot,
10
+ } from "./types.js";
11
+
12
+ export interface BackgroundDrainTracker {
13
+ goalId: string;
14
+ activeSignature: string;
15
+ }
16
+
17
+ export interface BackgroundDrainPlan {
18
+ nextTracker: BackgroundDrainTracker | null;
19
+ wakeSignature: string | null;
20
+ }
21
+
22
+ export function activeItems(snapshot: ActivitySnapshot): BackgroundWorkItem[] {
23
+ return snapshot.providers.flatMap((provider) => provider.items.filter((item) => item.active));
24
+ }
25
+
26
+ export function attentionItems(snapshot: ActivitySnapshot): BackgroundWorkItem[] {
27
+ return snapshot.providers.flatMap((provider) => provider.items.filter((item) => item.attention));
28
+ }
29
+
30
+ export function activeSignature(snapshot: ActivitySnapshot): string {
31
+ return activeItems(snapshot)
32
+ .map((item) => `${item.id}:${item.status}`)
33
+ .sort()
34
+ .join("|");
35
+ }
36
+
37
+ export function terminalAttentionSignature(snapshot: ActivitySnapshot): string {
38
+ return attentionItems(snapshot)
39
+ .map((item) => `${item.id}:${item.status}`)
40
+ .sort()
41
+ .join("|");
42
+ }
43
+
44
+ export function planBackgroundDrainWake(
45
+ tracker: BackgroundDrainTracker | null,
46
+ goal: GoalSnapshot | null,
47
+ snapshot: ActivitySnapshot,
48
+ ): BackgroundDrainPlan {
49
+ if (goal?.status !== "active") {
50
+ return { nextTracker: null, wakeSignature: null };
51
+ }
52
+
53
+ const active = activeSignature(snapshot);
54
+ if (active) {
55
+ return {
56
+ nextTracker: { goalId: goal.goalId, activeSignature: active },
57
+ wakeSignature: null,
58
+ };
59
+ }
60
+
61
+ if (!snapshot.backgroundRunning && tracker?.goalId === goal.goalId && tracker.activeSignature) {
62
+ return {
63
+ nextTracker: null,
64
+ wakeSignature: `${goal.goalId}:${tracker.activeSignature}`,
65
+ };
66
+ }
67
+
68
+ return { nextTracker: tracker?.goalId === goal.goalId ? tracker : null, wakeSignature: null };
69
+ }
70
+
71
+ export function summarizeActiveBackground(snapshot: ActivitySnapshot): string {
72
+ const active = activeItems(snapshot);
73
+ if (active.length === 0) {
74
+ return "No active background work.";
75
+ }
76
+ return active
77
+ .map((item) => {
78
+ const label = item.label ? `${item.label} (${item.id})` : item.id;
79
+ const health = item.unhealthy ? ", unhealthy" : "";
80
+ return `${label}: ${item.status}${health}`;
81
+ })
82
+ .join("; ");
83
+ }
84
+
85
+ export async function collectActivitySnapshot(
86
+ ctx: ExtensionContext,
87
+ providers: Iterable<BackgroundActivityProvider>,
88
+ foregroundRunning: boolean,
89
+ now = Date.now(),
90
+ ): Promise<ActivitySnapshot> {
91
+ const providerSnapshots: BackgroundProviderSnapshot[] = [];
92
+
93
+ for (const provider of providers) {
94
+ try {
95
+ const snapshot = await provider.getActivity(ctx);
96
+ const normalized: BackgroundProviderSnapshot = {
97
+ providerId: snapshot.providerId || provider.id,
98
+ items: snapshot.items,
99
+ };
100
+ const label = snapshot.label ?? provider.label;
101
+ if (label !== undefined) {
102
+ normalized.label = label;
103
+ }
104
+ providerSnapshots.push(normalized);
105
+ } catch (error) {
106
+ const failedSnapshot: BackgroundProviderSnapshot = {
107
+ providerId: provider.id,
108
+ items: [
109
+ {
110
+ id: `${provider.id}:provider-error`,
111
+ status: "failed",
112
+ active: false,
113
+ terminal: true,
114
+ attention: true,
115
+ details: {
116
+ error: error instanceof Error ? error.message : String(error),
117
+ },
118
+ },
119
+ ],
120
+ };
121
+ if (provider.label !== undefined) {
122
+ failedSnapshot.label = provider.label;
123
+ }
124
+ providerSnapshots.push(failedSnapshot);
125
+ }
126
+ }
127
+
128
+ const activeBackgroundCount = providerSnapshots.reduce(
129
+ (sum, provider) => sum + provider.items.filter((item) => item.active).length,
130
+ 0,
131
+ );
132
+ const unhealthyBackgroundCount = providerSnapshots.reduce(
133
+ (sum, provider) => sum + provider.items.filter((item) => item.active && item.unhealthy).length,
134
+ 0,
135
+ );
136
+ const terminalAttentionCount = providerSnapshots.reduce(
137
+ (sum, provider) => sum + provider.items.filter((item) => !item.active && item.attention).length,
138
+ 0,
139
+ );
140
+
141
+ const category: ActivityCategory = foregroundRunning
142
+ ? "foreground-running"
143
+ : activeBackgroundCount > 0
144
+ ? "background-running"
145
+ : "idle-waiting";
146
+
147
+ return {
148
+ version: 1,
149
+ category,
150
+ foregroundRunning,
151
+ backgroundRunning: activeBackgroundCount > 0,
152
+ activeBackgroundCount,
153
+ unhealthyBackgroundCount,
154
+ terminalAttentionCount,
155
+ providers: providerSnapshots,
156
+ generatedAt: now,
157
+ };
158
+ }
@@ -0,0 +1,76 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ import type { GoalSnapshot } from "./types.js";
4
+
5
+ export interface GoalTiming {
6
+ activeSeconds: number;
7
+ elapsedSeconds: number;
8
+ }
9
+
10
+ export function goalTiming(goal: GoalSnapshot, now = Math.floor(Date.now() / 1000)): GoalTiming {
11
+ const activeDelta =
12
+ goal.status === "active" && goal.activeStartedAt !== null
13
+ ? Math.max(0, now - goal.activeStartedAt)
14
+ : 0;
15
+ const end = goal.completedAt ?? now;
16
+
17
+ return {
18
+ activeSeconds: goal.usage.activeSeconds + activeDelta,
19
+ elapsedSeconds: Math.max(0, end - goal.createdAt),
20
+ };
21
+ }
22
+
23
+ export function formatClockDuration(totalSeconds: number): string {
24
+ const seconds = Math.max(0, Math.floor(totalSeconds));
25
+ const hours = Math.floor(seconds / 3_600);
26
+ const minutes = Math.floor((seconds % 3_600) / 60);
27
+ const remainder = seconds % 60;
28
+
29
+ if (hours > 0) {
30
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${remainder.toString().padStart(2, "0")}`;
31
+ }
32
+ return `${minutes}:${remainder.toString().padStart(2, "0")}`;
33
+ }
34
+
35
+ export function formatGoalClock(
36
+ goal: GoalSnapshot,
37
+ now = Math.floor(Date.now() / 1000),
38
+ ): string {
39
+ const timing = goalTiming(goal, now);
40
+ return (
41
+ `Goal [${goal.status}]: ${goal.objective}` +
42
+ ` | active ${formatClockDuration(timing.activeSeconds)}` +
43
+ ` | elapsed ${formatClockDuration(timing.elapsedSeconds)}`
44
+ );
45
+ }
46
+
47
+ export function renderGoalClockLine(
48
+ goal: GoalSnapshot,
49
+ width: number,
50
+ now = Math.floor(Date.now() / 1000),
51
+ fg?: (color: string, value: string) => string,
52
+ ): string {
53
+ if (width <= 0) {
54
+ return "";
55
+ }
56
+
57
+ const timing = goalTiming(goal, now);
58
+ const heading = `goal ${goal.status}`;
59
+ const active = formatClockDuration(timing.activeSeconds);
60
+ const fixedWidth = visibleWidth(`▸ ${heading} ${active} `);
61
+ if (fixedWidth >= width) {
62
+ return truncateToWidth(`▸ ${heading} ${active}`, width);
63
+ }
64
+ const objective = truncateToWidth(goal.objective, width - fixedWidth);
65
+ return styleGoalRailLine("▸", heading, `${active} ${objective}`, fg);
66
+ }
67
+
68
+ function styleGoalRailLine(
69
+ marker: string,
70
+ heading: string,
71
+ tail: string,
72
+ fg?: (color: string, value: string) => string,
73
+ ): string {
74
+ if (!fg) return `${marker} ${heading} ${tail}`;
75
+ return `${fg("dim", marker)} ${fg("warning", heading)} ${fg("dim", tail)}`;
76
+ }
@@ -0,0 +1,217 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { EXTENSION_NAME, type GoalSnapshot, type GoalStatus } from "./types.js";
4
+
5
+ const LEGACY_GOAL_ENTRY_TYPE = "pi-codex-goal";
6
+ const MAX_OBJECTIVE_CHARS = 8000;
7
+
8
+ interface SessionEntryLike {
9
+ type: string;
10
+ customType?: string;
11
+ data?: unknown;
12
+ }
13
+
14
+ interface GoalLike {
15
+ goalId: string;
16
+ objective: string;
17
+ status: GoalStatus;
18
+ tokenBudget?: number | null;
19
+ usage?: { tokensUsed?: number; activeSeconds?: number };
20
+ createdAt?: number;
21
+ updatedAt?: number;
22
+ activeStartedAt?: number | null;
23
+ completedAt?: number | null;
24
+ }
25
+
26
+ export type GoalEntrySource = "command" | "tool" | "runtime";
27
+
28
+ export type GoalCustomEntry =
29
+ | {
30
+ version: 1;
31
+ kind: "set";
32
+ source: GoalEntrySource;
33
+ goal: GoalSnapshot;
34
+ at: number;
35
+ }
36
+ | {
37
+ version: 1;
38
+ kind: "clear";
39
+ source: GoalEntrySource;
40
+ clearedGoalId: string | null;
41
+ at: number;
42
+ };
43
+
44
+ function isGoalStatus(value: unknown): value is GoalStatus {
45
+ return value === "active" || value === "paused" || value === "budgetLimited" || value === "complete";
46
+ }
47
+
48
+ function isGoalLike(value: unknown): value is GoalLike {
49
+ if (!value || typeof value !== "object") {
50
+ return false;
51
+ }
52
+ const candidate = value as Partial<GoalLike>;
53
+ return (
54
+ typeof candidate.goalId === "string" &&
55
+ typeof candidate.objective === "string" &&
56
+ isGoalStatus(candidate.status)
57
+ );
58
+ }
59
+
60
+ function unixSeconds(): number {
61
+ return Math.floor(Date.now() / 1000);
62
+ }
63
+
64
+ function nextGoalId(): string {
65
+ return `goal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
66
+ }
67
+
68
+ function normalizeGoal(goal: GoalLike): GoalSnapshot {
69
+ const now = unixSeconds();
70
+ const createdAt = typeof goal.createdAt === "number" ? goal.createdAt : now;
71
+ const updatedAt = typeof goal.updatedAt === "number" ? goal.updatedAt : createdAt;
72
+ return {
73
+ goalId: goal.goalId,
74
+ objective: goal.objective,
75
+ status: goal.status,
76
+ tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
77
+ usage: {
78
+ tokensUsed: typeof goal.usage?.tokensUsed === "number" ? goal.usage.tokensUsed : 0,
79
+ activeSeconds: typeof goal.usage?.activeSeconds === "number" ? goal.usage.activeSeconds : 0,
80
+ },
81
+ createdAt,
82
+ updatedAt,
83
+ activeStartedAt:
84
+ typeof goal.activeStartedAt === "number"
85
+ ? goal.activeStartedAt
86
+ : goal.status === "active"
87
+ ? updatedAt
88
+ : null,
89
+ completedAt:
90
+ typeof goal.completedAt === "number"
91
+ ? goal.completedAt
92
+ : goal.status === "complete"
93
+ ? updatedAt
94
+ : null,
95
+ };
96
+ }
97
+
98
+ export function createGoalSnapshot(
99
+ objective: string,
100
+ tokenBudget: number | null = null,
101
+ now = unixSeconds(),
102
+ ): GoalSnapshot {
103
+ return {
104
+ goalId: nextGoalId(),
105
+ objective,
106
+ status: "active",
107
+ tokenBudget,
108
+ usage: { tokensUsed: 0, activeSeconds: 0 },
109
+ createdAt: now,
110
+ updatedAt: now,
111
+ activeStartedAt: now,
112
+ completedAt: null,
113
+ };
114
+ }
115
+
116
+ export function validateObjective(objective: string): string | null {
117
+ if (objective.trim().length === 0) {
118
+ return "Goal objective cannot be empty.";
119
+ }
120
+ if (objective.length > MAX_OBJECTIVE_CHARS) {
121
+ return `Goal objective is too long; limit is ${MAX_OBJECTIVE_CHARS} characters.`;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ export function validateTokenBudget(tokenBudget: number | null | undefined): string | null {
127
+ if (tokenBudget === null || tokenBudget === undefined) {
128
+ return null;
129
+ }
130
+ if (!Number.isInteger(tokenBudget) || tokenBudget <= 0) {
131
+ return "Token budget must be a positive integer when provided.";
132
+ }
133
+ return null;
134
+ }
135
+
136
+ export function goalSetEntry(goal: GoalSnapshot, source: GoalEntrySource): GoalCustomEntry {
137
+ return { version: 1, kind: "set", source, goal, at: unixSeconds() };
138
+ }
139
+
140
+ export function goalClearEntry(clearedGoalId: string | null, source: GoalEntrySource): GoalCustomEntry {
141
+ return { version: 1, kind: "clear", source, clearedGoalId, at: unixSeconds() };
142
+ }
143
+
144
+ export function goalWithStatus(
145
+ goal: GoalSnapshot,
146
+ status: GoalStatus,
147
+ now = unixSeconds(),
148
+ ): GoalSnapshot {
149
+ const accruedActiveSeconds =
150
+ goal.status === "active" && goal.activeStartedAt !== null
151
+ ? Math.max(0, now - goal.activeStartedAt)
152
+ : 0;
153
+ const usage = {
154
+ ...goal.usage,
155
+ activeSeconds: goal.usage.activeSeconds + accruedActiveSeconds,
156
+ };
157
+
158
+ if (status === "active") {
159
+ return {
160
+ ...goal,
161
+ status,
162
+ usage,
163
+ updatedAt: now,
164
+ activeStartedAt: goal.status === "active" ? goal.activeStartedAt : now,
165
+ completedAt: null,
166
+ };
167
+ }
168
+
169
+ return {
170
+ ...goal,
171
+ status,
172
+ usage,
173
+ updatedAt: now,
174
+ activeStartedAt: null,
175
+ completedAt: status === "complete" ? now : null,
176
+ };
177
+ }
178
+
179
+ export function reconstructGoalSnapshot(entries: Iterable<SessionEntryLike>): GoalSnapshot | null {
180
+ let goal: GoalSnapshot | null = null;
181
+
182
+ for (const entry of entries) {
183
+ if (
184
+ entry.type !== "custom" ||
185
+ (entry.customType !== EXTENSION_NAME && entry.customType !== LEGACY_GOAL_ENTRY_TYPE)
186
+ ) {
187
+ continue;
188
+ }
189
+ if (!entry.data || typeof entry.data !== "object") {
190
+ continue;
191
+ }
192
+ const data = entry.data as { kind?: unknown; goal?: unknown; status?: unknown };
193
+ if (data.kind === "clear") {
194
+ goal = null;
195
+ continue;
196
+ }
197
+ if (data.kind === "set" && isGoalLike(data.goal)) {
198
+ goal = normalizeGoal(data.goal);
199
+ continue;
200
+ }
201
+ if (data.kind === "usage" && goal && isGoalStatus(data.status)) {
202
+ goal = {
203
+ ...goal,
204
+ status: data.status,
205
+ activeStartedAt:
206
+ data.status === "active" ? (goal.activeStartedAt ?? goal.updatedAt) : null,
207
+ completedAt: data.status === "complete" ? goal.updatedAt : null,
208
+ };
209
+ }
210
+ }
211
+
212
+ return goal;
213
+ }
214
+
215
+ export function currentGoalSnapshot(ctx: ExtensionContext): GoalSnapshot | null {
216
+ return reconstructGoalSnapshot(ctx.sessionManager.getBranch() as Iterable<SessionEntryLike>);
217
+ }
package/src/index.ts ADDED
@@ -0,0 +1,546 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ import {
5
+ collectActivitySnapshot,
6
+ planBackgroundDrainWake,
7
+ summarizeActiveBackground,
8
+ terminalAttentionSignature,
9
+ type BackgroundDrainTracker,
10
+ } from "./activity.js";
11
+ import {
12
+ createGoalSnapshot,
13
+ currentGoalSnapshot,
14
+ goalClearEntry,
15
+ goalSetEntry,
16
+ goalWithStatus,
17
+ validateObjective,
18
+ validateTokenBudget,
19
+ } from "./goal-state.js";
20
+ import { goalTiming, renderGoalClockLine } from "./goal-clock.js";
21
+ import { collectSubagentActivity } from "./subagents.js";
22
+ import {
23
+ EVENT_ACTIVITY,
24
+ EVENT_READY,
25
+ EVENT_REGISTER_PROVIDER,
26
+ EVENT_TERMINAL_ATTENTION,
27
+ EXTENSION_NAME,
28
+ EXTENSION_VERSION,
29
+ type ActivitySnapshot,
30
+ type BackgroundActivityProvider,
31
+ type GoalSnapshot,
32
+ } from "./types.js";
33
+
34
+ const POLL_INTERVAL_MS = 2_000;
35
+ const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 30_000;
36
+ const WAKE_DISABLED =
37
+ process.env.PI_BETTER_GOAL_DISABLE_WAKE === "1" ||
38
+ process.env.PI_BETTER_EXTENSION_DISABLE_WAKE === "1";
39
+ const IDLE_CONTINUATION_DELAY_MS = parseDurationEnv(
40
+ process.env.PI_BETTER_GOAL_IDLE_CONTINUATION_DELAY_MS,
41
+ DEFAULT_IDLE_CONTINUATION_DELAY_MS,
42
+ );
43
+
44
+ function parseDurationEnv(raw: string | undefined, fallback: number): number {
45
+ if (raw === undefined || raw.trim() === "") {
46
+ return fallback;
47
+ }
48
+ const parsed = Number(raw);
49
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
50
+ }
51
+
52
+ function continuationPrompt(goal: GoalSnapshot): string {
53
+ return [
54
+ "Continue working toward the active thread goal.",
55
+ "",
56
+ `Goal: ${goal.objective}`,
57
+ "",
58
+ "Keep working through clear low-risk next steps. Do not stop at a plan. Mark the goal complete only after an evidence-backed completion audit proves no required work remains.",
59
+ ].join("\n");
60
+ }
61
+
62
+ function formatGoal(goal: GoalSnapshot | null): string {
63
+ if (!goal) {
64
+ return "No goal is set.";
65
+ }
66
+ const budget = goal.tokenBudget === null ? "none" : String(goal.tokenBudget);
67
+ const timing = goalTiming(goal);
68
+ return [
69
+ `Goal: ${goal.objective}`,
70
+ `Status: ${goal.status}`,
71
+ `Token budget: ${budget}`,
72
+ `Tokens used: ${goal.usage.tokensUsed}`,
73
+ `Active time: ${timing.activeSeconds}s`,
74
+ `Elapsed time: ${timing.elapsedSeconds}s`,
75
+ ].join("\n");
76
+ }
77
+
78
+ function formatSnapshot(snapshot: ActivitySnapshot): string {
79
+ const providers = snapshot.providers.map((provider) => {
80
+ const active = provider.items.filter((item) => item.active).length;
81
+ const attention = provider.items.filter((item) => item.attention).length;
82
+ return `${provider.label ?? provider.providerId}: ${active} active, ${attention} attention`;
83
+ });
84
+ return [
85
+ `Activity: ${snapshot.category}`,
86
+ `Foreground running: ${snapshot.foregroundRunning}`,
87
+ `Background active: ${snapshot.activeBackgroundCount}`,
88
+ `Background unhealthy: ${snapshot.unhealthyBackgroundCount}`,
89
+ `Terminal attention: ${snapshot.terminalAttentionCount}`,
90
+ ...providers,
91
+ ].join("\n");
92
+ }
93
+
94
+ export default function (pi: ExtensionAPI): void {
95
+ const providers = new Map<string, BackgroundActivityProvider>();
96
+ providers.set("subagents", {
97
+ id: "subagents",
98
+ label: "Subagents",
99
+ getActivity: () => collectSubagentActivity(),
100
+ });
101
+
102
+ let currentCtx: ExtensionContext | undefined;
103
+ let foregroundRunning = false;
104
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
105
+ let collecting = false;
106
+ let latestSnapshot: ActivitySnapshot | null = null;
107
+ let backgroundDrainTracker: BackgroundDrainTracker | null = null;
108
+ let lastWakeSignature = "";
109
+ let lastAttentionSignature = "";
110
+ let continuationQueuedFor: string | null = null;
111
+ let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
112
+ let idleContinuationSignature = "";
113
+
114
+ const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
115
+
116
+ const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: "command" | "tool" | "runtime"): void => {
117
+ pi.appendEntry(EXTENSION_NAME, goalSetEntry(goal, source));
118
+ continuationQueuedFor = null;
119
+ backgroundDrainTracker = null;
120
+ clearIdleContinuation();
121
+ };
122
+
123
+ const clearGoal = (ctx: ExtensionContext, source: "command" | "tool" | "runtime"): void => {
124
+ const current = getGoal(ctx);
125
+ pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
126
+ continuationQueuedFor = null;
127
+ backgroundDrainTracker = null;
128
+ clearIdleContinuation();
129
+ };
130
+
131
+ const queueGoalContinuation = (goal: GoalSnapshot): void => {
132
+ if (goal.status !== "active" || continuationQueuedFor === goal.goalId) {
133
+ return;
134
+ }
135
+ clearIdleContinuation();
136
+ continuationQueuedFor = goal.goalId;
137
+ pi.sendMessage(
138
+ {
139
+ customType: EXTENSION_NAME,
140
+ content: continuationPrompt(goal),
141
+ display: false,
142
+ details: { kind: "continuation", goalId: goal.goalId },
143
+ },
144
+ { triggerTurn: true, deliverAs: "followUp" },
145
+ );
146
+ };
147
+
148
+ function clearIdleContinuation(): void {
149
+ if (idleContinuationTimer) {
150
+ clearTimeout(idleContinuationTimer);
151
+ idleContinuationTimer = undefined;
152
+ }
153
+ idleContinuationSignature = "";
154
+ }
155
+
156
+ const scheduleIdleContinuation = (
157
+ goal: GoalSnapshot,
158
+ ctx: ExtensionContext,
159
+ kind: "continuation" | "background-drained",
160
+ snapshot?: ActivitySnapshot,
161
+ ): void => {
162
+ if (WAKE_DISABLED || goal.status !== "active" || continuationQueuedFor === goal.goalId) {
163
+ return;
164
+ }
165
+ const signature = `${goal.goalId}:${kind}`;
166
+ if (idleContinuationTimer && idleContinuationSignature === signature) {
167
+ return;
168
+ }
169
+ clearIdleContinuation();
170
+ idleContinuationSignature = signature;
171
+ idleContinuationTimer = setTimeout(() => {
172
+ idleContinuationTimer = undefined;
173
+ idleContinuationSignature = "";
174
+ void sendIdleContinuationAfterAudit(goal.goalId, ctx, kind, snapshot);
175
+ }, IDLE_CONTINUATION_DELAY_MS);
176
+ idleContinuationTimer.unref?.();
177
+ };
178
+
179
+ const sendIdleContinuationAfterAudit = async (
180
+ goalId: string,
181
+ ctx: ExtensionContext,
182
+ kind: "continuation" | "background-drained",
183
+ priorSnapshot?: ActivitySnapshot,
184
+ ): Promise<void> => {
185
+ const goal = currentGoalSnapshot(ctx);
186
+ if (goal?.status !== "active" || goal.goalId !== goalId || continuationQueuedFor === goal.goalId || foregroundRunning) {
187
+ return;
188
+ }
189
+ const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
190
+ latestSnapshot = snapshot;
191
+ pi.events.emit(EVENT_ACTIVITY, snapshot);
192
+ if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
193
+ return;
194
+ }
195
+
196
+ continuationQueuedFor = goal.goalId;
197
+ const content = kind === "background-drained"
198
+ ? "Background activity for the active goal is no longer running. Inspect any subagent callbacks or final results, then continue the completion audit before marking the goal complete.\n\n" +
199
+ continuationPrompt(goal)
200
+ : continuationPrompt(goal);
201
+ pi.sendMessage(
202
+ {
203
+ customType: EXTENSION_NAME,
204
+ content,
205
+ display: false,
206
+ details: kind === "background-drained"
207
+ ? { kind, snapshot: priorSnapshot ?? snapshot }
208
+ : { kind, goalId: goal.goalId },
209
+ },
210
+ { triggerTurn: true, deliverAs: "followUp" },
211
+ );
212
+ };
213
+
214
+ const startOrReplaceGoal = (
215
+ objective: string,
216
+ tokenBudget: number | null,
217
+ ctx: ExtensionContext,
218
+ source: "command",
219
+ ): GoalSnapshot => {
220
+ const objectiveError = validateObjective(objective);
221
+ if (objectiveError) {
222
+ throw new Error(objectiveError);
223
+ }
224
+ const budgetError = validateTokenBudget(tokenBudget);
225
+ if (budgetError) {
226
+ throw new Error(budgetError);
227
+ }
228
+ const goal = createGoalSnapshot(objective.trim(), tokenBudget);
229
+ setGoal(goal, ctx, source);
230
+ queueGoalContinuation(goal);
231
+ return goal;
232
+ };
233
+
234
+ const publishSnapshot = async (ctx: ExtensionContext): Promise<ActivitySnapshot> => {
235
+ const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
236
+ latestSnapshot = snapshot;
237
+ pi.events.emit(EVENT_ACTIVITY, snapshot);
238
+
239
+ const attention = terminalAttentionSignature(snapshot);
240
+ if (attention && attention !== lastAttentionSignature) {
241
+ lastAttentionSignature = attention;
242
+ pi.events.emit(EVENT_TERMINAL_ATTENTION, snapshot);
243
+ }
244
+
245
+ if (!WAKE_DISABLED) {
246
+ const goal = currentGoalSnapshot(ctx);
247
+ const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
248
+ backgroundDrainTracker = wakePlan.nextTracker;
249
+ if (goal?.status === "active" && wakePlan.wakeSignature) {
250
+ const wakeSignature = wakePlan.wakeSignature;
251
+ if (wakeSignature !== lastWakeSignature) {
252
+ lastWakeSignature = wakeSignature;
253
+ scheduleIdleContinuation(goal, ctx, "background-drained", snapshot);
254
+ }
255
+ }
256
+ }
257
+
258
+ if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
259
+ clearIdleContinuation();
260
+ }
261
+
262
+ if (ctx.hasUI) {
263
+ const status = snapshot.backgroundRunning
264
+ ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
265
+ : undefined;
266
+ try {
267
+ ctx.ui.setStatus(EXTENSION_NAME, status);
268
+ } catch {
269
+ // UI status is best-effort only.
270
+ }
271
+ }
272
+
273
+ return snapshot;
274
+ };
275
+
276
+ const collectIfPossible = (): void => {
277
+ const ctx = currentCtx;
278
+ if (!ctx || collecting) {
279
+ return;
280
+ }
281
+ collecting = true;
282
+ void publishSnapshot(ctx).finally(() => {
283
+ collecting = false;
284
+ });
285
+ };
286
+
287
+ const startPolling = (): void => {
288
+ if (pollTimer) {
289
+ return;
290
+ }
291
+ pollTimer = setInterval(collectIfPossible, POLL_INTERVAL_MS);
292
+ pollTimer.unref?.();
293
+ collectIfPossible();
294
+ };
295
+
296
+ const stopPolling = (): void => {
297
+ if (!pollTimer) {
298
+ return;
299
+ }
300
+ clearInterval(pollTimer);
301
+ pollTimer = undefined;
302
+ };
303
+
304
+ const installGoalWidget = (ctx: ExtensionContext): void => {
305
+ if (!ctx.hasUI) {
306
+ return;
307
+ }
308
+ ctx.ui.setWidget(
309
+ EXTENSION_NAME,
310
+ (tui, theme) => {
311
+ const timer = setInterval(() => tui.requestRender(), 1_000);
312
+ timer.unref?.();
313
+ return {
314
+ dispose() {
315
+ clearInterval(timer);
316
+ },
317
+ invalidate() {},
318
+ render(width: number): string[] {
319
+ const goal = getGoal(ctx);
320
+ if (!goal) {
321
+ return [];
322
+ }
323
+ const fg = typeof theme?.fg === "function"
324
+ ? (color: string, value: string) => theme.fg(color as never, value)
325
+ : undefined;
326
+ return [renderGoalClockLine(goal, width, undefined, fg)];
327
+ },
328
+ };
329
+ },
330
+ { placement: "aboveEditor" },
331
+ );
332
+ };
333
+
334
+ pi.events.on(EVENT_REGISTER_PROVIDER, (provider) => {
335
+ const candidate = provider as Partial<BackgroundActivityProvider> | undefined;
336
+ if (!candidate || typeof candidate.id !== "string" || typeof candidate.getActivity !== "function") {
337
+ return;
338
+ }
339
+ providers.set(candidate.id, candidate as BackgroundActivityProvider);
340
+ collectIfPossible();
341
+ });
342
+
343
+ pi.registerCommand("goal", {
344
+ description: "Create, inspect, pause, resume, clear, or complete the active goal",
345
+ handler: async (args, ctx) => {
346
+ currentCtx = ctx;
347
+ const trimmed = args.trim();
348
+ const current = getGoal(ctx);
349
+
350
+ if (!trimmed) {
351
+ ctx.ui.notify(formatGoal(current), "info");
352
+ return;
353
+ }
354
+
355
+ if (trimmed === "pause") {
356
+ if (!current || current.status !== "active") {
357
+ ctx.ui.notify("Only active goals can be paused.", "warning");
358
+ return;
359
+ }
360
+ setGoal(goalWithStatus(current, "paused"), ctx, "command");
361
+ ctx.ui.notify("Goal paused.", "info");
362
+ return;
363
+ }
364
+
365
+ if (trimmed === "resume") {
366
+ if (!current || current.status !== "paused") {
367
+ ctx.ui.notify("Only paused goals can be resumed.", "warning");
368
+ return;
369
+ }
370
+ const goal = goalWithStatus(current, "active");
371
+ setGoal(goal, ctx, "command");
372
+ queueGoalContinuation(goal);
373
+ ctx.ui.notify("Goal resumed.", "info");
374
+ return;
375
+ }
376
+
377
+ if (trimmed === "clear") {
378
+ clearGoal(ctx, "command");
379
+ ctx.ui.notify("Goal cleared.", "info");
380
+ return;
381
+ }
382
+
383
+ if (trimmed === "complete") {
384
+ if (!current) {
385
+ ctx.ui.notify("No goal is set.", "warning");
386
+ return;
387
+ }
388
+ setGoal(goalWithStatus(current, "complete"), ctx, "command");
389
+ ctx.ui.notify("Goal marked complete.", "info");
390
+ return;
391
+ }
392
+
393
+ if (current && current.status !== "complete" && ctx.hasUI) {
394
+ const replace = await ctx.ui.confirm(
395
+ "Replace active goal?",
396
+ `Current goal: ${current.objective}`,
397
+ );
398
+ if (!replace) {
399
+ return;
400
+ }
401
+ }
402
+
403
+ try {
404
+ const goal = startOrReplaceGoal(trimmed, null, ctx, "command");
405
+ ctx.ui.notify(`Goal set: ${goal.objective}`, "info");
406
+ } catch (error) {
407
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
408
+ }
409
+ },
410
+ });
411
+
412
+ pi.registerTool({
413
+ name: "get_goal",
414
+ label: "Get Goal",
415
+ description: "Inspect the current pi-better-goal objective, status, and timing.",
416
+ promptSnippet: "Inspect the current goal, status, token budget, tokens used, active time, and total elapsed time.",
417
+ parameters: Type.Object({}),
418
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
419
+ const goal = getGoal(ctx);
420
+ return {
421
+ content: [{ type: "text", text: formatGoal(goal) }],
422
+ details: { goal, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
423
+ };
424
+ },
425
+ });
426
+
427
+ pi.registerTool({
428
+ name: "update_goal",
429
+ label: "Update Goal",
430
+ description: "Mark the current pi-better-goal objective complete after a completion audit.",
431
+ promptSnippet: "Mark the current Codex-style goal complete after verification.",
432
+ parameters: Type.Object({
433
+ status: Type.String({ description: "Only 'complete' is accepted." }),
434
+ }),
435
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
436
+ const input = params as { status: string };
437
+ if (input.status !== "complete") {
438
+ return {
439
+ content: [{ type: "text", text: "Only status:'complete' is accepted." }],
440
+ details: { ok: false, goal: getGoal(ctx) },
441
+ };
442
+ }
443
+ const current = getGoal(ctx);
444
+ if (!current) {
445
+ return {
446
+ content: [{ type: "text", text: "No goal is set." }],
447
+ details: { ok: false, goal: null },
448
+ };
449
+ }
450
+ if (current.status === "complete") {
451
+ return {
452
+ content: [{ type: "text", text: "Goal already complete." }],
453
+ details: { ok: true, goal: current },
454
+ };
455
+ }
456
+ const goal = goalWithStatus(current, "complete");
457
+ setGoal(goal, ctx, "tool");
458
+ return {
459
+ content: [{ type: "text", text: "Goal marked complete." }],
460
+ details: { ok: true, goal },
461
+ };
462
+ },
463
+ });
464
+
465
+ pi.registerCommand("better-activity", {
466
+ description: "Show foreground/background activity known to pi-better-goal",
467
+ handler: async (_args, ctx) => {
468
+ currentCtx = ctx;
469
+ const snapshot = await publishSnapshot(ctx);
470
+ ctx.ui.notify(formatSnapshot(snapshot), snapshot.backgroundRunning ? "info" : "info");
471
+ },
472
+ });
473
+
474
+ pi.registerTool({
475
+ name: "get_background_activity",
476
+ label: "Get Background Activity",
477
+ description: "Inspect foreground/background activity known to pi-better-goal.",
478
+ promptSnippet: "Inspect active background work such as async subagents",
479
+ parameters: Type.Object({}),
480
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
481
+ currentCtx = ctx;
482
+ const snapshot = await publishSnapshot(ctx);
483
+ return {
484
+ content: [{ type: "text", text: formatSnapshot(snapshot) }],
485
+ details: snapshot,
486
+ };
487
+ },
488
+ });
489
+
490
+ pi.on("session_start", async (_event, ctx) => {
491
+ currentCtx = ctx;
492
+ foregroundRunning = !ctx.isIdle();
493
+ pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
494
+ installGoalWidget(ctx);
495
+ startPolling();
496
+ });
497
+
498
+ pi.on("before_agent_start", async (event, ctx) => {
499
+ currentCtx = ctx;
500
+ const goal = currentGoalSnapshot(ctx);
501
+ const snapshot = await publishSnapshot(ctx);
502
+ if (goal?.status !== "active") {
503
+ return;
504
+ }
505
+
506
+ const backgroundInstruction = snapshot.backgroundRunning
507
+ ? ` The goal still has delegated background work running (${summarizeActiveBackground(snapshot)}). Foreground idleness alone is not goal completion; do not mark the goal complete until delegated background work reaches a terminal state and its result or failure has been inspected.`
508
+ : "";
509
+ return {
510
+ systemPrompt:
511
+ `${event.systemPrompt}\n\n` +
512
+ `Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit.${backgroundInstruction}`,
513
+ };
514
+ });
515
+
516
+ pi.on("agent_start", async (_event, ctx) => {
517
+ currentCtx = ctx;
518
+ foregroundRunning = true;
519
+ clearIdleContinuation();
520
+ continuationQueuedFor = null;
521
+ await publishSnapshot(ctx);
522
+ });
523
+
524
+ pi.on("agent_settled", async (_event, ctx) => {
525
+ currentCtx = ctx;
526
+ foregroundRunning = false;
527
+ const snapshot = await publishSnapshot(ctx);
528
+ const goal = getGoal(ctx);
529
+ if (goal?.status === "active" && !snapshot.backgroundRunning) {
530
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
531
+ }
532
+ });
533
+
534
+ pi.on("session_shutdown", async (_event, ctx) => {
535
+ stopPolling();
536
+ foregroundRunning = false;
537
+ clearIdleContinuation();
538
+ currentCtx = undefined;
539
+ try {
540
+ ctx.ui.setStatus(EXTENSION_NAME, undefined);
541
+ ctx.ui.setWidget(EXTENSION_NAME, undefined);
542
+ } catch {
543
+ // Best-effort cleanup.
544
+ }
545
+ });
546
+ }
@@ -0,0 +1,134 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import type { BackgroundProviderSnapshot, BackgroundWorkItem } from "./types.js";
6
+
7
+ export type SubagentRunStatus = "running" | "completed" | "failed" | "killed" | "orphaned" | "lost";
8
+ export type SubagentEffectiveStatus = SubagentRunStatus | "exited";
9
+
10
+ export interface SubagentRunMeta {
11
+ id: string;
12
+ name?: string;
13
+ status: SubagentRunStatus;
14
+ pid: number;
15
+ spawnPid: number;
16
+ model?: string;
17
+ cwd: string;
18
+ promptPreview: string;
19
+ startedAt: number;
20
+ endedAt?: number;
21
+ callback?: boolean;
22
+ batchId?: string;
23
+ batchName?: string;
24
+ }
25
+
26
+ export interface CollectSubagentActivityOptions {
27
+ baseDir?: string;
28
+ parentPid?: number;
29
+ processExists?: (pid: number) => boolean;
30
+ }
31
+
32
+ export function defaultSubagentBaseDir(): string {
33
+ return join(tmpdir(), "pi-better-subagents");
34
+ }
35
+
36
+ export function defaultProcessExists(pid: number): boolean {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch (error) {
41
+ const code = (error as NodeJS.ErrnoException).code;
42
+ return code === "EPERM";
43
+ }
44
+ }
45
+
46
+ export function effectiveSubagentStatus(
47
+ meta: Pick<SubagentRunMeta, "status" | "pid">,
48
+ processExists: (pid: number) => boolean = defaultProcessExists,
49
+ ): SubagentEffectiveStatus {
50
+ if (meta.status !== "running") {
51
+ return meta.status;
52
+ }
53
+ return processExists(meta.pid) ? "running" : "exited";
54
+ }
55
+
56
+ export function isCurrentParentSubagent(
57
+ meta: Pick<SubagentRunMeta, "spawnPid">,
58
+ parentPid = process.pid,
59
+ ): boolean {
60
+ return meta.spawnPid === parentPid;
61
+ }
62
+
63
+ export function readSubagentMetas(baseDir = defaultSubagentBaseDir()): SubagentRunMeta[] {
64
+ const runsDir = join(baseDir, "runs");
65
+ if (!existsSync(runsDir)) {
66
+ return [];
67
+ }
68
+
69
+ const metas: SubagentRunMeta[] = [];
70
+ for (const id of readdirSync(runsDir)) {
71
+ try {
72
+ const raw = readFileSync(join(runsDir, id, "meta.json"), "utf8");
73
+ const meta = JSON.parse(raw) as SubagentRunMeta;
74
+ if (typeof meta.id === "string" && typeof meta.status === "string") {
75
+ metas.push(meta);
76
+ }
77
+ } catch {
78
+ // Ignore partially-written or stale run directories.
79
+ }
80
+ }
81
+
82
+ return metas.sort((left, right) => right.startedAt - left.startedAt);
83
+ }
84
+
85
+ export function subagentStatusToItem(
86
+ meta: SubagentRunMeta,
87
+ status: SubagentEffectiveStatus,
88
+ ): BackgroundWorkItem {
89
+ const active = status === "running" || status === "orphaned";
90
+ const unhealthy = status === "orphaned";
91
+ const terminal = !active;
92
+ const attention = status === "orphaned" || status === "failed" || status === "killed" || status === "lost" || status === "exited";
93
+
94
+ const item: BackgroundWorkItem = {
95
+ id: meta.id,
96
+ status,
97
+ active,
98
+ unhealthy,
99
+ terminal,
100
+ attention,
101
+ startedAt: meta.startedAt,
102
+ details: {
103
+ callback: meta.callback !== false,
104
+ batchId: meta.batchId,
105
+ batchName: meta.batchName,
106
+ model: meta.model,
107
+ cwd: meta.cwd,
108
+ promptPreview: meta.promptPreview,
109
+ },
110
+ };
111
+ if (meta.name !== undefined) {
112
+ item.label = meta.name;
113
+ }
114
+ if (meta.endedAt !== undefined) {
115
+ item.endedAt = meta.endedAt;
116
+ }
117
+ return item;
118
+ }
119
+
120
+ export function collectSubagentActivity(
121
+ options: CollectSubagentActivityOptions = {},
122
+ ): BackgroundProviderSnapshot {
123
+ const parentPid = options.parentPid ?? process.pid;
124
+ const processExists = options.processExists ?? defaultProcessExists;
125
+ const metas = readSubagentMetas(options.baseDir).filter((meta) =>
126
+ isCurrentParentSubagent(meta, parentPid),
127
+ );
128
+
129
+ return {
130
+ providerId: "subagents",
131
+ label: "Subagents",
132
+ items: metas.map((meta) => subagentStatusToItem(meta, effectiveSubagentStatus(meta, processExists))),
133
+ };
134
+ }
package/src/types.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const EXTENSION_NAME = "pi-better-goal";
4
+ export const EXTENSION_VERSION = "0.1.0";
5
+
6
+ export const EVENT_READY = "pi-better-goal:ready";
7
+ export const EVENT_REGISTER_PROVIDER = "pi-better-goal:register-provider";
8
+ export const EVENT_ACTIVITY = "pi-better-goal:activity";
9
+ export const EVENT_TERMINAL_ATTENTION = "pi-better-goal:terminal-attention";
10
+
11
+ export type ActivityCategory =
12
+ | "foreground-running"
13
+ | "background-running"
14
+ | "idle-waiting"
15
+ | "complete";
16
+
17
+ export type BackgroundWorkStatus =
18
+ | "running"
19
+ | "orphaned"
20
+ | "completed"
21
+ | "failed"
22
+ | "killed"
23
+ | "lost"
24
+ | "exited"
25
+ | string;
26
+
27
+ export interface BackgroundWorkItem {
28
+ id: string;
29
+ label?: string;
30
+ status: BackgroundWorkStatus;
31
+ active: boolean;
32
+ unhealthy?: boolean;
33
+ terminal?: boolean;
34
+ attention?: boolean;
35
+ startedAt?: number;
36
+ endedAt?: number;
37
+ details?: Record<string, unknown>;
38
+ }
39
+
40
+ export interface BackgroundProviderSnapshot {
41
+ providerId: string;
42
+ label?: string;
43
+ items: BackgroundWorkItem[];
44
+ }
45
+
46
+ export interface BackgroundActivityProvider {
47
+ id: string;
48
+ label?: string;
49
+ getActivity(ctx: ExtensionContext): BackgroundProviderSnapshot | Promise<BackgroundProviderSnapshot>;
50
+ }
51
+
52
+ export interface ActivitySnapshot {
53
+ version: 1;
54
+ category: ActivityCategory;
55
+ foregroundRunning: boolean;
56
+ backgroundRunning: boolean;
57
+ activeBackgroundCount: number;
58
+ unhealthyBackgroundCount: number;
59
+ terminalAttentionCount: number;
60
+ providers: BackgroundProviderSnapshot[];
61
+ generatedAt: number;
62
+ }
63
+
64
+ export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
65
+
66
+ export interface GoalUsage {
67
+ tokensUsed: number;
68
+ activeSeconds: number;
69
+ }
70
+
71
+ export interface GoalSnapshot {
72
+ goalId: string;
73
+ objective: string;
74
+ status: GoalStatus;
75
+ tokenBudget: number | null;
76
+ usage: GoalUsage;
77
+ createdAt: number;
78
+ updatedAt: number;
79
+ activeStartedAt: number | null;
80
+ completedAt: number | null;
81
+ }