@mocito/pi-goal 0.1.11 → 0.2.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,39 @@
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.2.0] - 2026-10-09
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+ - Mark usage-refreshing queries as stateful and serialize them with goal mutations.
15
+
16
+ ### Fixed
17
+
18
+ - Restrict footer/widget updates to TUI mode; print, JSON and RPC retain goal lifecycle behavior without terminal writes.
19
+
20
+ ### Changed
21
+
22
+ - 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.
23
+ - Share install telemetry mechanics through `@mocito/install-telemetry` while preserving Pi-specific settings and state paths.
24
+ - 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.
25
+
26
+ ### Fixed
27
+
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - 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`.
32
+ - Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
33
+ - 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.
34
+ - 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.
35
+
36
+ ## [0.1.11] - 2026-07-17
6
37
 
7
38
  ### Fixed
8
39
 
@@ -10,20 +41,20 @@
10
41
  - Derive persisted `piGoalVersion` metadata from the package version so release versions cannot drift.
11
42
  - Ignore malformed persisted mutations instead of allowing invalid status, budget, or accounting data to corrupt reconstructed goal state.
12
43
 
13
- ## 0.1.10 - 2026-07-01
44
+ ## [0.1.10] - 2026-07-01
14
45
 
15
46
  ### Changed
16
47
 
17
48
  - Update Pi core development dependencies for Pi 0.80 compatibility.
18
49
 
19
- ## 0.1.9 - 2026-06-16
50
+ ## [0.1.9] - 2026-06-16
20
51
 
21
52
  ### Fixed
22
53
 
23
54
  - 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
55
  - 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
56
 
26
- ## 0.1.8 - 2026-06-08
57
+ ## [0.1.8] - 2026-06-08
27
58
 
28
59
  ### Fixed
29
60
 
@@ -31,7 +62,7 @@
31
62
  - 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
63
  - Preserve the elapsed-time fix for old sessions by ignoring legacy account mutation `timeUsedSeconds` fields during reconstruction.
33
64
 
34
- ## 0.1.7 - 2026-06-08
65
+ ## [0.1.7] - 2026-06-08
35
66
 
36
67
  ### Fixed
37
68
 
@@ -41,7 +72,7 @@
41
72
 
42
73
  - Pause goals on assistant-surfaced provider usage/quota/billing limit errors and repeated provider failures, not only HTTP 429 response hooks.
43
74
 
44
- ## 0.1.6 - 2026-06-06
75
+ ## [0.1.6] - 2026-06-06
45
76
 
46
77
  ### Changed
47
78
 
@@ -49,14 +80,26 @@
49
80
  - Aligned package structure with monorepo guidelines (added root `index.ts` re-export, `.editorconfig`, `.gitignore`; fixed extension entry point; removed non-canonical tsconfig flags).
50
81
  - Update `author` field to full name for monorepo consistency.
51
82
 
52
- ## 0.1.3
83
+ ## [0.1.3] - 2026-06-06
84
+
85
+ ### Changed
86
+
87
+ - Temporarily rename the package to unscoped `pi-goal`; this version was not published because that npm name was unavailable.
88
+
89
+ ## [0.1.2] - 2026-05-28
90
+
91
+ ### Changed
53
92
 
54
93
  - Move the extension entry point to `extensions/index.ts` so Pi displays the package extension compactly.
55
94
 
56
- ## 0.1.1
95
+ ## [0.1.1] - 2026-05-28
96
+
97
+ ### Added
57
98
 
58
99
  - Add best-effort install/update telemetry, gated by Pi telemetry/offline settings and disabled in CI.
59
100
 
60
- ## 0.1.0
101
+ ## [0.1.0] - 2026-05-28
102
+
103
+ ### Added
61
104
 
62
105
  - 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,148 @@ 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
63
+
64
+ On Pi 1.1.0 or newer, `get_goal` and `create_goal` declare output schemas:
65
+
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.
42
74
 
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.
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.
44
98
 
45
- The footer and optional editor widget show status, elapsed active time, token usage, and budget.
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.
46
102
 
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.
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 and optional editor widget show status, elapsed active time, token usage, and budget.
135
+ Print, JSON and RPC retain the goal lifecycle without terminal footer/widget updates.
136
+
137
+ 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.
138
+
139
+ ### Token accounting and attribution
140
+
141
+ The goal counts reported tokens from finalized assistant messages, tool results,
142
+ standalone usage entries (including cache warming), compactions, and branch
143
+ summaries. Failed or cancelled calls count when Pi persists their usage.
144
+ Nested tool usage is already aggregated by Pi onto the parent tool result and
145
+ is counted once, not by walking nested calls. Cached input tokens count too.
146
+ This is a token budget, not a currency or provider-quota budget.
147
+
148
+ Virtual-model selections do not change that rule: Pi records the physical
149
+ assistant's usage, and codemode classifier usage is aggregated onto its parent
150
+ tool result. However, a raw `ctx.modelRegistry.classify()` call inside a router
151
+ does **not** automatically create a session usage entry in Pi 1.1.0. The goal
152
+ cannot charge an unrecorded result. Paid automatic routers need an explicit,
153
+ single-count attribution contract before they can be treated as budget-safe;
154
+ do not infer free classification from an unchanged goal total. Classifier
155
+ judgments are not completion evidence or permission to bypass approvals.
156
+
157
+ The accounting interval starts at the goal's creation/replacement entry and
158
+ ends when it is replaced or cleared on that branch. Earlier work is excluded,
159
+ including entries before creation with the same timestamp. Entries need valid
160
+ timestamps within the interval and no later than the scan time.
161
+ As in earlier v1 releases, **pause stops continuation, not accounting**:
162
+ work while paused, limited, blocked, or complete is still charged to the
163
+ retained goal. Clear or replace the goal before doing unrelated work.
164
+ Completion reports are snapshots of finalized usage at completion.
165
+
166
+ Accounting uses raw branch history, not the model-context projection. Compacted
167
+ or context-omitted billed attempts still count. Reloads and forks retain the
168
+ inherited entry-ID ledger; divergent branch work is not imported. A tree
169
+ summary's own usage belongs to the destination branch. Navigating back before
170
+ work was done restores that branch's earlier total, not a session-wide spend cap.
171
+
172
+ Schema-v1 mutations remain compatible; no migration or session rewrite is needed.
173
+ Previously accounted assistant tokens and IDs are retained. The next scan
174
+ backfills newly supported usage entries once, so old goals may show higher
175
+ totals or reach their budgets after upgrading.
176
+
177
+ Budget enforcement scans saved usage at turn end, before settlement, after
178
+ compaction/tree navigation, on reload, before scheduled continuation, and when
179
+ goals are queried or changed. Pi 1.1 has no extension notification for newly
180
+ saved idle usage, so a session-scoped one-second poll checks for branch changes
181
+ while idle. It does not make provider requests. A budget notice updates visible
182
+ state and model context **without requesting another model turn**.
183
+
184
+ ### Idle cache warming
185
+
186
+ The extension requests `stop` at an idle warming decision when the retained
187
+ goal has a token budget or is `budget_limited`/`usage_limited`. It never requests
188
+ `warm`, changes Pi's settings, or overrides decisions for no-goal sessions or
189
+ unbudgeted, non-limited goals. Pi uses the last handler that returns an action;
190
+ a later extension can explicitly override this stop.
191
+
192
+ This policy is idle-only. It does not intercept every provider request, cancel
193
+ in-flight warming, or stop streaming-phase warming. Any persisted warming usage
194
+ within the goal interval is still accounted.
48
195
 
49
196
  ## Examples
50
197
 
@@ -83,15 +230,16 @@ Environment flags:
83
230
  ## Troubleshooting
84
231
 
85
232
  - 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`.
233
+ - 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
234
  - If context appears stale after tree navigation or reload, run `/goal status`; branch state is reconstructed from the active branch.
88
235
  - In print/JSON modes, commands and tools work, but interactive confirmations/editors are unavailable.
89
236
 
90
237
  ## Limitations
91
238
 
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.
239
+ - 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
240
  - 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
241
  - Automatic continuation is session-local, not a background daemon.
242
+ - Oversized stored objectives (over 4,000 characters) are not automatically continued or silently truncated. Edit the objective before resuming.
95
243
 
96
244
  ## Development and validation
97
245
 
@@ -105,6 +253,33 @@ npm run pack:dry-run --workspace packages/pi-goal
105
253
  npm audit --omit=dev
106
254
  ```
107
255
 
256
+ The session-contract suite uses real Pi loading, tool dispatch, provider
257
+ serialization, and session trees with synthetic goals and mocked provider
258
+ traffic. It checks direct and nested calls, codemode discovery, termination,
259
+ reloads, model changes, and branch reconstruction without live credentials or
260
+ provider requests.
261
+
262
+ Accounting regressions also cover multi-level metered codemode calls, failed
263
+ results, mocked cache warming and remote compaction, branch summaries, context
264
+ omissions, inherited fork histories, and tool-only budget exhaustion. The warming
265
+ tests load the compaction/reasoning hooks in both orders and verify that idle
266
+ refreshes do not change saved reasoning state.
267
+
268
+ The settlement suite exercises the production extension in real Pi sessions
269
+ with bounded mocked responses. It covers cancellation (including retry and
270
+ pre-settlement cancellation), successful/exhausted retries, compaction
271
+ success/failure, canonical context edits, user queues, extension ordering,
272
+ terminal tools, startup/resume/reload, replacement, forks, tree navigation, and
273
+ TUI/print/JSON/RPC bindings. It also advances controlled timers after settlement
274
+ to detect an obsolete timer that would restart the run. No live credentials are
275
+ used; HTTP responses are mocked and WebSocket traffic is blocked.
276
+
277
+ Run the focused lifecycle smoke test from the monorepo root:
278
+
279
+ ```bash
280
+ node --import tsx --test packages/pi-goal/tests/settlement-contract.test.mjs
281
+ ```
282
+
108
283
  Before publishing, also run the root validation loop:
109
284
 
110
285
  ```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.2.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 {
@@ -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
  }