@nredd/pi-session-kit 0.0.0-stage → 0.4.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 (52) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +191 -2
  3. package/dist/pi-stats-export.mjs +657 -0
  4. package/docs/stats-snapshots.md +122 -0
  5. package/package.json +55 -4
  6. package/src/child.ts +79 -0
  7. package/src/clock.ts +25 -0
  8. package/src/config.ts +64 -0
  9. package/src/diff/component.ts +214 -0
  10. package/src/diff/git.ts +289 -0
  11. package/src/diff/index.ts +65 -0
  12. package/src/diff/parse.ts +180 -0
  13. package/src/diff/view.ts +98 -0
  14. package/src/goal/index.ts +343 -0
  15. package/src/goal/state.ts +270 -0
  16. package/src/goal/subagents.ts +77 -0
  17. package/src/index.ts +52 -0
  18. package/src/loop/index.ts +151 -0
  19. package/src/loop/state.ts +107 -0
  20. package/src/monitor/README.md +12 -0
  21. package/src/monitor/coalescer.ts +76 -0
  22. package/src/monitor/index.ts +220 -0
  23. package/src/monitor/manager.ts +336 -0
  24. package/src/monitor/matcher.ts +113 -0
  25. package/src/monitor/messages.ts +93 -0
  26. package/src/monitor/output.ts +41 -0
  27. package/src/monitor/pidfiles.ts +415 -0
  28. package/src/monitor/process.ts +125 -0
  29. package/src/monitor/ring.ts +52 -0
  30. package/src/monitor/splitter.ts +57 -0
  31. package/src/monitor/types.ts +35 -0
  32. package/src/monitor/validate.ts +59 -0
  33. package/src/monitor/widget.ts +75 -0
  34. package/src/output.ts +25 -0
  35. package/src/rules/index.ts +94 -0
  36. package/src/rules/logic.ts +96 -0
  37. package/src/shared.ts +177 -0
  38. package/src/stats/aggregate.ts +287 -0
  39. package/src/stats/chart.ts +247 -0
  40. package/src/stats/cli.ts +50 -0
  41. package/src/stats/component.ts +496 -0
  42. package/src/stats/days.ts +46 -0
  43. package/src/stats/format.ts +49 -0
  44. package/src/stats/heatmap.ts +98 -0
  45. package/src/stats/index.ts +88 -0
  46. package/src/stats/library.ts +4 -0
  47. package/src/stats/load.ts +512 -0
  48. package/src/stats/palette.ts +22 -0
  49. package/src/stats/snapshot.ts +497 -0
  50. package/src/titles/aliases.ts +20 -0
  51. package/src/titles/index.ts +87 -0
  52. package/src/titles/logic.ts +160 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * `/goal`: keep a session working on an objective until the model proves it is done.
3
+ *
4
+ * While a goal is active, `agent_before_settle` turns a settle into one more turn (a hidden nudge
5
+ * message), unless something else already continues the run or the goal must yield (Plan mode,
6
+ * background subagents, queued messages, budget). The model ends the goal with `goal_update`.
7
+ * State lives only in `session-kit:goal` session entries, rebuilt from the branch at
8
+ * `session_start`, so two sessions never share anything.
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+ import { isChildSession, registerChildIdentity, registerParentTool } from "../child.ts";
14
+ import { type Clock, realClock, type TimerHandle } from "../clock.ts";
15
+ import type { KitConfig } from "../config.ts";
16
+ import { coordination, planModeActive, planModeEnteredSince, watchQuota, watchStops } from "../shared.ts";
17
+ import {
18
+ applyUpdate,
19
+ chipText,
20
+ decideNudge,
21
+ describeGoal,
22
+ GOAL_ENTRY,
23
+ GOAL_NUDGE_TYPE,
24
+ GOAL_TOOL,
25
+ type Goal,
26
+ type GoalSnapshot,
27
+ kickoffPrompt,
28
+ newGoal,
29
+ nudgeText,
30
+ parseGoalArgs,
31
+ pauseGoal,
32
+ recordTurn,
33
+ restoreGoal,
34
+ resumeGoal,
35
+ } from "./state.ts";
36
+ import { SubagentTracker } from "./subagents.ts";
37
+
38
+ const STATUS_KEY = "session-kit-goal";
39
+ /** How long a goal that skipped its nudge for subagents waits for their notice before waking itself. */
40
+ export const WAKE_MS = 4_000;
41
+ const SUBCOMMANDS = ["status", "pause", "resume", "clear"];
42
+
43
+ interface UsageLike {
44
+ input?: number;
45
+ output?: number;
46
+ cacheWrite?: number;
47
+ }
48
+
49
+ /** New tokens a finished assistant message consumed: input + output + cache writes (not cache reads). */
50
+ export function turnTokens(message: unknown): number {
51
+ const m = message as { role?: string; usage?: UsageLike } | undefined;
52
+ if (m?.role !== "assistant" || !m.usage) return 0;
53
+ return (m.usage.input ?? 0) + (m.usage.output ?? 0) + (m.usage.cacheWrite ?? 0);
54
+ }
55
+
56
+ export function register(pi: ExtensionAPI, _config: KitConfig, clock: Clock = realClock): void {
57
+ registerChildIdentity(pi);
58
+ const shared = coordination(pi);
59
+ let goal: Goal | undefined;
60
+ let lastCtx: ExtensionContext | undefined;
61
+ let wakeTimer: TimerHandle | undefined;
62
+ let skippedForAgents = false;
63
+
64
+ const clearWake = (): void => {
65
+ wakeTimer?.cancel();
66
+ wakeTimer = undefined;
67
+ };
68
+
69
+ const armWake = (): void => {
70
+ if (wakeTimer !== undefined || goal?.status !== "active" || shared.quotaParked.size > 0) return;
71
+ wakeTimer = clock.setTimeout(() => {
72
+ wakeTimer = undefined;
73
+ const ctx = lastCtx;
74
+ if (!ctx || !goal || goal.status !== "active" || isChildSession(pi) || shared.quotaParked.size > 0) return;
75
+ try {
76
+ // Only RUNNING agents hold the wake: a notice not delivered by now is not coming.
77
+ if (!ctx.isIdle() || tracker.runningCount() > 0 || planModeActive(ctx)) return;
78
+ skippedForAgents = false;
79
+ pi.sendUserMessage(nudgeText(goal));
80
+ } catch {
81
+ // Stale context: the session is gone.
82
+ }
83
+ }, WAKE_MS);
84
+ };
85
+
86
+ const tracker = new SubagentTracker(() => {
87
+ if (skippedForAgents) armWake();
88
+ });
89
+
90
+ const ensureTool = (ctx: ExtensionContext): void => {
91
+ const want = goal?.status === "active" && !isChildSession(pi) && !planModeActive(ctx);
92
+ const active = pi.getActiveTools();
93
+ const has = active.includes(GOAL_TOOL);
94
+ if (want && !has) pi.setActiveTools([...active, GOAL_TOOL]);
95
+ else if (!want && has) pi.setActiveTools(active.filter((name) => name !== GOAL_TOOL));
96
+ };
97
+
98
+ const syncUi = (ctx: ExtensionContext): void => {
99
+ shared.goalActive = goal?.status === "active";
100
+ ctx.ui.setStatus(STATUS_KEY, goal ? chipText(goal, tracker.runningCount()) : undefined);
101
+ ensureTool(ctx);
102
+ };
103
+
104
+ const commit = (ctx: ExtensionContext, next: Goal | undefined): void => {
105
+ goal = next;
106
+ pi.appendEntry<GoalSnapshot>(GOAL_ENTRY, { version: 1, goal: next ?? null });
107
+ syncUi(ctx);
108
+ };
109
+
110
+ /**
111
+ * Entering Plan mode pauses an active goal, even when Plan mode was entered AND left while the
112
+ * goal sat idle (no event fires then, so the branch's `plan-mode-state` entries are replayed
113
+ * back to the goal's last state entry). Returns whether Plan mode is on right now.
114
+ */
115
+ const guardPlanMode = (ctx: ExtensionContext): boolean => {
116
+ const on = planModeActive(ctx);
117
+ const excursion =
118
+ !on && goal?.status === "active" && planModeEnteredSince(ctx.sessionManager.getBranch(), GOAL_ENTRY);
119
+ if (!on && !excursion) return false;
120
+ if (goal?.status === "active") {
121
+ commit(ctx, pauseGoal(goal, "plan mode"));
122
+ ctx.ui.notify("goal paused: Plan mode was entered. /goal resume after leaving it", "info");
123
+ }
124
+ return on;
125
+ };
126
+
127
+ shared.reconcilePlanMode = guardPlanMode;
128
+
129
+ const rebuild = (ctx: ExtensionContext): void => {
130
+ lastCtx = ctx;
131
+ clearWake();
132
+ skippedForAgents = false;
133
+ tracker.dispose();
134
+ goal = undefined;
135
+ shared.goalActive = false;
136
+ if (isChildSession(pi)) return;
137
+ tracker.attach(pi.events);
138
+ goal = restoreGoal(ctx.sessionManager.getBranch());
139
+ syncUi(ctx);
140
+ if (goal?.reason === "session restored") {
141
+ ctx.ui.notify(`goal restored paused: ${goal.objective}. /goal resume to continue`, "info");
142
+ }
143
+ };
144
+
145
+ /**
146
+ * Start work right away when the session can take it. Print/JSON mode dispose the session as
147
+ * soon as the command returns, killing an un-awaited run, so there the next prompt starts the goal.
148
+ */
149
+ const kickoff = (ctx: ExtensionContext, text: string): void => {
150
+ if ((ctx.mode === "tui" || ctx.mode === "rpc") && ctx.isIdle()) pi.sendUserMessage(text);
151
+ };
152
+
153
+ pi.on("session_start", (_event, ctx) => rebuild(ctx));
154
+ pi.on("session_tree", (_event, ctx) => rebuild(ctx));
155
+ pi.on("session_shutdown", () => {
156
+ clearWake();
157
+ tracker.dispose();
158
+ shared.goalActive = false;
159
+ shared.reconcilePlanMode = undefined;
160
+ goal = undefined;
161
+ });
162
+
163
+ pi.on("before_agent_start", (_event, ctx) => {
164
+ if (isChildSession(pi)) return;
165
+ lastCtx = ctx;
166
+ guardPlanMode(ctx);
167
+ syncUi(ctx);
168
+ });
169
+
170
+ pi.on("agent_start", () => clearWake());
171
+ pi.on("input", (_event, ctx) => {
172
+ if (isChildSession(pi)) return undefined;
173
+ lastCtx = ctx;
174
+ guardPlanMode(ctx);
175
+ return undefined;
176
+ });
177
+ pi.on("turn_start", (_event, ctx) => {
178
+ if (isChildSession(pi)) return;
179
+ lastCtx = ctx;
180
+ guardPlanMode(ctx);
181
+ });
182
+
183
+ pi.on("turn_end", (event, ctx) => {
184
+ if (isChildSession(pi)) return;
185
+ lastCtx = ctx;
186
+ guardPlanMode(ctx);
187
+ if (goal?.status !== "active") return;
188
+ const next = recordTurn(goal, turnTokens(event.message));
189
+ commit(ctx, next);
190
+ if (next.status === "budget")
191
+ ctx.ui.notify(`goal stopped: ${next.reason}. /goal ${next.objective} --turns N to go on`, "warning");
192
+ });
193
+
194
+ pi.on("agent_before_settle", (event, ctx) => {
195
+ if (isChildSession(pi)) return undefined;
196
+ lastCtx = ctx;
197
+ const planMode = guardPlanMode(ctx);
198
+ const decision = decideNudge({
199
+ goal,
200
+ isChild: false,
201
+ planMode,
202
+ continueAlready: event.continue,
203
+ // A settle after an assistant message reports canContinue=false until a draft ends the
204
+ // context with a user-role message; our nudge is that draft. Only an empty context cannot continue.
205
+ canContinue: event.context.canContinue || event.context.llmMessages.some((m) => m.role !== "system"),
206
+ outcome: event.outcome,
207
+ pendingMessages: Math.max(event.context.pendingMessages.length, ctx.hasPendingMessages() ? 1 : 0),
208
+ subagentsBusy: tracker.busy(),
209
+ });
210
+ skippedForAgents = !decision.nudge && decision.reason === "subagents busy";
211
+ if (!decision.nudge || !goal) return undefined;
212
+ return {
213
+ entries: [
214
+ ...event.entries,
215
+ { type: "custom_message", customType: GOAL_NUDGE_TYPE, content: nudgeText(goal), display: false },
216
+ ],
217
+ continue: true,
218
+ };
219
+ });
220
+
221
+ // A parked/refused dispatch is not a failed provider request. Pause at its
222
+ // admission event, before settle can nudge and before a stale wake can fire.
223
+ watchQuota(pi, (reason, ctx) => {
224
+ clearWake();
225
+ skippedForAgents = false;
226
+ if (isChildSession(pi) || goal?.status !== "active") return;
227
+ commit(ctx, pauseGoal(goal, reason));
228
+ ctx.ui.notify(`goal paused: ${reason}. /goal resume after resolving quota`, "warning");
229
+ });
230
+
231
+ watchStops(pi, (outcome, ctx) => {
232
+ if (isChildSession(pi) || goal?.status !== "active") return;
233
+ const reason = outcome === "aborted" ? "run aborted" : "provider error";
234
+ commit(ctx, pauseGoal(goal, reason));
235
+ ctx.ui.notify(`goal paused: ${reason}. /goal resume to continue`, "warning");
236
+ });
237
+
238
+ pi.on("agent_settled", () => {
239
+ if (skippedForAgents) armWake();
240
+ });
241
+
242
+ pi.on("message_end", (event) => {
243
+ if ((event.message as { customType?: string }).customType === "subagent-notification") tracker.announced();
244
+ });
245
+ pi.on("tool_execution_end", (event) => {
246
+ if (event.toolName === "get_subagent_result") tracker.announced();
247
+ });
248
+
249
+ registerParentTool(pi, {
250
+ name: GOAL_TOOL,
251
+ label: "Goal Update",
252
+ description:
253
+ 'End the active /goal. status "complete" needs evidence: the check that proves the objective is met (command + result). status "blocked" needs the reason you cannot proceed.',
254
+ promptSnippet: "Finish or block the active /goal (complete needs evidence)",
255
+ parameters: Type.Object({
256
+ status: Type.Union([Type.Literal("complete"), Type.Literal("blocked")], {
257
+ description: "complete: objective met. blocked: cannot proceed.",
258
+ }),
259
+ evidence: Type.String({ description: "complete: what proves it. blocked: why you are stuck." }),
260
+ }),
261
+ defaultActive: false,
262
+ async execute(_id, params, _signal, _onUpdate, ctx) {
263
+ const result = applyUpdate(goal, params.status, params.evidence);
264
+ if (!result.ok) throw new Error(result.error);
265
+ commit(ctx, result.goal);
266
+ ctx.ui.notify(
267
+ `goal ${result.goal.status}: ${result.goal.objective}`,
268
+ result.goal.status === "complete" ? "info" : "warning",
269
+ );
270
+ return {
271
+ content: [{ type: "text", text: `Goal marked ${result.goal.status}.` }],
272
+ details: { status: result.goal.status },
273
+ };
274
+ },
275
+ });
276
+
277
+ /**
278
+ * Registered from `session_start`, not at load: a child (subagent) session is only recognisable
279
+ * by its name, which does not exist at factory time. pi resolves extension commands lazily, so a
280
+ * late registration is executable; children never register it.
281
+ */
282
+ const registerGoalCommand = (): void => {
283
+ pi.registerCommand("goal", {
284
+ description:
285
+ "Work on an objective until done: /goal <objective> [--turns N] [--tokens N] | status | pause | resume | clear",
286
+ getArgumentCompletions: (prefix) => {
287
+ const items = SUBCOMMANDS.filter((name) => name.startsWith(prefix)).map((name) => ({
288
+ value: name,
289
+ label: name,
290
+ }));
291
+ return items.length > 0 ? items : null;
292
+ },
293
+ handler: async (args, ctx) => {
294
+ if (isChildSession(pi)) return;
295
+ lastCtx = ctx;
296
+ guardPlanMode(ctx);
297
+ const command = parseGoalArgs(args);
298
+ if (command.kind === "error") return ctx.ui.notify(command.message, "error");
299
+ if (command.kind === "status") {
300
+ const waiting = tracker.busy() > 0 ? `\nwaiting on ${tracker.busy()} subagent(s)` : "";
301
+ return ctx.ui.notify(describeGoal(goal) + waiting, "info");
302
+ }
303
+ if (command.kind === "clear") {
304
+ if (goal) commit(ctx, undefined);
305
+ return ctx.ui.notify("goal cleared", "info");
306
+ }
307
+ if (command.kind === "pause") {
308
+ if (goal?.status !== "active") return ctx.ui.notify("no active goal to pause", "error");
309
+ commit(ctx, pauseGoal(goal, "paused by user"));
310
+ return ctx.ui.notify("goal paused. /goal resume to continue", "info");
311
+ }
312
+ if (planModeActive(ctx)) {
313
+ return ctx.ui.notify(
314
+ "refused: Plan mode is active. Leave it (/plan) before starting or resuming a goal",
315
+ "error",
316
+ );
317
+ }
318
+ if (shared.quotaParked.size > 0) {
319
+ return ctx.ui.notify(
320
+ "refused: quota dispatch is still parked. Resolve it before starting or resuming a goal",
321
+ "error",
322
+ );
323
+ }
324
+ if (command.kind === "resume") {
325
+ if (!goal) return ctx.ui.notify("no goal to resume", "error");
326
+ const result = resumeGoal(goal);
327
+ if (!result.ok) return ctx.ui.notify(result.error, "error");
328
+ commit(ctx, result.goal);
329
+ kickoff(ctx, kickoffPrompt(result.goal, true));
330
+ return;
331
+ }
332
+ if (command.kind !== "set") return;
333
+ const started = newGoal(command.objective, command.maxTurns, command.maxTokens);
334
+ commit(ctx, started);
335
+ kickoff(ctx, kickoffPrompt(started, false));
336
+ },
337
+ });
338
+ };
339
+
340
+ pi.on("session_start", () => {
341
+ if (!isChildSession(pi)) registerGoalCommand();
342
+ });
343
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Pure `/goal` logic: command parsing, the goal state machine, budgets and the nudge decision.
3
+ * No pi imports, so everything here is unit-testable without a runtime.
4
+ */
5
+
6
+ export const GOAL_ENTRY = "session-kit:goal";
7
+ export const GOAL_NUDGE_TYPE = "session-kit:goal-nudge";
8
+ export const GOAL_TOOL = "goal_update";
9
+ export const DEFAULT_TURNS = 30;
10
+
11
+ export type GoalStatus = "active" | "paused" | "complete" | "blocked" | "budget";
12
+
13
+ export interface Goal {
14
+ objective: string;
15
+ status: GoalStatus;
16
+ /** Parent turns consumed while the goal was active. */
17
+ turns: number;
18
+ maxTurns: number;
19
+ /** New tokens (input + output + cache writes) consumed while active. */
20
+ tokens: number;
21
+ maxTokens?: number;
22
+ /** Why the goal is not active: pause cause, blocker, or budget. */
23
+ reason?: string;
24
+ /** The proof given on completion. */
25
+ evidence?: string;
26
+ }
27
+
28
+ /** Persisted snapshot; the last one on a branch wins. `null` means cleared. */
29
+ export interface GoalSnapshot {
30
+ version: 1;
31
+ goal: Goal | null;
32
+ }
33
+
34
+ export type GoalCommand =
35
+ | { kind: "set"; objective: string; maxTurns: number; maxTokens?: number }
36
+ | { kind: "status" | "pause" | "resume" | "clear" }
37
+ | { kind: "error"; message: string };
38
+
39
+ const SUBCOMMANDS = new Set(["status", "pause", "resume", "clear"]);
40
+ const COUNT = /^(\d+)([km])?$/i;
41
+
42
+ /** `30`, `200k`, `1m` -> a positive integer. */
43
+ export function parseCount(raw: string): number | undefined {
44
+ const match = COUNT.exec(raw.trim());
45
+ if (!match) return undefined;
46
+ const unit = match[2]?.toLowerCase();
47
+ const value = Number(match[1]) * (unit === "k" ? 1_000 : unit === "m" ? 1_000_000 : 1);
48
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
49
+ }
50
+
51
+ /** Parse `/goal` arguments. A subcommand word only counts when it is the whole argument. */
52
+ export function parseGoalArgs(raw: string): GoalCommand {
53
+ const text = raw.trim();
54
+ if (text === "") return { kind: "status" };
55
+ if (SUBCOMMANDS.has(text)) return { kind: text as "status" | "pause" | "resume" | "clear" };
56
+ const words: string[] = [];
57
+ let maxTurns = DEFAULT_TURNS;
58
+ let maxTokens: number | undefined;
59
+ const tokens = text.split(/\s+/);
60
+ for (let i = 0; i < tokens.length; i++) {
61
+ const token = tokens[i] as string;
62
+ const flag = /^--(turns|tokens)(?:=(.*))?$/.exec(token);
63
+ if (!flag) {
64
+ words.push(token);
65
+ continue;
66
+ }
67
+ const name = flag[1] as "turns" | "tokens";
68
+ const value = flag[2] ?? tokens[++i] ?? "";
69
+ const count = parseCount(value);
70
+ if (count === undefined) return { kind: "error", message: `--${name} needs a positive integer (got "${value}")` };
71
+ if (name === "turns") maxTurns = count;
72
+ else maxTokens = count;
73
+ }
74
+ const objective = words.join(" ");
75
+ if (objective === "") return { kind: "error", message: "usage: /goal <objective> [--turns N] [--tokens N]" };
76
+ return { kind: "set", objective, maxTurns, ...(maxTokens === undefined ? {} : { maxTokens }) };
77
+ }
78
+
79
+ export function newGoal(objective: string, maxTurns = DEFAULT_TURNS, maxTokens?: number): Goal {
80
+ return {
81
+ objective,
82
+ status: "active",
83
+ turns: 0,
84
+ maxTurns,
85
+ tokens: 0,
86
+ ...(maxTokens === undefined ? {} : { maxTokens }),
87
+ };
88
+ }
89
+
90
+ interface EntryLike {
91
+ type?: string;
92
+ customType?: string;
93
+ data?: unknown;
94
+ }
95
+
96
+ const STATUSES: readonly string[] = ["active", "paused", "complete", "blocked", "budget"];
97
+
98
+ const isCount = (value: unknown): value is number =>
99
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
100
+ const isCap = (value: unknown): value is number => isCount(value) && value > 0;
101
+
102
+ /**
103
+ * Validate a persisted goal field by field; anything malformed yields `undefined` so a damaged
104
+ * entry can never restore a goal with a disabled budget.
105
+ */
106
+ function parseGoal(value: unknown): Goal | undefined {
107
+ if (typeof value !== "object" || value === null) return undefined;
108
+ const g = value as Record<string, unknown>;
109
+ if (typeof g.objective !== "string" || typeof g.status !== "string" || !STATUSES.includes(g.status)) return undefined;
110
+ if (!isCount(g.turns) || !isCap(g.maxTurns) || !isCount(g.tokens)) return undefined;
111
+ if (g.maxTokens !== undefined && !isCap(g.maxTokens)) return undefined;
112
+ if (g.reason !== undefined && typeof g.reason !== "string") return undefined;
113
+ if (g.evidence !== undefined && typeof g.evidence !== "string") return undefined;
114
+ return {
115
+ objective: g.objective,
116
+ status: g.status as GoalStatus,
117
+ turns: g.turns,
118
+ maxTurns: g.maxTurns,
119
+ tokens: g.tokens,
120
+ ...(g.maxTokens === undefined ? {} : { maxTokens: g.maxTokens }),
121
+ ...(g.reason === undefined ? {} : { reason: g.reason }),
122
+ ...(g.evidence === undefined ? {} : { evidence: g.evidence }),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Rebuild the goal from a session branch. A goal that was active when the session was written
128
+ * comes back PAUSED: nothing is running after a restart, and resuming is an explicit act.
129
+ */
130
+ export function restoreGoal(entries: readonly EntryLike[]): Goal | undefined {
131
+ for (let i = entries.length - 1; i >= 0; i--) {
132
+ const entry = entries[i];
133
+ if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY) continue;
134
+ const snapshot = entry.data as Partial<GoalSnapshot> | undefined;
135
+ if (snapshot?.version !== 1 || snapshot.goal === null) return undefined;
136
+ const goal = parseGoal(snapshot.goal);
137
+ if (!goal) return undefined;
138
+ return goal.status === "active" ? { ...goal, status: "paused", reason: "session restored" } : goal;
139
+ }
140
+ return undefined;
141
+ }
142
+
143
+ export function budgetExhausted(goal: Goal): "turns" | "tokens" | undefined {
144
+ if (goal.turns >= goal.maxTurns) return "turns";
145
+ if (goal.maxTokens !== undefined && goal.tokens >= goal.maxTokens) return "tokens";
146
+ return undefined;
147
+ }
148
+
149
+ /** Charge one finished parent turn; ends the goal when a budget runs out. No-op unless active. */
150
+ export function recordTurn(goal: Goal, tokens: number): Goal {
151
+ if (goal.status !== "active") return goal;
152
+ const next: Goal = { ...goal, turns: goal.turns + 1, tokens: goal.tokens + Math.max(0, Math.trunc(tokens)) };
153
+ const spent = budgetExhausted(next);
154
+ return spent ? { ...next, status: "budget", reason: `${spent} budget exhausted` } : next;
155
+ }
156
+
157
+ export function pauseGoal(goal: Goal, reason: string): Goal {
158
+ return goal.status === "active" ? { ...goal, status: "paused", reason } : goal;
159
+ }
160
+
161
+ export type ResumeResult = { ok: true; goal: Goal } | { ok: false; error: string };
162
+
163
+ /** `paused` and `blocked` goals resume; finished and budget-exhausted ones need a new `/goal`. */
164
+ export function resumeGoal(goal: Goal): ResumeResult {
165
+ if (goal.status === "active") return { ok: false, error: "goal is already active" };
166
+ if (goal.status === "complete")
167
+ return { ok: false, error: "goal is complete; start a new one with /goal <objective>" };
168
+ if (goal.status === "budget") {
169
+ return {
170
+ ok: false,
171
+ error: `${goal.reason ?? "budget exhausted"}; start a new one with /goal <objective> --turns N`,
172
+ };
173
+ }
174
+ const { reason: _reason, ...rest } = goal;
175
+ return budgetExhausted(rest)
176
+ ? { ok: false, error: "budget exhausted; start a new one with /goal <objective> --turns N" }
177
+ : { ok: true, goal: { ...rest, status: "active" } };
178
+ }
179
+
180
+ export type UpdateResult = { ok: true; goal: Goal } | { ok: false; error: string };
181
+
182
+ /** The `goal_update` tool: `complete` needs non-empty evidence, same rule as rpiv-todo. */
183
+ export function applyUpdate(goal: Goal | undefined, status: string, evidence: string | undefined): UpdateResult {
184
+ if (goal?.status !== "active") return { ok: false, error: "no active goal" };
185
+ const proof = (evidence ?? "").trim();
186
+ if (status === "complete") {
187
+ if (proof === "") {
188
+ return {
189
+ ok: false,
190
+ error: "evidence required to mark the goal complete: the check that proves it (command + result)",
191
+ };
192
+ }
193
+ return { ok: true, goal: { ...goal, status: "complete", evidence: proof, reason: undefined } };
194
+ }
195
+ if (status === "blocked") {
196
+ return { ok: true, goal: { ...goal, status: "blocked", reason: proof || "no reason given" } };
197
+ }
198
+ return { ok: false, error: `status must be "complete" or "blocked" (got "${status}")` };
199
+ }
200
+
201
+ export interface NudgeInput {
202
+ goal: Goal | undefined;
203
+ isChild: boolean;
204
+ planMode: boolean;
205
+ /** `event.continue`: another handler already continued this settle. */
206
+ continueAlready: boolean;
207
+ canContinue: boolean;
208
+ outcome: "completed" | "aborted" | "error";
209
+ /** Queued messages (e.g. a subagent notice follow-up) pi will run anyway. */
210
+ pendingMessages: number;
211
+ /** Background subagents running, or finished with their notice still unannounced. */
212
+ subagentsBusy: number;
213
+ }
214
+
215
+ export type NudgeDecision = { nudge: true } | { nudge: false; reason: string };
216
+
217
+ /** Whether a settle should be turned into one more goal turn, and if not, why. */
218
+ export function decideNudge(input: NudgeInput): NudgeDecision {
219
+ const skip = (reason: string): NudgeDecision => ({ nudge: false, reason });
220
+ if (input.isChild) return skip("child session");
221
+ if (input.goal?.status !== "active") return skip("no active goal");
222
+ if (input.planMode) return skip("plan mode");
223
+ if (input.outcome !== "completed") return skip(`run ${input.outcome}`);
224
+ if (input.continueAlready) return skip("already continued");
225
+ if (!input.canContinue) return skip("cannot continue");
226
+ if (input.pendingMessages > 0) return skip("queued messages pending");
227
+ if (input.subagentsBusy > 0) return skip("subagents busy");
228
+ if (budgetExhausted(input.goal)) return skip("budget exhausted");
229
+ return { nudge: true };
230
+ }
231
+
232
+ /** The first message of a goal, and of a resume. */
233
+ export function kickoffPrompt(goal: Goal, resumed: boolean): string {
234
+ const budget = `${goal.maxTurns} turns${goal.maxTokens === undefined ? "" : `, ${goal.maxTokens} tokens`}`;
235
+ return [
236
+ `${resumed ? "Resuming goal" : "Goal"}: ${goal.objective}`,
237
+ `Work on it until it is done (budget: ${budget}). When finished, call goal_update with status "complete" and evidence (the check that proves it). If you cannot proceed, call goal_update with status "blocked" and the reason.`,
238
+ ].join("\n");
239
+ }
240
+
241
+ /** The hidden settle nudge. */
242
+ export function nudgeText(goal: Goal): string {
243
+ return `[goal ${goal.turns}/${goal.maxTurns} turns] Not complete yet: ${goal.objective}\nKeep working. When done call goal_update status "complete" with evidence; if stuck, status "blocked" with the reason.`;
244
+ }
245
+
246
+ /** Footer chip text. */
247
+ export function chipText(goal: Goal, waitingOnAgents = 0): string {
248
+ const used = compact(goal.tokens);
249
+ const tokens =
250
+ goal.maxTokens !== undefined ? ` ${used}/${compact(goal.maxTokens)} tok` : goal.tokens > 0 ? ` ${used} tok` : "";
251
+ const state = goal.status === "active" ? "" : ` ${goal.status}`;
252
+ const wait =
253
+ waitingOnAgents > 0 && goal.status === "active"
254
+ ? ` (waiting on ${waitingOnAgents} agent${waitingOnAgents === 1 ? "" : "s"})`
255
+ : "";
256
+ return `goal ${goal.turns}/${goal.maxTurns}${tokens}${state}${wait}`;
257
+ }
258
+
259
+ function compact(n: number): string {
260
+ return n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}m` : n >= 1_000 ? `${+(n / 1_000).toFixed(1)}k` : String(n);
261
+ }
262
+
263
+ /** `/goal status` body. */
264
+ export function describeGoal(goal: Goal | undefined): string {
265
+ if (!goal) return "no goal. /goal <objective> [--turns N] [--tokens N]";
266
+ const lines = [`goal (${goal.status}): ${goal.objective}`, chipText(goal)];
267
+ if (goal.reason) lines.push(`reason: ${goal.reason}`);
268
+ if (goal.evidence) lines.push(`evidence: ${goal.evidence}`);
269
+ return lines.join("\n");
270
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Which background subagents a goal must wait for, from the `subagents:*` events pi-subagents
3
+ * emits on `pi.events` (see `~/code/pi-subagents/src/index.ts`).
4
+ *
5
+ * - `running`: between `created`/`started` and `completed`/`failed`.
6
+ * - `unannounced`: a background agent finished but its completion notice has not been delivered.
7
+ * pi-subagents queues notices in an outbox and flushes them at `agent_end`, i.e. AFTER the
8
+ * settle boundary, so a nudge sent at settle would run a turn in front of the notice.
9
+ *
10
+ * A nudge is skipped while either set is non-empty, so the notice (a `followUp` that triggers a
11
+ * turn) is delivered first and the goal nudges at the following settle.
12
+ */
13
+
14
+ export interface EventBus {
15
+ on(event: string, handler: (data: unknown) => void): () => void;
16
+ }
17
+
18
+ function idOf(data: unknown): string | undefined {
19
+ const id = (data as { id?: unknown } | null)?.id;
20
+ return typeof id === "string" ? id : undefined;
21
+ }
22
+
23
+ export class SubagentTracker {
24
+ private readonly running = new Set<string>();
25
+ private readonly unannounced = new Set<string>();
26
+ private readonly background = new Set<string>();
27
+ private detach: (() => void)[] = [];
28
+
29
+ /** Called when the last running agent finishes, e.g. to re-arm the wake timer. */
30
+ constructor(private readonly onIdle: () => void = () => {}) {}
31
+
32
+ attach(events: EventBus): void {
33
+ this.detach.push(
34
+ events.on("subagents:created", (data) => {
35
+ const id = idOf(data);
36
+ if (!id) return;
37
+ this.running.add(id);
38
+ if ((data as { isBackground?: unknown }).isBackground === true) this.background.add(id);
39
+ }),
40
+ events.on("subagents:started", (data) => {
41
+ const id = idOf(data);
42
+ if (id) this.running.add(id);
43
+ }),
44
+ events.on("subagents:completed", (data) => this.finish(idOf(data))),
45
+ events.on("subagents:failed", (data) => this.finish(idOf(data))),
46
+ );
47
+ }
48
+
49
+ private finish(id: string | undefined): void {
50
+ if (!id) return;
51
+ this.running.delete(id);
52
+ if (this.background.delete(id)) this.unannounced.add(id);
53
+ if (this.running.size === 0) this.onIdle();
54
+ }
55
+
56
+ /** The notices were delivered (or the results fetched): nothing is waiting to be announced. */
57
+ announced(): void {
58
+ this.unannounced.clear();
59
+ }
60
+
61
+ busy(): number {
62
+ return this.running.size + this.unannounced.size;
63
+ }
64
+
65
+ runningCount(): number {
66
+ return this.running.size;
67
+ }
68
+
69
+ /** Drop every subscription and id (session shutdown). */
70
+ dispose(): void {
71
+ for (const off of this.detach) off();
72
+ this.detach = [];
73
+ this.running.clear();
74
+ this.unannounced.clear();
75
+ this.background.clear();
76
+ }
77
+ }