@mocito/pi-goal 0.1.11 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,8 +1,45 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ All notable changes to this project will be documented in this file.
4
4
 
5
- ## 0.1.11 - 2026-07-17
5
+ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning for releases.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-10-11
10
+
11
+ ### Changed
12
+
13
+ - Compact the footer into a palette-neutral Nerd Font goal icon plus distinct active, paused, blocked, usage-limited, budget-limited, and complete icons. Retain used/budget token counts in every budgeted state; leave command output, notifications, and widget text unchanged. Require Nerd Font 3 or later for footer icons.
14
+
15
+ ## [0.2.0] - 2026-10-09
16
+
17
+ ### Added
18
+
19
+ - Declare `{ goal }` structured summaries for `get_goal` and `create_goal`, including `goal: null` for empty state, plus `goal` namespace discovery and behavior annotations. Codemode receives objects without `JSON.parse`; terminal updates remain model-only without a new schema.
20
+ - Mark usage-refreshing queries as stateful and serialize them with goal mutations.
21
+
22
+ ### Fixed
23
+
24
+ - Restrict footer/widget updates to TUI mode; print, JSON and RPC retain goal lifecycle behavior without terminal writes.
25
+
26
+ ### Changed
27
+
28
+ - Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
29
+ - Share install telemetry mechanics through `@mocito/install-telemetry` while preserving Pi-specific settings and state paths.
30
+ - Require Pi 1.1.0 for usage-accounting lifecycle and cache-warming hooks. Stop idle cache warming for retained token-budgeted or usage-limited goals without changing no-goal sessions.
31
+
32
+ ### Fixed
33
+
34
+ - Request automatic goal continuation through `agent_before_settle` after Pi's recovery and queued work, rather than an `agent_end` timer. Preserve other handlers' proposals and append-only history while sending at most one current goal context to the model.
35
+ - Do not restart cancelled/failed runs or failed automatic compaction, including after successful queued work within the same run. Provider-limit notices, like budget notices, no longer request a wrap-up turn.
36
+ - Preserve explicit start/resume/reload activation and wait for tree navigation to finish. Invalidate stale activations across goal/session/branch changes; reject oversized stored objectives without truncation.
37
+ - Keep `update_goal` model-only on supported Pi runtimes (Pi 1.0.2 or newer), preventing codemode and other nested tool calls from completing or blocking a goal before the model inspects verification results. Preserve direct final-turn updates and script access to `get_goal` and `create_goal`.
38
+ - Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
39
+ - Count finalized tool, standalone, compaction, and branch-summary usage in goal budgets, including already-aggregated nested calls and context-omitted billed attempts. Preserve schema-v1 ledgers and backfill previously missed entries once within the branch-local goal lifetime.
40
+ - Enforce saved usage before continuation and settlement, after compaction/navigation, on goal queries, and through a session-scoped idle usage check. Budget notices no longer request an extra model turn.
41
+
42
+ ## [0.1.11] - 2026-07-17
6
43
 
7
44
  ### Fixed
8
45
 
@@ -10,20 +47,20 @@
10
47
  - Derive persisted `piGoalVersion` metadata from the package version so release versions cannot drift.
11
48
  - Ignore malformed persisted mutations instead of allowing invalid status, budget, or accounting data to corrupt reconstructed goal state.
12
49
 
13
- ## 0.1.10 - 2026-07-01
50
+ ## [0.1.10] - 2026-07-01
14
51
 
15
52
  ### Changed
16
53
 
17
54
  - Update Pi core development dependencies for Pi 0.80 compatibility.
18
55
 
19
- ## 0.1.9 - 2026-06-16
56
+ ## [0.1.9] - 2026-06-16
20
57
 
21
58
  ### Fixed
22
59
 
23
60
  - Notify the model when a goal transitions to `budget_limited` or `usage_limited` so it can stop work and call `update_goal` instead of silently continuing to spend tokens after the budget is exhausted or the provider hits a rate limit. Previously the model was only ever informed via a UI notification that it could not see, leaving the current turn to keep running for additional assistant entries and allowing `update_goal` to mark the goal `complete` long after the budget had been blown.
24
61
  - Branch the continuation prompt on the `budget_limited` reason so any future continuation of a budget-limited goal instructs the model to wrap up and call `update_goal` rather than push forward.
25
62
 
26
- ## 0.1.8 - 2026-06-08
63
+ ## [0.1.8] - 2026-06-08
27
64
 
28
65
  ### Fixed
29
66
 
@@ -31,7 +68,7 @@
31
68
  - Reject `update_goal` when it is batched with verification/tool calls in the same assistant turn, forcing completion/blocking decisions to happen after inspecting verification results.
32
69
  - Preserve the elapsed-time fix for old sessions by ignoring legacy account mutation `timeUsedSeconds` fields during reconstruction.
33
70
 
34
- ## 0.1.7 - 2026-06-08
71
+ ## [0.1.7] - 2026-06-08
35
72
 
36
73
  ### Fixed
37
74
 
@@ -41,7 +78,7 @@
41
78
 
42
79
  - Pause goals on assistant-surfaced provider usage/quota/billing limit errors and repeated provider failures, not only HTTP 429 response hooks.
43
80
 
44
- ## 0.1.6 - 2026-06-06
81
+ ## [0.1.6] - 2026-06-06
45
82
 
46
83
  ### Changed
47
84
 
@@ -49,14 +86,26 @@
49
86
  - Aligned package structure with monorepo guidelines (added root `index.ts` re-export, `.editorconfig`, `.gitignore`; fixed extension entry point; removed non-canonical tsconfig flags).
50
87
  - Update `author` field to full name for monorepo consistency.
51
88
 
52
- ## 0.1.3
89
+ ## [0.1.3] - 2026-06-06
90
+
91
+ ### Changed
92
+
93
+ - Temporarily rename the package to unscoped `pi-goal`; this version was not published because that npm name was unavailable.
94
+
95
+ ## [0.1.2] - 2026-05-28
96
+
97
+ ### Changed
53
98
 
54
99
  - Move the extension entry point to `extensions/index.ts` so Pi displays the package extension compactly.
55
100
 
56
- ## 0.1.1
101
+ ## [0.1.1] - 2026-05-28
102
+
103
+ ### Added
57
104
 
58
105
  - Add best-effort install/update telemetry, gated by Pi telemetry/offline settings and disabled in CI.
59
106
 
60
- ## 0.1.0
107
+ ## [0.1.0] - 2026-05-28
108
+
109
+ ### Added
61
110
 
62
111
  - Initial `@mocito/pi-goal` package with `/goal`, goal tools, branch-scoped session persistence, usage accounting, UI status, and automatic continuation.
package/README.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # @mocito/pi-goal
2
2
 
3
- Persistent long-running goals for Pi, modeled after Codex `/goal`.
3
+ Give Pi a durable objective and let it keep working until the job is verified done.
4
+
5
+ `pi-goal` adds branch-aware, long-running goals modeled after Codex `/goal`, with automatic continuation, token budgets, progress visibility, and explicit completion checks.
6
+
7
+ ## Features
8
+
9
+ - **Work beyond one turn** — Pi continues an active goal after successful runs, without restarting cancelled or failed work.
10
+ - **Persistent branch-aware state** — goals survive reloads and follow session trees, forks, and clones without leaking across divergent branches.
11
+ - **Budget control** — cap token use, monitor remaining budget, and pause or resume work at any time.
12
+ - **Visible progress** — track status, active time, token usage, and budget from Pi's UI.
13
+ - **Verified endings** — model tools require explicit completion evidence and stop repeated retries when work is blocked or provider limits intervene.
4
14
 
5
15
  ## Install
6
16
 
@@ -8,6 +18,10 @@ Persistent long-running goals for Pi, modeled after Codex `/goal`.
8
18
  pi install npm:@mocito/pi-goal
9
19
  ```
10
20
 
21
+ Requires Pi 1.1.0 or newer for complete usage accounting and the settlement
22
+ and cache-warming hooks. Development and session-contract tests use Pi 1.1.0.
23
+ There is no legacy fallback for nested terminal updates.
24
+
11
25
  For local development:
12
26
 
13
27
  ```bash
@@ -36,15 +50,177 @@ Replacing a non-complete goal asks for confirmation when UI is available.
36
50
  - `create_goal` creates a goal only when explicitly requested and fails if one already exists.
37
51
  - `update_goal` lets the model mark a goal `complete` or `blocked` only. It should mark complete only after requirement-by-requirement verification, and blocked only after the same blocker repeats for at least three goal turns.
38
52
 
39
- ## Behavior
53
+ `update_goal` is model-only: it remains available directly to the model with
54
+ codemode disabled, `on`, or `only`, but cannot be called from codemode scripts or
55
+ other tools through `ctx.executeTool()`. Run verification first, inspect the
56
+ results, then call `update_goal` as the only tool call in a separate final
57
+ assistant turn. `get_goal` and `create_goal` remain callable from scripts.
40
58
 
41
- Goal state is stored as immutable `pi-goal` custom session entries and reconstructed from `ctx.sessionManager.getBranch()`, so state follows Pi session branches, tree navigation, forks, and reloads.
59
+ The extension enforces this call boundary, not the truth of the model's
60
+ verification evidence. Trusted extensions still run with the user's permissions.
61
+
62
+ ### Structured script contract
42
63
 
43
- When an active goal is idle, the extension injects a hidden `pi-goal-context` message and triggers another turn. A context filter keeps only the latest goal context message for the current goal to avoid linear context growth.
64
+ On Pi 1.1.0 or newer, `get_goal` and `create_goal` declare output schemas:
44
65
 
45
- The footer and optional editor widget show status, elapsed active time, token usage, and budget.
66
+ - `get_goal` returns `{ goal: summary | null }`, including a stable `null` when
67
+ no goal exists. Its direct no-goal text stays `No goal is set.`.
68
+ - `create_goal` returns `{ goal: summary }`. It still requires an explicit
69
+ creation request; discovery metadata is not permission to create a goal.
70
+ - `summary` contains `goalId`, `objective`, `status`, `tokensUsed`,
71
+ `timeUsedSeconds`, `createdAt`, `updatedAt`, and optional `tokenBudget`,
72
+ `remainingTokens`, `activeStartedAt`. Internal accounting ledgers,
73
+ verification metadata and branch entries are not included.
46
74
 
47
- Provider usage-limit handling pauses active goals when Pi exposes HTTP 429 responses or assistant error messages that indicate subscription, quota, billing, balance, or repeated provider failures. This prevents automatic continuation from retrying indefinitely after provider limits such as 5-hour subscription caps. When the budget is exhausted or a provider limit is detected, a visible `pi-goal-event` message is also delivered to the model so it can stop work and call `update_goal` to finalize the goal instead of continuing to spend tokens on a turn that has effectively been cut off.
75
+ ```js
76
+ const { goal } = await tools.get_goal({});
77
+ text(goal ? { status: goal.status, remainingTokens: goal.remainingTokens } : "No goal");
78
+ ```
79
+
80
+ Remove old `JSON.parse(await tools.get_goal({}))` wrappers. Direct results remain
81
+ readable JSON and the renderers retain their summaries. Invalid requests and
82
+ creation conflicts throw; scripts must catch rejection. Neither tool uses a
83
+ structured success object with `isError: true`. Legacy oversized objectives remain
84
+ inspectable without silent truncation; new objectives retain the 4,000-character
85
+ input limit.
86
+
87
+ Await `describeNamespace("goal")`, `describeTool("get_goal")`, or
88
+ `searchTools("goal budget", { namespace: "goal" })`, including with zero inline
89
+ budget. No deferred/codemode-only option or default exposure change is added.
90
+ `update_goal` remains absent from nested discovery and has no output schema.
91
+
92
+ All goal tools run sequentially. `get_goal` is **not** annotated read-only because
93
+ it refreshes and can persist usage accounting; its repeated bookkeeping is
94
+ idempotent. Creation is non-idempotent. Terminal update is destructive and
95
+ non-idempotent. All are local-only, and the annotations are advisory, not
96
+ approval or evidence verification. Pi owns aggregation of nested usage; these
97
+ snapshots do not add a second usage report.
98
+
99
+ ## Behavior
100
+
101
+ Goal state is stored as immutable `pi-goal` custom session entries and reconstructed from `ctx.sessionManager.getBranch()`, so state follows Pi session branches, tree navigation, forks, and reloads.
102
+
103
+ After a successful run, the extension accounts finalized usage and proposes a
104
+ hidden `pi-goal-context` message at Pi's actionable `agent_before_settle` boundary.
105
+ Pi finishes its retry, overflow recovery, compaction, and queued work before
106
+ reaching that boundary. Pi then validates the proposed context and owns the next
107
+ request; there is no post-run `agent_end` timer or new run from `agent_settled`.
108
+ A context filter keeps only the latest message for the current active goal.
109
+ Earlier messages remain in append-only session history.
110
+
111
+ Cancellation, terminal assistant errors, and failed/cancelled automatic
112
+ compaction suppress goal continuation for that run. Cancellation and ordinary
113
+ errors leave the stored goal active; they do not silently pause or clear it.
114
+ The compaction-failure stop survives queued steering/follow-up work and resets
115
+ only when the whole run reaches the notification-only `agent_settled` event.
116
+ Explicit new input can start work again. Use `/goal pause` to keep it paused.
117
+ Pending steering, follow-up, and user messages take priority and are not consumed
118
+ or duplicated by the goal extension.
119
+
120
+ Initial `/goal` commands, `/goal resume`, active session startup/reload, and tree
121
+ navigation retain a separate activation path because there may be no running
122
+ boundary. Tree activation waits for navigation to finish. Each activation
123
+ rechecks goal/session/branch identity, saved usage, idle state, and pending work.
124
+ A real run or a goal/session/branch transition invalidates an obsolete activation.
125
+ Resuming or reloading a session with an active goal can therefore restart it,
126
+ including after an earlier cancellation.
127
+
128
+ Boundary proposals preserve other extensions' entries and continuation requests.
129
+ The incoming `context.canContinue` describes the preview before the goal message:
130
+ it can be false after a normal assistant reply. The goal's custom-message draft
131
+ makes that preview runnable; Pi checks the final preview after all handlers.
132
+ Declining goal continuation does not veto another extension's continuation.
133
+
134
+ The TUI footer shows a goal icon, a distinct state icon, and compact used/budget
135
+ token counts when a budget exists. The editor widget and `/goal status` retain
136
+ text details, including elapsed active time and token usage.
137
+ Print, JSON and RPC retain the goal lifecycle without terminal footer/widget updates.
138
+
139
+ ### Footer icons
140
+
141
+ The footer starts with **󰣉** (`nf-md-bullseye_arrow`, `U+F08C9`), followed by:
142
+
143
+ | Icon | State | Nerd Font glyph |
144
+ | --- | --- | --- |
145
+ | 󰐊 | Active | `nf-md-play` (`U+F040A`) |
146
+ | 󰏤 | Paused | `nf-md-pause` (`U+F03E4`) |
147
+ | 󰌾 | Blocked | `nf-md-lock` (`U+F033E`) |
148
+ | 󰔟 | Provider usage-limited | `nf-md-timer_sand` (`U+F051F`) |
149
+ | 󰆙 | Token budget-limited | `nf-md-counter` (`U+F0199`) |
150
+ | 󰄬 | Complete | `nf-md-check` (`U+F012C`) |
151
+
152
+ For example, `󰣉 󰐊 12k/50k` is an active goal with about 12,000 tokens used
153
+ against a 50,000-token budget. Budget counts remain visible in every retained
154
+ state, including paused and limited states. Without a budget, only the icons
155
+ appear. Clearing the goal clears the footer.
156
+
157
+ The footer omits repeated labels and resume instructions; use `/goal status`
158
+ for details and `/goal resume` to resume. Notifications and the active-goal
159
+ widget remain in text. Icons use the terminal's default text color and carry
160
+ state by shape, not stored ANSI colors.
161
+
162
+ Configure the terminal to use **Nerd Font 3 or later**, preferably its **Mono**
163
+ variant. There is no text footer option or missing-glyph detection; unsupported
164
+ fonts may display boxes. Goal storage, accounting, and continuation are unchanged.
165
+
166
+ Provider usage-limit handling pauses active goals when Pi exposes HTTP 429 responses or assistant error messages that indicate subscription, quota, billing, balance, or repeated provider failures. This prevents automatic continuation from retrying indefinitely after provider limits such as 5-hour subscription caps. When the budget is exhausted or a provider limit is detected, a visible `pi-goal-event` notice is saved to model context without requesting a wrap-up turn. The model can act on that notice during a request independently started by Pi, the user, or another extension; the notice itself does not spend more tokens.
167
+
168
+ ### Token accounting and attribution
169
+
170
+ The goal counts reported tokens from finalized assistant messages, tool results,
171
+ standalone usage entries (including cache warming), compactions, and branch
172
+ summaries. Failed or cancelled calls count when Pi persists their usage.
173
+ Nested tool usage is already aggregated by Pi onto the parent tool result and
174
+ is counted once, not by walking nested calls. Cached input tokens count too.
175
+ This is a token budget, not a currency or provider-quota budget.
176
+
177
+ Virtual-model selections do not change that rule: Pi records the physical
178
+ assistant's usage, and codemode classifier usage is aggregated onto its parent
179
+ tool result. However, a raw `ctx.modelRegistry.classify()` call inside a router
180
+ does **not** automatically create a session usage entry in Pi 1.1.0. The goal
181
+ cannot charge an unrecorded result. Paid automatic routers need an explicit,
182
+ single-count attribution contract before they can be treated as budget-safe;
183
+ do not infer free classification from an unchanged goal total. Classifier
184
+ judgments are not completion evidence or permission to bypass approvals.
185
+
186
+ The accounting interval starts at the goal's creation/replacement entry and
187
+ ends when it is replaced or cleared on that branch. Earlier work is excluded,
188
+ including entries before creation with the same timestamp. Entries need valid
189
+ timestamps within the interval and no later than the scan time.
190
+ As in earlier v1 releases, **pause stops continuation, not accounting**:
191
+ work while paused, limited, blocked, or complete is still charged to the
192
+ retained goal. Clear or replace the goal before doing unrelated work.
193
+ Completion reports are snapshots of finalized usage at completion.
194
+
195
+ Accounting uses raw branch history, not the model-context projection. Compacted
196
+ or context-omitted billed attempts still count. Reloads and forks retain the
197
+ inherited entry-ID ledger; divergent branch work is not imported. A tree
198
+ summary's own usage belongs to the destination branch. Navigating back before
199
+ work was done restores that branch's earlier total, not a session-wide spend cap.
200
+
201
+ Schema-v1 mutations remain compatible; no migration or session rewrite is needed.
202
+ Previously accounted assistant tokens and IDs are retained. The next scan
203
+ backfills newly supported usage entries once, so old goals may show higher
204
+ totals or reach their budgets after upgrading.
205
+
206
+ Budget enforcement scans saved usage at turn end, before settlement, after
207
+ compaction/tree navigation, on reload, before scheduled continuation, and when
208
+ goals are queried or changed. Pi 1.1 has no extension notification for newly
209
+ saved idle usage, so a session-scoped one-second poll checks for branch changes
210
+ while idle. It does not make provider requests. A budget notice updates visible
211
+ state and model context **without requesting another model turn**.
212
+
213
+ ### Idle cache warming
214
+
215
+ The extension requests `stop` at an idle warming decision when the retained
216
+ goal has a token budget or is `budget_limited`/`usage_limited`. It never requests
217
+ `warm`, changes Pi's settings, or overrides decisions for no-goal sessions or
218
+ unbudgeted, non-limited goals. Pi uses the last handler that returns an action;
219
+ a later extension can explicitly override this stop.
220
+
221
+ This policy is idle-only. It does not intercept every provider request, cancel
222
+ in-flight warming, or stop streaming-phase warming. Any persisted warming usage
223
+ within the goal interval is still accounted.
48
224
 
49
225
  ## Examples
50
226
 
@@ -83,15 +259,16 @@ Environment flags:
83
259
  ## Troubleshooting
84
260
 
85
261
  - If continuation does not start, run `/goal status` and confirm the goal is `active`.
86
- - If a goal stops unexpectedly, check whether it reached its token budget or the provider returned a rate/usage limit. Usage-limit pauses may include a provider reset hint when one is available. A budget-exhausted or rate-limited goal is also surfaced to the model as a `pi-goal-event` in the conversation so it can call `update_goal`; if the model never receives that, the goal stays in `budget_limited` until you run `/goal resume` or `/goal clear`.
262
+ - If a goal stops unexpectedly, check whether the run was cancelled, failed, or hit a token budget/provider limit. Usage-limit pauses may include a provider reset hint. Budget/provider notices do not start another request; the goal remains `budget_limited` or `usage_limited` until explicitly resumed, cleared, or finalized during later work.
87
263
  - If context appears stale after tree navigation or reload, run `/goal status`; branch state is reconstructed from the active branch.
88
264
  - In print/JSON modes, commands and tools work, but interactive confirmations/editors are unavailable.
89
265
 
90
266
  ## Limitations
91
267
 
92
- - Token budget is enforced after finalized assistant usage is visible; v1 cannot hard-stop mid-turn, but the model is notified as soon as the overrun is detected so it can stop further work in the same or next turn.
268
+ - Token budget is enforced after finalized usage is saved. It stops goal-driven continuation, not an in-flight provider call, Pi's own recovery, user-requested work, or another extension's continuation. Usage that a provider/tool does not report cannot be counted.
93
269
  - Usage-limit handling is best-effort via HTTP responses and assistant error messages; provider transports vary in how much structured limit information they expose.
94
270
  - Automatic continuation is session-local, not a background daemon.
271
+ - Oversized stored objectives (over 4,000 characters) are not automatically continued or silently truncated. Edit the objective before resuming.
95
272
 
96
273
  ## Development and validation
97
274
 
@@ -105,6 +282,40 @@ npm run pack:dry-run --workspace packages/pi-goal
105
282
  npm audit --omit=dev
106
283
  ```
107
284
 
285
+ The session-contract suite uses real Pi loading, tool dispatch, provider
286
+ serialization, and session trees with synthetic goals and mocked provider
287
+ traffic. It checks direct and nested calls, codemode discovery, termination,
288
+ reloads, model changes, and branch reconstruction without live credentials or
289
+ provider requests.
290
+
291
+ Accounting regressions also cover multi-level metered codemode calls, failed
292
+ results, mocked cache warming and remote compaction, branch summaries, context
293
+ omissions, inherited fork histories, and tool-only budget exhaustion. The warming
294
+ tests load the compaction/reasoning hooks in both orders and verify that idle
295
+ refreshes do not change saved reasoning state.
296
+
297
+ The settlement suite exercises the production extension in real Pi sessions
298
+ with bounded mocked responses. It covers cancellation (including retry and
299
+ pre-settlement cancellation), successful/exhausted retries, compaction
300
+ success/failure, canonical context edits, user queues, extension ordering,
301
+ terminal tools, startup/resume/reload, replacement, forks, tree navigation, and
302
+ TUI/print/JSON/RPC bindings. It also advances controlled timers after settlement
303
+ to detect an obsolete timer that would restart the run. No live credentials are
304
+ used; HTTP responses are mocked and WebSocket traffic is blocked.
305
+
306
+ Run the focused lifecycle smoke test from the monorepo root:
307
+
308
+ ```bash
309
+ node --import tsx --test packages/pi-goal/tests/settlement-contract.test.mjs
310
+ ```
311
+
312
+ Footer smoke test with a Nerd Font Mono: create a budgeted goal, pause it, and
313
+ resume it. Check the distinct play/pause icons and retained budget numbers.
314
+ Check blocked, usage-limited, budget-limited, and complete icons in retained
315
+ sessions, then clear the goal and check that its status disappears. Repeat at
316
+ narrow/wide widths and after light/dark theme changes; `/goal status` must still
317
+ show text details.
318
+
108
319
  Before publishing, also run the root validation loop:
109
320
 
110
321
  ```bash
package/SECURITY.md CHANGED
@@ -25,6 +25,52 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
25
25
 
26
26
  The package does not require API keys and does not read provider credentials. Its model tools can only inspect the current goal, create a new explicitly requested goal when none exists, or mark the current goal `complete`/`blocked`.
27
27
 
28
- On startup, the extension sends a best-effort install/update telemetry ping to `mocito.dev` once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, goal objectives, file paths, session data, config values, or API keys. Telemetry writes a local deduplication marker under Pi's agent extensions directory.
28
+ On supported Pi runtimes (Pi 1.1.0 or newer), `update_goal` uses Pi's
29
+ `model-only` exposure. Codemode discovery and nested dispatch through
30
+ `ctx.executeTool()` cannot reach it. A direct update must be the only tool call
31
+ in its assistant turn, after the model has inspected verification results.
32
+ Older runtimes are unsupported; there is no legacy nested-call fallback.
33
+ `get_goal` and `create_goal` remain callable from scripts.
34
+
35
+ Those two tools expose a declared `{ goal }` summary through `structuredContent`,
36
+ not the full persisted entry or accounting ledger. The objective is user data,
37
+ not automatically secret-scanned. Direct text and renderer details keep the
38
+ same summary fields. `get_goal` can persist refreshed accounting, so its metadata
39
+ does not claim a pure read; all goal tool calls are sequential. Namespace and
40
+ annotation metadata are advisory and do not grant creation or completion authority.
41
+
42
+ This is a tool-call boundary, not an independent verifier of completion or
43
+ blocker evidence. The model remains responsible for that evidence. A trusted
44
+ extension can execute arbitrary code with the user's permissions; model-only
45
+ exposure is not a sandbox against installed extensions.
46
+
47
+ On startup, `@mocito/install-telemetry` sends a best-effort install/update telemetry ping to the configured telemetry endpoint once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, goal objectives, file paths, session data, config values, or API keys. Telemetry writes a local deduplication marker under Pi's agent extensions directory.
29
48
 
30
49
  Goal objectives are treated as untrusted user-provided task data when continuation context is built. They are JSON-encoded before being embedded in the hidden context message to reduce prompt-injection risk from delimiter-breaking text.
50
+
51
+ Budget accounting reads finalized usage from the raw current branch, including
52
+ context-omitted entries. Its metadata contains only token totals, entry IDs,
53
+ timestamps, and aggregate scan diagnostics—not prompts, tool arguments, raw
54
+ responses, credentials, or encrypted compaction checkpoints. A session-scoped
55
+ idle poll reads the branch leaf ID and scans changed history without making
56
+ network requests.
57
+
58
+ Budgets are best-effort token accounting, not a hard spending or authorization
59
+ boundary. Unreported usage cannot be counted, active requests can overrun, and
60
+ other extensions can override idle warming decisions. Pause and terminal goal
61
+ states stop goal continuation but do not exclude later branch usage; clear or
62
+ replace a retained goal before unrelated work.
63
+
64
+ Automatic goal continuation is proposed at Pi's `agent_before_settle` boundary,
65
+ after recovery and queued work, rather than starting a new run from a timer.
66
+ Cancelled/error outcomes and failed automatic compaction do not request more
67
+ goal work. Budget and provider-limit notices are context-only and do not request
68
+ a wrap-up turn. These checks control the goal extension's requests, not Pi's own
69
+ recovery, user input, or another trusted extension's continuation.
70
+
71
+ Explicit activation on creation, resume, startup/reload, and tree navigation
72
+ rechecks the current goal, session, branch, pending work, and finalized budget.
73
+ Old activations are invalidated on lifecycle changes. An active stored goal can
74
+ resume on reload; use `/goal pause` to persist a stop across reloads.
75
+ Continuation retains JSON framing for untrusted objectives and refuses stored
76
+ objectives beyond the public 4,000-character limit instead of truncating them.
@@ -19,7 +19,35 @@ export default function piGoal(pi: ExtensionAPI) {
19
19
 
20
20
  let goal: GoalState | null = null;
21
21
  let consecutiveAssistantErrors = 0;
22
- const scheduler = new GoalContinuationScheduler(pi, { getGoal: () => goal });
22
+ let automaticCompactionFailed = false;
23
+ const scheduler = new GoalContinuationScheduler(pi, { getGoal: () => goal, beforeContinue: accountAndEnforceBudget });
24
+ let idleAccountingTimer: ReturnType<typeof setInterval> | undefined;
25
+
26
+ function stopIdleAccounting(): void {
27
+ if (idleAccountingTimer) clearInterval(idleAccountingTimer);
28
+ idleAccountingTimer = undefined;
29
+ }
30
+
31
+ function startIdleAccounting(ctx: ExtensionContext): void {
32
+ stopIdleAccounting();
33
+ let lastLeaf = ctx.sessionManager.getLeafId?.();
34
+ // Pi 1.1 emits warming entry_appended only to SDK listeners, not extensions.
35
+ // Poll the cheap public leaf ID; scan billing history only when it changes.
36
+ idleAccountingTimer = setInterval(() => {
37
+ try {
38
+ if (!ctx.isIdle() || !goal) return;
39
+ const leaf = ctx.sessionManager.getLeafId?.();
40
+ if (leaf === lastLeaf) return;
41
+ accountAndEnforceBudget(ctx);
42
+ lastLeaf = ctx.sessionManager.getLeafId?.();
43
+ } catch {
44
+ // SDK disposal can invalidate contexts without dispatching shutdown.
45
+ // Never keep using a stale context; normal lifecycle checks still apply.
46
+ stopIdleAccounting();
47
+ }
48
+ }, 1000);
49
+ idleAccountingTimer.unref();
50
+ }
23
51
 
24
52
  const setGoal = (next: GoalState | null) => { goal = next; };
25
53
 
@@ -36,11 +64,8 @@ export default function piGoal(pi: ExtensionAPI) {
36
64
  // Send a visible message to the model so it can react to a terminal
37
65
  // status transition (budget exhausted, provider limit hit) instead of
38
66
  // silently continuing to spend tokens on work that is about to be cut
39
- // off. The scheduler has already been cleared at this point, so this
40
- // is the model's only chance to learn about the transition before the
41
- // user notices.
67
+ // off. Terminal notices are context-only: they must not buy another request.
42
68
  function notifyAgentOfTerminalTransition(
43
- ctx: ExtensionContext,
44
69
  content: string,
45
70
  details: Record<string, unknown>,
46
71
  ): void {
@@ -51,7 +76,7 @@ export default function piGoal(pi: ExtensionAPI) {
51
76
  display: true,
52
77
  details: { ...details, piGoalVersion: PI_GOAL_VERSION },
53
78
  },
54
- { triggerTurn: true, deliverAs: ctx.isIdle() ? "steer" : "followUp" },
79
+ { triggerTurn: false },
55
80
  );
56
81
  }
57
82
 
@@ -74,7 +99,6 @@ export default function piGoal(pi: ExtensionAPI) {
74
99
  updateGoalUi(ctx, goal);
75
100
  scheduler.clear();
76
101
  notifyAgentOfTerminalTransition(
77
- ctx,
78
102
  `<provider_limit>\nYour persistent Pi goal has been paused because the provider hit a usage/rate limit${suffix}. The goal is in the "usage_limited" state. Do not do any more work that would use additional tokens. Use /goal resume to continue after the limit resets, or call update_goal with status "complete" or "blocked" to finalize the goal now.\n</provider_limit>`,
79
103
  {
80
104
  kind: "provider_limit",
@@ -103,7 +127,6 @@ export default function piGoal(pi: ExtensionAPI) {
103
127
  ctx.ui.notify("Goal token budget reached.", "warning");
104
128
  scheduler.clear();
105
129
  notifyAgentOfTerminalTransition(
106
- ctx,
107
130
  `<budget_exceeded>\nYour persistent Pi goal's token budget of ${snapshot.tokenBudget} has been reached (used ${snapshot.tokensUsed} tokens). Stop work immediately to avoid burning more tokens. If all requirements are satisfied, call update_goal with status "complete". If the goal cannot be completed, call update_goal with status "blocked". Do not do any more work that would use additional tokens.\n</budget_exceeded>`,
108
131
  {
109
132
  kind: "budget_exceeded",
@@ -119,6 +142,7 @@ export default function piGoal(pi: ExtensionAPI) {
119
142
  registerGoalRenderers(pi);
120
143
  registerGoalTools(pi, {
121
144
  getGoal: () => goal,
145
+ refreshUsage: accountAndEnforceBudget,
122
146
  setGoal,
123
147
  afterGoalChanged,
124
148
  clearContinuation: () => scheduler.clear(),
@@ -128,6 +152,7 @@ export default function piGoal(pi: ExtensionAPI) {
128
152
  description: "set or view the goal for a long-running task",
129
153
  getArgumentCompletions: goalCompletions,
130
154
  handler: async (args, ctx) => {
155
+ accountAndEnforceBudget(ctx);
131
156
  await handleGoalCommand(pi, args, ctx, {
132
157
  getGoal: () => goal,
133
158
  setGoal,
@@ -138,6 +163,9 @@ export default function piGoal(pi: ExtensionAPI) {
138
163
  });
139
164
 
140
165
  pi.on("session_start", async (_event, ctx) => {
166
+ scheduler.clear();
167
+ automaticCompactionFailed = false;
168
+ consecutiveAssistantErrors = 0;
141
169
  const diagnostics: string[] = [];
142
170
  goal = reconstructGoalState(ctx.sessionManager.getBranch() as any[], diagnostics);
143
171
  if (goal?.status === "active" && !goal.activeStartedAt) {
@@ -147,22 +175,48 @@ export default function piGoal(pi: ExtensionAPI) {
147
175
  goal = applyGoalMutation(goal, resumed);
148
176
  }
149
177
  if (diagnostics.length) ctx.ui.notify(`pi-goal: ${diagnostics[0]}`, "warning");
178
+ accountAndEnforceBudget(ctx);
150
179
  updateGoalUi(ctx, goal);
180
+ startIdleAccounting(ctx);
151
181
  if (goal?.status === "active" && ctx.isIdle() && !ctx.hasPendingMessages()) scheduler.schedule(ctx, "continue");
152
182
  });
153
183
 
154
184
  pi.on("session_tree", async (_event, ctx) => {
155
185
  scheduler.clear();
186
+ automaticCompactionFailed = false;
187
+ consecutiveAssistantErrors = 0;
156
188
  goal = reconstructGoalState(ctx.sessionManager.getBranch() as any[]);
189
+ accountAndEnforceBudget(ctx);
157
190
  updateGoalUi(ctx, goal);
158
- if (goal?.status === "active" && ctx.isIdle() && !ctx.hasPendingMessages()) scheduler.schedule(ctx, "continue");
191
+ startIdleAccounting(ctx);
192
+ if (goal?.status === "active" && !ctx.hasPendingMessages()) scheduler.schedule(ctx, "continue", { afterTree: true });
159
193
  });
160
194
 
161
195
  pi.on("session_compact", async (_event, ctx) => {
162
196
  goal = reconstructGoalState(ctx.sessionManager.getBranch() as any[]);
197
+ accountAndEnforceBudget(ctx);
163
198
  updateGoalUi(ctx, goal);
164
199
  });
165
200
 
201
+ pi.on("session_compact_failed", (event) => {
202
+ if (event.reason !== "manual") {
203
+ automaticCompactionFailed = true;
204
+ scheduler.clear();
205
+ }
206
+ });
207
+
208
+ pi.on("agent_start", () => {
209
+ // A real run supersedes any idle activation. Never leave a timer behind
210
+ // that could restart the session after cancellation or recovery.
211
+ scheduler.clear();
212
+ });
213
+
214
+ pi.on("agent_settled", () => {
215
+ // agent_start also fires for queued work inside the same high-level run.
216
+ // Keep a compaction failure latched until that entire run has settled.
217
+ automaticCompactionFailed = false;
218
+ });
219
+
166
220
  pi.on("message_end", async (event: any, ctx) => {
167
221
  if (event.message?.role !== "assistant") return;
168
222
  const classification = classifyAssistantError(event.message);
@@ -183,12 +237,26 @@ export default function piGoal(pi: ExtensionAPI) {
183
237
  });
184
238
 
185
239
  pi.on("turn_end", async (_event, ctx) => {
240
+ // Unlike message_end, this boundary sees persisted assistant/tool entries.
186
241
  accountAndEnforceBudget(ctx);
187
242
  });
188
243
 
189
244
  pi.on("agent_end", async (_event, ctx) => {
190
245
  accountAndEnforceBudget(ctx);
191
- if (goal?.status === "active") scheduler.schedule(ctx, "continue");
246
+ });
247
+
248
+ pi.on("agent_before_settle", (event, ctx) => {
249
+ accountAndEnforceBudget(ctx);
250
+ if (!automaticCompactionFailed) return scheduler.beforeSettle(event, ctx);
251
+ });
252
+
253
+ pi.on("cache_warming_decision", async (_event, ctx) => {
254
+ accountAndEnforceBudget(ctx);
255
+ // Never enable warming or change no-goal sessions. Pi chains actions in
256
+ // registration order, so a later handler can explicitly override this stop.
257
+ if (ctx.isIdle() && goal && (goal.tokenBudget != null || goal.status === "budget_limited" || goal.status === "usage_limited")) {
258
+ return { action: "stop" };
259
+ }
192
260
  });
193
261
 
194
262
  pi.on("after_provider_response", async (event, ctx) => {
@@ -208,7 +276,13 @@ export default function piGoal(pi: ExtensionAPI) {
208
276
  accountAndEnforceBudget(ctx);
209
277
  scheduler.clear();
210
278
  });
279
+ pi.on("session_before_tree", (_event, ctx) => {
280
+ accountAndEnforceBudget(ctx);
281
+ scheduler.clear();
282
+ });
211
283
  pi.on("session_shutdown", async (_event, ctx) => {
284
+ stopIdleAccounting();
285
+ accountAndEnforceBudget(ctx);
212
286
  if (goal?.status === "active") {
213
287
  const time = realizedTimeUsed(goal);
214
288
  const stopped = statusMutation(goal, "active", time, undefined, transitionMeta("session_shutdown", goal, time, nowIso()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mocito/pi-goal",
3
- "version": "0.1.11",
3
+ "version": "0.3.0",
4
4
  "description": "Persistent long-running goals for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,18 +53,21 @@
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-ai": "^0.80.0",
57
- "@earendil-works/pi-coding-agent": "^0.80.0",
58
- "@earendil-works/pi-tui": "^0.80.0",
59
- "@types/node": "^26.1.0",
60
- "typebox": "^1.3.4",
61
- "tsx": "^4.23.0",
62
- "typescript": "^6.0.3"
56
+ "@earendil-works/pi-ai": "1.1.0",
57
+ "@earendil-works/pi-coding-agent": "1.1.0",
58
+ "@earendil-works/pi-tui": "1.1.0",
59
+ "@types/node": "^26.6.3",
60
+ "tsx": "^4.23.15",
61
+ "typebox": "^1.3.34",
62
+ "typescript": "^7.0.2"
63
63
  },
64
64
  "publishConfig": {
65
65
  "access": "public"
66
66
  },
67
67
  "engines": {
68
- "node": ">=20.6.0"
68
+ "node": ">=22.19.0"
69
+ },
70
+ "dependencies": {
71
+ "@mocito/install-telemetry": "0.1.1"
69
72
  }
70
73
  }
package/src/accounting.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { BranchEntry, GoalMutation, GoalState } from "./types.js";
2
- import { GOAL_SCHEMA_VERSION } from "./types.js";
2
+ import { GOAL_ENTRY_TYPE, GOAL_SCHEMA_VERSION } from "./types.js";
3
3
  import { withPiGoalVersion } from "./metadata.js";
4
4
  import { nowIso } from "./utils.js";
5
+ import { isKnownMutation } from "./state.js";
5
6
 
6
7
  export interface UsageAccountingResult {
7
8
  mutation?: GoalMutation;
@@ -12,7 +13,10 @@ export interface UsageAccountingResult {
12
13
 
13
14
  export function assistantUsageTokens(message: any): number {
14
15
  if (!message || message.role !== "assistant") return 0;
15
- const usage = message.usage;
16
+ return usageTokens(message.usage);
17
+ }
18
+
19
+ function usageTokens(usage: any): number {
16
20
  if (!usage || typeof usage !== "object") return 0;
17
21
  if (Number.isFinite(usage.totalTokens)) return Math.max(0, Math.floor(usage.totalTokens));
18
22
  return [usage.input, usage.output, usage.cacheRead, usage.cacheWrite]
@@ -20,28 +24,47 @@ export function assistantUsageTokens(message: any): number {
20
24
  .reduce((sum, n) => sum + Math.max(0, Math.floor(n)), 0);
21
25
  }
22
26
 
27
+ function entryUsage(entry: BranchEntry): any {
28
+ if (entry.type === "message" && (entry.message?.role === "assistant" || entry.message?.role === "toolResult")) {
29
+ // Pi already aggregates every nesting level onto the transcript tool result.
30
+ return entry.message.usage;
31
+ }
32
+ if (entry.type === "usage" || entry.type === "compaction" || entry.type === "branch_summary") return entry.usage;
33
+ }
34
+
23
35
  export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEntry[], endMs = Date.now()): UsageAccountingResult {
24
36
  const accounted = new Set(goal.accountedUsage.entryIds);
25
37
  const createdMs = Date.parse(goal.createdAt);
38
+ // Entry order disambiguates work finalized in the same millisecond as creation.
39
+ // Timestamp-only fallback supports existing callers with an unpersisted goal.
40
+ const startIndex = branchEntries.findIndex((entry) => entry?.type === "custom" && entry.customType === GOAL_ENTRY_TYPE
41
+ && isKnownMutation(entry.data) && entry.data.goalId === goal.goalId && (entry.data.kind === "create" || entry.data.kind === "replace"));
26
42
  let addedTokens = 0;
27
43
  let scannedAssistantEntries = 0;
44
+ let scannedUsageEntries = 0;
28
45
  let cacheTokensIncluded = false;
29
46
  const addedEntryIds: string[] = [];
30
- for (const entry of branchEntries) {
31
- if (entry?.type !== "message" || !entry.id || entry.message?.role !== "assistant") continue;
32
- scannedAssistantEntries++;
47
+ for (const entry of branchEntries.slice(startIndex + 1)) {
48
+ if (!entry || typeof entry.id !== "string" || !entry.id) continue;
49
+ // A retained goal ends at replacement/clear, not pause or terminal status.
50
+ if (entry.type === "custom" && entry.customType === GOAL_ENTRY_TYPE
51
+ && isKnownMutation(entry.data)
52
+ && (entry.data.kind === "create" || entry.data.kind === "replace" || entry.data.kind === "clear")) break;
33
53
  if (accounted.has(entry.id)) continue;
34
54
  const entryMs = Date.parse(entry.timestamp ?? "");
35
- if (Number.isFinite(createdMs) && Number.isFinite(entryMs) && entryMs < createdMs) continue;
36
- const usage = entry.message?.usage;
55
+ if (!Number.isFinite(createdMs) || !Number.isFinite(entryMs) || entryMs < createdMs || entryMs > endMs) continue;
56
+ const usage = entryUsage(entry);
57
+ if (!usage) continue;
58
+ scannedUsageEntries++;
59
+ if (entry.message?.role === "assistant") scannedAssistantEntries++;
37
60
  if (usage && (Number.isFinite(usage.cacheRead) || Number.isFinite(usage.cacheWrite))) cacheTokensIncluded = true;
38
- const tokens = assistantUsageTokens(entry.message);
61
+ const tokens = usageTokens(usage);
39
62
  if (tokens <= 0) continue;
40
63
  addedTokens += tokens;
64
+ accounted.add(entry.id);
41
65
  addedEntryIds.push(entry.id);
42
66
  }
43
67
  if (addedTokens === 0) return { goal, addedTokens: 0, addedEntryIds };
44
- void endMs;
45
68
  const mutation: GoalMutation = {
46
69
  schemaVersion: GOAL_SCHEMA_VERSION,
47
70
  kind: "account",
@@ -51,7 +74,7 @@ export function accountUsageFromBranch(goal: GoalState, branchEntries: BranchEnt
51
74
  at: nowIso(),
52
75
  meta: withPiGoalVersion({
53
76
  source: "accounting",
54
- accounting: { scannedAssistantEntries, addedEntryCount: addedEntryIds.length, cacheTokensIncluded },
77
+ accounting: { scannedAssistantEntries, scannedUsageEntries, addedEntryCount: addedEntryIds.length, cacheTokensIncluded },
55
78
  }),
56
79
  };
57
80
  return {
package/src/commands.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import type { GoalMutation, GoalState, ParsedGoalCommand } from "./types.js";
2
+ import type { GoalMutation, GoalState, GoalStatus, ParsedGoalCommand } from "./types.js";
3
3
  import { GOAL_EVENT_TYPE, GOAL_SCHEMA_VERSION, GOAL_SUMMARY_TYPE } from "./types.js";
4
4
  import { accountUsageFromBranch } from "./accounting.js";
5
5
  import { appendGoalMutation, applyGoalMutation, createGoalMutation, replaceGoalMutation, statusMutation } from "./state.js";
@@ -138,18 +138,21 @@ function accountCurrent(pi: ExtensionAPI, ctx: ExtensionCommandContext, goal: Go
138
138
  return result.goal;
139
139
  }
140
140
 
141
+ const GOAL_ICON = "\u{f08c9}"; // Nerd Fonts 3 nf-md-bullseye_arrow
142
+ const GOAL_STATUS_ICONS: Record<GoalStatus, string> = {
143
+ active: "\u{f040a}", // nf-md-play
144
+ paused: "\u{f03e4}", // nf-md-pause
145
+ blocked: "\u{f033e}", // nf-md-lock
146
+ usage_limited: "\u{f051f}", // nf-md-timer_sand
147
+ budget_limited: "\u{f0199}", // nf-md-counter
148
+ complete: "\u{f012c}", // nf-md-check
149
+ };
150
+
141
151
  export function formatGoalFooter(goal: GoalState | null): string | undefined {
142
152
  if (!goal) return undefined;
143
153
  const summary = goalToSummary(goal);
144
- const budget = summary.tokenBudget ? ` (${summary.tokensUsed >= 1000 ? Math.round(summary.tokensUsed / 1000) + "k" : summary.tokensUsed}/${summary.tokenBudget >= 1000 ? Math.round(summary.tokenBudget / 1000) + "k" : summary.tokenBudget})` : "";
145
- switch (summary.status) {
146
- case "active": return summary.tokenBudget ? `Goal: active${budget}` : "Pursuing goal";
147
- case "paused": return "Goal paused (/goal resume)";
148
- case "blocked": return "Goal blocked (/goal resume)";
149
- case "usage_limited": return "Goal hit usage limits (/goal resume)";
150
- case "budget_limited": return `Goal unmet${budget}`;
151
- case "complete": return `Goal achieved${budget}`;
152
- }
154
+ const budget = summary.tokenBudget ? ` ${summary.tokensUsed >= 1000 ? Math.round(summary.tokensUsed / 1000) + "k" : summary.tokensUsed}/${summary.tokenBudget >= 1000 ? Math.round(summary.tokenBudget / 1000) + "k" : summary.tokenBudget}` : "";
155
+ return `${GOAL_ICON} ${GOAL_STATUS_ICONS[summary.status]}${budget}`;
153
156
  }
154
157
 
155
158
  export function formatWidget(goal: GoalState | null): string[] | undefined {
@@ -1,63 +1,99 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentBeforeSettleEvent, BoundaryResult, CustomMessageEntryDraft, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { buildGoalContextMessage } from "./prompts.js";
3
3
  import { PI_GOAL_VERSION } from "./metadata.js";
4
- import { GOAL_CONTEXT_TYPE } from "./types.js";
4
+ import { GOAL_CONTEXT_TYPE, MAX_OBJECTIVE_CHARS } from "./types.js";
5
5
  import type { GoalContextReason, GoalState } from "./types.js";
6
6
 
7
7
  export interface ContinuationRuntime {
8
8
  getGoal(): GoalState | null;
9
+ beforeContinue?(ctx: ExtensionContext): void;
9
10
  }
10
11
 
11
12
  export class GoalContinuationScheduler {
12
- private scheduled = false;
13
- private inFlight = false;
14
13
  private timer: NodeJS.Timeout | undefined;
15
14
  private turnCount = 0;
16
- private lastGoalId: string | undefined;
15
+ private generation = 0;
17
16
 
18
17
  constructor(private readonly pi: ExtensionAPI, private readonly runtime: ContinuationRuntime) {}
19
18
 
20
- schedule(ctx: ExtensionContext, reason: GoalContextReason | string = "continue"): void {
19
+ /** Only for explicit activation when there is no running settlement boundary. */
20
+ schedule(ctx: ExtensionContext, reason: GoalContextReason | string = "continue", options: { afterTree?: boolean } = {}): void {
21
21
  const goal = this.runtime.getGoal();
22
- if (!goal || goal.status !== "active") return;
23
- if (ctx.hasPendingMessages()) return;
24
- if (this.scheduled || this.inFlight) return;
25
- this.scheduled = true;
26
- this.lastGoalId = goal.goalId;
27
- this.timer = setTimeout(() => {
22
+ if (!goal || goal.status !== "active" || (!options.afterTree && !ctx.isIdle()) || ctx.hasPendingMessages()) return;
23
+ // A replacement or a second explicit activation supersedes the old timer.
24
+ this.clear();
25
+ const generation = this.generation;
26
+ const sessionId = ctx.sessionManager.getSessionId();
27
+ const leafId = ctx.sessionManager.getLeafId();
28
+ const fire = () => {
28
29
  this.timer = undefined;
29
- void this.fire(ctx, reason as GoalContextReason);
30
- }, 0);
30
+ try {
31
+ if (generation !== this.generation || ctx.signal?.aborted || ctx.hasPendingMessages()) return;
32
+ if (ctx.sessionManager.getSessionId() !== sessionId) return;
33
+ if (!ctx.isIdle()) {
34
+ // Pi releases the navigation operation only after ALL session_tree
35
+ // handlers return. A later async handler may still be finishing.
36
+ // agent_start or any invalidation clears this activation meanwhile.
37
+ if (options.afterTree) this.timer = setTimeout(fire, 50);
38
+ return;
39
+ }
40
+ if (leafId && !ctx.sessionManager.getBranch().some((entry) => entry.id === leafId)) return;
41
+ this.runtime.beforeContinue?.(ctx);
42
+ const current = this.runtime.getGoal();
43
+ if (generation !== this.generation || !current || current.status !== "active" || current.goalId !== goal.goalId) return;
44
+ const draft = this.buildDraft(current, reason as GoalContextReason);
45
+ if (!draft) return;
46
+ const { type: _type, ...message } = draft;
47
+ this.pi.sendMessage(message, { triggerTurn: true });
48
+ } catch {
49
+ // A disposed/replaced SDK runtime invalidates captured contexts, even
50
+ // without session_shutdown. Fail closed rather than restart stale work.
51
+ }
52
+ };
53
+ this.timer = setTimeout(fire, 0);
31
54
  }
32
55
 
33
- private async fire(ctx: ExtensionContext, reason: GoalContextReason): Promise<void> {
34
- this.scheduled = false;
56
+ /** The caller accounts finalized usage before making this synchronous proposal. */
57
+ beforeSettle(event: AgentBeforeSettleEvent, ctx: ExtensionContext): BoundaryResult | undefined {
58
+ this.clear();
35
59
  const goal = this.runtime.getGoal();
36
- if (!goal || goal.status !== "active" || goal.goalId !== this.lastGoalId) return;
37
- if (ctx.hasPendingMessages()) return;
38
- this.inFlight = true;
39
- this.turnCount++;
40
- try {
41
- this.pi.sendMessage({
42
- customType: GOAL_CONTEXT_TYPE,
43
- content: buildGoalContextMessage(goal, reason === "created" || reason === "resumed" || reason === "objective_updated" ? reason : "continue"),
44
- display: false,
45
- details: { goalId: goal.goalId, reason, turnCount: this.turnCount, piGoalVersion: PI_GOAL_VERSION, status: goal.status, usage: { tokensUsed: goal.tokensUsed, timeUsedSeconds: goal.timeUsedSeconds } },
46
- }, { triggerTurn: true, deliverAs: ctx.isIdle() ? "steer" : "followUp" });
47
- } finally {
48
- this.inFlight = false;
60
+ if (!goal || goal.status !== "active" || event.outcome !== "completed" || ctx.signal?.aborted) return;
61
+ if (goal.objective.length > MAX_OBJECTIVE_CHARS) return;
62
+ if (ctx.hasPendingMessages() || event.context.pendingMessages.length > 0) return;
63
+
64
+ const proposed = event.entries.some((entry) => entry.type === "custom_message"
65
+ && entry.customType === GOAL_CONTEXT_TYPE && (entry.details as { goalId?: string } | undefined)?.goalId === goal.goalId);
66
+ if (proposed && event.context.canContinue) {
67
+ return { continue: true };
49
68
  }
69
+ // canContinue describes the preview BEFORE our draft. It is normally false
70
+ // after an assistant reply. This user-role custom message makes it runnable;
71
+ // Pi rebuilds and validates the final preview after all handlers have run.
72
+ const draft = this.buildDraft(goal, "continue");
73
+ if (draft) return { entries: [...event.entries, draft], continue: true };
74
+ }
75
+
76
+ private buildDraft(goal: GoalState, reason: GoalContextReason): CustomMessageEntryDraft | undefined {
77
+ // Public inputs are validated already; do not turn oversized legacy/raw
78
+ // session data into an unbounded prompt or silently truncate the objective.
79
+ if (goal.objective.length > MAX_OBJECTIVE_CHARS) return;
80
+ return {
81
+ type: "custom_message",
82
+ customType: GOAL_CONTEXT_TYPE,
83
+ content: buildGoalContextMessage(goal, reason === "created" || reason === "resumed" || reason === "objective_updated" ? reason : "continue"),
84
+ display: false,
85
+ details: { goalId: goal.goalId, reason, turnCount: ++this.turnCount, piGoalVersion: PI_GOAL_VERSION, status: goal.status, usage: { tokensUsed: goal.tokensUsed, timeUsedSeconds: goal.timeUsedSeconds } },
86
+ };
50
87
  }
51
88
 
52
89
  clear(): void {
53
- this.scheduled = false;
54
- this.inFlight = false;
90
+ this.generation++;
55
91
  if (this.timer) clearTimeout(this.timer);
56
92
  this.timer = undefined;
57
93
  }
58
94
 
59
95
  isScheduled(): boolean {
60
- return this.scheduled;
96
+ return this.timer !== undefined;
61
97
  }
62
98
  }
63
99
 
@@ -1,12 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  const PACKAGE_NAME = "pi-goal";
8
- const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
9
- const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
8
+ const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
10
9
  const CI_ENVIRONMENT_VARIABLES = [
11
10
  "APPVEYOR",
12
11
  "BITBUCKET_BUILD_NUMBER",
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
24
23
  "VERCEL",
25
24
  ];
26
25
 
27
- interface InstallTelemetryState {
28
- lastReportedVersion?: string;
29
- }
30
-
31
26
  interface PiSettingsDocument {
32
27
  enableInstallTelemetry?: unknown;
33
28
  }
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
51
46
  return normalized !== "0" && normalized !== "false" && normalized !== "no";
52
47
  }
53
48
 
54
- function isCiEnvironment(): boolean {
55
- if (isTruthyEnvFlag(process.env.CI)) return true;
56
- return CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]));
57
- }
58
-
59
- function isInstallTelemetryEnabled(): boolean {
60
- if (isCiEnvironment()) return false;
61
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
62
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
49
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
50
+ if (isTruthyEnvFlag(env.CI)) return false;
51
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
52
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
63
53
 
64
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
65
- return settings.enableInstallTelemetry !== false;
54
+ const settings = readJsonFile(settingsPath) as PiSettingsDocument;
55
+ if (settings.enableInstallTelemetry === false) return false;
56
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
57
+ return true;
66
58
  }
67
59
 
68
60
  function getPackageVersion(): string {
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
70
62
  return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
71
63
  }
72
64
 
73
- function getInstallTelemetryUserAgent(version: string): string {
74
- const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
75
- const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
76
- return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
77
- }
78
-
79
- async function reportInstallTelemetryAsync(): Promise<void> {
65
+ export function reportInstallTelemetry(): void {
80
66
  try {
81
- if (!isInstallTelemetryEnabled()) return;
82
-
83
- const version = getPackageVersion();
84
- const extensionsDir = join(getAgentDir(), "extensions");
85
- const statePath = join(extensionsDir, "pi-goal-install.json");
86
- const state = readJsonFile(statePath) as InstallTelemetryState;
87
- if (state.lastReportedVersion === version) return;
88
-
89
- await mkdir(extensionsDir, { recursive: true });
90
- await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
91
-
92
- const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
93
- await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
94
- headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
95
- signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
96
- });
67
+ void report({
68
+ endpoint: INSTALL_TELEMETRY_ENDPOINT,
69
+ tool: PACKAGE_NAME,
70
+ version: getPackageVersion(),
71
+ statePath: join(getAgentDir(), "extensions", "pi-goal-install.json"),
72
+ enabled: isInstallTelemetryEnabled(),
73
+ }).catch(() => undefined);
97
74
  } catch {
98
- // Best-effort telemetry: ignore settings, filesystem, and network failures.
75
+ // Best-effort telemetry: ignore local policy and filesystem failures.
99
76
  }
100
77
  }
101
-
102
- export function reportInstallTelemetry(): void {
103
- void reportInstallTelemetryAsync();
104
- }
package/src/state.ts CHANGED
@@ -69,7 +69,7 @@ export function applyGoalMutation(current: GoalState | null, mutation: GoalMutat
69
69
  }
70
70
  }
71
71
 
72
- function isKnownMutation(value: Partial<GoalMutation> | undefined): value is GoalMutation {
72
+ export function isKnownMutation(value: Partial<GoalMutation> | undefined): value is GoalMutation {
73
73
  if (!value || value.schemaVersion !== GOAL_SCHEMA_VERSION || typeof value.kind !== "string" || !validTimestamp(value.at)) return false;
74
74
  const mutation = value as Record<string, unknown>;
75
75
  if (value.kind === "clear") return optionalNonNegativeInteger(mutation.timeUsedSeconds);
@@ -0,0 +1,33 @@
1
+ import { Type } from "typebox";
2
+ import type { GoalSummary } from "./types.js";
3
+
4
+ export const GOAL_NAMESPACE = {
5
+ name: "goal",
6
+ description: "Inspect or explicitly create the current branch's persistent goal.",
7
+ instructions: "get_goal and create_goal return { goal }, with goal:null only when no goal is set. Await dependent calls: create_goal mutates branch state and fails if a goal exists. get_goal refreshes usage bookkeeping before returning a snapshot. Errors reject rather than returning a success object. Creation requires an explicit user/system/developer request. update_goal remains model-only: call it directly, alone in a final assistant turn after verification, never from a script.",
8
+ };
9
+
10
+ const summary = Type.Object({
11
+ goalId: Type.String(),
12
+ // Creation is limited to 4,000 chars; legacy stored objectives remain readable.
13
+ objective: Type.String(),
14
+ status: Type.Union(["active", "paused", "blocked", "usage_limited", "budget_limited", "complete"].map(value => Type.Literal(value))),
15
+ tokenBudget: Type.Optional(Type.Integer({ minimum: 1 })),
16
+ tokensUsed: Type.Number({ minimum: 0 }),
17
+ remainingTokens: Type.Optional(Type.Number()),
18
+ timeUsedSeconds: Type.Number({ minimum: 0 }),
19
+ createdAt: Type.String(), updatedAt: Type.String(), activeStartedAt: Type.Optional(Type.String()),
20
+ }, { additionalProperties: false });
21
+
22
+ export const GET_GOAL_OUTPUT = Type.Object({ goal: Type.Union([summary, Type.Null()]) }, { additionalProperties: false });
23
+ export const CREATE_GOAL_OUTPUT = Type.Object({ goal: summary }, { additionalProperties: false });
24
+
25
+ export function goalResult(goal: GoalSummary | null) {
26
+ const text = JSON.stringify({ goal }, null, 2);
27
+ const payload = JSON.parse(text);
28
+ return {
29
+ content: [{ type: "text" as const, text: goal ? text : "No goal is set." }],
30
+ structuredContent: payload,
31
+ details: payload,
32
+ };
33
+ }
package/src/tools.ts CHANGED
@@ -8,9 +8,11 @@ import { findToolCallContext, transitionMeta } from "./metadata.js";
8
8
  import type { BranchEntry, GoalState, GoalStatus } from "./types.js";
9
9
  import { goalToSummary, goalUsageSummary, nowIso, realizedTimeUsed, truncateOneLine } from "./utils.js";
10
10
  import { validateObjective, validateTokenBudget } from "./validation.js";
11
+ import { CREATE_GOAL_OUTPUT, GET_GOAL_OUTPUT, GOAL_NAMESPACE, goalResult } from "./tool-contracts.js";
11
12
 
12
13
  export interface ToolRuntime {
13
14
  getGoal(): GoalState | null;
15
+ refreshUsage?(ctx: ExtensionContext): void;
14
16
  setGoal(goal: GoalState | null): void;
15
17
  afterGoalChanged(ctx: ExtensionContext, event?: string): void;
16
18
  clearContinuation(): void;
@@ -34,15 +36,21 @@ const UpdateGoalParams = Type.Object({
34
36
  export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void {
35
37
  pi.registerTool({
36
38
  name: "get_goal",
39
+ namespace: GOAL_NAMESPACE,
40
+ outputSchema: GET_GOAL_OUTPUT,
41
+ executionMode: "sequential",
42
+ // Querying refreshes/persists usage accounting; it is not a pure read.
43
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
37
44
  label: "Get Goal",
38
45
  description: "Return the current persistent Pi goal and usage for this session branch.",
39
46
  promptSnippet: "Inspect the active persistent goal and token/time budget.",
40
47
  promptGuidelines: ["Use get_goal when you need current pi-goal objective, status, usage, or remaining budget."],
41
48
  parameters: Type.Object({}, { additionalProperties: false }),
42
- async execute() {
49
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
50
+ runtime.refreshUsage?.(ctx);
43
51
  const goal = runtime.getGoal();
44
52
  const payload = goal ? goalToSummary(goal) : null;
45
- return { content: [{ type: "text", text: payload ? JSON.stringify({ goal: payload }, null, 2) : "No goal is set." }], details: { goal: payload } };
53
+ return goalResult(payload);
46
54
  },
47
55
  renderCall(_args, theme) {
48
56
  return new Text(theme.fg("toolTitle", theme.bold("get_goal")), 0, 0);
@@ -55,6 +63,9 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
55
63
 
56
64
  pi.registerTool({
57
65
  name: "create_goal",
66
+ namespace: GOAL_NAMESPACE,
67
+ outputSchema: CREATE_GOAL_OUTPUT,
68
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
58
69
  label: "Create Goal",
59
70
  description: "Create a persistent Pi goal only when explicitly requested by user/system/developer instructions. Fails if a goal already exists.",
60
71
  promptSnippet: "Create a new persistent goal when explicitly requested.",
@@ -76,7 +87,7 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
76
87
  runtime.setGoal(goal);
77
88
  runtime.afterGoalChanged(ctx, "Goal created.");
78
89
  const summary = goalToSummary(goal);
79
- return { content: [{ type: "text", text: JSON.stringify({ goal: summary }, null, 2) }], details: { goal: summary } };
90
+ return goalResult(summary);
80
91
  },
81
92
  renderCall(args, theme) {
82
93
  return new Text(`${theme.fg("toolTitle", theme.bold("create_goal"))} ${theme.fg("accent", truncateOneLine(String((args as any).objective ?? "")))}`, 0, 0);
@@ -89,15 +100,19 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
89
100
 
90
101
  pi.registerTool({
91
102
  name: "update_goal",
103
+ namespace: GOAL_NAMESPACE,
104
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
92
105
  label: "Update Goal",
93
- description: "Mark the current Pi goal complete or blocked. Only model-controlled terminal goal updates are accepted.",
106
+ description: "Mark the current Pi goal complete or blocked. Call directly as the only tool call in a separate final assistant turn, after inspecting verification results. Not callable from codemode or other tools.",
94
107
  promptSnippet: "Mark the persistent goal complete or blocked after strict verification.",
95
108
  promptGuidelines: [
109
+ "Call update_goal directly as the only tool call in a separate final assistant turn, never from codemode or another tool.",
96
110
  "Use update_goal with status complete only after explicit requirement-by-requirement verification proves the full goal is satisfied.",
97
111
  "Use update_goal with status blocked only after the same blocker repeats for at least three consecutive goal turns.",
98
112
  "Never use update_goal for pause, resume, budget_limited, usage_limited, or edits; those are user/system-controlled.",
99
113
  ],
100
114
  parameters: UpdateGoalParams,
115
+ exposure: "model-only",
101
116
  executionMode: "sequential",
102
117
  async execute(toolCallId, params, _signal, _onUpdate, ctx) {
103
118
  if (params.status !== "complete" && params.status !== "blocked") throw new Error("update_goal only accepts status complete or blocked.");
@@ -142,6 +157,8 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: ToolRuntime): void
142
157
 
143
158
  export function assertStandaloneUpdateGoalCall(branchEntries: BranchEntry[], toolCallId: string) {
144
159
  const toolContext = findToolCallContext(branchEntries, toolCallId);
160
+ // A direct executor may have no transcript context. This guard checks sibling
161
+ // calls, not caller authority: Pi enforces nested-call denial via model-only exposure.
145
162
  if (!toolContext) return undefined;
146
163
  if (toolContext.siblingToolCalls.length > 1) {
147
164
  throw new Error("update_goal must be the only tool call in its assistant turn. Run verification commands first, inspect their results, then call update_goal in a separate final turn.");
package/src/types.ts CHANGED
@@ -48,6 +48,7 @@ export interface GoalMutationMeta {
48
48
  };
49
49
  accounting?: {
50
50
  scannedAssistantEntries?: number;
51
+ scannedUsageEntries?: number;
51
52
  addedEntryCount?: number;
52
53
  cacheTokensIncluded?: boolean;
53
54
  };
package/src/ui.ts CHANGED
@@ -3,11 +3,13 @@ import type { GoalState } from "./types.js";
3
3
  import { formatGoalFooter, formatWidget } from "./commands.js";
4
4
 
5
5
  export function updateGoalUi(ctx: ExtensionContext, goal: GoalState | null): void {
6
+ if (ctx.mode !== "tui") return;
6
7
  ctx.ui.setStatus("pi-goal", formatGoalFooter(goal));
7
8
  ctx.ui.setWidget("pi-goal", formatWidget(goal), { placement: "belowEditor" });
8
9
  }
9
10
 
10
11
  export function clearGoalUi(ctx: ExtensionContext): void {
12
+ if (ctx.mode !== "tui") return;
11
13
  ctx.ui.setStatus("pi-goal", undefined);
12
14
  ctx.ui.setWidget("pi-goal", undefined);
13
15
  }