@astrosheep/pi-goal-next 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,13 +29,13 @@ For a project-local extension, add the package path to the project's Pi extensio
29
29
  /goal clear
30
30
  ```
31
31
 
32
- Creating a goal is refused while an unfinished goal exists. `edit` clears the current goal and creates a new active goal with its limits preserved. `pause` and `resume` are user commands; `clear` removes the goal semantically from the journal. Token suffixes accepted by create are `k` and `M` (for example, `200k` and `2M`).
32
+ Creating a goal is refused while an unfinished goal exists. `edit` atomically replaces the current goal, preserves its limits, and records `goal.replaced`; it never exposes an intermediate cleared state. `pause` and `resume` are user commands; `clear` removes the goal semantically from the journal. Token suffixes accepted by create are `k` and `M` (for example, `200k` and `2M`).
33
33
 
34
34
  ## Model tools
35
35
 
36
36
  - `get_goal` returns the current snapshot plus `remainingBudget` and `elapsedSeconds`, or reports that no goal exists.
37
37
  - `create_goal` creates an active goal after an explicit request. It accepts `objective` and optional `token_budget` (a positive integer).
38
- - `update_goal` accepts only `complete` or `blocked` after the model's self-audit. The host does not validate the declaration; on `complete` the result reports the final token usage.
38
+ - `update_goal` accepts only `complete` or `blocked` after the model's self-audit. `complete` is permitted from `active` and `budget_limited`, not `paused` or `blocked`; the host does not validate the declaration. On `complete` the result reports the final token usage.
39
39
 
40
40
  The prompt text is copied byte-for-byte from Codex Goal (`continuation.md`, `budget_limit.md`, `objective_updated.md`); the only deletion is Codex's `update_plan` "Progress visibility" paragraph, because Pi has no `update_plan` tool. Continuation prompts state the objective inside `<objective>` as user-provided data and carry the budget, evidence, fidelity, completion-audit, and blocked-audit rules. The three-consecutive-turn blocked audit is prompt-level guidance; the runtime does not enforce it.
41
41
 
@@ -53,24 +53,24 @@ The prompt text is copied byte-for-byte from Codex Goal (`continuation.md`, `bud
53
53
  ^
54
54
  update_goal blocked|
55
55
 
56
- active --accounting limit--> budget_limited
56
+ active --accounting limit--> budget_limited --update_goal complete--> complete
57
57
  any unfinished --/goal clear--> no goal
58
58
  ```
59
59
 
60
- `complete` is terminal. A goal is one journal-folded goal per session branch; a new goal cannot be created until the prior one is complete (or cleared). `blocked` is a model declaration, not an automatic retry decision. Codex's three-consecutive-turn blocker guard lives verbatim in the continuation prompt and the `update_goal` description; it is prompt-level only, not enforced in runtime code.
60
+ `complete` is terminal. It may be declared from `active` or `budget_limited`, never from `paused` or `blocked`. A goal is one journal-folded goal per session branch; a new goal cannot be created until the prior one is complete (or cleared). `blocked` is a model declaration, not an automatic retry decision. Codex's three-consecutive-turn blocker guard lives verbatim in the continuation prompt and the `update_goal` description; it is prompt-level only, not enforced in runtime code.
61
61
 
62
62
  ## Limits and accounting
63
63
 
64
64
  - The default maximum is 25 continuation turns (`maxContinuations`) per run. Change it with `/goal turns N`. `/goal resume` resets the run's continuation count and immediately schedules work when idle. It preserves the objective, journal history, cumulative usage, and token budget. If the token budget is exhausted or the continuation allowance is zero, resume reports the limit instead of claiming success.
65
65
  - A token budget is unset by default. Set one at creation with `/goal --tokens N[k|M] ...` or later with `/goal budget N`.
66
66
  - Usage comes directly from each completed assistant/tool-result event. Repeated delivery of the same live message object is deduplicated. Pi has not assigned a session entry id at this event boundary; historical messages are not re-accounted on reload.
67
- - Missing provider usage is recorded as unknown, never treated as confirmed zero.
67
+ - In usage journal intents, omitted usage fields serialize as zero; an explicit `null` preserves existing unknown semantics. An absent entire Pi usage object remains unknown.
68
68
  - Nested or subagent usage is counted only when Pi exposes it as `toolResult.usage` data; see [limits](docs/limits.md).
69
- - The final completing turn is included in accounting.
69
+ - Usage is attributed to the goal that owns the run. The final completing turn remains charged; runs begun after a goal is complete, paused, or blocked are unrelated and are not charged to it.
70
70
 
71
71
  ## Completion and blocking contract
72
72
 
73
- The model calls `update_goal` after auditing the current goal against the Codex completion audit carried in every continuation prompt. The host accepts `complete` and `blocked` at face value and performs no independent verification; the prompt is the only guard. `complete` reports final token usage in the tool result, and `blocked` is meant to follow three consecutive turns with the same blocker. `paused` is user-only (`/goal pause`). Runtime accounting may instead move an active goal to `budget_limited`; the model cannot declare that state, and the transition triggers one `budget_limit.md` steering message.
73
+ The model calls `update_goal` after auditing the current goal against the Codex completion audit carried in every continuation prompt. The host accepts `complete` and `blocked` at face value and performs no independent verification; the prompt is the only guard. `complete` may transition an `active` or `budget_limited` goal and reports final token usage; it is prohibited from `paused` and `blocked`. `blocked` is meant to follow three consecutive turns with the same blocker. `paused` is user-only (`/goal pause`). Runtime accounting may instead move an active goal to `budget_limited`; the model cannot declare that state, and the transition triggers one `budget_limit.md` steering message.
74
74
 
75
75
  Automatic continuation waits for a fresh normal assistant completion and for the entire Pi run to settle, including tools, retries, compaction, and queued input. Errors, cancellation (including cancellation after text finishes), and runs ending at a tool boundary do not trigger continuation or budget steering. Ordinary user input has no continuation attached; once the response to that input finishes normally, an active goal may continue.
76
76
 
@@ -10,7 +10,7 @@ Pi 0.85.1 extension package adding a long-running `/goal`. Behavior follows Code
10
10
 
11
11
  `active | paused | blocked | budget_limited | complete`
12
12
 
13
- - Model's `update_goal` accepts only: `complete` | `blocked`. The model self-audits; the host does not validate the declaration.
13
+ - Model's `update_goal` accepts only: `complete` | `blocked`. `complete` is permitted from `active` and `budget_limited`, but not from `paused` or `blocked`. The model self-audits; the host does not validate the declaration.
14
14
  - `budget_limited` is set only by runtime/system paths (accounting).
15
15
  - `clear` is a journal entry `goal.cleared`; `fold()` yields null. Deletion is semantic.
16
16
  - One goal per session branch. `create_goal` refuses when an unfinished goal exists.
@@ -37,11 +37,12 @@ Dependency direction: `index → {tools, commands, lifecycle, ui} → {goal-comm
37
37
 
38
38
  ```ts
39
39
  current(): GoalSnapshot | null // { goal, revision }
40
+ getRevision(): number // available even when current() is null
40
41
  commit(intent, expectedRevision): Promise<{ ok, snapshot } | { conflict, snapshot }>
41
42
  subscribe(fn): unsubscribe
42
43
  ```
43
44
 
44
- Internally: check `revision === expectedRevision` and no pending → mark pending → `await store.append` → success: swap in, revision+1, notify; failure: release pending, report. Critical section wraps one append only — never verification, prompt building, or sendMessage.
45
+ Internally: check `revision === expectedRevision` and no pending → mark pending → `await store.append` → success: swap in, revision+1, notify; failure: release pending, report. `getRevision()` remains available after a clear, so a clear→create sequence can use the current CAS revision. Critical section wraps one append only — never verification, prompt building, or sendMessage.
45
46
 
46
47
  ## Two counters
47
48
 
@@ -62,7 +63,7 @@ User messages win: `input` invalidates outstanding continuation attempts before
62
63
 
63
64
  1. `session_start`: invalidate pending work, rebuild, and transition any restored `active` goal to `paused`. Never silently resume.
64
65
  2. `session_before_tree`: invalidate pending work; the next event rebuilds from the selected branch.
65
- 3. `session_before_compact`: provide `goal.summarize()` text if the hook supports the required fields.
66
+ 3. Pi generates its normal conversation summary without a goal override. Goal state remains in its separate journal; `get_goal` and continuation prompts restore the relevant goal context.
66
67
  4. `agent_start`: reset completion state and capture `ctx.signal`. Retain that signal after Pi clears its current signal at idle, so cancellation during message/agent-end handlers still suppresses continuation.
67
68
  5. `message_end`: account the current message and capture its stop reason. Pi dispatches this hook **before** appending the message to SessionManager; never substitute the last persisted same-role message.
68
69
  6. `input` / user `message_start`: invalidate old scheduling and track input receipt through delivery.
@@ -72,8 +73,9 @@ Retry messages are separately accounted because each response costs real tokens.
72
73
 
73
74
  ## accounting rules
74
75
 
75
- - Dedup key: an in-memory ID assigned by WeakMap to each assistant/toolResult event message object. Session entry IDs do not yet exist at `message_end`. Usage deltas are journaled immediately; historical messages are not replayed into accounting on reload.
76
- - Only count usage Pi reports. Missing usage recorded as `unknown`, never assumed zero.
76
+ - Dedup key: an in-memory ID assigned by WeakMap to each assistant/toolResult event message object. Session entry IDs do not yet exist at `message_end`. Usage deltas are journaled immediately; historical messages are not replayed into accounting on reload. A message is acknowledged only after its usage commit succeeds. Failed writes remain pending for the next message or settled event; unresolved writes suppress automatic continuation.
77
+ - Usage is attributed to the goal that owns the run. The final run completing a goal remains charged; later unrelated runs after complete, pause, or block do not charge that goal.
78
+ - In usage journal intents, omitted usage fields serialize as zero; an explicit `null` preserves existing unknown semantics. An absent entire Pi usage object remains unknown.
77
79
  - toolResult.usage (nested/subagent usage) counts only when present; the coverage gap is disclosed in UI + limits.md.
78
80
  - Final completing turn IS accounted (same as both reference implementations).
79
81
 
@@ -93,7 +95,7 @@ The blocked audit is prompt-level only: the runtime does not count blocking turn
93
95
 
94
96
  `/goal` or `/goal status` · `/goal [--tokens N[k|M]] <objective>` (create; refuses while unfinished) · `/goal edit <objective>` · `/goal pause` · `/goal resume` · `/goal clear` · `/goal budget <tokens|none>` · `/goal turns <max-continuations>`.
95
97
 
96
- A successful `resume` atomically journals `resetContinuations: true` on the user transition to active, resetting the run's continuation count while preserving the objective, usage, budget, and historical entries. It also accepts an already-active goal. Exhausted token budgets and zero continuation allowances are reported without resuming. A successful `create` or `resume` calls `continuation.onSettled()` so an idle session starts pursuing immediately (Codex starts the turn directly). A successful `edit` sends `objectiveUpdatedPrompt(goal)` with `triggerTurn: true`; `pause` and `clear` send nothing (the active-status check stops continuation).
98
+ A successful `resume` atomically journals `resetContinuations: true` on the user transition to active, resetting the run's continuation count while preserving the objective, usage, budget, and historical entries. It also accepts an already-active goal. Exhausted token budgets and zero continuation allowances are reported without resuming. A successful `create` or `resume` calls `continuation.onSettled()` so an idle session starts pursuing immediately (Codex starts the turn directly). A successful `edit` atomically appends `goal.replaced`, preserving limits without an intermediate clear, then sends `objectiveUpdatedPrompt(goal)` with `triggerTurn: true`; `pause` and `clear` send nothing (the active-status check stops continuation).
97
99
 
98
100
  ## Defaults
99
101
 
@@ -104,7 +106,7 @@ max continuations (turns): 25 · token budget: unset (opt-in) · the blocked-loo
104
106
  1. Stale already-sent continuation runs one more turn (no retraction, no selective abort).
105
107
  2. Subagent/nested tokens counted only when toolResult.usage present.
106
108
  3. Stale identity rides on continuation custom messages observed via `message_start`; if those events disappear, prompt self-termination is the fallback.
107
- 4. Whether compaction hook accepts appended summary needs verification; harmless if not.
109
+ 4. Pi owns normal conversation-summary generation; goal context is restored separately from the journal through `get_goal` and continuation prompts.
108
110
 
109
111
  ## Testing
110
112
 
package/docs/limits.md CHANGED
@@ -16,9 +16,17 @@ The extension uses the public Pi 0.85.1 event, session, message, and send APIs.
16
16
 
17
17
  **Cannot guarantee:** Pi does not expose nested or subagent token usage consistently for every tool result.
18
18
 
19
- **What it does:** Counts nested usage only when it is present in `toolResult.usage`; missing usage is recorded as unknown. Top-level assistant and tool-result events are deduplicated by live message-object identity, since their session entry IDs do not yet exist during `message_end`. Journaled usage survives reload; historical events are not counted again.
19
+ **What it does:** Counts nested usage only when it is present in `toolResult.usage`. In usage journal intents, omitted usage fields serialize as zero; an explicit `null` preserves existing unknown semantics. An absent entire Pi usage object remains unknown. Top-level assistant and tool-result events are deduplicated by live message-object identity, since their session entry IDs do not yet exist during `message_end`. Journaled usage survives reload; historical events are not counted again.
20
20
 
21
- **User sees:** The status line can show `unknown messages=N`, and budget totals can be lower than provider-side totals when nested usage was not reported.
21
+ **User sees:** The status line can show `unknown messages=N`, and budget totals can be lower than provider-side totals when nested usage was not reported. If a usage journal write fails, automatic continuation is suppressed and the next message or settled event retries it. Pending, uncommitted usage is held in memory and cannot survive a reload or branch change.
22
+
23
+ ### Goal-owned run usage
24
+
25
+ **Cannot guarantee:** Usage for an in-flight response cannot be split precisely at the instant a user pauses or replaces a goal.
26
+
27
+ **What it does:** Attributes usage to the goal that owns a run. A goal's final completing run remains charged; later unrelated runs after it is complete, paused, or blocked do not charge that goal.
28
+
29
+ **User sees:** Finishing, pausing, or blocking a goal does not cause later unrelated conversation usage to accumulate against it.
22
30
 
23
31
  ### Continuation message identity
24
32
 
@@ -28,13 +36,13 @@ The extension uses the public Pi 0.85.1 event, session, message, and send APIs.
28
36
 
29
37
  **User sees:** If Pi stops emitting custom-message events, stale turns degrade to prompt self-termination only; subsequent continuation remains subject to normal checks.
30
38
 
31
- ### Compaction extension content
39
+ ### Compaction and goal context
32
40
 
33
- **Cannot guarantee:** Pi may not accept extension-provided appended compaction content on every hook/version path.
41
+ **Cannot guarantee:** Pi controls the content and timing of its normal conversation summary.
34
42
 
35
- **What it does:** The 0.85.1 hook accepts a `CompactionResult`; it returns the goal summary when the hook provides `preparation.firstKeptEntryId` and numeric `tokensBefore`. The defensive field check remains for older/newer hook variants. Continuation prompts remain self-contained.
43
+ **What it does:** Does not override Pi's compaction summary. Goal state remains in a separate journal and is restored through `get_goal` and self-contained continuation prompts.
36
44
 
37
- **User sees:** After some compactions the normal goal summary may be absent from the compaction context, while the goal itself remains in the journal.
45
+ **User sees:** Normal conversation compaction proceeds under Pi's standard behavior; the goal remains available independently of that summary.
38
46
 
39
47
  ## Additional implementation boundaries
40
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-goal-next",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Persistent autonomous goals for pi, with Codex-verbatim goal semantics: continuation prompts, self-audited completion, budgets, and CAS-journaled state.",
5
5
  "type": "module",
6
6
  "files": [
package/src/accounting.ts CHANGED
@@ -19,6 +19,11 @@ export function createAccounting() {
19
19
  const unknown = new Set<string>();
20
20
  const deltas = new Map<string, Delta>();
21
21
 
22
+ function previewMessage(message: Message): Omit<Delta, "stale"> {
23
+ const u = message.usage;
24
+ return { input: n(u?.input) ?? 0, output: n(u?.output) ?? 0, cacheRead: n(u?.cacheRead) ?? 0, cacheWrite: n(u?.cacheWrite) ?? 0, unknownMessages: u ? 0 : 1 };
25
+ }
26
+
22
27
  function recordMessage(message: Message): { duplicate: boolean; delta: Delta } | null {
23
28
  if (!message || typeof message.entryId !== "string" || !message.entryId) return null;
24
29
  if (seen.has(message.entryId)) return { duplicate: true, delta: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, unknownMessages: 0, stale: stale.has(message.entryId) } };
@@ -51,5 +56,5 @@ export function createAccounting() {
51
56
  const unknownText = u.unknownMessages || unknown.size ? `, unknown messages=${u.unknownMessages + unknown.size}` : "";
52
57
  return `usage input=${u.input} output=${u.output} cacheRead=${u.cacheRead} cacheWrite=${u.cacheWrite}${unknownText}${staleText}`;
53
58
  }
54
- return { recordMessage, recordStale, settleTurn, summary };
59
+ return { previewMessage, hasMessage: (id: string) => seen.has(id), recordMessage, recordStale, settleTurn, summary };
55
60
  }
package/src/commands.ts CHANGED
@@ -28,7 +28,7 @@ export function registerGoalCommands(piLike: CommandPiLike, deps: CommandDeps):
28
28
  piLike.registerCommand("goal", { description: "Manage the current goal", handler: async (raw: string, ctx?: { ui?: { notify(message: string, level?: string): void } }) => {
29
29
  const run = async (cmd: ReturnType<typeof parseGoalCommand>): Promise<string> => {
30
30
  const current = goalCommit.current();
31
- const revision = current?.revision ?? 0;
31
+ const revision = current?.revision ?? goalCommit.getRevision();
32
32
  const finish = (r: Awaited<ReturnType<GoalCommitLike["commit"]>>, ok: string) => r.kind === "ok" ? ok : "Goal update failed.";
33
33
  switch (cmd.kind) {
34
34
  case "status": return summarize(current);
@@ -39,10 +39,8 @@ export function registerGoalCommands(piLike: CommandPiLike, deps: CommandDeps):
39
39
  return r.kind === "ok" ? "Goal created." : "Goal update failed.";
40
40
  }
41
41
  case "edit": {
42
- const previous = current;
43
- const cleared = await goalCommit.commit({ type: "clear" }, revision);
44
- if (cleared.kind !== "ok") return "Goal update failed.";
45
- const r = await goalCommit.commit({ type: "create", id: newGoalId(), objective: cmd.objective as string, tokenBudget: previous?.goal.tokenBudget, maxContinuations: previous?.goal.maxContinuations }, cleared.snapshot?.revision ?? revision);
42
+ if (!current) return "Goal update failed.";
43
+ const r = await goalCommit.commit({ type: "replace", id: newGoalId(), objective: cmd.objective as string }, revision);
46
44
  if (r.kind === "ok" && r.snapshot) deps.send({ customType: "pi-goal-next/objective_updated", content: objectiveUpdatedPrompt(r.snapshot.goal), display: false, details: { goalId: r.snapshot.goal.id } }, { triggerTurn: true });
47
45
  return r.kind === "ok" ? "Goal edited." : "Goal update failed.";
48
46
  }
@@ -10,7 +10,7 @@ export type CommitResult =
10
10
  | { kind: "ok"; snapshot: GoalSnapshot | null }
11
11
  | { kind: "conflict"; snapshot: GoalSnapshot | null }
12
12
  | { kind: "error"; error: unknown };
13
- export type GoalCommitLike = Pick<ReturnType<typeof createGoalCommit>, "current" | "commit">;
13
+ export type GoalCommitLike = Pick<ReturnType<typeof createGoalCommit>, "current" | "commit" | "getRevision">;
14
14
 
15
15
  export function createGoalCommit(store: GoalStore) {
16
16
  let bootstrapped = false;
@@ -34,13 +34,16 @@ export function createGoalCommit(store: GoalStore) {
34
34
  return goal ? { goal, revision } : null;
35
35
  }
36
36
 
37
+ function getRevision(): number { bootstrap(); return revision; }
38
+
37
39
  function entryFor(intent: Intent, next: Goal | null, previous: Goal | null): Entry {
38
40
  const seq = ++sequence;
39
41
  switch (intent.type) {
40
42
  case "create": return { type: "goal.created", version: 1, seq, goal: next! };
43
+ case "replace": return { type: "goal.replaced", version: 1, seq, goal: next! };
41
44
  case "clear": return { type: "goal.cleared", version: 1, seq };
42
45
  case "transition": return { type: "goal.transition", version: 1, seq, from: previous!.status, to: intent.to, by: intent.by, ...(intent.userRequest ? { userRequest: intent.userRequest } : {}), ...(intent.resetContinuations ? { resetContinuations: true } : {}) };
43
- case "usage": return { type: "goal.usage", version: 1, seq, input: intent.input ?? null, output: intent.output ?? null, cacheRead: intent.cacheRead ?? null, cacheWrite: intent.cacheWrite ?? null, unknownMessages: intent.unknownMessages ?? 0 };
46
+ case "usage": return { type: "goal.usage", version: 1, seq, input: intent.input === undefined ? 0 : intent.input, output: intent.output === undefined ? 0 : intent.output, cacheRead: intent.cacheRead === undefined ? 0 : intent.cacheRead, cacheWrite: intent.cacheWrite === undefined ? 0 : intent.cacheWrite, unknownMessages: intent.unknownMessages ?? 0 };
44
47
  case "continuation_sent": return { type: "goal.continuation_sent", version: 1, seq, generation: intent.generation };
45
48
  case "stale_turn": return { type: "goal.stale_turn", version: 1, seq, generation: intent.generation };
46
49
  case "limit_config": return { type: "goal.limit_config", version: 1, seq, tokenBudget: intent.tokenBudget, maxContinuations: intent.maxContinuations };
@@ -89,5 +92,5 @@ export function createGoalCommit(store: GoalStore) {
89
92
  for (const subscriber of subscribers) subscriber(snapshot);
90
93
  }
91
94
 
92
- return { current, commit, subscribe, rebuild };
95
+ return { current, getRevision, commit, subscribe, rebuild };
93
96
  }
package/src/goal.ts CHANGED
@@ -7,6 +7,7 @@ export type Goal = {
7
7
  };
8
8
  export type Entry =
9
9
  | { type: "goal.created"; version: 1; seq: number; goal: Goal }
10
+ | { type: "goal.replaced"; version: 1; seq: number; goal: Goal }
10
11
  | { type: "goal.transition"; version: 1; seq: number; from: Status; to: Status; by: Actor; userRequest?: string; resetContinuations?: boolean }
11
12
  | { type: "goal.cleared"; version: 1; seq: number }
12
13
  | { type: "goal.usage"; version: 1; seq: number; input: number | null; output: number | null; cacheRead: number | null; cacheWrite: number | null; unknownMessages: number }
@@ -15,6 +16,7 @@ export type Entry =
15
16
  | { type: "goal.limit_config"; version: 1; seq: number; tokenBudget: number | null; maxContinuations: number };
16
17
  export type Intent =
17
18
  | { type: "create"; id: string; objective: string; tokenBudget?: number | null; maxContinuations?: number }
19
+ | { type: "replace"; id: string; objective: string }
18
20
  | { type: "transition"; to: Status; by: Actor; userRequest?: string; resetContinuations?: boolean }
19
21
  | { type: "clear" }
20
22
  | { type: "usage"; input?: number | null; output?: number | null; cacheRead?: number | null; cacheWrite?: number | null; unknownMessages?: number }
@@ -40,6 +42,10 @@ function safeAdd(a: number, b: number, name: string): number {
40
42
  return result;
41
43
  }
42
44
  export function transition(state: Goal | null, intent: Intent): Goal | null {
45
+ if (intent.type === "replace") {
46
+ if (!state) throw new GoalError("missing", "no goal exists");
47
+ return transition(null, { type: "create", id: intent.id, objective: intent.objective, tokenBudget: state.tokenBudget, maxContinuations: state.maxContinuations });
48
+ }
43
49
  if (intent.type === "create") {
44
50
  if (state && unfinished(state.status)) throw new GoalError("unfinished", "an unfinished goal already exists");
45
51
  if (typeof intent.objective !== "string" || !intent.objective.trim() || !intent.id) throw new GoalError("invalid", "objective and id are required");
@@ -54,7 +60,7 @@ export function transition(state: Goal | null, intent: Intent): Goal | null {
54
60
  if (!statuses.includes(intent.to)) throw new GoalError("invalid", "unknown status");
55
61
  if (intent.to === "paused" && !intent.userRequest?.trim()) throw new GoalError("forbidden", "paused requires user request evidence");
56
62
  if (intent.by === "agent" && !["complete", "blocked"].includes(intent.to)) throw new GoalError("forbidden", "agent cannot set this status");
57
- if (intent.by === "agent" && intent.to === "complete" && state.status !== "active") throw new GoalError("forbidden", "agent can complete only an active goal");
63
+ if (intent.by === "agent" && intent.to === "complete" && !["active", "budget_limited"].includes(state.status)) throw new GoalError("forbidden", "agent can complete only an active or budget-limited goal");
58
64
  const reset = intent.resetContinuations === true;
59
65
  if (reset && (intent.by !== "user" || intent.to !== "active")) throw new GoalError("forbidden", "only user resume can reset continuations");
60
66
  if (reset && state.tokenBudget !== null && usageKeys.reduce((sum, key) => sum + state.usage[key], 0) >= state.tokenBudget) throw new GoalError("budget", "token budget exhausted; adjust /goal budget before resuming");
@@ -96,7 +102,7 @@ export function fold(entries: readonly Entry[]): Goal | null {
96
102
  let state: Goal | null = null;
97
103
  for (const entry of entries) {
98
104
  if (!entry.type.startsWith("goal.")) continue;
99
- if (entry.type === "goal.created") state = { ...entry.goal };
105
+ if (entry.type === "goal.created" || entry.type === "goal.replaced") state = { ...entry.goal };
100
106
  else if (entry.type === "goal.cleared") state = null;
101
107
  else if (state && entry.type === "goal.transition") state = transition(state, { type: "transition", to: entry.to, by: entry.by, userRequest: entry.userRequest, resetContinuations: entry.resetContinuations });
102
108
  else if (state && entry.type === "goal.limit_config") state = transition(state, { type: "limit_config", tokenBudget: entry.tokenBudget, maxContinuations: entry.maxContinuations });
package/src/lifecycle.ts CHANGED
@@ -1,11 +1,10 @@
1
- import { summarize } from "./goal.ts";
2
1
  import { budgetLimitPrompt } from "./prompts.ts";
3
2
  import type { Message } from "./accounting.ts";
4
3
  import type { GoalSnapshot, CommitResult } from "./goal-commit.ts";
5
4
  import type { Intent } from "./goal.ts";
6
5
  import { createAccounting } from "./accounting.ts";
7
6
  import type { Continuation } from "./continuation.ts";
8
- import type { SessionBeforeCompactEvent, MessageEndEvent, MessageStartEvent, InputEvent, AgentSettledEvent, SessionStartEvent } from "@earendil-works/pi-coding-agent";
7
+ import type { MessageEndEvent, MessageStartEvent, InputEvent, AgentSettledEvent, SessionStartEvent } from "@earendil-works/pi-coding-agent";
9
8
 
10
9
  export type LifecycleDeps = {
11
10
  goalCommit: { current(): GoalSnapshot | null; commit(intent: Intent, expectedRevision: number): Promise<CommitResult> };
@@ -17,13 +16,15 @@ export type LifecycleDeps = {
17
16
 
18
17
  export type PiEvents = { on(name: string, handler: (event: any, ctx: any) => unknown): void };
19
18
  export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
20
- async function commitWithRetry(intent: Intent, attempts = 3): Promise<void> {
19
+ async function commitWithRetry(intent: Intent, attempts = 3, expectedGoalId?: string): Promise<boolean> {
21
20
  for (let i = 0; i < attempts; i++) {
22
21
  const snapshot = deps.goalCommit.current();
23
- if (!snapshot) return;
22
+ if (!snapshot || (expectedGoalId !== undefined && snapshot.goal.id !== expectedGoalId)) return false;
24
23
  const result = await deps.goalCommit.commit(intent, snapshot.revision);
25
- if (result.kind !== "conflict") return;
24
+ if (result.kind === "ok") return true;
25
+ if (result.kind === "error") return false;
26
26
  }
27
+ return false;
27
28
  }
28
29
  let pendingRebuild = false;
29
30
  const bootstrap = () => deps.rebuild();
@@ -35,6 +36,30 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
35
36
  let epoch = 0;
36
37
  let inputPending = false;
37
38
  let runSignal: AbortSignal | undefined;
39
+ // Keep ownership through completion so its remaining tool results/final reply
40
+ // count, but never transfer an old run's usage to a replacement goal.
41
+ let accountingGoalId: string | null = null;
42
+ let startGoalId: string | null = null;
43
+ const pendingUsage = new Map<string, { goalId: string; message: Message }>();
44
+ let usageFlush: Promise<boolean> | undefined;
45
+ async function flushUsage(): Promise<boolean> {
46
+ if (usageFlush) return usageFlush;
47
+ usageFlush = (async () => {
48
+ for (const [id, item] of pendingUsage) {
49
+ if (deps.goalCommit.current()?.goal.id !== item.goalId) { pendingUsage.delete(id); continue; }
50
+ const delta = deps.accounting.previewMessage(item.message);
51
+ if (!await commitWithRetry({ type: "usage", ...delta }, 3, item.goalId)) return false;
52
+ deps.accounting.recordMessage(item.message);
53
+ pendingUsage.delete(id);
54
+ }
55
+ return true;
56
+ })();
57
+ try { return await usageFlush; } finally { usageFlush = undefined; }
58
+ }
59
+ const eligibleGoalId = () => {
60
+ const goal = deps.goalCommit.current()?.goal;
61
+ return goal && (goal.status === "active" || goal.status === "budget_limited") ? goal.id : null;
62
+ };
38
63
  // message_end precedes SessionManager.appendMessage. Its object is authoritative;
39
64
  // no persisted entry ID exists yet. Deduplicate live events by object identity.
40
65
  const messageIds = new WeakMap<object, string>();
@@ -49,11 +74,16 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
49
74
  lastAssistantStop = undefined;
50
75
  // Retain the signal: ctx.signal becomes undefined once the run is idle.
51
76
  runSignal = ctx.signal;
77
+ startGoalId = deps.goalCommit.current()?.goal.id ?? null;
78
+ accountingGoalId = eligibleGoalId();
52
79
  });
53
80
 
54
81
  pi.on("session_start", async (_event: SessionStartEvent, ctx: any) => {
55
82
  invalidate();
56
83
  inputPending = false;
84
+ accountingGoalId = null;
85
+ startGoalId = null;
86
+ pendingUsage.clear();
57
87
  await bootstrap();
58
88
  const snapshot = deps.goalCommit.current();
59
89
  if (snapshot?.goal?.status === "active") {
@@ -64,38 +94,34 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
64
94
  pi.on("session_before_tree", async (_event: any, ctx: any) => {
65
95
  invalidate();
66
96
  inputPending = false;
97
+ accountingGoalId = null;
98
+ startGoalId = null;
99
+ pendingUsage.clear();
67
100
  pendingRebuild = true;
68
101
  // Pi's tree hook does not expose the post-navigation branch; rebuild on the next event.
69
102
  void ctx;
70
103
  });
71
104
 
72
- pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, _ctx: any) => {
73
- const snapshot = deps.goalCommit.current();
74
- if (!snapshot) return;
75
- if (event?.preparation?.firstKeptEntryId && Number.isFinite(event.preparation.tokensBefore)) {
76
- return { compaction: { summary: summarize(snapshot.goal), firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore } };
77
- }
78
- // Older Pi hooks may not support extension-provided compaction content.
79
- return;
80
- });
81
-
82
105
  const before = async (_ctx: any) => { if (pendingRebuild) { pendingRebuild = false; await bootstrap(); } };
83
106
  pi.on("message_end", async (event: MessageEndEvent, ctx: any) => {
84
107
  await before(ctx);
85
108
  const m = event.message;
86
109
  if (m.role !== "assistant" && m.role !== "toolResult") return;
87
110
  if (m.role === "assistant") lastAssistantStop = m.stopReason;
111
+ // A create_goal tool can establish ownership after agent_start.
112
+ const eligible = eligibleGoalId();
113
+ if (!accountingGoalId && (!startGoalId || startGoalId === eligible)) accountingGoalId = eligible;
114
+ if (!accountingGoalId || deps.goalCommit.current()?.goal.id !== accountingGoalId) return;
88
115
  let entryId = messageIds.get(m);
89
116
  if (!entryId) { entryId = `live-message:${++messageSeq}`; messageIds.set(m, entryId); }
90
117
  const message: Message = { ...m, entryId };
91
- const recorded = deps.accounting.recordMessage(message);
92
- if (recorded && !recorded.duplicate) {
93
- const delta = recorded.delta;
94
- await commitWithRetry({ type: "usage", input: delta.input, output: delta.output, cacheRead: delta.cacheRead, cacheWrite: delta.cacheWrite, unknownMessages: delta.unknownMessages });
95
- }
118
+ if (!deps.accounting.hasMessage(entryId)) pendingUsage.set(entryId, { goalId: accountingGoalId, message });
119
+ await flushUsage();
96
120
  });
97
121
  pi.on("agent_settled", async (_event: AgentSettledEvent, ctx: any) => {
98
122
  await before(ctx);
123
+ if (!await flushUsage()) return; // No automatic work while usage persistence is unresolved.
124
+ accountingGoalId = null;
99
125
  const snapshot = deps.goalCommit.current();
100
126
  if (!snapshot) { steeredGoalId = null; steered = false; return; }
101
127
  const settledEpoch = epoch;
@@ -124,7 +150,12 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
124
150
  await deps.continuation.onSettled(canSend);
125
151
  });
126
152
  pi.on("message_start", async (event: MessageStartEvent, ctx: any) => {
127
- if (event.message.role === "user") { inputPending = false; invalidate(); }
153
+ if (event.message.role === "user") {
154
+ inputPending = false;
155
+ invalidate();
156
+ startGoalId = deps.goalCommit.current()?.goal.id ?? null;
157
+ accountingGoalId = eligibleGoalId();
158
+ }
128
159
  await before(ctx);
129
160
  await deps.continuation.onMessageStart(event.message);
130
161
  });
package/src/store.ts CHANGED
@@ -3,7 +3,7 @@ export type BranchContext = { sessionManager: { getBranch(): readonly unknown[]
3
3
  export type PiCustomEntry = { type: "custom"; customType: string; data: unknown };
4
4
  export type PiAppender = { appendEntry(customType: string, data: unknown): unknown };
5
5
  export const VERSION = 1 as const;
6
- const entryTypes = new Set(["goal.created", "goal.transition", "goal.cleared", "goal.usage", "goal.continuation_sent", "goal.stale_turn", "goal.limit_config"]);
6
+ const entryTypes = new Set(["goal.created", "goal.replaced", "goal.transition", "goal.cleared", "goal.usage", "goal.continuation_sent", "goal.stale_turn", "goal.limit_config"]);
7
7
  export function isEntry(entry: unknown): entry is Entry { return !!entry && typeof entry === "object" && (entry as { version?: unknown }).version === VERSION && entryTypes.has((entry as { type?: unknown }).type as string) && Number.isSafeInteger((entry as { seq?: unknown }).seq); }
8
8
  export function readBranch(ctx: BranchContext): Entry[] { return ctx.sessionManager.getBranch().filter((item): item is PiCustomEntry => { if (!item || typeof item !== "object") return false; const wrapped = item as Partial<PiCustomEntry>; return wrapped.type === "custom" && wrapped.customType === "pi-goal-next"; }).map(item => item.data).filter(isEntry); }
9
9
  export async function append(pi: PiAppender, entry: Entry): Promise<void> { if (!isEntry(entry)) throw new Error("unsupported journal version"); await pi.appendEntry("pi-goal-next", entry); }
package/src/tools.ts CHANGED
@@ -40,7 +40,7 @@ export function registerGoalTools(piLike: PiLike, deps: GoalToolDeps): void {
40
40
  if (!params || typeof params.objective !== "string" || !params.objective.trim()) return result("Invalid objective: a non-empty objective is required.");
41
41
  const current = goalCommit.current();
42
42
  if (current && current.goal.status !== "complete") return result("Cannot create goal: an unfinished goal already exists.");
43
- const revision = current?.revision ?? 0;
43
+ const revision = current?.revision ?? goalCommit.getRevision();
44
44
  const intent: Intent = { type: "create", id: newGoalId(), objective: params.objective, tokenBudget: params.token_budget ?? null };
45
45
  const r = await goalCommit.commit(intent, revision);
46
46
  return result(r.kind === "ok" ? "Goal created." : commitMessage(r));