@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 +5 -3
- package/docs/architecture.md +17 -12
- package/docs/limits.md +4 -4
- package/package.json +1 -1
- package/src/commands.ts +7 -1
- package/src/continuation.ts +4 -3
- package/src/goal-commit.ts +1 -1
- package/src/goal.ts +8 -5
- package/src/lifecycle.ts +49 -30
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
|
|
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
|
-
|
|
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.
|
package/docs/architecture.md
CHANGED
|
@@ -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` |
|
|
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):
|
|
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
|
-
`
|
|
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: `
|
|
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
|
|
62
|
-
2. `session_before_tree`:
|
|
63
|
-
3. `session_before_compact`:
|
|
64
|
-
4. `
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:**
|
|
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
|
|
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
|
|
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
|
+
"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
|
-
|
|
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
|
}
|
package/src/continuation.ts
CHANGED
|
@@ -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",
|
package/src/goal-commit.ts
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
if (
|
|
87
|
-
if (
|
|
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
|
-
|
|
99
|
-
|
|
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
|
}
|