@llblab/pi-kit 0.35.4 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/AGENTS.md +2 -0
  2. package/BACKLOG.md +4 -0
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +51 -2
  5. package/node_modules/@llblab/pi-actors/AGENTS.md +3 -1
  6. package/node_modules/@llblab/pi-actors/CHANGELOG.md +4 -0
  7. package/node_modules/@llblab/pi-actors/README.md +2 -2
  8. package/node_modules/@llblab/pi-actors/dist/index.js +1 -0
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.d.ts +1 -0
  10. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +3 -0
  11. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +7 -2
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +20 -6
  13. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +1 -0
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +74 -74
  15. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  16. package/node_modules/@llblab/pi-actors/dist/skills/actors/references/runs.md +1 -1
  17. package/node_modules/@llblab/pi-actors/docs/README.md +1 -1
  18. package/node_modules/@llblab/pi-actors/docs/async-runs.md +3 -1
  19. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +22 -20
  20. package/node_modules/@llblab/pi-actors/index.ts +1 -0
  21. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +4 -0
  22. package/node_modules/@llblab/pi-actors/lib/pi.ts +33 -9
  23. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +72 -69
  24. package/node_modules/@llblab/pi-actors/package.json +1 -1
  25. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  26. package/node_modules/@llblab/pi-actors/skills/actors/references/runs.md +1 -1
  27. package/package.json +6 -4
package/AGENTS.md CHANGED
@@ -27,6 +27,8 @@
27
27
  - Do not copy extension source, Skills, or documentation into this repository.
28
28
  - Preserve package independence: a kit release may advance any subset of included extensions without forcing lockstep extension releases.
29
29
  - Adding packages or newly exposed capabilities requires a kit minor bump. For updates to the existing composition, derive the kit version bump from the highest dependency bump in the synchronized release cohort: dependency patch-only updates require a kit patch bump, while any dependency minor update requires a kit minor bump. Do not let lower-level dependency bumps force a larger kit bump.
30
+ - Stable patch/minor updates within the current major of existing direct packages are explicitly authorized to commit coherent exact pins and a visible kit bump directly to main, without PRs or manual approval. Major/prerelease releases and composition/resource changes remain manual.
31
+ - Keep dependency preparation read-only and secret-free; isolate main writes on a fresh runner and accept only declared data artifacts without executing them. Never force or rebase over concurrent main work. Explicitly dispatch the OIDC publisher for the expected commit, validate again before publication, and never overwrite release tags.
30
32
  - Use English for public and repository-facing text.
31
33
  - Do not commit, publish, tag, create a remote repository, or mutate Pi settings without explicit authorization.
32
34
 
package/BACKLOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Package pins, resource order and bundled runtime ownership remain authoritative in `package.json`. Release outcomes belong in [CHANGELOG.md](./CHANGELOG.md).
4
4
 
5
+ ## Automatic-update operations
6
+
7
+ - **First changed-cohort smoke:** After the first live automatic pin update, confirm the four-file main commit, explicit `workflow_dispatch` to `publish-pi-kit.yml`, matching expected commit, and final npm/tag/GitHub Release identities. No-update runs and local bare-repository tests do not prove this hosted changed-cohort path. Do not fabricate an update or weaken branch protection for a smoke test; inspect the real run or disable the updater on failure.
8
+
5
9
  ## Carried checks
6
10
 
7
11
  - **Installed 0.35.4 Telegram smoke (operator-owned):** After separately authorized kit installation/reload of every Pi process together, run `/telegram-new` in one follower instance and confirm the same Thread is reused with a new letter slot and title, with no second tab and no orphan left behind. Then restart that instance, open a new session in the same folder, and confirm it adopts the retained Thread instead of provisioning another. Do not erase accepted work or replay uncertain input. Packed validation does not certify installed clients.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.36.0: Bundled Package Updates (2026-10-21)
6
+
7
+ - `Automatic updates`: Replaces the dependency PR/approval step with validated direct-to-main adoption of stable same-major patch/minor releases. Exact pins, bundle aliases, lockfile, README and kit version remain synchronized; preparation stays read-only and a fresh data-only writer rejects unsafe payloads and concurrent main changes.
8
+ - `Release handoff`: Explicitly dispatches the OIDC publisher with the committed SHA because GITHUB_TOKEN pushes do not trigger ordinary push workflows. The publisher rejects a different commit before tagging. No updates means no fabricated commit or release; existing untagged versions can retry a missed handoff. Major/prerelease and composition changes remain manual; packaging checks do not establish extension safety.
9
+ - `Exact pins`: `@llblab/pi-actors` 0.54.0 → 0.55.0.
10
+ - `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
11
+
12
+ ## 0.35.5: Reviewed Updates and OIDC Releases
13
+
14
+ - `Update preparation`: Adds daily/manual npm discovery and one human-reviewed update PR containing exact pins, both bundle aliases, regenerated lockfile, README inventory, kit version and changelog. Preparation is read-only without secrets or dependency hooks/bin links; a fresh write runner accepts only four checked data files. Major/prerelease updates and resource changes remain manual; failed validation prevents PR creation, and no auto-merge is enabled.
15
+ - `Publication`: Replaces tag-triggered publication with a main-only npm Trusted Publisher using short-lived OIDC, without a persistent npm token. It reads the reviewed version, skips an unchanged latest tag, validates before a non-forced tag push, and publishes npm before the GitHub Release. Existing-version recovery requires matching tag/npm commit identity; public packed inventory remains verified. Stage-only permission is rejected, not silently substituted.
16
+ - `Review and evidence`: Adds automation regression tests, executable lock/bundle/tarball coherence checks, CODEOWNERS and maintainer setup/recovery guidance. The npm trust binding and enforced main-branch review protection remain administrator-owned activation steps.
17
+ - `Compatibility`: Keeps all eight dependency pins, bundled resource order and runtime entrypoints unchanged. This patch changes update/release tooling and documentation; it does not install or reload Pi clients.
18
+
5
19
  ## 0.35.4: Telegram Session Thread Continuity
6
20
 
7
21
  - `Telegram pin`: Advances only the exact Telegram pin from `0.57.2` to `0.57.3`. A session replaced inside one Pi process now reuses its preserved Thread on every registration path instead of provisioning a second tab, and a new session registering in a folder whose previous session is provably gone adopts that binding's exact target and slot while the predecessor binding is re-keyed. A live predecessor or an ambiguous candidate set keeps ordinary provisioning.
package/README.md CHANGED
@@ -12,7 +12,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
12
12
 
13
13
  | Package | Version | Purpose |
14
14
  | --- | ---: | --- |
15
- | [`@llblab/pi-actors`](https://github.com/llblab/pi-actors) | `0.54.0` | Inspectable local Runs, reusable Recipes, persistent tools, and delegation Skills |
15
+ | [`@llblab/pi-actors`](https://github.com/llblab/pi-actors) | `0.55.0` | Inspectable local Runs, reusable Recipes, persistent tools, and delegation Skills |
16
16
  | [`@llblab/pi-claude-usage`](https://github.com/llblab/pi-claude-usage) | `0.2.2` | Claude subscription quota status and shared per-model Fast toggle for Opus, mirrored in Telegram |
17
17
  | [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.3.0` | Isolated nested Pi TUI with named npm extensions and compatible model selection |
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.1` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
@@ -58,6 +58,55 @@ npm run validate
58
58
 
59
59
  The repository-local `.npmrc` keeps Pi-provided peer packages out of the kit's lockfile; Pi supplies those peers at runtime. To advance an included package, update its exact version in `dependencies`, run `npm install`, synchronize bundled dependencies, each declared extension pointer and its target, declared Skill paths, tests, the table above, and the changelog, then validate the packed artifact. Keep every pointer target identical to the entrypoint its pin declares. A bundled package must resolve auxiliary files, workers and sibling packages from its own module URL rather than from the declared entry path, because a pointer redirects only that entry. Expose only resources declared by the published owning package; do not use version ranges or unpublished local paths.
60
60
 
61
+ ## Update automation
62
+
63
+ Updates are **fully automatic for newer stable patch/minor releases within each existing package's current major**. The updater validates and commits the coherent update directly to `main`, then requests publication. There is no dependency PR, merge step or manual approval. This is an explicit maintainer policy accepting the risk of upstream code; exact versions and successful packaging checks do not establish extension safety.
64
+
65
+ ### Daily update
66
+
67
+ [Update pi-kit dependencies](./.github/workflows/update-pi-kit-deps.yml) runs daily at **03:00 UTC** and supports `workflow_dispatch` on `main`. Manual dispatch defaults to `dry_run: true`: prepare and validate the complete diff, show the report and retain the data artifact, but do not commit or publish. Select `false` for the live automatic path.
68
+
69
+ The dependency-free [updater](./scripts/check-updates.mjs) reads npm's `latest` metadata for existing direct dependencies. A custom updater, rather than Dependabot, keeps one coherent update cohort:
70
+
71
+ - Exact dependency pins, both `bundledDependencies`/`bundleDependencies` aliases, and `package-lock.json` regenerated by `npm install --ignore-scripts --bin-links=false`.
72
+ - The README package/version inventory, versioned upstream documentation links, and [changelog](./CHANGELOG.md).
73
+ - The kit version: patch-only direct updates produce a kit patch; any direct minor update produces a kit minor. Transitive bumps do not determine the kit bump. Pending `Unreleased` outcomes move into the new release within the changelog budget.
74
+ - Existing package membership, extension pointers and resource order. Major updates, non-stable `latest` versions and changed resource declarations remain manual; automation does not guess a new composition.
75
+
76
+ Preparation runs in a **read-only job without repository secrets**. Both installation and clean `npm ci --ignore-scripts --bin-links=false` disable dependency lifecycle hooks and executable links, then `npm run validate` checks the package and bundled inventory. Failure stops before any main write.
77
+
78
+ A **separate fresh writer runner** receives only `package.json`, `package-lock.json`, `README.md` and `CHANGELOG.md` from that run's artifact. The trusted updater rejects extra paths, symlinks, script/resource/package-set changes, hidden rollbacks, incorrect kit bumps and unexpected prose edits. It does not install dependencies or execute transferred scripts. The writer requires a clean tracked checkout at the same main baseline, commits only those four files, and uses a normal push to `main`. A concurrent main change rejects the update; it never force-pushes, rebases or bypasses protection.
79
+
80
+ The report lists exact `old → new` pins and upstream release/compare links for audit. A read-only local discovery plan is available through `node scripts/check-updates.mjs --plan`; `npm run deps:check` prepares and validates local changes but does not push them. Internal `--apply-prepared` is the privileged clean-runner commit/push path, not a normal local update command.
81
+
82
+ ### Automatic publication
83
+
84
+ Pushes made with `GITHUB_TOKEN` do not trigger another workflow's ordinary `push` event. The writer therefore explicitly dispatches [Publish pi-kit](./.github/workflows/publish-pi-kit.yml) using `workflow_dispatch` and an `expected_commit` SHA. GitHub permits this dispatch exception with `GITHUB_TOKEN`; no PAT is needed. The publisher rejects a different main commit before tag creation, even if the version would otherwise no-op.
85
+
86
+ On a live run with no new pins, the fresh job still checks for an untagged main version awaiting publication. This can retry a failed handoff before any tag exists; an unchanged already-tagged version does not manufacture a commit or release. The updater reports that publication was requested, not that it succeeded: inspect the separate publisher run for the final result.
87
+
88
+ The publisher also supports ordinary human pushes to `main` changing `package.json` and manual dispatch on `main`. It **reads, never bumps**, the kit version. If `v<version>` equals the latest `v*` tag in version-sort order, it exits successfully without republishing. Older versions and existing non-latest tags are rejected, not overwritten.
89
+
90
+ For a new version the order is: verify the hosted main-workflow OIDC environment and npm >= 11.5.1 → clean `npm ci --ignore-scripts --bin-links=false` → `npm run validate` → create and push `vX.Y.Z` → `npm publish --access public` through Trusted Publishing → verify public npm identity and packed inventory → `gh release create --generate-notes`. Publication is serialized. An npm version-conflict is accepted only when the tag and npm `gitHead` both match HEAD; authentication, staging-only permission and contradictory identities remain errors.
91
+
92
+ A tag may remain if npm or GitHub fails **after** successful validation and tag push. A same-version rerun intentionally no-ops and does not automatically repair this partial state. Recovery requires a separate maintainer decision: verify the existing remote tag and npm state, preserve the exact tagged commit, and repeat clean validation before any missing publication. Use an explicitly reviewed OIDC recovery or interactive maintainer publication with 2FA; never silently fall back to a persistent token or staging. Verify the public package before creating any missing GitHub Release. Never move/delete a published tag or reuse a version for different content.
93
+
94
+ ### Manual changes and operations
95
+
96
+ Major/prerelease adoption, adding packages, changing resources and editing automation remain manual engineering work. [Release / validate](./.github/workflows/release.yml) still checks normal PRs; [CODEOWNERS](./.github/CODEOWNERS) requests owner review for workflows, manifests and scripts. CODEOWNERS does not gate the expressly authorized direct dependency updates.
97
+
98
+ Inspect failed workflow logs and upstream source/changelogs when validation fails or runtime behavior is suspect. Pre-1.0 minor versions can break compatibility. Check supported Pi/Node versions, transitive dependencies, executable hooks, pointer targets and auxiliary-file resolution from the owning package's `import.meta.url` when preparing manual changes. Packed validation does not certify installed-client rendering or runtime safety.
99
+
100
+ Disable the updater workflow before manually holding or rolling back a problematic pin, otherwise a later automatic run may advance it again. Publish corrective content at a new kit version; never retag or replace an npm version. Pi installation and live-client reload remain separate operator actions.
101
+
102
+ ### Repository setup and secrets
103
+
104
+ - **npm Trusted Publishing (required; no `NPM_TOKEN`):** Configure the npm package for GitHub user/organization `llblab`, repository `pi-kit`, workflow filename `publish-pi-kit.yml` (without `.github/workflows/`), and no environment name. Explicitly allow **`npm publish`**, not only staging. A new binding must complete its first successful publication within two days or be recreated. Preflight checks runtime prerequisites, not registry authorization. See [npm's Trusted Publishing documentation](https://docs.npmjs.com/trusted-publishers/).
105
+ - **Actions permissions:** Preparation and PR validation are read-only. Only the fresh updater writer requests `contents: write` and `actions: write` for the main push and explicit publisher dispatch; it does not request PR permissions. Repository-wide default token permissions may remain read-only because jobs declare their needs. The publisher requests `contents: write` for the tag/GitHub Release and `id-token: write` for short-lived OIDC.
106
+ - **Main protection:** The repository must permit the updater's authorized main push. Required-PR or signing rules can reject it; automation fails rather than weakening rules, adding bypasses or force-pushing. Any compatibility decision about those protections is administrator-owned. The former Actions create-PR checkbox is no longer required by this updater.
107
+ - **`PI_KIT_PAT` (optional, publisher tag only):** Only needed if tag pushes must trigger other downstream workflows. The updater always uses `GITHUB_TOKEN` plus explicit dispatch, not a PAT. No token is committed to source or git configuration.
108
+ - **Do not substitute stage-only tokens:** Granular-token direct publication is being removed in January 2027. Stage-only publication requires separate maintainer promotion with 2FA; this pipeline instead uses explicitly permitted direct OIDC publication and never silently stages.
109
+
61
110
  ## Security
62
111
 
63
- Pi extensions execute with the user's system permissions, and Skills can guide executable actions. Review each included package and release before advancing its pin. Private Knowledge, credentials, and personal Pi configuration are not bundled.
112
+ Pi extensions execute with the user's system permissions, and Skills can guide executable actions. This repository explicitly opts into adopting supported upstream releases **without human review before publication**. Validation and npm audit are packaging signals, not a security decision; reproducible exact pins do not make new code trustworthy. Disable automatic updates if human source review is required. Private Knowledge, credentials and personal Pi configuration are not bundled.
@@ -140,7 +140,9 @@ Tool result/error text contributes exactly one leading line break. Keep model-fa
140
140
 
141
141
  File watchers accelerate reconciliation; a bounded interval recovers missed terminal and retained-attention events. Canonical attention observation uses stable retained ids and bounds seen memory to the current suffix across compaction; only allowlisted legacy outbox fallback uses line offsets. Ordinary `notify` and `followup` hints prime at startup without replay and require durable state or artifacts for recovery. Explicit `steer` instead re-enters through the owner journal until exact presentation records its newer generation-fenced Trace marker.
142
142
 
143
- Ordinary terminals retain inherited delivery lineage from each nested Actor process to the top-level coordinator. Only that root coordinator may schedule completion follow-ups; descendant sessions stay quiet, descendants wait for their containing top-level Run to terminate, and the complete ready forest is flattened into one immutable settled/idle batch with parent-child rows and bounded semantic outputs. The same batch renders as one visible operator card and one model prompt. Batch delivery is generation-fenced and bounded at 256 durable members, 64 model-facing rows, 16 KiB of context, and a 1 MiB journal. Successful `sendMessage()` acceptance durably finalizes the completion batch and authorizes exact-generation handled markers, so a missed context callback cannot block later completion epochs. Send failure retains the pending batch for retry; queued recovery remains only for the narrow crash boundary between transport acceptance and finalization. Restart recovery walks the bounded active Pi session parent chain: exact queued evidence waits without resend, proven absence returns the envelope to pending, and incomplete or conflicting evidence remains queued with diagnostics. Retry duplicates collapse before presentation, malformed envelopes fail closed, and automatic review waits behind an active completion batch.
143
+ Ordinary terminals retain inherited delivery lineage from each nested Actor process to the top-level coordinator. Only that root coordinator schedules completion delivery; descendant sessions stay quiet and wait for their complete containing top-level tree. A completed active `turn_end` snapshots the ready forest into one immutable batch, preserves earlier boundary entries, and returns one visible custom-message entry plus one continuation, never a native steering/follow-up enqueue. Aborted/error boundaries and pending native input defer projection. Already-idle Pi retains debounced follow-up delivery. The same batch renders as one operator card and one model prompt, fenced by owner and generation and bounded at 256 durable members, 64 model-facing rows, 16 KiB of context, and a 1 MiB journal.
144
+
145
+ Returning a boundary draft is not acceptance: keep it pending until exact model context or bounded active-session parent-chain evidence proves the committed entry. Successful idle `sendMessage()` acceptance finalizes immediately; durable queued phase also proves acceptance. Only proven absence permits retry; incomplete or conflicting pending evidence stays pending with diagnostics. Exact acceptance authorizes generation-fenced terminal handled markers and releases later epochs without depending on one context callback. Retry duplicates collapse before presentation, malformed envelopes fail closed, and automatic review waits behind an active completion batch.
144
146
 
145
147
  When deferred Run results gate the next step, wait for their completion batch. Inspect early only for operator request, meaningful attention, or diagnosis of an overdue Run.
146
148
 
@@ -2,6 +2,10 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.55.0: Active Completion Overlays
6
+
7
+ - `Active Completion Overlays`: Batches ready Actor trees at the next completed model-step boundary, after the full tool batch, so the running coordinator receives fresh results before idle without native steering. Preserves prior boundary entries, exact owner/generation fences, and debounced idle wakeups. Proposed entries remain pending until exact context or bounded session evidence proves acceptance; dropped proposals retry without premature handled markers or duplicate accepted reports.
8
+
5
9
  ## 0.54.0: Pi 1.0 Baseline and Package Licensing
6
10
 
7
11
  - `Licensing`: Includes the MIT LICENSE in source checkouts and npm packages, preserving existing author attribution.
@@ -15,7 +15,7 @@ An **actor** is any runnable local capability: a script, tool, service, pipeline
15
15
 
16
16
  Multi-instance systems commonly put instance creation and routing in an external gateway. pi-actors supports a different topology: the current Pi instance remains the coordinator, companion extensions such as Telegram provide presence, and explicit Runs perform bounded delegated work. The coordinator receives high-level outcomes, decomposes them, stays available for decisions, and owns integration plus final validation instead of becoming another undifferentiated worker.
17
17
 
18
- This topology does not require every task to become a subagent. Short work with one natural validation boundary stays inline; delegation pays when clean context, asynchronous execution, independent judgement, parallel ownership, or coordinator availability exceeds its coordination cost. Bounded implementation can run with reasoning off while the coordinator selectively launches clean-context reasoning-enabled reviewers; several independent reviews can provide broader evidence than one author self-review. Settled completion batches, durable Trace, and declared artifacts replace tight polling loops.
18
+ This topology does not require every task to become a subagent. Short work with one natural validation boundary stays inline; delegation pays when clean context, asynchronous execution, independent judgement, parallel ownership, or coordinator availability exceeds its coordination cost. Bounded implementation can run with reasoning off while the coordinator selectively launches clean-context reasoning-enabled reviewers; several independent reviews can provide broader evidence than one author self-review. Boundary-delivered completion batches, durable Trace, and declared artifacts replace tight polling loops.
19
19
 
20
20
  ## Install
21
21
 
@@ -153,7 +153,7 @@ Trace fields are exact: `id`, `ts`, `kind`, and optional `summary`, `data`, `lev
153
153
 
154
154
  Generic command lifecycle is Trace-only: runner-owned `command.done` records preserve completion and execution evidence but never request or project attention, including malformed legacy records carrying `steer`. There is no Recipe-level command-completion delivery switch. Semantic attention is explicit: `notify` shows visible status without a model turn, `followup` supplies ordinary checkpoint context, and `steer` requests urgent delivery at Pi's next safe assistant/tool boundary. `notify` and `followup` remain retained wake hints that may compact away, so persist recovery state or an artifact first. `steer` is admitted to the bounded owner delivery journal before `deliverAs: "steer"`, acknowledged only in exact model-bound context, and never suppresses the eventual root-terminal batch. Store large evidence in artifacts or bounded execution captures.
155
155
 
156
- Ordinary finite Runs project terminal results through one root-coordinator completion scheduler. Nested actors inherit durable parent-generation lineage: their own Pi sessions never emit completion turns, their results wait until the containing top-level Run terminates, and the ready trees join other top-level completions accumulated during the same active/idle epoch. One immutable bounded forest causes one automatic agent turn after the root Pi settles. The operator sees one gray completion card, while the agent receives the same parent-child tree with bounded actor outputs as one prompt; terminal generations are acknowledged after Pi durably accepts the exact batch, preventing one missed context callback from blocking later completion epochs. Sequence, parallel, repeat, and imported branches remain internal, while separately launched Runs retain exact independent generations. Silent Runs and synchronous stop acknowledgments remain non-projecting.
156
+ Ordinary finite Runs project terminal results through one root-coordinator completion scheduler. Nested actors inherit durable parent-generation lineage: their own Pi sessions never emit completion turns, and their results wait for the complete containing top-level tree. While the root Pi is active, the next completed `turn_end` boundary snapshots all ready trees into one immutable bounded batch, appends one visible custom message after the full tool batch, and requests one next model step. This overlay uses boundary entries, not Pi's native steering or follow-up queue, so the model can integrate fresh results before its current run settles. Already-idle Pi retains a short debounce and one automatic follow-up wake. The operator and model receive the same parent-child tree with bounded actor outputs. Proposed boundary entries stay pending until exact model context or bounded active-session evidence proves acceptance; an idle send finalizes on successful Pi acceptance without requiring a later context callback. Sequence, parallel, repeat, and imported branches remain internal, while separately launched Runs retain exact independent generations. Silent Runs and synchronous stop acknowledgments remain non-projecting.
157
157
 
158
158
  ## Control
159
159
 
@@ -12,6 +12,7 @@ export default function toolRegistryExtension(pi) {
12
12
  pi.on("resources_discover", async () => runtime.discoverResources(import.meta.url));
13
13
  }
14
14
  pi.on("session_start", async (_event, ctx) => runtime.onSessionStart(ctx));
15
+ pi.on("turn_end", (event, ctx) => runtime.onTurnEnd(event, ctx));
15
16
  pi.on("agent_settled", async (_event, ctx) => runtime.onAgentSettled(ctx));
16
17
  pi.on("context", async (event, ctx) => ({
17
18
  messages: runtime.onContext(event.messages, ctx),
@@ -14,6 +14,7 @@ export interface ActorExtensionRuntime {
14
14
  } | undefined;
15
15
  getRunOwnerId(ctx: Pi.ExtensionContext): string;
16
16
  onAgentSettled(ctx: Pi.ExtensionContext): void;
17
+ onTurnEnd(event: Pi.TurnEndEvent, ctx: Pi.ExtensionContext): Pi.TurnEndEventResult | undefined;
17
18
  onContext(messages: unknown[], ctx: Pi.ExtensionContext): unknown[];
18
19
  onSessionShutdown(reason: string, ctx: Pi.ExtensionContext): void;
19
20
  onSessionStart(ctx: Pi.ExtensionContext): Promise<void>;
@@ -118,6 +118,9 @@ export function createActorExtensionRuntime(pi) {
118
118
  if (!runUiRuntime.flushCompletionBatch(ctx))
119
119
  automaticReview.schedule();
120
120
  },
121
+ onTurnEnd(event, ctx) {
122
+ return runUiRuntime.onTurnEnd(event, ctx);
123
+ },
121
124
  onContext(messages, ctx) {
122
125
  return runUiRuntime.projectContext(messages, ctx);
123
126
  },
@@ -3,9 +3,9 @@
3
3
  * Zones: pi agent sdk boundary, extension host adapters
4
4
  * Owns direct pi SDK imports and exposes narrow pi-actors-facing helpers/types for the composition root.
5
5
  */
6
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { ExtensionAPI, ExtensionContext, CustomMessageEntryDraft, TurnEndEvent, TurnEndEventResult } from "@earendil-works/pi-coding-agent";
7
7
  import * as SessionEvidence from "./session-evidence.ts";
8
- export type { ExtensionAPI, ExtensionContext };
8
+ export type { ExtensionAPI, ExtensionContext, TurnEndEvent, TurnEndEventResult };
9
9
  export declare const RUN_COMPLETION_BATCH_CUSTOM_TYPE = "pi-actors-run-batch";
10
10
  export declare const RUN_STEER_CUSTOM_TYPE = "pi-actors-run-steer";
11
11
  export interface PiNotificationSink {
@@ -19,6 +19,7 @@ export interface PiNotificationSink {
19
19
  }
20
20
  export declare function getSessionId(ctx: ExtensionContext): string;
21
21
  export declare function createNotificationSink(pi: ExtensionAPI, ctx: ExtensionContext): PiNotificationSink;
22
+ export declare function createRunCompletionBatchEntry(batchId: string, content: string): CustomMessageEntryDraft;
22
23
  export declare function sendRunCompletionBatch(pi: ExtensionAPI, batchId: string, content: string): void;
23
24
  export declare function sendRunSteer(pi: ExtensionAPI, input: {
24
25
  content: string;
@@ -41,6 +42,10 @@ export declare function dedupeRunSteerContext(messages: unknown[]): {
41
42
  }>;
42
43
  };
43
44
  export declare function removeRunSteerFromContext(messages: unknown[], steerId: string): unknown[];
45
+ export declare function inspectRunCompletionBatchSessionEvidence(ctx: ExtensionContext, input: {
46
+ batchId: string;
47
+ content: string;
48
+ }): SessionEvidence.ActiveSessionEntryEvidence;
44
49
  export declare function inspectRunSteerSessionEvidence(ctx: ExtensionContext, input: {
45
50
  content: string;
46
51
  eventId: string;
@@ -18,8 +18,9 @@ export function createNotificationSink(pi, ctx) {
18
18
  }),
19
19
  };
20
20
  }
21
- export function sendRunCompletionBatch(pi, batchId, content) {
22
- pi.sendMessage({
21
+ export function createRunCompletionBatchEntry(batchId, content) {
22
+ return {
23
+ type: "custom_message",
23
24
  customType: RUN_COMPLETION_BATCH_CUSTOM_TYPE,
24
25
  content,
25
26
  display: true,
@@ -29,10 +30,11 @@ export function sendRunCompletionBatch(pi, batchId, content) {
29
30
  kind: "completion_batch",
30
31
  },
31
32
  },
32
- }, {
33
- deliverAs: "followUp",
34
- triggerTurn: true,
35
- });
33
+ };
34
+ }
35
+ export function sendRunCompletionBatch(pi, batchId, content) {
36
+ const { type: _type, ...message } = createRunCompletionBatchEntry(batchId, content);
37
+ pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
36
38
  }
37
39
  export function sendRunSteer(pi, input) {
38
40
  pi.sendMessage({
@@ -172,6 +174,18 @@ export function dedupeRunSteerContext(messages) {
172
174
  export function removeRunSteerFromContext(messages, steerId) {
173
175
  return messages.filter((message) => steerMessage(message)?.steerId !== steerId);
174
176
  }
177
+ export function inspectRunCompletionBatchSessionEvidence(ctx, input) {
178
+ return SessionEvidence.inspectBoundedActiveSessionEntries({
179
+ getEntry: (id) => ctx.sessionManager.getEntry(id),
180
+ leaf: ctx.sessionManager.getLeafEntry(),
181
+ match: (entry) => {
182
+ const batch = completionBatchMessage(entry);
183
+ if (!batch || batch.batchId !== input.batchId)
184
+ return undefined;
185
+ return batch.content === input.content ? "present" : "conflict";
186
+ },
187
+ });
188
+ }
175
189
  export function inspectRunSteerSessionEvidence(ctx, input) {
176
190
  return SessionEvidence.inspectBoundedActiveSessionEntries({
177
191
  getEntry: (id) => ctx.sessionManager.getEntry(id),
@@ -9,6 +9,7 @@ import * as Pi from "./pi.ts";
9
9
  export interface RunUiRuntime {
10
10
  close(): void;
11
11
  flushCompletionBatch(ctx: Pi.ExtensionContext): boolean;
12
+ onTurnEnd(event: Pi.TurnEndEvent, ctx: Pi.ExtensionContext): Pi.TurnEndEventResult | undefined;
12
13
  projectContext(messages: unknown[], ctx: Pi.ExtensionContext): unknown[];
13
14
  shutdown(eventReason: string, ownerId: string | undefined, ctx?: Pi.ExtensionContext): void;
14
15
  start(ctx: Pi.ExtensionContext, ownerId: string): void;
@@ -23,6 +23,7 @@ export function createRunUiRuntime(deps) {
23
23
  const deliveryObservation = Observability.createRunUiObservationState();
24
24
  const retirementAttempts = new Set();
25
25
  const steerDiagnostics = new Set();
26
+ const completionDiagnostics = new Set();
26
27
  const close = () => {
27
28
  running = false;
28
29
  activeContext = undefined;
@@ -47,6 +48,7 @@ export function createRunUiRuntime(deps) {
47
48
  deliveryTimeout = undefined;
48
49
  recoverQueuedSteers = false;
49
50
  steerDiagnostics.clear();
51
+ completionDiagnostics.clear();
50
52
  if (animationInterval)
51
53
  clearInterval(animationInterval);
52
54
  animationInterval = undefined;
@@ -185,6 +187,57 @@ export function createRunUiRuntime(deps) {
185
187
  });
186
188
  return true;
187
189
  };
190
+ const acceptCompletionBatch = (ownerId, batchId) => {
191
+ const input = { batchId, ownerId, tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir };
192
+ if (!RunDelivery.markRunCompletionBatchQueued(input))
193
+ return false;
194
+ if (!RunDelivery.markRunCompletionBatchPresented(input))
195
+ return false;
196
+ const accepted = journal(ownerId).completion_batch;
197
+ return accepted ? finishPresentedBatch(ownerId, accepted) : false;
198
+ };
199
+ const reconcileCompletionBatch = (ctx, ownerId) => {
200
+ const batch = journal(ownerId).completion_batch;
201
+ if (!batch)
202
+ return false;
203
+ if (batch.phase === "pending") {
204
+ // Returning boundary drafts is not acceptance: prove the exact entry before acknowledging it.
205
+ const evidence = Pi.inspectRunCompletionBatchSessionEvidence(ctx, {
206
+ batchId: batch.batch_id,
207
+ content: RunDelivery.formatRunCompletionBatchMessage(batch),
208
+ });
209
+ if (evidence.status === "absent")
210
+ return false;
211
+ if (evidence.status !== "present") {
212
+ const key = `${batch.batch_id}:${evidence.status}:${evidence.reason ?? ""}`;
213
+ if (!completionDiagnostics.has(key)) {
214
+ completionDiagnostics.add(key);
215
+ ctx.ui.notify(`Actor completion recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Batch ${batch.batch_id} remains pending.`, "warning");
216
+ }
217
+ return true;
218
+ }
219
+ }
220
+ return !acceptCompletionBatch(ownerId, batch.batch_id);
221
+ };
222
+ const pendingCompletionBatch = (ownerId) => {
223
+ const existing = journal(ownerId).completion_batch;
224
+ if (existing)
225
+ return existing;
226
+ const snapshot = Observability.readRunDeliverySnapshot(deliveryObservation, ownerId);
227
+ admitCompletionTransitions(ownerId, snapshot);
228
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
229
+ return journal(ownerId).completion_batch;
230
+ };
231
+ const isCurrentOwner = (ctx) => {
232
+ if (!running || !activeOwnerId)
233
+ return false;
234
+ try {
235
+ return Pi.getSessionId(ctx) === activeOwnerId;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ };
188
241
  const isIdle = (ctx) => typeof ctx.isIdle !== "function" || ctx.isIdle();
189
242
  const isDeliveryRoot = (ownerId) => {
190
243
  const inheritedOwner = RunDeliveryLineage.getProcessDeliveryOwnerId();
@@ -344,52 +397,20 @@ export function createRunUiRuntime(deps) {
344
397
  });
345
398
  flushCompletionBatch = (ctx) => {
346
399
  const ownerId = activeOwnerId;
347
- if (!running || !ownerId)
400
+ if (!ownerId || !isCurrentOwner(ctx))
348
401
  return false;
349
- try {
350
- if (Pi.getSessionId(ctx) !== ownerId)
351
- return false;
352
- }
353
- catch {
354
- return false;
355
- }
356
402
  if (flushSteers(ctx, ownerId))
357
403
  return true;
358
404
  if (!isDeliveryRoot(ownerId))
359
405
  return false;
360
- let batch = journal(ownerId).completion_batch;
361
- if (!batch) {
362
- const snapshot = Observability.readRunDeliverySnapshot(deliveryObservation, ownerId);
363
- admitCompletionTransitions(ownerId, snapshot);
364
- Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
365
- batch = journal(ownerId).completion_batch;
366
- }
406
+ if (reconcileCompletionBatch(ctx, ownerId))
407
+ return true;
408
+ const batch = pendingCompletionBatch(ownerId);
367
409
  if (!batch)
368
410
  return false;
369
- if (batch.phase === "presented") {
370
- finishPresentedBatch(ownerId, batch);
371
- const snapshot = Observability.readRunDeliverySnapshot(deliveryObservation, ownerId);
372
- admitCompletionTransitions(ownerId, snapshot);
373
- Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
374
- batch = journal(ownerId).completion_batch;
375
- if (!batch)
376
- return false;
377
- }
378
411
  if (!isIdle(ctx))
379
412
  return true;
380
413
  const content = RunDelivery.formatRunCompletionBatchMessage(batch);
381
- if (batch.phase === "queued") {
382
- if (!RunDelivery.markRunCompletionBatchPresented({
383
- batchId: batch.batch_id,
384
- ownerId,
385
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
386
- }))
387
- return true;
388
- const accepted = journal(ownerId).completion_batch;
389
- if (accepted)
390
- finishPresentedBatch(ownerId, accepted);
391
- return flushCompletionBatch(ctx);
392
- }
393
414
  if (!isIdle(ctx))
394
415
  return true;
395
416
  try {
@@ -404,40 +425,33 @@ export function createRunUiRuntime(deps) {
404
425
  });
405
426
  throw error;
406
427
  }
407
- if (!RunDelivery.markRunCompletionBatchQueued({
408
- batchId: batch.batch_id,
409
- ownerId,
410
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
411
- })) {
412
- throw new Error("Completion batch changed before queue acknowledgment");
413
- }
414
- if (!RunDelivery.markRunCompletionBatchPresented({
415
- batchId: batch.batch_id,
416
- ownerId,
417
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
418
- })) {
428
+ if (!acceptCompletionBatch(ownerId, batch.batch_id)) {
419
429
  throw new Error("Completion batch changed before durable acceptance");
420
430
  }
421
- const accepted = journal(ownerId).completion_batch;
422
- if (accepted)
423
- finishPresentedBatch(ownerId, accepted);
424
431
  ctx.ui.notify(`Actor completions ready: ${batch.members.length}`, "info");
425
432
  return true;
426
433
  };
427
434
  return {
428
435
  close,
429
436
  flushCompletionBatch,
437
+ onTurnEnd(event, ctx) {
438
+ const ownerId = activeOwnerId;
439
+ if (!ownerId || !isCurrentOwner(ctx) || !isDeliveryRoot(ownerId) ||
440
+ event.outcome !== "completed" || isIdle(ctx) || ctx.signal?.aborted ||
441
+ ctx.hasPendingMessages?.() || event.context.pendingMessages.length > 0)
442
+ return undefined;
443
+ if (flushSteers(ctx, ownerId) || reconcileCompletionBatch(ctx, ownerId))
444
+ return undefined;
445
+ const batch = pendingCompletionBatch(ownerId);
446
+ if (!batch)
447
+ return undefined;
448
+ const entry = Pi.createRunCompletionBatchEntry(batch.batch_id, RunDelivery.formatRunCompletionBatchMessage(batch));
449
+ return { entries: [...event.entries, entry], continue: true };
450
+ },
430
451
  projectContext(messages, ctx) {
431
452
  const ownerId = activeOwnerId;
432
- if (!running || !ownerId)
433
- return messages;
434
- try {
435
- if (Pi.getSessionId(ctx) !== ownerId)
436
- return messages;
437
- }
438
- catch {
453
+ if (!ownerId || !isCurrentOwner(ctx))
439
454
  return messages;
440
- }
441
455
  const steerContext = Pi.dedupeRunSteerContext(messages);
442
456
  let contextMessages = steerContext.messages;
443
457
  for (const steer of journal(ownerId).steers) {
@@ -472,21 +486,7 @@ export function createRunUiRuntime(deps) {
472
486
  if (content !== RunDelivery.formatRunCompletionBatchMessage(batch)) {
473
487
  return Pi.removeRunCompletionBatchFromContext(projected.messages, batch.batch_id);
474
488
  }
475
- if (!RunDelivery.markRunCompletionBatchQueued({
476
- batchId: batch.batch_id,
477
- ownerId,
478
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
479
- }))
480
- return projected.messages;
481
- if (!RunDelivery.markRunCompletionBatchPresented({
482
- batchId: batch.batch_id,
483
- ownerId,
484
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
485
- }))
486
- return projected.messages;
487
- const presented = journal(ownerId).completion_batch;
488
- if (presented)
489
- finishPresentedBatch(ownerId, presented);
489
+ acceptCompletionBatch(ownerId, batch.batch_id);
490
490
  return projected.messages;
491
491
  },
492
492
  shutdown(eventReason, ownerId, ctx) {
@@ -107,7 +107,7 @@ Run = Recipe + Trace + Control
107
107
  ```
108
108
 
109
109
  1. Spawn with the exact logical Recipe identity and caller-owned values.
110
- 2. Retain the returned `run:<id>` and normally wait for the root coordinator's settled completion batch instead of polling. Nested actor completions accumulate under their containing top-level Run and arrive in that one tree-compressed batch; descendant sessions do not need notification options.
110
+ 2. Retain the returned `run:<id>` and normally wait for the root coordinator's completion batch instead of polling. While Pi is active, ready results arrive together at the next safe model-step boundary after the full tool batch; already-idle Pi wakes through a debounced follow-up. Nested actor completions wait for their complete containing top-level tree and arrive in that one compressed batch; descendant sessions do not need notification options.
111
111
  3. Inspect `view=trace` when retained observations or attention matter.
112
112
  4. Inspect `view=control` before diagnosing service readiness, stale work, or saturation.
113
113
  5. Send `message` only for an action declared and consumed by that controlled Recipe.
@@ -14,7 +14,7 @@ A rare Skill Recipe may declare `singleton: true`. Do not pass `as`: the runtime
14
14
 
15
15
  ## Observe
16
16
 
17
- Normally wait for the settled completion batch. Inspect only when requested, when meaningful attention arrives, or when the Run is overdue or blocked:
17
+ Normally wait for the completion batch: ready trees reach an active root coordinator at the next safe model-step boundary after the full tool batch, or wake an already-idle coordinator through a debounced follow-up. Inspect only when requested, when meaningful attention arrives, or when the Run is overdue or blocked:
18
18
 
19
19
  ```text
20
20
  inspect target=run:<id> view=recipe
@@ -7,7 +7,7 @@ Living index of all documentation in the `/docs` directory.
7
7
  - [command-templates.md](./command-templates.md) — Portable synchronous command execution standard
8
8
  - [template-recipes.md](./template-recipes.md) — Saved JSON/Markdown recipe standard, imports, and reusable command-template graph composition
9
9
  - [async-runs.md](./async-runs.md) — Run lifecycle, state, Control, Trace, cancellation, and terminal reconciliation
10
- - [coordinator-delivery.md](./coordinator-delivery.md) — Accepted next-minor design for durable terminal batching and explicit urgent Pi steering
10
+ - [coordinator-delivery.md](./coordinator-delivery.md) — Active model-step completion overlays, idle batching, durable acceptance, and explicit urgent Pi steering
11
11
  - [actor-inspector.md](./actor-inspector.md) — Owner-filtered actor-instance navigation through Recipe, Trace, and Control
12
12
  - [inspection.md](./inspection.md) — Complete `inspect` target/view matrix, authorization boundaries, and diagnostic routes
13
13
  - [tool-registry.md](./tool-registry.md) — Local `pi-actors` registry storage and `register_tool` adaptation
@@ -110,7 +110,9 @@ Statuses include `running`, `done`, `failed`, `exited`, `cancelled`, and `killed
110
110
 
111
111
  Ambient observation detects root terminal transitions and explicit retained Trace attention. Terminal transitions reconcile before semantic attention. Canonical attention is an in-memory wake hint, not a durable queue: observers prime retained ids at startup, deliver each later retained unseen id once, and bound memory to the current retained set across compaction. Persist durable recovery state or an artifact before emitting attention; compaction may discard older hints and its marker makes that history loss explicit. Terminal follow-up delivery persists handled/failure evidence so reloads retry unhandled transitions without duplicating completed notifications.
112
112
 
113
- Ordinary finite Runs project terminal results through one root-coordinator completion scheduler. Every detached runner passes its delivery-root owner and exact parent Run generation to nested actor processes. A descendant terminal remains authoritative but ineligible until its containing top-level Run is terminal; descendant Pi sessions never schedule completion follow-ups. After `agent_settled`, session recovery, or an idle debounce, the root scheduler snapshots the ready trees plus concurrently completed top-level Runs into one owner-fenced immutable batch of at most 256 exact generations. One batch causes one automatic agent turn, renders one visible gray operator card, exposes at most 64 bounded parent-child rows with semantic outputs to the model, and marks member terminals handled after Pi durably accepts the exact batch. Pending send failures retain bounded retry evidence. The narrow crash boundary between acceptance and finalization remains recoverable from bounded active-session evidence, but normal hot delivery never waits on a later context callback and therefore cannot block subsequent epochs. Duplicate exact context envelopes collapse before presentation.
113
+ Ordinary finite Runs project terminal results through one root-coordinator completion scheduler. Every detached runner passes its delivery-root owner and exact parent Run generation to nested actor processes. A descendant terminal remains authoritative but ineligible until its complete containing top-level tree is terminal; descendant Pi sessions never schedule completion delivery. At an active completed `turn_end`, the root scheduler snapshots all ready trees into one owner-fenced immutable batch of at most 256 exact generations, preserves preceding boundary entries, and returns one visible `custom_message` plus `continue: true`. The full tool batch finishes before insertion, and the next model request receives fresh results within the same agent run without using native steering or follow-up queues. Aborted/error boundaries and pending native input defer insertion. Already-idle Pi still wakes through `agent_settled`, session recovery, or a short idle debounce. One batch renders one gray operator card and exposes at most 64 bounded parent-child rows with semantic outputs.
114
+
115
+ Boundary proposals remain pending until exact model context or bounded active-session evidence proves the entry was committed; returning a draft never marks member terminals handled. An idle send finalizes immediately on Pi acceptance, and durable queued phase also proves acceptance. Recovery retries a pending batch only after proving absence from the bounded active session chain; incomplete or conflicting evidence stays pending with diagnostics. Exact acceptance marks the member generations handled and releases the next epoch without depending on one later context callback. Pending send failures retain bounded retry evidence. Duplicate exact context envelopes collapse before presentation. See [Coordinator Delivery](./coordinator-delivery.md) for scheduling and recovery details.
114
116
 
115
117
  Sequence, parallel, repeat, and imported branches are internal execution topology and never own branch-level turns. Each separately launched Run owns its own generation and terminal lifecycle; compatible singleton reuse is not a new launch. Explicit semantic attention may intentionally add a checkpoint turn; Runs marked silent and synchronously acknowledged stop outcomes suppress automatic projection. Large semantic results stay outside compact completion rows and remain available in structured details, execution captures, or artifacts.
116
118
 
@@ -8,10 +8,10 @@ Separate durable Run completion truth from the scheduling of model turns:
8
8
 
9
9
  ```text
10
10
  one Run generation -> one terminal record with inherited tree lineage
11
- one ready completion forest per bounded epoch -> one root-coordinator turn
11
+ one ready completion forest per bounded epoch -> one root-coordinator model step
12
12
  ```
13
13
 
14
- Ordinary terminals accumulate across nested actor sessions while Pi is active. Descendants wait for their containing top-level Run, then all ready trees and concurrently completed top-level Runs reach only the root coordinator in one bounded batch after Pi settles. Only an explicitly actor-authored urgent semantic checkpoint may steer an active agent loop.
14
+ Ordinary terminals accumulate across nested actor sessions. Descendants wait for their complete containing top-level tree, then all ready trees and concurrently completed top-level Runs reach only the root coordinator in one bounded batch. During active work, a public `turn_end` overlay supplies fresh results to the next model request after the full tool batch, without waiting for idle or using native steering. Already-idle Pi retains debounced follow-up wakeups. Explicit actor-authored urgent semantic checkpoints remain a separate native-steer delivery class.
15
15
 
16
16
  ## Non-Goals
17
17
 
@@ -27,7 +27,7 @@ Ordinary terminals accumulate across nested actor sessions while Pi is active. D
27
27
  - `attention: "notify"` remains visible UI status without a model turn.
28
28
  - `attention: "followup"` retains its existing explicit semantic follow-up behavior.
29
29
  - New `attention: "steer"` requests urgent semantic delivery at Pi's next safe assistant/tool boundary.
30
- - Terminal transitions inherit exact parent-generation and root-owner lineage; only the root session batches ready completion trees instead of sending one follow-up per Run or descendant session.
30
+ - Terminal transitions inherit exact parent-generation and root-owner lineage; only the root session batches ready completion trees into a model-step overlay or an already-idle follow-up, never one turn per Run or descendant session.
31
31
 
32
32
  `command.done` remains non-projectable even if malformed or legacy Trace attaches any attention value.
33
33
 
@@ -37,7 +37,7 @@ Ordinary terminals accumulate across nested actor sessions while Pi is active. D
37
37
  - `runs-trace.ts` owns admission of the new `steer` attention value.
38
38
  - `observability.ts` discovers terminal candidates and explicit semantic attention without deciding Pi delivery timing.
39
39
  - A new `run-delivery.ts` domain owns the owner-scoped delivery journal, batch construction, bounds, phases, formatting, recovery, and acknowledgments.
40
- - `run-ui-runtime.ts` owns idle detection, debounce, reconciliation, flushing, and stale-context containment.
40
+ - `run-ui-runtime.ts` owns active model-step boundaries, idle detection, debounce, reconciliation, flushing, and stale-context containment.
41
41
  - `extension-runtime.ts` orders completion flushes before automatic Recipe review.
42
42
  - `index.ts` remains a thin registration root and adds only the required Pi lifecycle adapter.
43
43
  - `pi.ts` exposes narrow ports for batched follow-up and urgent steer delivery.
@@ -75,13 +75,13 @@ Completion batch shape:
75
75
 
76
76
  Phases are monotonic:
77
77
 
78
- 1. `pending`: The exact member snapshot is durable but no Pi message has been accepted.
78
+ 1. `pending`: The exact member snapshot is durable but Pi acceptance is unproven. Returning a boundary draft does not advance this phase.
79
79
  2. `queued`: Pi accepted a custom message carrying the exact batch or steer ID.
80
- 3. `presented`: A Pi `context` event observed that ID in messages being supplied to an LLM call.
80
+ 3. `presented`: Exact completion acceptance is proven, or a Pi `context` event observed the urgent-steer ID in messages being supplied to an LLM call.
81
81
 
82
82
  Every journal mutation uses the canonical token-owned lock, expected-phase fencing, owner and generation validation, and atomic replacement. Repeated transitions are idempotent. Corrupt, oversized, foreign-owner, or stale-generation state fails closed with bounded diagnostics.
83
83
 
84
- Completion and steer acknowledgments intentionally differ. Successful `sendMessage()` acceptance finalizes an ordinary completion batch immediately and marks its members through the existing terminal-handled authority; this prevents a missed context callback from blocking every later epoch. Urgent steer still requires exact model-context presentation. If a completion member was synchronously archived or pruned after queueing, the bounded delivery snapshot remains sufficient and the missing state write becomes a diagnostic rather than invalidating acceptance.
84
+ Completion and steer acknowledgments intentionally differ. An active completion overlay remains pending until exact model context or bounded active-session evidence proves its committed entry. Successful idle `sendMessage()` acceptance finalizes an ordinary completion batch immediately; durable queued phase also proves acceptance. Both paths mark members through the existing terminal-handled authority, so a missed context callback cannot block every later epoch. Urgent steer still requires exact model-context presentation. If a completion member was synchronously archived or pruned after acceptance, the bounded delivery snapshot remains sufficient and the missing state write becomes a diagnostic rather than invalidating acceptance.
85
85
 
86
86
  ## Completion Collection
87
87
 
@@ -97,17 +97,20 @@ It excludes:
97
97
 
98
98
  Candidates sort by terminal timestamp, then stable Run identity, then `run_instance_id`. Replacement generations with the same logical Run id remain distinct internal members.
99
99
 
100
- While `ctx.isIdle()` is false, candidates remain durable in their Run state and no terminal follow-up is sent. A flush snapshots eligible candidates into one immutable batch. While transport acceptance is pending, newer terminals stay unhandled for the next bounded completion epoch.
100
+ While Pi is active, candidates remain durable until the next completed `turn_end`; watchers do not enqueue ordinary terminal follow-ups into the native message queue. The boundary itself reads current Run state, so a missed watcher cannot make the next step stale. A flush snapshots eligible candidates into one immutable batch. While acceptance is pending, newer terminals stay unhandled for the next bounded completion epoch.
101
101
 
102
102
  ## Batch Flush
103
103
 
104
104
  Flush one batch when:
105
105
 
106
- 1. `agent_settled` fires for the still-active context and `ctx.isIdle()` remains true;
107
- 2. terminals arrive while Pi is already idle and survive one short debounce window;
108
- 3. session restoration discovers unhandled terminal generations or recoverable queued delivery state.
106
+ 1. A completed active `turn_end` reaches the exact root owner without an aborted signal or native pending input;
107
+ 2. `agent_settled` fires for the still-active owner and `ctx.isIdle()` remains true;
108
+ 3. Terminals arrive while Pi is already idle and survive the 100 ms default debounce window;
109
+ 4. Session restoration discovers unhandled terminal generations or recoverable delivery state.
109
110
 
110
- The custom message uses `customType: "pi-actors-run-batch"`, `display: true`, `deliverAs: "followUp"`, and `triggerTurn: true`. It restores the visible gray completion card for the operator while supplying the same single tree-compressed prompt to the model. The bounded content retains the exact batch identity for session evidence and deduplication even though Pi omits private message details from model context. It includes:
111
+ Active delivery returns `{ entries: [...event.entries, entry], continue: true }`, where `entry` is one `custom_message` with `customType: "pi-actors-run-batch"` and `display: true`. It preserves previous extensions' boundary entries and requests one next model step in the same agent run after the entire tool batch. Aborted/error boundaries, queued native input, and outstanding urgent steer defer the overlay. It never uses `sendMessage(..., { deliverAs: "steer" })` or a native follow-up queue for active completion delivery.
112
+
113
+ Already-idle delivery uses the same custom message with `deliverAs: "followUp"` and `triggerTurn: true` to wake Pi. Both paths render one gray operator card and supply the same tree-compressed prompt to the model. The bounded content retains the exact batch identity for session evidence and deduplication even though Pi omits private message details from model context. It includes:
111
114
 
112
115
  - batch ID and completion window;
113
116
  - counts by terminal status;
@@ -120,14 +123,14 @@ Completion member details remain redacted through existing terminal projection r
120
123
 
121
124
  ## Acceptance, Presentation, And Recovery
122
125
 
123
- Ordinary completion delivery atomically advances through queued acceptance to finalization immediately after `sendMessage()` returns, marks every still-present member generation terminal-handled, and retains a bounded owner receipt. A `context` lifecycle adapter remains for exact urgent-steer presentation and harmless completion deduplication; presented steer writes `delivery.steer_presented` in the exact Run generation.
126
+ Returning boundary drafts is a proposal, not an SDK receipt. An active completion batch advances through queued acceptance to finalization only when exact model-bound `context` or a bounded active-session parent-chain read proves the committed entry. If another extension replaces or invalidates the proposed draft chain, the absent batch remains pending and can retry with the same identity. Idle delivery advances immediately after `sendMessage()` returns. Acceptance marks every still-present member generation terminal-handled and retains a bounded owner receipt. The `context` lifecycle adapter also deduplicates completion envelopes and handles exact urgent-steer presentation; presented steer writes `delivery.steer_presented` in the exact Run generation.
124
127
 
125
128
  The generation-fenced Trace marker prevents a retained historical steer from replaying after bounded owner receipts rotate: suffix compaction cannot retain the older steer while discarding its newer presentation marker. Missing, archived, pruned, or replaced Run state needs no marker because it can no longer replay that original generation.
126
129
 
127
130
  Recovery rules:
128
131
 
129
132
  - Send failure: keep `pending`, record failure evidence, and retry.
130
- - Crash after queueing: inspect existing owned Pi session evidence for the exact custom message ID.
133
+ - Pending batch after a boundary attempt or restart: inspect the bounded active Pi session chain for the exact batch ID and content. Proven presence finalizes; proven absence permits retry; incomplete or conflicting evidence stays pending with a diagnostic.
131
134
  - Completion send accepted: finalize immediately and release the next epoch.
132
135
  - Crash leaves a queued completion: queued itself proves `sendMessage()` acceptance, so finalize without resend or content reformatting.
133
136
  - Presented urgent steer: never resend.
@@ -179,12 +182,12 @@ Another extension may start work during `agent_settled`; recheck `ctx.isIdle()`
179
182
 
180
183
  Implementation is complete only when source and packed-extension tests prove:
181
184
 
182
- 1. Multiple Runs finishing during one agent run cause no immediate terminal turns and one settled batch.
185
+ 1. Multiple Runs finishing during active work produce one boundary entry and one next model request before idle, after all tools in the current batch finish; no native steering/follow-up enqueue occurs.
183
186
  2. Idle completions inside the debounce window form one batch.
184
187
  3. Completion/settled races project every generation exactly once.
185
188
  4. Send failure and restart before queueing retry without a handled marker.
186
189
  5. Restart in the narrow queue-acceptance/finalization boundary neither loses nor duplicates the batch.
187
- 6. Successful completion acceptance acknowledges members atomically and idempotently; urgent steer still requires exact `context` presentation.
190
+ 6. Boundary proposals do not acknowledge terminals early. Exact committed-entry evidence or idle send acceptance acknowledges members atomically and idempotently; urgent steer still requires exact `context` presentation.
188
191
  7. Replacement generations sharing a Run id remain distinct.
189
192
  8. Silent, stopped, cancelled, handled, and foreign-owner Runs remain excluded.
190
193
  9. Legacy or malformed `command.done` attention, including `steer`, remains non-projectable.
@@ -192,11 +195,10 @@ Implementation is complete only when source and packed-extension tests prove:
192
195
  11. Overflow, corruption, journal backpressure, archive/prune races, and stale contexts fail safely.
193
196
  12. Completion flushing precedes automatic Recipe review.
194
197
  13. Pi 1.0.0 is the minimum source and packed lifecycle baseline; coding-agent and TUI peers share that floor.
198
+ 14. The packed extension runs through the public SDK with a synthetic provider, proving one batch reaches the next request before settlement for both a full tool batch and a tool-free final answer, while preserving preceding boundary entries.
195
199
 
196
200
  Focused observability and delivery tests precede TypeScript/build/import checks. The acceptance checkpoint then runs full product validation, dependency audit, package dry-run, and ABCd context validation.
197
201
 
198
- ## Rollout
199
-
200
- This is one minor release because durable batching, acceptance acknowledgment, lifecycle ordering, and explicit steer share one model-delivery invariant. Completion acceptance must release later epochs without depending on a context callback; steer must not ship before its durable presentation-deduplication path exists.
202
+ ## Product Boundaries
201
203
 
202
- Update README and Run documentation only when implementation establishes the new public behavior. Move the accepted outcome from BACKLOG to CHANGELOG only after complete validation.
204
+ Active overlays and idle follow-ups share the same batch identity, bounds, lineage, terminal authority, and acceptance journal. They differ only in the Pi scheduling port and its acceptance evidence. Urgent semantic steer remains independent; completion status never implies urgency. The extension has no dependency on Telegram and does not modify Telegram reply or preview ownership.
@@ -17,6 +17,7 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
17
17
  );
18
18
  }
19
19
  pi.on("session_start", async (_event, ctx) => runtime.onSessionStart(ctx));
20
+ pi.on("turn_end", (event, ctx) => runtime.onTurnEnd(event, ctx));
20
21
  pi.on("agent_settled", async (_event, ctx) => runtime.onAgentSettled(ctx));
21
22
  pi.on("context", async (event, ctx) => ({
22
23
  messages: runtime.onContext(event.messages, ctx) as typeof event.messages,
@@ -26,6 +26,7 @@ export interface ActorExtensionRuntime {
26
26
  discoverResources(metaUrl: string): { skillPaths: string[] } | undefined;
27
27
  getRunOwnerId(ctx: Pi.ExtensionContext): string;
28
28
  onAgentSettled(ctx: Pi.ExtensionContext): void;
29
+ onTurnEnd(event: Pi.TurnEndEvent, ctx: Pi.ExtensionContext): Pi.TurnEndEventResult | undefined;
29
30
  onContext(messages: unknown[], ctx: Pi.ExtensionContext): unknown[];
30
31
  onSessionShutdown(reason: string, ctx: Pi.ExtensionContext): void;
31
32
  onSessionStart(ctx: Pi.ExtensionContext): Promise<void>;
@@ -146,6 +147,9 @@ export function createActorExtensionRuntime(
146
147
  if (!activeRunOwnerId || getRunOwnerId(ctx) !== activeRunOwnerId) return;
147
148
  if (!runUiRuntime.flushCompletionBatch(ctx)) automaticReview.schedule();
148
149
  },
150
+ onTurnEnd(event, ctx) {
151
+ return runUiRuntime.onTurnEnd(event, ctx);
152
+ },
149
153
  onContext(messages, ctx) {
150
154
  return runUiRuntime.projectContext(messages, ctx);
151
155
  },
@@ -7,11 +7,14 @@
7
7
  import type {
8
8
  ExtensionAPI,
9
9
  ExtensionContext,
10
+ CustomMessageEntryDraft,
11
+ TurnEndEvent,
12
+ TurnEndEventResult,
10
13
  } from "@earendil-works/pi-coding-agent";
11
14
 
12
15
  import * as SessionEvidence from "./session-evidence.ts";
13
16
 
14
- export type { ExtensionAPI, ExtensionContext };
17
+ export type { ExtensionAPI, ExtensionContext, TurnEndEvent, TurnEndEventResult };
15
18
 
16
19
  export const RUN_COMPLETION_BATCH_CUSTOM_TYPE = "pi-actors-run-batch";
17
20
  export const RUN_STEER_CUSTOM_TYPE = "pi-actors-run-steer";
@@ -44,12 +47,12 @@ export function createNotificationSink(
44
47
  };
45
48
  }
46
49
 
47
- export function sendRunCompletionBatch(
48
- pi: ExtensionAPI,
50
+ export function createRunCompletionBatchEntry(
49
51
  batchId: string,
50
52
  content: string,
51
- ): void {
52
- pi.sendMessage({
53
+ ): CustomMessageEntryDraft {
54
+ return {
55
+ type: "custom_message",
53
56
  customType: RUN_COMPLETION_BATCH_CUSTOM_TYPE,
54
57
  content,
55
58
  display: true,
@@ -59,10 +62,16 @@ export function sendRunCompletionBatch(
59
62
  kind: "completion_batch",
60
63
  },
61
64
  },
62
- }, {
63
- deliverAs: "followUp",
64
- triggerTurn: true,
65
- });
65
+ };
66
+ }
67
+
68
+ export function sendRunCompletionBatch(
69
+ pi: ExtensionAPI,
70
+ batchId: string,
71
+ content: string,
72
+ ): void {
73
+ const { type: _type, ...message } = createRunCompletionBatchEntry(batchId, content);
74
+ pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
66
75
  }
67
76
 
68
77
  export function sendRunSteer(
@@ -219,6 +228,21 @@ export function removeRunSteerFromContext(
219
228
  return messages.filter((message) => steerMessage(message)?.steerId !== steerId);
220
229
  }
221
230
 
231
+ export function inspectRunCompletionBatchSessionEvidence(
232
+ ctx: ExtensionContext,
233
+ input: { batchId: string; content: string },
234
+ ): SessionEvidence.ActiveSessionEntryEvidence {
235
+ return SessionEvidence.inspectBoundedActiveSessionEntries({
236
+ getEntry: (id) => ctx.sessionManager.getEntry(id),
237
+ leaf: ctx.sessionManager.getLeafEntry(),
238
+ match: (entry) => {
239
+ const batch = completionBatchMessage(entry);
240
+ if (!batch || batch.batchId !== input.batchId) return undefined;
241
+ return batch.content === input.content ? "present" : "conflict";
242
+ },
243
+ });
244
+ }
245
+
222
246
  export function inspectRunSteerSessionEvidence(
223
247
  ctx: ExtensionContext,
224
248
  input: { content: string; eventId: string; steerId: string },
@@ -15,6 +15,7 @@ import * as RunDeliveryLineage from "./run-delivery-lineage.ts";
15
15
  export interface RunUiRuntime {
16
16
  close(): void;
17
17
  flushCompletionBatch(ctx: Pi.ExtensionContext): boolean;
18
+ onTurnEnd(event: Pi.TurnEndEvent, ctx: Pi.ExtensionContext): Pi.TurnEndEventResult | undefined;
18
19
  projectContext(messages: unknown[], ctx: Pi.ExtensionContext): unknown[];
19
20
  shutdown(
20
21
  eventReason: string,
@@ -51,6 +52,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
51
52
  const deliveryObservation = Observability.createRunUiObservationState();
52
53
  const retirementAttempts = new Set<string>();
53
54
  const steerDiagnostics = new Set<string>();
55
+ const completionDiagnostics = new Set<string>();
54
56
 
55
57
  const close = (): void => {
56
58
  running = false;
@@ -72,6 +74,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
72
74
  deliveryTimeout = undefined;
73
75
  recoverQueuedSteers = false;
74
76
  steerDiagnostics.clear();
77
+ completionDiagnostics.clear();
75
78
  if (animationInterval) clearInterval(animationInterval);
76
79
  animationInterval = undefined;
77
80
  };
@@ -242,6 +245,53 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
242
245
  });
243
246
  return true;
244
247
  };
248
+ const acceptCompletionBatch = (ownerId: string, batchId: string): boolean => {
249
+ const input = { batchId, ownerId, tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir };
250
+ if (!RunDelivery.markRunCompletionBatchQueued(input)) return false;
251
+ if (!RunDelivery.markRunCompletionBatchPresented(input)) return false;
252
+ const accepted = journal(ownerId).completion_batch;
253
+ return accepted ? finishPresentedBatch(ownerId, accepted) : false;
254
+ };
255
+ const reconcileCompletionBatch = (ctx: Pi.ExtensionContext, ownerId: string): boolean => {
256
+ const batch = journal(ownerId).completion_batch;
257
+ if (!batch) return false;
258
+ if (batch.phase === "pending") {
259
+ // Returning boundary drafts is not acceptance: prove the exact entry before acknowledging it.
260
+ const evidence = Pi.inspectRunCompletionBatchSessionEvidence(ctx, {
261
+ batchId: batch.batch_id,
262
+ content: RunDelivery.formatRunCompletionBatchMessage(batch),
263
+ });
264
+ if (evidence.status === "absent") return false;
265
+ if (evidence.status !== "present") {
266
+ const key = `${batch.batch_id}:${evidence.status}:${evidence.reason ?? ""}`;
267
+ if (!completionDiagnostics.has(key)) {
268
+ completionDiagnostics.add(key);
269
+ ctx.ui.notify(
270
+ `Actor completion recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Batch ${batch.batch_id} remains pending.`,
271
+ "warning",
272
+ );
273
+ }
274
+ return true;
275
+ }
276
+ }
277
+ return !acceptCompletionBatch(ownerId, batch.batch_id);
278
+ };
279
+ const pendingCompletionBatch = (ownerId: string): RunDelivery.RunCompletionBatch | undefined => {
280
+ const existing = journal(ownerId).completion_batch;
281
+ if (existing) return existing;
282
+ const snapshot = Observability.readRunDeliverySnapshot(deliveryObservation, ownerId);
283
+ admitCompletionTransitions(ownerId, snapshot);
284
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
285
+ return journal(ownerId).completion_batch;
286
+ };
287
+ const isCurrentOwner = (ctx: Pi.ExtensionContext): boolean => {
288
+ if (!running || !activeOwnerId) return false;
289
+ try {
290
+ return Pi.getSessionId(ctx) === activeOwnerId;
291
+ } catch {
292
+ return false;
293
+ }
294
+ };
245
295
  const isIdle = (ctx: Pi.ExtensionContext): boolean =>
246
296
  typeof ctx.isIdle !== "function" || ctx.isIdle();
247
297
  const isDeliveryRoot = (ownerId: string): boolean => {
@@ -422,48 +472,14 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
422
472
 
423
473
  flushCompletionBatch = (ctx: Pi.ExtensionContext): boolean => {
424
474
  const ownerId = activeOwnerId;
425
- if (!running || !ownerId) return false;
426
- try {
427
- if (Pi.getSessionId(ctx) !== ownerId) return false;
428
- } catch {
429
- return false;
430
- }
475
+ if (!ownerId || !isCurrentOwner(ctx)) return false;
431
476
  if (flushSteers(ctx, ownerId)) return true;
432
477
  if (!isDeliveryRoot(ownerId)) return false;
433
- let batch = journal(ownerId).completion_batch;
434
- if (!batch) {
435
- const snapshot = Observability.readRunDeliverySnapshot(
436
- deliveryObservation,
437
- ownerId,
438
- );
439
- admitCompletionTransitions(ownerId, snapshot);
440
- Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
441
- batch = journal(ownerId).completion_batch;
442
- }
478
+ if (reconcileCompletionBatch(ctx, ownerId)) return true;
479
+ const batch = pendingCompletionBatch(ownerId);
443
480
  if (!batch) return false;
444
- if (batch.phase === "presented") {
445
- finishPresentedBatch(ownerId, batch);
446
- const snapshot = Observability.readRunDeliverySnapshot(
447
- deliveryObservation,
448
- ownerId,
449
- );
450
- admitCompletionTransitions(ownerId, snapshot);
451
- Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
452
- batch = journal(ownerId).completion_batch;
453
- if (!batch) return false;
454
- }
455
481
  if (!isIdle(ctx)) return true;
456
482
  const content = RunDelivery.formatRunCompletionBatchMessage(batch);
457
- if (batch.phase === "queued") {
458
- if (!RunDelivery.markRunCompletionBatchPresented({
459
- batchId: batch.batch_id,
460
- ownerId,
461
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
462
- })) return true;
463
- const accepted = journal(ownerId).completion_batch;
464
- if (accepted) finishPresentedBatch(ownerId, accepted);
465
- return flushCompletionBatch(ctx);
466
- }
467
483
  if (!isIdle(ctx)) return true;
468
484
  try {
469
485
  Pi.sendRunCompletionBatch(deps.pi, batch.batch_id, content);
@@ -476,22 +492,9 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
476
492
  });
477
493
  throw error;
478
494
  }
479
- if (!RunDelivery.markRunCompletionBatchQueued({
480
- batchId: batch.batch_id,
481
- ownerId,
482
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
483
- })) {
484
- throw new Error("Completion batch changed before queue acknowledgment");
485
- }
486
- if (!RunDelivery.markRunCompletionBatchPresented({
487
- batchId: batch.batch_id,
488
- ownerId,
489
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
490
- })) {
495
+ if (!acceptCompletionBatch(ownerId, batch.batch_id)) {
491
496
  throw new Error("Completion batch changed before durable acceptance");
492
497
  }
493
- const accepted = journal(ownerId).completion_batch;
494
- if (accepted) finishPresentedBatch(ownerId, accepted);
495
498
  ctx.ui.notify(
496
499
  `Actor completions ready: ${batch.members.length}`,
497
500
  "info",
@@ -502,14 +505,25 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
502
505
  return {
503
506
  close,
504
507
  flushCompletionBatch,
508
+ onTurnEnd(event, ctx) {
509
+ const ownerId = activeOwnerId;
510
+ if (
511
+ !ownerId || !isCurrentOwner(ctx) || !isDeliveryRoot(ownerId) ||
512
+ event.outcome !== "completed" || isIdle(ctx) || ctx.signal?.aborted ||
513
+ ctx.hasPendingMessages?.() || event.context.pendingMessages.length > 0
514
+ ) return undefined;
515
+ if (flushSteers(ctx, ownerId) || reconcileCompletionBatch(ctx, ownerId)) return undefined;
516
+ const batch = pendingCompletionBatch(ownerId);
517
+ if (!batch) return undefined;
518
+ const entry = Pi.createRunCompletionBatchEntry(
519
+ batch.batch_id,
520
+ RunDelivery.formatRunCompletionBatchMessage(batch),
521
+ );
522
+ return { entries: [...event.entries, entry], continue: true };
523
+ },
505
524
  projectContext(messages, ctx) {
506
525
  const ownerId = activeOwnerId;
507
- if (!running || !ownerId) return messages;
508
- try {
509
- if (Pi.getSessionId(ctx) !== ownerId) return messages;
510
- } catch {
511
- return messages;
512
- }
526
+ if (!ownerId || !isCurrentOwner(ctx)) return messages;
513
527
  const steerContext = Pi.dedupeRunSteerContext(messages);
514
528
  let contextMessages = steerContext.messages;
515
529
  for (const steer of journal(ownerId).steers) {
@@ -549,18 +563,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
549
563
  batch.batch_id,
550
564
  );
551
565
  }
552
- if (!RunDelivery.markRunCompletionBatchQueued({
553
- batchId: batch.batch_id,
554
- ownerId,
555
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
556
- })) return projected.messages;
557
- if (!RunDelivery.markRunCompletionBatchPresented({
558
- batchId: batch.batch_id,
559
- ownerId,
560
- tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
561
- })) return projected.messages;
562
- const presented = journal(ownerId).completion_batch;
563
- if (presented) finishPresentedBatch(ownerId, presented);
566
+ acceptCompletionBatch(ownerId, batch.batch_id);
564
567
  return projected.messages;
565
568
  },
566
569
  shutdown(eventReason, ownerId, ctx) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -107,7 +107,7 @@ Run = Recipe + Trace + Control
107
107
  ```
108
108
 
109
109
  1. Spawn with the exact logical Recipe identity and caller-owned values.
110
- 2. Retain the returned `run:<id>` and normally wait for the root coordinator's settled completion batch instead of polling. Nested actor completions accumulate under their containing top-level Run and arrive in that one tree-compressed batch; descendant sessions do not need notification options.
110
+ 2. Retain the returned `run:<id>` and normally wait for the root coordinator's completion batch instead of polling. While Pi is active, ready results arrive together at the next safe model-step boundary after the full tool batch; already-idle Pi wakes through a debounced follow-up. Nested actor completions wait for their complete containing top-level tree and arrive in that one compressed batch; descendant sessions do not need notification options.
111
111
  3. Inspect `view=trace` when retained observations or attention matter.
112
112
  4. Inspect `view=control` before diagnosing service readiness, stale work, or saturation.
113
113
  5. Send `message` only for an action declared and consumed by that controlled Recipe.
@@ -14,7 +14,7 @@ A rare Skill Recipe may declare `singleton: true`. Do not pass `as`: the runtime
14
14
 
15
15
  ## Observe
16
16
 
17
- Normally wait for the settled completion batch. Inspect only when requested, when meaningful attention arrives, or when the Run is overdue or blocked:
17
+ Normally wait for the completion batch: ready trees reach an active root coordinator at the next safe model-step boundary after the full tool batch, or wake an already-idle coordinator through a debounced follow-up. Inspect only when requested, when meaningful attention arrives, or when the Run is overdue or blocked:
18
18
 
19
19
  ```text
20
20
  inspect target=run:<id> view=recipe
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.35.4",
3
+ "version": "0.36.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -29,8 +29,10 @@
29
29
  "scripts": {
30
30
  "test": "node --test test/*.test.mjs",
31
31
  "audit": "npm audit --omit=dev",
32
- "pack:check": "npm pack --dry-run",
33
- "validate": "npm test && npm run audit && npm run pack:check"
32
+ "deps:check": "node scripts/check-updates.mjs",
33
+ "check:coherence": "node scripts/validate.mjs",
34
+ "pack:check": "node scripts/validate.mjs --pack",
35
+ "validate": "npm run check:coherence && npm test && npm run audit && npm run pack:check"
34
36
  },
35
37
  "files": [
36
38
  "extensions/",
@@ -42,7 +44,7 @@
42
44
  "banner.jpg"
43
45
  ],
44
46
  "dependencies": {
45
- "@llblab/pi-actors": "0.54.0",
47
+ "@llblab/pi-actors": "0.55.0",
46
48
  "@llblab/pi-claude-usage": "0.2.2",
47
49
  "@llblab/pi-clean-room": "0.3.0",
48
50
  "@llblab/pi-codex-usage": "0.13.1",