@astrosheep/pi-goal-next 0.1.3 → 0.1.5

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
@@ -61,9 +61,9 @@ The prompt text is copied byte-for-byte from Codex Goal (`continuation.md`, `bud
61
61
 
62
62
  ## Limits and accounting
63
63
 
64
- - The default maximum is 25 continuation turns (`maxContinuations`). Change it with `/goal turns N`.
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
- - Usage is attributed by message entry id, separately for assistant and tool-result messages. Duplicate entry ids are ignored.
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
67
  - Missing provider usage is recorded as unknown, never treated as confirmed zero.
68
68
  - Nested or subagent usage is counted only when Pi exposes it as `toolResult.usage` data; see [limits](docs/limits.md).
69
69
  - The final completing turn is included in accounting.
@@ -72,4 +72,6 @@ The prompt text is copied byte-for-byte from Codex Goal (`continuation.md`, `bud
72
72
 
73
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.
74
74
 
75
- Continuation messages are sent only after a successful journal commit and only while Pi is idle with no pending messages. A continuation carries the goal id, generation, and sequence number. User input and compare-and-swap conflicts prevent a new continuation from being sent.
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
+
77
+ Continuation messages are sent only after a successful journal commit. The plugin checks cancellation, goal state, and pending input again after that commit, immediately before sending. A continuation carries the goal id, generation, and sequence number. Input received during scheduling invalidates the automatic send. Explicit `/goal` create/resume can start work immediately when idle.
@@ -27,7 +27,7 @@ Pi 0.85.1 extension package adding a long-running `/goal`. Behavior follows Code
27
27
  | `prompts.ts` | Pure: the three Codex-verbatim goal templates — `continuationPrompt(goal)`, `budgetLimitPrompt(goal)`, `objectiveUpdatedPrompt(goal)`; `escapeXmlText` applies to the objective only. | IO, model calls, host-side validation |
28
28
  | `tools.ts` | Codex-verbatim tool descriptions, TypeBox schema → `goalCommit.commit` → tool result. Three tools: get_goal / create_goal / update_goal. `update_goal` accepts only complete\|blocked and reports final usage on complete. | writing rules text, touching store/continuation |
29
29
  | `commands.ts` | `/goal` subcommands → goal-commit. | direct store access |
30
- | `lifecycle.ts` | Stateless event mapping (owns only sessionId). Session/branch/compaction policies plus budget-limit steering, see below. | holding Goal state, growing beyond the listed policies |
30
+ | `lifecycle.ts` | Event ordering, live-message identities, run cancellation signal, and input/settlement fences. Session/branch/compaction policies plus budget-limit steering, see below. | holding Goal state |
31
31
  | `ui.ts` | status/widget text from `current()` + `accounting.summary()`. Read-only. | writes |
32
32
  | `index.ts` | dependency assembly + registration only. | logic |
33
33
 
@@ -46,28 +46,33 @@ Internally: check `revision === expectedRevision` and no pending → mark pendin
46
46
  ## Two counters
47
47
 
48
48
  - `revision` (goal-commit): +1 per commit, CAS basis.
49
- - `generation` (continuation): the implementation currently invalidates it on tree navigation (`session_before_tree`). Pause, clear, resume, and session restore do not call the continuation invalidation hook; status changes are still protected by the active-status check and the commit CAS. Every continuation message carries `{ goalId, generation, seq }` in details.
49
+ - `generation` (continuation): invalidated on session restore, tree navigation, input receipt, and user-message delivery. Pause and clear are protected by the active-status check and the commit CAS. Every continuation message carries `{ goalId, generation, seq }` in details. A separate lifecycle epoch rejects settlement work overtaken by another run or input.
50
50
 
51
51
  ## Continuation protocol
52
52
 
53
- `agent_settled` read `current()` + require `ctx.isIdle() && !ctx.hasPendingMessages()` `commit(continuation_sent {generation, seq}, revision)` only on success `pi.sendMessage(continuation, { triggerTurn: true })`. On conflict: abandon this round, wait for next settle.
53
+ `message_end` captures the current event's stop reason. Only a fresh `stop` or `length` response authorizes automatic work at `agent_settled`, after Pi has drained tools, retries, compaction, and queued messages. Settlement consumes that authorization once. The run's captured abort signal must not be aborted, no new input may be awaiting delivery, and Pi must be idle with no queued messages.
54
+
55
+ Then read `current()` → `commit(continuation_sent {generation}, revision)` → recheck lifecycle authorization, generation, goal identity/status, idle state, and pending messages → `pi.sendMessage(continuation, { triggerTurn: true })`. On conflict or invalidation, abandon this attempt. An abandoned attempt may consume a sequence number because Pi cannot atomically commit the journal and admit a new turn. Explicit `/goal` create/resume can start idle work without a preceding assistant response.
54
56
 
55
57
  Stale continuation: Pi has no message retraction and `abort` cannot selectively cancel a goal message, so **never abort**. On `message_start`, lifecycle notifies continuation; if the custom continuation message is ours and generation is stale, record `stale_turn`. The current implementation does not propagate that marker to accounting usage deltas and does not have a separate stale-turn scheduling fence; subsequent scheduling still uses the normal active-status, idle/pending-message, limit, and CAS checks. Every continuation message body is Codex's `continuation.md` verbatim: the objective is wrapped in `<objective>` as user-provided data, and the budget, evidence, fidelity, completion-audit, blocked-audit, and closing rules are stated inline.
56
58
 
57
- User messages win: `hasPendingMessages()` check + CAS conflict as backstop.
59
+ User messages win: `input` invalidates outstanding continuation attempts before Pi queues the message. Delivery at `message_start` clears the input-preflight fence and invalidates old completion state. Idle/queue checks are repeated after the commit await. Ordinary input injects no goal prompt; a normal response to that input may continue the still-active goal once the whole run settles.
58
60
 
59
61
  ## lifecycle.ts — the lifecycle policies
60
62
 
61
- 1. `session_start`: any restored `active` goal → commit a system transition to `paused` (the journal does not contain a separate `loaded` entry). Never silently resume; the continuation generation is not explicitly invalidated by this hook.
62
- 2. `session_before_tree`: tell continuation to void generation; next event triggers rebuild from `getBranch()` via goal-commit.
63
- 3. `session_before_compact`: append `goal.summarize()` text to the compaction if the hook allows (verify at implementation; continuation messages are self-contained regardless).
64
- 4. `agent_settled`: commit the accounting verdict, then — if the current goal is `budget_limited` and this goal instance has not been steered yet — send `budgetLimitPrompt(goal)` with `triggerTurn: true`. Steering is sent once per goal instance (`steeredGoalId`/`steered` closure state, reset on a null snapshot or a new goal id). Then run `continuation.onSettled()`, except that it skips continuation when the previous turn ended error/aborted (structured `stopReason`; such turns produced no completed work).
63
+ 1. `session_start`: invalidate pending work, rebuild, and transition any restored `active` goal to `paused`. Never silently resume.
64
+ 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
+ 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
+ 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
+ 6. `input` / user `message_start`: invalidate old scheduling and track input receipt through delivery.
69
+ 7. `agent_settled`: require and consume a fresh normal, uncancelled completion. Apply accounting limits, recheck input/run eligibility, and send budget steering once per goal instance if eligible. Otherwise run `continuation.onSettled(canSend)` with the same eligibility checks across the commit await. Error, aborted, toolUse-only, and empty runs send nothing.
65
70
 
66
- No other business. Retry needs no lifecycle handling accounting dedupes by message id; retry messages have new ids and are honestly counted (they cost real tokens).
71
+ Retry messages are separately accounted because each response costs real tokens. Only the final successful run can authorize continuation.
67
72
 
68
73
  ## accounting rules
69
74
 
70
- - Dedup key: session entry id of the assistant/toolResult message.
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.
71
76
  - Only count usage Pi reports. Missing usage → recorded as `unknown`, never assumed zero.
72
77
  - toolResult.usage (nested/subagent usage) counts only when present; the coverage gap is disclosed in UI + limits.md.
73
78
  - Final completing turn IS accounted (same as both reference implementations).
@@ -88,7 +93,7 @@ The blocked audit is prompt-level only: the runtime does not count blocking turn
88
93
 
89
94
  `/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>`.
90
95
 
91
- 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).
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).
92
97
 
93
98
  ## Defaults
94
99
 
@@ -103,4 +108,4 @@ max continuations (turns): 25 · token budget: unset (opt-in) · the blocked-loo
103
108
 
104
109
  ## Testing
105
110
 
106
- vitest or node:test (implementer's choice, state it). fake-pi support harness drives events. Required: goal transitions incl. illegal ones; CAS conflict/pending/append-failure; branch fold isolation; dedup by message id; settleTurn verdicts incl. final-turn accounting; continuation send-only-after-commit, conflict-abandon, generation fencing, stale turn; prompt templates (escaping, budget math, injection guard, no update_plan); tools schema; command flows; lifecycle policies incl. budget steering; integration replay of a full goal session on fake-pi.
111
+ node:test covers goal transitions, journal replay, CAS conflicts, limits, prompts, commands, and lifecycle races. `test/pi-session.test.ts` additionally runs real Pi 0.85.1 AgentSessions with in-memory settings/sessions and a deterministic model stream (no network). It verifies pre-append event ordering, usage accounting, normal→error/abort transitions, abort during streaming and at message/agent-end boundaries, steering delivery, and the absence of a continuation attached to user input. The pre-fix 0.1.4 code fails the real-session error/abort regressions.
package/docs/limits.md CHANGED
@@ -16,7 +16,7 @@ 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 messages are deduplicated by entry id.
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.
20
20
 
21
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.
22
22
 
@@ -42,7 +42,7 @@ The extension uses the public Pi 0.85.1 event, session, message, and send APIs.
42
42
 
43
43
  **Cannot guarantee:** Restoring a session does not prove that an in-flight continuation was never sent before the process stopped.
44
44
 
45
- **What it does:** Rebuilds from the selected branch and converts any restored `active` goal to `paused` with a system transition. It does not silently resume and does not explicitly invalidate the continuation generation in this hook.
45
+ **What it does:** Invalidates outstanding scheduling, rebuilds from the selected branch, and converts any restored `active` goal to `paused` with a system transition. It does not silently resume.
46
46
 
47
47
  **User sees:** A restored active goal is paused and requires `/goal resume`. A previously sent message may still be present in the provider transcript, but the restored status prevents normal continuation scheduling.
48
48
 
@@ -74,6 +74,6 @@ The extension uses the public Pi 0.85.1 event, session, message, and send APIs.
74
74
 
75
75
  **Cannot guarantee:** Event delivery and message queue timing are controlled by Pi.
76
76
 
77
- **What it does:** Requires idle state and no pending messages before the continuation CAS commit, then sends only after that commit succeeds. A user message arriving concurrently is handled by the pending-message check or a CAS conflict.
77
+ **What it does:** Requires a fresh normal completion and an uncancelled run. Input receipt invalidates pending scheduling before Pi queues the message; its delivery clears the input-preflight fence. Idle/queue state, cancellation, and lifecycle eligibility are checked both before and after the continuation CAS commit. Pi does not expose an atomic journal-commit-and-start-if-idle operation, so a cancelled attempt may consume a continuation sequence number without sending a prompt.
78
78
 
79
- **User sees:** A continuation can be skipped after a race with user input; the goal remains available for the next settled event or explicit resume.
79
+ **User sees:** A continuation can be skipped after a race with user input; the goal remains available after a later normal completion or explicit resume. If another extension handles/rejects input without delivering a user message, automatic scheduling stays suppressed until a later user message is delivered; explicit resume remains available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-goal-next",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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/commands.ts CHANGED
@@ -49,7 +49,13 @@ export function registerGoalCommands(piLike: CommandPiLike, deps: CommandDeps):
49
49
  case "clear": return finish(await goalCommit.commit({ type: "clear" }, revision), "Goal cleared.");
50
50
  case "pause": return finish(await goalCommit.commit({ type: "transition", to: "paused", by: "user", userRequest: '"/goal pause"' }, revision), "Goal paused.");
51
51
  case "resume": {
52
- const r = await goalCommit.commit({ type: "transition", to: "active", by: "user" }, revision);
52
+ if (!current) return "Cannot resume: no goal exists.";
53
+ const goal = current.goal;
54
+ if (goal.status === "complete") return "Cannot resume: goal is complete.";
55
+ const used = goal.usage.input + goal.usage.output + goal.usage.cacheRead + goal.usage.cacheWrite;
56
+ if (goal.tokenBudget !== null && used >= goal.tokenBudget) return "Cannot resume: token budget exhausted. Adjust /goal budget first; cumulative usage is preserved.";
57
+ if (goal.maxContinuations === 0) return "Cannot resume: continuation allowance is zero. Adjust /goal turns first.";
58
+ const r = await goalCommit.commit({ type: "transition", to: "active", by: "user", resetContinuations: true }, revision);
53
59
  if (r.kind === "ok") await deps.kick();
54
60
  return r.kind === "ok" ? "Goal resumed." : "Goal update failed.";
55
61
  }
@@ -18,7 +18,7 @@ export type ContinuationDeps = {
18
18
 
19
19
  export type Continuation = {
20
20
  invalidate(): void;
21
- onSettled(): Promise<void>;
21
+ onSettled(canSend?: () => boolean): Promise<void>;
22
22
  onMessageStart(message: unknown): Promise<void>;
23
23
  hadStaleTurn(): boolean;
24
24
  };
@@ -32,8 +32,8 @@ export function createContinuation(deps: ContinuationDeps): Continuation {
32
32
  generation += 1;
33
33
  }
34
34
 
35
- async function onSettled(): Promise<void> {
36
- if (!deps.isIdle() || deps.hasPendingMessages()) return;
35
+ async function onSettled(canSend: () => boolean = () => true): Promise<void> {
36
+ if (!canSend() || !deps.isIdle() || deps.hasPendingMessages()) return;
37
37
  const snapshot = deps.getSnapshot();
38
38
  if (!snapshot || snapshot.goal.status !== "active") return;
39
39
  const { goal, revision } = snapshot;
@@ -45,6 +45,7 @@ export function createContinuation(deps: ContinuationDeps): Continuation {
45
45
  if (result.kind !== "ok" || generation !== leaseGeneration) return;
46
46
  const latest = deps.getSnapshot();
47
47
  if (!latest || latest.goal.id !== goal.id || latest.goal.status !== "active") return; // user may have cleared/paused during the commit await
48
+ if (!canSend() || !deps.isIdle() || deps.hasPendingMessages()) return;
48
49
 
49
50
  deps.send({
50
51
  customType: "pi-goal-next/continuation",
@@ -39,7 +39,7 @@ export function createGoalCommit(store: GoalStore) {
39
39
  switch (intent.type) {
40
40
  case "create": return { type: "goal.created", version: 1, seq, goal: next! };
41
41
  case "clear": return { type: "goal.cleared", version: 1, seq };
42
- case "transition": return { type: "goal.transition", version: 1, seq, from: previous!.status, to: intent.to, by: intent.by, ...(intent.userRequest ? { userRequest: intent.userRequest } : {}) };
42
+ 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
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 };
44
44
  case "continuation_sent": return { type: "goal.continuation_sent", version: 1, seq, generation: intent.generation };
45
45
  case "stale_turn": return { type: "goal.stale_turn", version: 1, seq, generation: intent.generation };
package/src/goal.ts CHANGED
@@ -7,7 +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.transition"; version: 1; seq: number; from: Status; to: Status; by: Actor; userRequest?: string }
10
+ | { type: "goal.transition"; version: 1; seq: number; from: Status; to: Status; by: Actor; userRequest?: string; resetContinuations?: boolean }
11
11
  | { type: "goal.cleared"; version: 1; seq: number }
12
12
  | { type: "goal.usage"; version: 1; seq: number; input: number | null; output: number | null; cacheRead: number | null; cacheWrite: number | null; unknownMessages: number }
13
13
  | { type: "goal.continuation_sent"; version: 1; seq: number; generation: number }
@@ -15,7 +15,7 @@ export type Entry =
15
15
  | { type: "goal.limit_config"; version: 1; seq: number; tokenBudget: number | null; maxContinuations: number };
16
16
  export type Intent =
17
17
  | { type: "create"; id: string; objective: string; tokenBudget?: number | null; maxContinuations?: number }
18
- | { type: "transition"; to: Status; by: Actor; userRequest?: string }
18
+ | { type: "transition"; to: Status; by: Actor; userRequest?: string; resetContinuations?: boolean }
19
19
  | { type: "clear" }
20
20
  | { type: "usage"; input?: number | null; output?: number | null; cacheRead?: number | null; cacheWrite?: number | null; unknownMessages?: number }
21
21
  | { type: "continuation_sent"; generation: number }
@@ -55,8 +55,11 @@ export function transition(state: Goal | null, intent: Intent): Goal | null {
55
55
  if (intent.to === "paused" && !intent.userRequest?.trim()) throw new GoalError("forbidden", "paused requires user request evidence");
56
56
  if (intent.by === "agent" && !["complete", "blocked"].includes(intent.to)) throw new GoalError("forbidden", "agent cannot set this status");
57
57
  if (intent.by === "agent" && intent.to === "complete" && state.status !== "active") throw new GoalError("forbidden", "agent can complete only an active goal");
58
- if (state.status === "complete" || intent.to === state.status) throw new GoalError("illegal", "illegal status transition");
59
- return { ...state, status: intent.to };
58
+ const reset = intent.resetContinuations === true;
59
+ if (reset && (intent.by !== "user" || intent.to !== "active")) throw new GoalError("forbidden", "only user resume can reset continuations");
60
+ 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");
61
+ if (state.status === "complete" || (intent.to === state.status && !reset)) throw new GoalError("illegal", "illegal status transition");
62
+ return { ...state, status: intent.to, ...(reset ? { continuationSeq: 0 } : {}) };
60
63
  }
61
64
  if (intent.type === "limit_config") {
62
65
  if (!Number.isInteger(intent.maxContinuations) || intent.maxContinuations < 0 || (intent.tokenBudget !== null && (!Number.isFinite(intent.tokenBudget) || intent.tokenBudget < 0))) throw new GoalError("invalid", "invalid limits");
@@ -95,7 +98,7 @@ export function fold(entries: readonly Entry[]): Goal | null {
95
98
  if (!entry.type.startsWith("goal.")) continue;
96
99
  if (entry.type === "goal.created") state = { ...entry.goal };
97
100
  else if (entry.type === "goal.cleared") state = null;
98
- else if (state && entry.type === "goal.transition") state = transition(state, { type: "transition", to: entry.to, by: entry.by, userRequest: entry.userRequest });
101
+ else if (state && entry.type === "goal.transition") state = transition(state, { type: "transition", to: entry.to, by: entry.by, userRequest: entry.userRequest, resetContinuations: entry.resetContinuations });
99
102
  else if (state && entry.type === "goal.limit_config") state = transition(state, { type: "limit_config", tokenBudget: entry.tokenBudget, maxContinuations: entry.maxContinuations });
100
103
  else if (state && entry.type === "goal.continuation_sent") state = transition(state, { type: "continuation_sent", generation: entry.generation });
101
104
  else if (state && entry.type === "goal.usage") state = transition(state, { type: "usage", input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite, unknownMessages: entry.unknownMessages });
package/src/lifecycle.ts CHANGED
@@ -15,27 +15,6 @@ export type LifecycleDeps = {
15
15
  rebuild(): void;
16
16
  };
17
17
 
18
- function branchTail(ctx: any): any[] {
19
- const branch = ctx?.sessionManager?.getBranch?.();
20
- return Array.isArray(branch) ? branch : [];
21
- }
22
-
23
- function messageFromEvent(event: any, ctx: any): Message | null {
24
- const message = event?.message ?? event;
25
- if (!message || (message.role !== "assistant" && message.role !== "toolResult")) return null;
26
- const tail = branchTail(ctx);
27
- const candidate = [...tail].reverse().find((entry: any) => {
28
- const m = entry?.message ?? entry;
29
- return m?.role === message.role;
30
- });
31
- const m = candidate?.message ?? candidate ?? message;
32
- const entryId = candidate?.id ?? candidate?.entryId ?? m?.id ?? m?.entryId ?? message.id ?? message.entryId;
33
- // Entry identity is heuristic-by-position because branch entries may omit message ids.
34
- return typeof entryId === "string"
35
- ? { entryId, role: message.role, usage: m?.usage ?? message.usage, toolName: m?.toolName ?? message.toolName, stopReason: m?.stopReason ?? message.stopReason }
36
- : null;
37
- }
38
-
39
18
  export type PiEvents = { on(name: string, handler: (event: any, ctx: any) => unknown): void };
40
19
  export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
41
20
  async function commitWithRetry(intent: Intent, attempts = 3): Promise<void> {
@@ -53,8 +32,28 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
53
32
  let steered = false;
54
33
  // Stop reason of the most recent assistant turn; gates continuation on normal completion.
55
34
  let lastAssistantStop: string | undefined;
35
+ let epoch = 0;
36
+ let inputPending = false;
37
+ let runSignal: AbortSignal | undefined;
38
+ // message_end precedes SessionManager.appendMessage. Its object is authoritative;
39
+ // no persisted entry ID exists yet. Deduplicate live events by object identity.
40
+ const messageIds = new WeakMap<object, string>();
41
+ let messageSeq = 0;
42
+ const invalidate = () => {
43
+ epoch += 1;
44
+ lastAssistantStop = undefined;
45
+ deps.continuation.invalidate();
46
+ };
47
+ pi.on("agent_start", (_event: unknown, ctx: any) => {
48
+ epoch += 1;
49
+ lastAssistantStop = undefined;
50
+ // Retain the signal: ctx.signal becomes undefined once the run is idle.
51
+ runSignal = ctx.signal;
52
+ });
56
53
 
57
54
  pi.on("session_start", async (_event: SessionStartEvent, ctx: any) => {
55
+ invalidate();
56
+ inputPending = false;
58
57
  await bootstrap();
59
58
  const snapshot = deps.goalCommit.current();
60
59
  if (snapshot?.goal?.status === "active") {
@@ -63,7 +62,8 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
63
62
  });
64
63
 
65
64
  pi.on("session_before_tree", async (_event: any, ctx: any) => {
66
- deps.continuation.invalidate();
65
+ invalidate();
66
+ inputPending = false;
67
67
  pendingRebuild = true;
68
68
  // Pi's tree hook does not expose the post-navigation branch; rebuild on the next event.
69
69
  void ctx;
@@ -82,9 +82,12 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
82
82
  const before = async (_ctx: any) => { if (pendingRebuild) { pendingRebuild = false; await bootstrap(); } };
83
83
  pi.on("message_end", async (event: MessageEndEvent, ctx: any) => {
84
84
  await before(ctx);
85
- const message = messageFromEvent(event, ctx);
86
- if (!message) return;
87
- if (message.role === "assistant") lastAssistantStop = message.stopReason;
85
+ const m = event.message;
86
+ if (m.role !== "assistant" && m.role !== "toolResult") return;
87
+ if (m.role === "assistant") lastAssistantStop = m.stopReason;
88
+ let entryId = messageIds.get(m);
89
+ if (!entryId) { entryId = `live-message:${++messageSeq}`; messageIds.set(m, entryId); }
90
+ const message: Message = { ...m, entryId };
88
91
  const recorded = deps.accounting.recordMessage(message);
89
92
  if (recorded && !recorded.duplicate) {
90
93
  const delta = recorded.delta;
@@ -95,14 +98,21 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
95
98
  await before(ctx);
96
99
  const snapshot = deps.goalCommit.current();
97
100
  if (!snapshot) { steeredGoalId = null; steered = false; return; }
98
- // Cancellation gates every automatic send, including budget-limit steering.
99
- if (lastAssistantStop === "error" || lastAssistantStop === "aborted") return;
101
+ const settledEpoch = epoch;
102
+ const settledSignal = runSignal;
103
+ const canSend = () => epoch === settledEpoch && !settledSignal?.aborted && !inputPending && ctx.isIdle() && !ctx.hasPendingMessages();
104
+ // Only a fresh normal completion can authorize automatic work. Consume it
105
+ // once: duplicate settled events and tool-boundary aborts cannot restart us.
106
+ const stop = lastAssistantStop;
107
+ lastAssistantStop = undefined;
108
+ if ((stop !== "stop" && stop !== "length") || !canSend()) return;
100
109
  if (snapshot.goal.status !== "active" && snapshot.goal.status !== "budget_limited") return;
101
110
  const verdict = deps.accounting.settleTurn(snapshot.goal);
102
111
  if (verdict.kind !== "ok") {
103
112
  const latest = deps.goalCommit.current();
104
113
  if (latest && latest.goal.status !== verdict.kind) await commitWithRetry({ type: "transition", to: verdict.kind, by: "system" });
105
114
  }
115
+ if (!canSend()) return;
106
116
  const latest = deps.goalCommit.current();
107
117
  if (!latest) { steeredGoalId = null; steered = false; return; }
108
118
  const goal = latest.goal;
@@ -111,8 +121,17 @@ export function registerLifecycle(pi: PiEvents, deps: LifecycleDeps): void {
111
121
  steered = true;
112
122
  deps.send({ customType: "pi-goal-next/budget_limit", content: budgetLimitPrompt(goal), display: false, details: { goalId: goal.id } }, { triggerTurn: true });
113
123
  }
114
- await deps.continuation.onSettled();
124
+ await deps.continuation.onSettled(canSend);
125
+ });
126
+ pi.on("message_start", async (event: MessageStartEvent, ctx: any) => {
127
+ if (event.message.role === "user") { inputPending = false; invalidate(); }
128
+ await before(ctx);
129
+ await deps.continuation.onMessageStart(event.message);
130
+ });
131
+ pi.on("input", async (_event: InputEvent, ctx: any) => {
132
+ // Runs before Pi queues the input, so hasPendingMessages alone is too late.
133
+ inputPending = true;
134
+ invalidate();
135
+ await before(ctx);
115
136
  });
116
- pi.on("message_start", async (event: MessageStartEvent, ctx: any) => { await before(ctx); await deps.continuation.onMessageStart(event?.message ?? event); });
117
- pi.on("input", async (_event: InputEvent, ctx: any) => { await before(ctx); });
118
137
  }