pi-ui-extend 1.0.47 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,8 +123,26 @@ Later files override earlier ones. Disable modules with `disabledModules`, `PI_T
123
123
 
124
124
  ## Install
125
125
 
126
+ ### Download a ready-to-run release
127
+
128
+ [GitHub Releases](https://github.com/dnaroid/pix/releases) contains published
129
+ portable TUI archives and Pix Desktop installers when a release has completed
130
+ the native build/verification pipeline. Choose **TUI** or **Desktop** and your
131
+ OS/CPU: Windows x64, macOS Apple Silicon, macOS Intel, or Linux x64.
132
+
133
+ These packages contain Node.js and the Pix/ACP runtime; no separate Node/npm
134
+ installation is required to launch them. For TUI, extract the whole archive and
135
+ run `pix` (macOS/Linux) or `pix.cmd` (Windows) from your terminal. For Desktop,
136
+ use the `.dmg`, Windows installer, `.AppImage`, or `.deb` for your system.
137
+ Git, tools needed by your projects, provider credentials and optional voice/
138
+ clipboard helpers are separate. Unsigned/ad-hoc builds may show OS warnings;
139
+ check the release notes before installing. See the [release guide](docs/release.md)
140
+ for building, signing, checksums and updates. npm installation remains available below.
141
+
126
142
  ### Requirements
127
143
 
144
+ The Node/npm requirements below apply to the npm/source installation channel.
145
+
128
146
  - **Node.js `>=22.19.0 <25`**
129
147
  - macOS, Linux, or Windows terminal with 256-color support
130
148
  - npm or another way to run the published npm package
@@ -0,0 +1,4 @@
1
+ export declare const PIX_RELEASES_URL = "https://github.com/dnaroid/pix/releases/latest";
2
+ export declare function isReleaseInstall(packageRoot: string): boolean;
3
+ export declare function fetchLatestReleaseVersion(_packageName: string, currentVersion: string, timeoutMs: number): Promise<string | undefined>;
4
+ export declare function releaseUpdateHint(): string;
@@ -0,0 +1,25 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const PIX_RELEASES_URL = "https://github.com/dnaroid/pix/releases/latest";
4
+ export function isReleaseInstall(packageRoot) {
5
+ // Presence alone fails closed: a damaged manifest must never enable global npm mutation.
6
+ return existsSync(join(packageRoot, ".pix-portable.json"));
7
+ }
8
+ export async function fetchLatestReleaseVersion(_packageName, currentVersion, timeoutMs) {
9
+ const response = await fetch("https://api.github.com/repos/dnaroid/pix/releases/latest", {
10
+ headers: { accept: "application/vnd.github+json", "User-Agent": `pix/${currentVersion}` },
11
+ signal: AbortSignal.timeout(timeoutMs),
12
+ });
13
+ if (response.status === 404)
14
+ return undefined;
15
+ if (!response.ok)
16
+ throw new Error(`GitHub Releases returned ${response.status}`);
17
+ const data = await response.json();
18
+ if (data.draft === true || data.prerelease === true)
19
+ return undefined;
20
+ return typeof data.tag_name === "string" && /^v\d+\.\d+\.\d+$/u.test(data.tag_name)
21
+ ? data.tag_name.slice(1) : undefined;
22
+ }
23
+ export function releaseUpdateHint() {
24
+ return `Download the complete package for your OS/CPU from ${PIX_RELEASES_URL}. Close Pix before replacing it. Settings and sessions remain in your user profile. This installation does not update global npm/Pi packages.`;
25
+ }
@@ -1,3 +1,4 @@
1
+ export { isReleaseInstall } from "./release-update.js";
1
2
  type PixUpdateTestDeps = {
2
3
  checkPixUpdate: typeof checkPixUpdate;
3
4
  runCommand: typeof runCommand;
@@ -59,4 +60,3 @@ export declare function getPixSelfUpdateCommand(packageName: string, latestVersi
59
60
  export declare function getGlobalPiUpdateCommand(targetVersion: string, pixPackageRoot?: string): PixSelfUpdateCommand | undefined;
60
61
  export declare function runPixUpdateCli(argv?: readonly string[]): Promise<number>;
61
62
  declare function runCommand(command: PixSelfUpdateCommand): Promise<void>;
62
- export {};
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
6
+ import { fetchLatestReleaseVersion, isReleaseInstall, releaseUpdateHint } from "./release-update.js";
7
+ export { isReleaseInstall } from "./release-update.js";
6
8
  const DEFAULT_UPDATE_TIMEOUT_MS = 10_000;
7
9
  const NPM_REGISTRY_URL = "https://registry.npmjs.org";
8
10
  const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
@@ -25,6 +27,7 @@ Options:
25
27
  -h, --help Show this help
26
28
 
27
29
  Inside the TUI, /update performs the same non-mutating check.
30
+ Portable/Desktop GitHub Releases are replaced as a complete package; --force never changes global packages for those installations.
28
31
  The bundled skills payload under skills/ is copied into ~/.agents/skills on startup.
29
32
  The pi-tools-suite payload under external/pi-tools-suite is updated with Pix and linked into ~/.pi/agent/extensions on startup.`;
30
33
  }
@@ -54,7 +57,9 @@ export function getPixPackageVersion(packageRoot) {
54
57
  }
55
58
  export async function checkPixUpdate(options = {}) {
56
59
  const packageInfo = readPixPackageInfo(options.packageRoot);
57
- return await checkPackageUpdate(packageInfo, options);
60
+ return await checkPackageUpdate(packageInfo, isReleaseInstall(packageInfo.packageRoot)
61
+ ? { ...options, fetchLatestVersion: options.fetchLatestVersion ?? fetchLatestReleaseVersion }
62
+ : options);
58
63
  }
59
64
  export async function checkPiUpdate(options = {}) {
60
65
  const packageRoot = options.packageRoot ?? findPiPackageRoot(options.pixPackageRoot);
@@ -133,7 +138,7 @@ async function checkPackageUpdate(packageInfo, options) {
133
138
  try {
134
139
  const latestVersion = await (options.fetchLatestVersion ?? fetchLatestNpmVersion)(packageInfo.name, packageInfo.version, options.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS);
135
140
  if (!latestVersion) {
136
- return { ...base, status: "unknown", reason: "npm registry did not return a latest version" };
141
+ return { ...base, status: "unknown", reason: "the distribution channel did not return a latest version" };
137
142
  }
138
143
  return {
139
144
  ...base,
@@ -150,6 +155,11 @@ async function checkPackageUpdate(packageInfo, options) {
150
155
  }
151
156
  }
152
157
  export function formatPixUpdateCheck(result) {
158
+ if (isReleaseInstall(result.packageRoot)) {
159
+ return ["Pix update (GitHub Release)", `current: ${result.packageName} v${result.currentVersion}`,
160
+ ...(result.latestVersion ? [`latest: ${result.latestVersion}`] : []),
161
+ `status: ${result.status}${result.reason ? ` (${result.reason})` : ""}`, releaseUpdateHint()].join("\n");
162
+ }
153
163
  const lines = [
154
164
  "Pix update",
155
165
  `current: ${result.packageName} v${result.currentVersion}`,
@@ -204,6 +214,8 @@ export function formatGlobalPiCheck(result) {
204
214
  return lines.join("\n");
205
215
  }
206
216
  export function formatPixStartupUpdateDialog(result) {
217
+ if (isReleaseInstall(result.packageRoot))
218
+ return formatPixUpdateCheck(result);
207
219
  const lines = [
208
220
  "A new Pix version is available.",
209
221
  `current: ${result.packageName} v${result.currentVersion}`,
@@ -231,6 +243,8 @@ export function getGlobalPiUpdateCommand(targetVersion, pixPackageRoot = readPix
231
243
  return getGlobalPackageUpdateCommand(PI_PACKAGE_NAME, targetVersion, pixPackageRoot);
232
244
  }
233
245
  function getGlobalPackageUpdateCommand(packageName, version, packageRoot) {
246
+ if (isReleaseInstall(packageRoot))
247
+ return undefined;
234
248
  if (!packageRootLooksPackageManaged(packageRoot))
235
249
  return undefined;
236
250
  const installSpec = version ? `${packageName}@${version}` : packageName;
@@ -266,6 +280,10 @@ export async function runPixUpdateCli(argv = process.argv.slice(2)) {
266
280
  }
267
281
  const check = await pixUpdateDeps.checkPixUpdate();
268
282
  console.log(formatPixUpdateCheck(check));
283
+ if (isReleaseInstall(check.packageRoot)) {
284
+ // Portable/Desktop installs are replaced as a unit. Even --force must not modify global packages.
285
+ return options.checkOnly && check.status !== "unknown" && check.status !== "unavailable" ? 0 : 1;
286
+ }
269
287
  let globalPiCheck = checkGlobalPiInstall(check.packageRoot);
270
288
  console.log(`\n${formatGlobalPiCheck(globalPiCheck)}`);
271
289
  if (options.checkOnly)
@@ -9,7 +9,7 @@ import { createId } from "../id.js";
9
9
  import { runProcess } from "../process.js";
10
10
  import { copyTextToClipboard } from "../screen/clipboard.js";
11
11
  import { formatAccountUsageReport, queryAccountUsageReport } from "../model/model-usage-status.js";
12
- import { checkGlobalPiInstall, checkPixUpdate, formatGlobalPiCheck, formatPixUpdateCheck, parsePixUpdateArgs, pixUpdateUsage, } from "../cli/update.js";
12
+ import { checkGlobalPiInstall, isReleaseInstall, checkPixUpdate, formatGlobalPiCheck, formatPixUpdateCheck, parsePixUpdateArgs, pixUpdateUsage, } from "../cli/update.js";
13
13
  import { createStartupInfoMessage } from "../cli/startup-info.js";
14
14
  import { createReloadContextInventory, formatReloadContextInventory } from "./reload-context-inventory.js";
15
15
  import { getCompleteSessionStats } from "../session/session-stats.js";
@@ -259,6 +259,17 @@ export class SessionCommandActions {
259
259
  const result = await checkPixUpdate();
260
260
  if (!isCommandScopeActive(this.host, scope))
261
261
  return;
262
+ if (isReleaseInstall(result.packageRoot)) {
263
+ this.host.addEntry({ id: createId("system"), kind: "system", text: formatPixUpdateCheck(result) });
264
+ this.host.setSessionStatus(runtime.session);
265
+ if (result.status === "newer")
266
+ this.host.toast.info("A new Pix release is available");
267
+ else if (result.status === "current")
268
+ this.host.toast.success("Pix is up to date");
269
+ else
270
+ this.host.toast.warning("Pix update check incomplete");
271
+ return;
272
+ }
262
273
  const globalPiResult = checkGlobalPiInstall(result.packageRoot);
263
274
  const forceHint = options.force ? "\n\n/update is check-only. To force a reinstall, run `pix update --force` in your shell and restart Pix." : "";
264
275
  this.host.addEntry({
@@ -39,7 +39,9 @@ current product non-goals.
39
39
  markdown/diff rendering, or authentication management.
40
40
  - Bundling Node or a platform-specific standalone `pix-acp` executable in this
41
41
  first slice. Development uses Node plus pix-acp's pinned pi npm dependency;
42
- distributable sidecar packaging is a follow-up milestone.
42
+ distributable packaging is now governed by
43
+ [`release-distribution.md`](../specs/release-distribution.md), using bundled Node,
44
+ Pix and ACP resources for release builds while preserving source-mode development.
43
45
  - Reimplementing the pi runtime in Rust or the webview.
44
46
 
45
47
  ## Behavior
package/docs/release.md CHANGED
@@ -2,6 +2,125 @@
2
2
 
3
3
  Use this checklist before publishing `pi-ui-extend` so installs work on macOS, Linux, and Windows and the `pi-tools-suite` extension payload is included.
4
4
 
5
+ ## Downloadable TUI and Desktop releases
6
+
7
+ One stable `vX.Y.Z` tag produces npm publication and a **draft GitHub Release**
8
+ containing portable TUI and Desktop installers. The contract is in
9
+ [`specs/release-distribution.md`](../specs/release-distribution.md).
10
+
11
+ | Target | TUI | Desktop |
12
+ | --- | --- | --- |
13
+ | Windows x64 | `.zip` with `pix.cmd` | NSIS `-setup.exe` and `.msi` |
14
+ | macOS Apple Silicon | `.tar.gz` with `pix` | `.dmg` |
15
+ | macOS Intel | `.tar.gz` with `pix` | `.dmg` |
16
+ | Linux x64 | `.tar.gz` with `pix` | `.AppImage` and `.deb` |
17
+
18
+ Each download contains its own pinned Node.js and dependencies. Users do not need
19
+ Node/npm to launch Pix. Keep the complete TUI directory together and run its
20
+ launcher from a terminal; add that directory to PATH or symlink `pix` on Unix.
21
+ Desktop includes ACP and does not require an npm-installed Pix. Git, shells,
22
+ project runtimes, optional helpers and provider credentials remain separate.
23
+ macOS release packages require 13.5 or newer, matching the bundled Node 24 binary.
24
+ Linux bundles are built on Ubuntu 22.04; do not advertise support for older
25
+ glibc/WebKit environments without running additional compatibility tests.
26
+
27
+ ### Local build
28
+
29
+ Select a supported Node on PATH (`.node-version` is the CI/build pin), install
30
+ Rust and the platform's [Tauri build prerequisites](https://v2.tauri.app/start/prerequisites/), then run:
31
+
32
+ ```bash
33
+ npm ci --ignore-scripts
34
+ npm ci --prefix acp --ignore-scripts
35
+ npm ci --prefix desktop --ignore-scripts
36
+ npm run release:build
37
+ # Or specify this machine's target:
38
+ npm run release:build -- macos-arm64
39
+ # Only the portable TUI archive:
40
+ npm run release:build -- macos-arm64 --tui-only
41
+ ```
42
+
43
+ Use the corresponding native machine/runner for each target. Cross-host packaging
44
+ is rejected because native modules must match both CPU and OS. Output is under
45
+ `.artifacts/releases/<target>/assets/`. Builds test the real unpacked package and
46
+ the relocated/installed GUI, including an app copied from a mounted read-only DMG.
47
+ The release builder sets `CI=true` even locally so DMG creation does not need Finder
48
+ automation permissions. Development commands (`dev:desktop`, `watch:all`,
49
+ `build:desktop`) remain separate and do not make distributable bundles.
50
+
51
+ To iterate on the Desktop wrapper after preparing its runtime, use
52
+ `npm run release:build:desktop -- <target>`; rebuild the full release after any
53
+ Pix/ACP/dependency change. `release:smoke` and `release:smoke:desktop` rerun checks
54
+ against already built artifacts. These harnesses create temporary user profiles;
55
+ do not invoke `verify.mjs` or the native diagnostic directly with your real HOME.
56
+
57
+ ### Package contents and size checks
58
+
59
+ The TUI archive includes Pix and its Node runtime, **not ACP**. The Desktop
60
+ runtime additionally includes ACP. Preparation cleans generated `dist` directories
61
+ before compilation so old emitted files or Vosk models cannot leak into a release;
62
+ it does not delete source files or the user's `models/` directory.
63
+
64
+ The packager removes foreign esbuild/PTY/clipboard variants and debug source maps,
65
+ then shares only ACP packages proven equivalent to the parent installation by
66
+ file content and dependency graph. Different dependency versions/contexts remain
67
+ isolated. Runtime TypeScript, declarations, native binaries for this target,
68
+ WASM and license files are preserved.
69
+
70
+ Inspect `.artifacts/releases/<target>/pix/SIZE.json` for TUI and
71
+ `.artifacts/releases/<target>/desktop/pix/SIZE.json` for the Desktop backend.
72
+ Adjacent `OPTIMIZATIONS.json` reports explain the pruning and sharing decisions.
73
+ Sizes count regular-file bytes, not filesystem block allocation. The native
74
+ Desktop host and signing metadata are additional to its backend payload.
75
+
76
+ Builds fail above these budgets: TUI 384 MiB unpacked / 160 MiB download;
77
+ Desktop backend 512 MiB unpacked / 240 MiB installer. The limits live in
78
+ `scripts/release/size-budget.mjs`; review dependency growth before changing them.
79
+ CI also uploads `size-<target>` report artifacts separately from release assets.
80
+ Smoke tests execute native esbuild, PTY and extension loading after unpacking,
81
+ and additionally exercise ACP and the native application for Desktop.
82
+
83
+ ### CI and publishing
84
+
85
+ The `Publish` workflow uses native Ubuntu 22.04 x64, macOS 15 ARM64, macOS 15 Intel
86
+ and Windows 2022 x64 runners. `Actions → Publish → Run workflow` builds and tests
87
+ all packages without publishing anything. The ten installers/archives are
88
+ available as four Actions artifacts. On a version-tag push, the final job waits
89
+ for the full native matrix and npm publication, validates the complete set,
90
+ adds `SHA256SUMS`, and uploads everything to a draft release. Only then review
91
+ the release notes/signing status and click **Publish release** in GitHub.
92
+
93
+ The draft is intentional: an unsigned or incomplete first build must not silently
94
+ become the public latest version. Reruns can replace draft assets, but the release
95
+ script refuses to overwrite published assets. Publish a new version for fixes.
96
+ Checksums detect damaged downloads; they do not replace trusted code signatures.
97
+
98
+ ### Optional signing secrets
99
+
100
+ Without signing secrets, macOS builds use ad-hoc signatures and Windows installers
101
+ are unsigned. Such builds are suitable for testing but may trigger OS warnings.
102
+ Trusted signing/notarization cannot be configured without the maintainer's keys.
103
+
104
+ For macOS, configure repository Actions secrets `APPLE_CERTIFICATE` (base64 P12),
105
+ `APPLE_CERTIFICATE_PASSWORD`, and `APPLE_SIGNING_IDENTITY` (Developer ID Application).
106
+ For Tauri notarization also set `APPLE_ID`, `APPLE_PASSWORD` (app-specific password)
107
+ and `APPLE_TEAM_ID`. The workflow imports the certificate into a temporary keychain,
108
+ signs nested runtime binaries before the app, and cleans the keychain afterwards.
109
+ The Node executable has separate JIT/native-addon entitlements. An ad-hoc signature
110
+ is not Developer ID signing. The TUI tarball is not a notarized installer; test its
111
+ download/quarantine UX separately before promising warning-free installation.
112
+
113
+ For Windows, configure `WINDOWS_CERTIFICATE` (base64 PFX) and
114
+ `WINDOWS_CERTIFICATE_PASSWORD`. The workflow imports it into the runner's user
115
+ certificate store, passes its thumbprint to Tauri and removes it afterwards.
116
+ Hardware-backed/cloud signing requires adapting that signing step to the selected
117
+ provider; the PFX path does not claim to cover every certificate provider.
118
+
119
+ Keep keys out of the repository and out of release files. Official references:
120
+ [macOS signing](https://v2.tauri.app/distribute/sign/macos/),
121
+ [Windows signing](https://v2.tauri.app/distribute/sign/windows/), and
122
+ [Tauri resource bundling](https://v2.tauri.app/develop/resources/).
123
+
5
124
  ## Local release check
6
125
 
7
126
  ```bash
@@ -20,7 +139,7 @@ The normative CI invariants are recorded in [`specs/ci-release.md`](../specs/ci-
20
139
  The GitHub Actions release workflow has two different responsibilities and they should stay separate:
21
140
 
22
141
  - `build-and-test` runs on Ubuntu, macOS, and Windows. This is the cross-platform correctness gate: type checks, unit/integration tests, platform-specific host checks, and the Pix build belong here.
23
- - Package smoke tests validate the artifact a user actually installs. Run the full payload/tarball smoke test on Ubuntu, and a smaller pack/install/CLI sanity check on Windows. A separate macOS package smoke job is unnecessary unless packaging gains macOS-specific behavior.
142
+ - npm package smoke tests run the full payload/tarball test on Ubuntu and a smaller pack/install/CLI check on Windows. Portable/Tauri release artifacts additionally run smoke tests on all four native targets.
24
143
 
25
144
  Keep CI tests deterministic across runner speed and operating systems:
26
145
 
@@ -58,7 +177,12 @@ npm run publish-npm -- 0.2.0 # exact version
58
177
 
59
178
  The command requires a clean working tree on `master`, pulls latest from `origin/master`, runs `release:check`, runs `npm version`, runs the tarball smoke test, then pushes the branch and `v*` tag. The tag workflow verifies that `package.json` matches the tag before `npm publish --access public`.
60
179
 
61
- Only the root `package.json` version is bumped for Pix releases. Do not bump `external/pi-tools-suite/package.json` unless publishing the suite as a separate package.
180
+ The root `package.json` version is authoritative. Its npm `version` hook synchronizes
181
+ ACP, Desktop, Tauri, Cargo and lockfile versions and stages them before npm creates
182
+ the release commit/tag. `npm run release:version:check` detects drift; use
183
+ `npm run release:version` to repair manifests after a manual root-version edit.
184
+ Only stable `X.Y.Z` versions within MSI bounds are supported by this release path.
185
+ Do not bump `external/pi-tools-suite/package.json` unless publishing the suite separately.
62
186
 
63
187
  ## Tarball smoke test
64
188
 
@@ -96,6 +220,14 @@ Some modules have optional runtime dependencies or host services:
96
220
 
97
221
  ## Update UX
98
222
 
223
+ Portable TUI and packaged Desktop detect their `.pix-portable.json` marker and
224
+ check stable GitHub Releases instead of npm. `/update`, `pix update --check`, and
225
+ the Desktop report point to a complete replacement download. `pix update` or
226
+ `--force` cannot modify global npm/Pi packages for these installs. Close Pix before
227
+ replacing the complete package; sessions/settings remain in the user profile.
228
+ Automatic binary replacement/Tauri Updater is not enabled in this first release
229
+ pipeline. The following package-manager behavior applies only to npm installs.
230
+
99
231
  - `/update` inside Pix performs a non-mutating Pix update check and reports whether the global Pi package in the same package-manager prefix matches Pix's pinned Pi SDK version.
100
232
  - `pix update --check` performs the same compatibility check without a TTY and never mutates either package.
101
233
  - `pix update` first updates a package-manager installation of Pix when needed, then installs the global `@earendil-works/pi-coding-agent` at the exact version pinned by the resulting Pix package. This keeps the shared `pi-tools-suite` host ABI aligned. The next Pix startup refreshes the user extension link.
@@ -0,0 +1,96 @@
1
+ import { detectToolGroupSpans, findConversationIndexEntry } from "./conversation-index.js"
2
+ import { estimateMessageTokens, stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js"
3
+ import type { CompressionCandidate } from "./pruner-types.js"
4
+ import type { DcpState } from "./state.js"
5
+
6
+ export interface DcpContextMapTelemetry {
7
+ readonly revision: number
8
+ readonly sessionEpoch: number
9
+ readonly generatedAt: number
10
+ readonly tokenEstimates: {
11
+ readonly candidate: number
12
+ readonly protected: number
13
+ readonly compressed: number
14
+ readonly retained: number
15
+ }
16
+ }
17
+
18
+ type ContextMapKind = keyof DcpContextMapTelemetry["tokenEstimates"]
19
+
20
+ function isSafeNonnegativeInteger(value: unknown): value is number {
21
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
22
+ }
23
+
24
+ /** Capture immediately after applyPruning, before any await or carrier changes. */
25
+ export function captureDcpContextTokenEstimates(
26
+ entries: DcpState["conversationIndexSnapshot"],
27
+ preparedMessages: readonly any[],
28
+ metadata: DcpState["messageMetaSnapshot"],
29
+ ): number[] | undefined {
30
+ if (entries.length !== preparedMessages.length) return undefined
31
+ const tokens: number[] = []
32
+ for (let index = 0; index < entries.length; index++) {
33
+ const entry = entries[index]!
34
+ const message = preparedMessages[index]
35
+ if (entry.index !== index || entry.role !== (message?.role ?? "")) return undefined
36
+ const cached = entry.visibleId ? metadata.get(entry.visibleId) : undefined
37
+ // Reuse the body estimate made by this exact pruning pass. Non-addressable
38
+ // messages have no cache; only these need the ordinary content estimator.
39
+ const estimate = cached?.contentHash === entry.contentHash && cached.tokenEstimate !== undefined
40
+ ? cached.tokenEstimate
41
+ : estimateMessageTokens(stripStaleDcpMetadataFromMessage(message))
42
+ if (!isSafeNonnegativeInteger(estimate)) return undefined
43
+ tokens.push(estimate)
44
+ }
45
+ return tokens
46
+ }
47
+
48
+ /** Classifies the already-prepared provider projection without reading history or planning. */
49
+ export function projectDcpContextMapTelemetry(
50
+ entries: DcpState["conversationIndexSnapshot"],
51
+ candidate: CompressionCandidate | null,
52
+ preparedTokenEstimates: readonly number[] | undefined,
53
+ revision: number,
54
+ sessionEpoch: number,
55
+ generatedAt = Date.now(),
56
+ ): DcpContextMapTelemetry | undefined {
57
+ if (
58
+ entries.length === 0 || entries.length !== preparedTokenEstimates?.length ||
59
+ !isSafeNonnegativeInteger(revision) || revision === 0 || !isSafeNonnegativeInteger(sessionEpoch) ||
60
+ !isSafeNonnegativeInteger(generatedAt) || generatedAt === 0 || !Number.isFinite(new Date(generatedAt).getTime())
61
+ ) return undefined
62
+
63
+ const protectedIndexes = new Set<number>()
64
+ for (const group of detectToolGroupSpans(entries)) {
65
+ if (!group.complete) {
66
+ for (let index = group.startIndex; index <= group.endIndex; index++) protectedIndexes.add(index)
67
+ }
68
+ }
69
+ const candidateIndexes = new Set<number>()
70
+ if (candidate) {
71
+ const start = findConversationIndexEntry(entries, candidate.startId)?.index
72
+ const end = findConversationIndexEntry(entries, candidate.endId)?.index
73
+ if (start !== undefined && end !== undefined) {
74
+ for (let index = Math.min(start, end); index <= Math.max(start, end); index++) candidateIndexes.add(index)
75
+ }
76
+ }
77
+
78
+ const tokenEstimates: Record<ContextMapKind, number> = {
79
+ candidate: 0, protected: 0, compressed: 0, retained: 0,
80
+ }
81
+ for (let index = 0; index < entries.length; index++) {
82
+ const entry = entries[index]!
83
+ const tokens = preparedTokenEstimates[index]
84
+ if (!isSafeNonnegativeInteger(tokens)) return undefined
85
+ const kind: ContextMapKind = entry.origin === "dcp-control" ? "retained"
86
+ : protectedIndexes.has(index) ? "protected"
87
+ : candidateIndexes.has(index) ? "candidate"
88
+ : entry.origin === "block" && entry.blockId !== undefined ? "compressed"
89
+ : "retained"
90
+ if (!Number.isSafeInteger(tokenEstimates[kind] + tokens)) return undefined
91
+ tokenEstimates[kind] += tokens
92
+ }
93
+ const total = tokenEstimates.candidate + tokenEstimates.protected + tokenEstimates.compressed + tokenEstimates.retained
94
+ if (!Number.isSafeInteger(total) || total <= 0) return undefined
95
+ return { revision, sessionEpoch, generatedAt, tokenEstimates }
96
+ }
@@ -78,6 +78,7 @@ import { inferDcpBlockedReason, planDcpBudget, type DcpBlockedReason } from "./p
78
78
  import { createBudgetedAutoCompressionBlock } from "./auto-compress-budget.js"
79
79
  import { compressionPlanningTokens, outstandingCompressionTokens, resetCompressionProgress, routineRecoveryTokens, trackCompressionProgress } from "./compression-progress.js"
80
80
  import { captureDcpTransactionGuard, cloneDcpTransactionState, runDcpStateTransaction, invalidateDcpStateOwner } from "./state-transaction.js"
81
+ import { captureDcpContextTokenEstimates, projectDcpContextMapTelemetry } from "./context-map-telemetry.js"
81
82
 
82
83
  const PIX_DCP_RUNTIME_STATS_SYMBOL = Symbol.for("pix.dcp.runtime-stats")
83
84
 
@@ -163,7 +164,15 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
163
164
  const state = dependencies.state ?? createState()
164
165
  // Pix ACP runs one pi process per session. Expose only this tiny live metric
165
166
  // to the patched RPC session-stats surface; DCP state remains extension-owned.
166
- runtimeGlobals[PIX_DCP_RUNTIME_STATS_SYMBOL] = () => ({ tokensSaved: state.tokensSaved })
167
+ let contextMapRevision = 0
168
+ let contextMapPass = 0
169
+ let contextMap: ReturnType<typeof projectDcpContextMapTelemetry> | undefined
170
+ // This process-local bridge is read-only. Its map is produced only by the
171
+ // existing context pass, never by Desktop inspection or a streaming delta.
172
+ runtimeGlobals[PIX_DCP_RUNTIME_STATS_SYMBOL] = () => ({
173
+ tokensSaved: state.tokensSaved,
174
+ contextMap: contextMap?.sessionEpoch === state.sessionEpoch ? contextMap : undefined,
175
+ })
167
176
  let journalMirror: DcpJournalMirror | undefined
168
177
  let journalSupported = false
169
178
  let journalBlockedReason: string | undefined
@@ -283,6 +292,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
283
292
  try { ctx.ui.notify(message, "warning") } catch { /* Headless/notification failure is not a persistence failure. */ }
284
293
  }
285
294
  const invalidateOwner = () => {
295
+ contextMap = undefined
296
+ contextMapPass++
286
297
  invalidateDcpStateOwner(state)
287
298
  routinePressureTracker.reset()
288
299
  // Successful exposure belongs to the old provider/branch too, not only
@@ -353,6 +364,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
353
364
 
354
365
  // ── 5. session_start: restore state from session entries ──────────────────
355
366
  pi.on("session_start", async (event, ctx) => {
367
+ contextMap = undefined
368
+ contextMapPass++
356
369
  resetState(state)
357
370
  routinePressureTracker.reset()
358
371
  freshToolResults.reset()
@@ -387,6 +400,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
387
400
  // Journal operations are committed at the mutation boundary; shutdown does
388
401
  // not write a full runtime snapshot.
389
402
  pi.on("session_shutdown", async () => {
403
+ contextMap = undefined
404
+ contextMapPass++
390
405
  routinePressureTracker.reset()
391
406
  freshToolResults.reset()
392
407
  journalMirror = undefined
@@ -472,6 +487,9 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
472
487
  // ── 10. context: apply pruning and inject nudges ──────────────────────────
473
488
  pi.on("context", async (event, ctx) => {
474
489
  const contextEpoch = state.sessionEpoch
490
+ const mapPass = ++contextMapPass
491
+ contextMap = undefined
492
+ let mapEntries = state.conversationIndexSnapshot
475
493
  const effectiveConfig = configForContext(ctx)
476
494
  ensureEphemeralJournal(ctx)
477
495
  const contextMessages = event.messages
@@ -509,6 +527,18 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
509
527
  state: summarizeDcpState(state, effectiveConfig),
510
528
  ...details,
511
529
  }, ctx)
530
+ if (mapPass === contextMapPass) {
531
+ contextMap = providerReady
532
+ ? projectDcpContextMapTelemetry(
533
+ mapEntries,
534
+ candidate ?? emergencyCompressionCandidate,
535
+ mapTokenEstimates,
536
+ ++contextMapRevision,
537
+ contextEpoch,
538
+ Date.now(),
539
+ )
540
+ : undefined
541
+ }
512
542
  return { messages }
513
543
  }
514
544
 
@@ -545,6 +575,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
545
575
  writeDcpDebugLog(effectiveConfig, "context.rehydrated_tool_records", { ...rehydration }, ctx)
546
576
  }
547
577
  let prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
578
+ mapEntries = state.conversationIndexSnapshot
579
+ let mapTokenEstimates = captureDcpContextTokenEstimates(mapEntries, prunedMessages, state.messageMetaSnapshot)
548
580
  // Stable IDs and any pruning decisions that affect this provider-visible
549
581
  // projection must be committed before the request can use them.
550
582
  await persistJournalState(ctx, state)
@@ -1011,6 +1043,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
1011
1043
  // Re-apply pruning so the new block takes effect on this
1012
1044
  // same context pass instead of the next one.
1013
1045
  prunedMessages = preparedProjection ?? applyPruning(contextMessages, state, effectiveConfig)
1046
+ mapEntries = state.conversationIndexSnapshot
1047
+ mapTokenEstimates = captureDcpContextTokenEstimates(mapEntries, prunedMessages, state.messageMetaSnapshot)
1014
1048
  const clearedAnchors = clearDcpNudgeAnchors(state)
1015
1049
  warnedProgress.clear()
1016
1050
  state.progressRecovery = undefined
@@ -1128,6 +1162,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
1128
1162
  )
1129
1163
  if (emergencyPruneResult.prunedToolCallIds.length > 0) {
1130
1164
  prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
1165
+ mapEntries = state.conversationIndexSnapshot
1166
+ mapTokenEstimates = captureDcpContextTokenEstimates(mapEntries, prunedMessages, state.messageMetaSnapshot)
1131
1167
  const clearedAnchors = clearDcpNudgeAnchors(state)
1132
1168
  state.consecutiveIgnoredStrongNudges = 0
1133
1169
  state.progressRecovery = undefined
@@ -0,0 +1,35 @@
1
+ import type { DcpState } from "./state.js";
2
+ import { stableMessageKeys } from "./pruner-message-ids.js";
3
+
4
+ /** Match the SDK provider transform, not UI visibility or tool-result errors. */
5
+ export function isReplayableContextMessage(message: any): boolean {
6
+ return !(message?.role === "assistant" &&
7
+ (message.stopReason === "error" || message.stopReason === "aborted"));
8
+ }
9
+
10
+ /**
11
+ * SDK retry removes failed attempts from live agent state but keeps them in
12
+ * JSONL. Reload restores them; the provider later omits error/aborted assistants.
13
+ * Normalize before exact block replay so those history-only attempts cannot
14
+ * split a range that was committed from the live, post-retry context.
15
+ *
16
+ * Older blocks may explicitly include such an attempt. Keep every referenced
17
+ * occurrence until the existing matcher has checked its exact ID/order/hash;
18
+ * neither membership arrays nor signed message contents are rewritten. Any
19
+ * remaining attempts are omitted after replay, before new IDs/plans are built.
20
+ */
21
+ export function prepareDcpReplayMessages(messages: any[], state: DcpState): any[] {
22
+ if (messages.every(isReplayableContextMessage)) return messages;
23
+
24
+ const referencedIds = new Set<string>();
25
+ for (const block of state.compressionBlocks) {
26
+ if (!block.active) continue;
27
+ for (const members of [block.mutationMembers, block.sourceMembers]) {
28
+ for (const member of members ?? []) referencedIds.add(member.stableId);
29
+ }
30
+ }
31
+ const keys = stableMessageKeys(messages);
32
+ return messages.filter((message, index) =>
33
+ isReplayableContextMessage(message) || referencedIds.has(keys[index]!),
34
+ );
35
+ }
@@ -5,6 +5,7 @@ import { stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js";
5
5
  import { injectMessageIds } from "./pruner-message-ids.js";
6
6
  import { copyRawMutationHash } from "./conversation-index.js";
7
7
  import { applyToolOutputPruning } from "./pruner-tools.js";
8
+ import { isReplayableContextMessage, prepareDcpReplayMessages } from "./pruner-replay-context.js";
8
9
 
9
10
  export type {
10
11
  CompressionCandidate,
@@ -54,7 +55,7 @@ export function applyPruning(
54
55
  ): any[] {
55
56
  // Deep-clone each message and its content to prevent mutations from
56
57
  // affecting the original objects across context events.
57
- const msgs: any[] = messages.map((m: any) => {
58
+ let msgs: any[] = messages.map((m: any) => {
58
59
  const clone = { ...m };
59
60
  if (Array.isArray(clone.content)) {
60
61
  clone.content = clone.content.map((contentBlock: any) =>
@@ -70,6 +71,8 @@ export function applyPruning(
70
71
  return stripped;
71
72
  });
72
73
 
74
+ msgs = prepareDcpReplayMessages(msgs, state);
75
+
73
76
  // 1. Count user turns → update state.currentTurn. Do this before inserting
74
77
  // synthetic compression summaries; the raw session is the source of truth.
75
78
  state.currentTurn = msgs.filter((m) => m.role === "user").length;
@@ -79,6 +82,11 @@ export function applyPruning(
79
82
  syncCompressionBlocks(msgs, state, config);
80
83
  applyCompressionBlocks(msgs, state);
81
84
 
85
+ // Terminal attempts retained solely for old exact ledgers must not become
86
+ // new addressable source, protocol groups or budget pressure. Raw session
87
+ // entries remain untouched; this is the same omission the provider makes.
88
+ msgs = msgs.filter(isReplayableContextMessage);
89
+
82
90
  // Existing explicit/emergency pruning decisions are replayed, but routine
83
91
  // context construction never discovers new retroactive deletions. A new user
84
92
  // turn must remain append-only for provider prefix caching; destructive
@@ -10,6 +10,7 @@ import { applyTaskMutation } from "./state/state-reducer.js";
10
10
  import { getState, replaceState } from "./state/store.js";
11
11
  import {
12
12
  activateTodoStateScope,
13
+ appendTodoStateSnapshot,
13
14
  DEFAULT_PROMPT_GUIDELINES,
14
15
  DEFAULT_PROMPT_SNIPPET,
15
16
  publishTodoState,
@@ -456,6 +457,7 @@ export default function (pi: ExtensionAPI) {
456
457
  }
457
458
  const autoClear = autoClearCompletedTodos(result.state);
458
459
  replaceState(autoClear.state);
460
+ appendTodoStateSnapshot(pi as any, action, params as Record<string, unknown>);
459
461
  publishTodoState(pi as any, ctx, action, params as Record<string, unknown>);
460
462
  if (todoThinkingEnabled) applyTodoThinkingAfterCommit(result.state, ctx, { action, params });
461
463
  try {
@@ -249,7 +249,7 @@ export function publishTodoState(
249
249
  publishRpcSessionState(ctx as TodoStateEventContext | undefined, TODO_STATE_EVENT, snapshot);
250
250
  }
251
251
 
252
- function appendTodoStateSnapshot(
252
+ export function appendTodoStateSnapshot(
253
253
  pi: TodoStateEntryWriter,
254
254
  action: TaskAction,
255
255
  params: Record<string, unknown>,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.47",
3
+ "version": "2.0.0",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -59,7 +59,7 @@
59
59
  "rebuild:all": "npm run build:desktop",
60
60
  "check": "npm run check:inner",
61
61
  "precheck:inner": "npm run generate-schemas --silent",
62
- "check:inner": "npm run sync:sdk-pin:check --silent && tsc --noEmit && npm run test:inner",
62
+ "check:inner": "npm run sync:sdk-pin:check --silent && npm run release:version:check --silent && tsc --noEmit && npm run test:inner && npm run test:release",
63
63
  "test": "npm run test:inner",
64
64
  "test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
65
65
  "test:tools-suite": "npm --prefix external/pi-tools-suite test",
@@ -82,6 +82,15 @@
82
82
  "smoke-test": "bash scripts/smoke-test-package.sh",
83
83
  "smoke-test:cli": "node scripts/smoke-test-package-cli.mjs",
84
84
  "release:check": "npm run check && npm run build:pix && npm run pack:dry-run",
85
+ "release:version": "node scripts/release/sync-version.mjs",
86
+ "release:version:check": "node scripts/release/sync-version.mjs --check",
87
+ "version": "node scripts/release/sync-version.mjs --stage",
88
+ "release:prepare": "node scripts/release/prepare.mjs",
89
+ "release:build": "node scripts/release/bundle.mjs",
90
+ "release:build:desktop": "node scripts/release/build-desktop.mjs",
91
+ "release:smoke": "node scripts/release/smoke.mjs",
92
+ "release:smoke:desktop": "node scripts/release/smoke-desktop.mjs",
93
+ "test:release": "node --test scripts/release/test/*.test.mjs",
85
94
  "publish-npm": "bash scripts/publish.sh",
86
95
  "generate-schemas": "tsx scripts/generate-schemas.ts",
87
96
  "generate-schemas:check": "tsx scripts/generate-schemas.ts --check",