diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5d8a4e1b..ac448951 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,7 @@ body: attributes: label: Omperator version description: Use the displayed T4 Code version while the application rebrand is in progress. - placeholder: "0.1.31" + placeholder: "0.1.32" validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index cbcd2d02..20e79280 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -19,7 +19,7 @@ body: attributes: label: Omperator version description: Use the displayed T4 Code version while the application rebrand is in progress. - placeholder: "0.1.31" + placeholder: "0.1.32" - type: dropdown id: platform attributes: diff --git a/README.md b/README.md index c390cb9f..ec4be105 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ![T4 Code main window](docs/assets/t4-code-main.png) -[**Download v0.1.31**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.31) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.32**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.32) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements The installed Apple Silicon Mac app packages its own standalone `t4-host` and matching OMP authority runtime. Source builds, Linux hosts, and remote hosts need the matching OMP build with the smaller authority bridge. -T4 Code v0.1.31 was verified with OMP 17.0.5 built from [`fc0c3913`](https://github.com/wolfiesch/oh-my-pi/commit/fc0c391334c08ab260057756aa84bd2b07741ee7), tagged [`t4code-17.0.5-appserver-13`](https://github.com/wolfiesch/oh-my-pi/tree/t4code-17.0.5-appserver-13). That integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It exposes the bounded `t4-omp-authority/1` bridge used by T4's standalone host and removes the old public OMP appserver launchers. It also includes snapshot-consistent paged authority inventories with explicit partial-state metadata, stale-lock lifecycle recovery, sparse authority-side session lists, atomic `xd://` mount publication, bounded newest-first transcript paging, stale-owner recovery, privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. It also does not include the authority bridge needed by T4's standalone host. T4 Code vendors `@oh-my-pi/app-wire` 0.7.0 from integration commit [`796bb7dc`](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. +T4 Code v0.1.32 was verified with OMP 17.0.5 built from [`fc0c3913`](https://github.com/wolfiesch/oh-my-pi/commit/fc0c391334c08ab260057756aa84bd2b07741ee7), tagged [`t4code-17.0.5-appserver-13`](https://github.com/wolfiesch/oh-my-pi/tree/t4code-17.0.5-appserver-13). That integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It exposes the bounded `t4-omp-authority/1` bridge used by T4's standalone host and removes the old public OMP appserver launchers. It also includes snapshot-consistent paged authority inventories with explicit partial-state metadata, stale-lock lifecycle recovery, sparse authority-side session lists, atomic `xd://` mount publication, bounded newest-first transcript paging, stale-owner recovery, privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. It also does not include the authority bridge needed by T4's standalone host. T4 Code vendors `@oh-my-pi/app-wire` 0.7.0 from integration commit [`796bb7dc`](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. T4 owns the client wire, generic host service, and standalone daemon in `@t4-code/host-wire`, `@t4-code/host-service`, and `@t4-code/host-daemon`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. The released package still launches `t4-host` against the strict `t4-omp-authority/1` bridge, while OMP owns session files, locks, agent execution, and takeover decisions. A separately pinned unmodified official OMP 17.0.9 now passes T4's direct RPC and packaged-host behavior gates on macOS ARM64 and Linux x64/ARM64; packaged desktop and managed-session cutover proofs remain before that path replaces the released fallback. @@ -23,8 +23,11 @@ T4 owns the client wire, generic host service, and standalone daemon in `@t4-cod No Windows build, Intel Mac build, or native iOS application is currently shipped. iPhone and iPad access use the responsive Tailnet browser/PWA compatibility client. -## What changed in v0.1.31 +## What changed in v0.1.32 +- New desktop-created sessions restore their current thinking level and fast-mode state after attachment and reconnect, so those controls work without manual recovery. +- Attaching to an existing or TUI-owned session remains observer-only and no longer risks claiming the legacy OMP session lock. +- Client runtime changes now select both current and pinned-legacy bridge continuity gates in pull-request CI. - The session rail now matches the Codex desktop organization model: search, activity filters, sort controls, collapsible projects, flat and grouped views, and persistent preferences. - Project menus can create sessions, open folders in the system file manager, collapse a group, or hide it from the rail. Hidden projects remain recoverable through the filter menu. - Large sessions paint their newest saved transcript entries before older history finishes loading. @@ -53,7 +56,7 @@ access use the responsive Tailnet browser/PWA compatibility client. ### Android 1. On the Android phone, sign in to Tailscale with an account that can reach the T4 Code host. -2. Download [`T4-Code-0.1.31-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-android.apk). +2. Download [`T4-Code-0.1.32-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.32/T4-Code-0.1.32-android.apk). 3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. 4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. The app saves the address; you can add more hosts later and switch between them. @@ -62,8 +65,8 @@ The APK does not contain a host daemon or expose one to the public internet. It ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-linux-amd64.deb -sudo apt install ./T4-Code-0.1.31-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.32/T4-Code-0.1.32-linux-amd64.deb +sudo apt install ./T4-Code-0.1.32-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -71,14 +74,14 @@ Use `apt install` rather than `dpkg -i` so system dependencies resolve automatic ### Linux (AppImage) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-linux-x86_64.AppImage -chmod +x T4-Code-0.1.31-linux-x86_64.AppImage -./T4-Code-0.1.31-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.32/T4-Code-0.1.32-linux-x86_64.AppImage +chmod +x T4-Code-0.1.32-linux-x86_64.AppImage +./T4-Code-0.1.32-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) -1. Download [`T4-Code-0.1.31-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-mac-arm64.dmg) (or [`T4-Code-0.1.31-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-mac-arm64.zip)). +1. Download [`T4-Code-0.1.32-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.32/T4-Code-0.1.32-mac-arm64.dmg) (or [`T4-Code-0.1.32-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.32/T4-Code-0.1.32-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. Open T4 Code normally. The release workflow verifies the pinned publisher, hardened runtime, secure timestamp, Apple notarization, stapled ticket, and Gatekeeper acceptance before publication. 4. To start terminal or CMUX sessions that can hand off to T4 Code, open **Settings → Hosts** and choose **Install t4-omp**. This adds `~/.local/bin/t4-omp` and leaves any existing `omp` command unchanged. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7a9b3fb4..91267403 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/target-manager.ts b/apps/desktop/src/target-manager.ts index 99a5239b..45fe9bf5 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -408,7 +408,7 @@ export class DesktopTargetManager { capabilities: requestedCapabilities, requestedFeatures: this.requestedFeatures, compatibilityRequestedFeatures: this.compatibilityRequestedFeatures, - client: { name: "T4 Code", version: "0.1.31", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.32", build: "desktop", platform: process.platform }, reconnect: { baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json index 88f0d4ba..2b3ad988 100644 --- a/apps/mobile/capacitor.config.json +++ b/apps/mobile/capacitor.config.json @@ -3,7 +3,7 @@ "appName": "T4 Code", "webDir": "dist", "loggingBehavior": "debug", - "appendUserAgent": " T4CodeMobile/0.1.31", + "appendUserAgent": " T4CodeMobile/0.1.32", "android": { "path": "android", "minWebViewVersion": 60, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index be106ffa..3d82d2d9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/mobile", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "scripts": { diff --git a/apps/site/package.json b/apps/site/package.json index b861c334..3362ca5e 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index aaa8b6a6..64f05f74 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -13,8 +13,8 @@ export const OMP_UPSTREAM_TAG = "v17.0.5"; export const OMP_UPSTREAM_COMMIT = "9fd6e97113f5ed3a847e66d346970efdf8afcad9"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; export const APP_WIRE_VERSION = "0.7.0"; -export const RELEASE_TAG = "v0.1.31"; -export const RELEASE_VERSION = "0.1.31"; +export const RELEASE_TAG = "v0.1.32"; +export const RELEASE_VERSION = "0.1.32"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; export const RELEASE_MANIFEST_URL = `${SITE_URL}/releases/latest.json`; @@ -49,11 +49,11 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("android", "apk", "universal", "T4-Code-0.1.31-android.apk", "Android APK"), - asset("linux", "deb", "x86_64", "T4-Code-0.1.31-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.31-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.31-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.31-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.32-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.32-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.32-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.32-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.32-mac-arm64.zip", "macOS .zip"), ]; export function assetsFor(platform: Platform): readonly ReleaseAsset[] { diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts index 9db35d27..65679850 100644 --- a/apps/site/test/release.test.ts +++ b/apps/site/test/release.test.ts @@ -1,4 +1,4 @@ -// Release contract guard: exact v0.1.31 asset names and URLs, and the +// Release contract guard: exact v0.1.32 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -20,13 +20,13 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the five contracted v0.1.31 filenames", () => { + it("carries the five contracted v0.1.32 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.31-android.apk", - "T4-Code-0.1.31-linux-amd64.deb", - "T4-Code-0.1.31-linux-x86_64.AppImage", - "T4-Code-0.1.31-mac-arm64.dmg", - "T4-Code-0.1.31-mac-arm64.zip", + "T4-Code-0.1.32-android.apk", + "T4-Code-0.1.32-linux-amd64.deb", + "T4-Code-0.1.32-linux-x86_64.AppImage", + "T4-Code-0.1.32-mac-arm64.dmg", + "T4-Code-0.1.32-mac-arm64.zip", ]); }); @@ -38,8 +38,8 @@ describe("release assets", () => { it("targets the public LycaonLLC repo", () => { expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code"); - expect(RELEASE_TAG).toBe("v0.1.31"); - expect(RELEASE_VERSION).toBe("0.1.31"); + expect(RELEASE_TAG).toBe("v0.1.32"); + expect(RELEASE_VERSION).toBe("0.1.32"); expect(RELEASE_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json"); }); diff --git a/apps/web/package.json b/apps/web/package.json index 4a35c4af..87af8538 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/platform/browser-shell-port.ts b/apps/web/src/platform/browser-shell-port.ts index 7dd36e13..b19db2ba 100644 --- a/apps/web/src/platform/browser-shell-port.ts +++ b/apps/web/src/platform/browser-shell-port.ts @@ -339,7 +339,7 @@ export function createBrowserShellPort( }, client: { name: "T4 Code", - version: "0.1.31", + version: "0.1.32", build: mobilePlatform ?? "browser", platform: mobilePlatform ?? (platform === "darwin" ? "darwin" : "linux"), }, diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index 398f6462..0e7fa5fb 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -266,12 +266,12 @@ }, "t4Host": { "wirePackage": "@t4-code/host-wire", - "wirePackageVersion": "0.1.31", + "wirePackageVersion": "0.1.32", "wireSchemaVersion": "0.7.0", "servicePackage": "@t4-code/host-service", - "servicePackageVersion": "0.1.31", + "servicePackageVersion": "0.1.32", "daemonPackage": "@t4-code/host-daemon", - "daemonPackageVersion": "0.1.31", + "daemonPackageVersion": "0.1.32", "protocol": "omp-app/1", "authorityBridgeProtocol": "t4-omp-authority/1", "sourcePaths": [ @@ -291,6 +291,6 @@ }, "desktop": { "package": "@t4-code/protocol", - "version": "0.1.31" + "version": "0.1.32" } } diff --git a/deploy/charts/t4-cluster/Chart.yaml b/deploy/charts/t4-cluster/Chart.yaml index cc1f12e0..e447ee57 100644 --- a/deploy/charts/t4-cluster/Chart.yaml +++ b/deploy/charts/t4-cluster/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: t4-cluster description: Default-off portable T4 Kubernetes operator and stateless cluster gateway version: 0.1.0 -appVersion: "0.1.31" +appVersion: "0.1.32" type: application kubeVersion: ">=1.30.0-0" diff --git a/docs/CURRENT_RELEASE_NOTES.md b/docs/CURRENT_RELEASE_NOTES.md index 02e538cb..73471139 100644 --- a/docs/CURRENT_RELEASE_NOTES.md +++ b/docs/CURRENT_RELEASE_NOTES.md @@ -10,9 +10,19 @@ session and transcript behavior, native Browser workspace, terminal, files, revi credentials, and signed update path. The public demo now builds from the same React client shipped inside Electron, eliminating a second product implementation. +## Session controls without lock takeover + +New sessions created in T4 Code now restore their semantic thinking-level and fast-mode state after +attachment and transport reconnects. Existing sessions, including sessions owned by an OMP TUI, +remain observer-only: T4 does not issue the legacy state read that can start a supervisor and claim +the session lock. + +Pull-request CI now treats client runtime changes as bridge-boundary changes and runs both the +current-runtime and pinned-legacy continuity gates. + ## A session rail built for large libraries -T4 Code v0.1.31 makes a large session library easier to navigate. The rail now supports text search, activity filters, newest/oldest sorting, grouped and flat layouts, collapsible project folders, and saved display preferences. Those controls follow the Codex desktop organization model while keeping OMP as the source of truth. +T4 Code v0.1.32 makes a large session library easier to navigate. The rail now supports text search, activity filters, newest/oldest sorting, grouped and flat layouts, collapsible project folders, and saved display preferences. Those controls follow the Codex desktop organization model while keeping OMP as the source of truth. Project menus can create a session in that folder, reveal the folder in the system file manager, collapse the group, or hide it from the rail. Hidden projects are not deleted and can be restored from the filter menu. The reveal action is deliberately narrow: the host accepts only project paths already present in its session catalog. @@ -48,7 +58,7 @@ Session-linked Host Browser Previews continue to open in their dedicated workspa ## Runtime provenance -T4 Code v0.1.31 vendors app-wire 0.7.0 from integration commit [796bb7dc](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. The frozen package remains compatibility evidence; T4 owns the active `omp-app/1` wire schema. +T4 Code v0.1.32 vendors app-wire 0.7.0 from integration commit [796bb7dc](https://github.com/lyc-aon/oh-my-pi/commit/796bb7dca45027bd4b7b94017cdf41ef214a11f2), source tree `0c195a01ba0bb98fbf4d4863aee59bf23a6e81b7`. The frozen package remains compatibility evidence; T4 owns the active `omp-app/1` wire schema. The verified OMP 17.0.5 runtime is built from commit [fc0c3913](https://github.com/wolfiesch/oh-my-pi/commit/fc0c391334c08ab260057756aa84bd2b07741ee7) and tagged [t4code-17.0.5-appserver-13](https://github.com/wolfiesch/oh-my-pi/tree/t4code-17.0.5-appserver-13). It provides the bounded authority bridge used by T4's standalone host and no longer exposes the old public appserver launchers. It pages snapshot-consistent session inventories across bounded frames, marks over-limit inventories partial, and allows lifecycle actions only when a lock is missing or provably stale. It also keeps session-list metadata sparse before bridge encoding, publishes `xd://` mounts atomically with their transport tools, and preserves bounded newest-first transcript paging, stale-owner recovery, privacy-safe local project reveal, lazy session indexing, cross-session attention and transcript search, and the negotiated browser-preview command surface. Unsupported optional capabilities remain hidden when the host does not advertise them. diff --git a/docs/OMP_BRIDGE.md b/docs/OMP_BRIDGE.md index 4d1ab550..ec2ded4b 100644 --- a/docs/OMP_BRIDGE.md +++ b/docs/OMP_BRIDGE.md @@ -52,7 +52,7 @@ The simplified rollout does not weaken the hard boundaries. We retain strict pro ## Released product state -T4 v0.1.31 is paired with immutable OMP tag `t4code-17.0.5-appserver-13` at commit `fc0c391334c08ab260057756aa84bd2b07741ee7`. Its published Apple Silicon binary is 120,893,008 bytes with SHA-256 `60ce5193eadc2884a4313289ff1166b6a1daae97f3a3ec66dc260ea5c5713756`. +T4 v0.1.32 is paired with immutable OMP tag `t4code-17.0.5-appserver-13` at commit `fc0c391334c08ab260057756aa84bd2b07741ee7`. Its published Apple Silicon binary is 120,893,008 bytes with SHA-256 `60ce5193eadc2884a4313289ff1166b6a1daae97f3a3ec66dc260ea5c5713756`. The standalone OMP release carries an ad-hoc integrity signature because the fork release workflow does not have a Developer ID identity. The protected T4 product build remains the distribution-signing boundary: it must sign both the bundled OMP executable and `t4-host` with T4's Developer ID identity before shipping the macOS app. diff --git a/e2e/cluster-operator.spec.ts b/e2e/cluster-operator.spec.ts index 1d042b02..44ae3f1c 100644 --- a/e2e/cluster-operator.spec.ts +++ b/e2e/cluster-operator.spec.ts @@ -307,6 +307,29 @@ class OperatorWireFixture { ...DECOY_PREVIEW, }); return; + case "session.state.get": + this.response(socket, frame, { + isStreaming: true, + isCompacting: false, + isPaused: false, + messageCount: 0, + queuedMessageCount: 0, + steeringMode: "one-at-a-time", + followUpMode: "one-at-a-time", + interruptMode: "immediate", + model: { + id: "gpt-5.6-sol", + provider: "openai-codex", + selector: "openai-codex/gpt-5.6-sol", + }, + thinking: "medium", + thinkingLevels: ["low", "medium", "high", "xhigh", "max"], + thinkingSupported: true, + fast: false, + fastAvailable: true, + fastActive: false, + }); + return; case "session.steer": case "session.prompt": this.response(socket, frame, { accepted: true }); @@ -413,14 +436,21 @@ test.describe("OMP/T4 cluster GUI boundaries", () => { test("desktop attach, steer, and reconnect preserve canonical cursors without duplicate rows", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await openSession(page); + await expect.poll(() => wire.commands.some((frame) => frame.command === "session.attach")).toBe(true); + expect(wire.commands.some((frame) => frame.command === "session.state.get")).toBe(false); const composer = page.getByRole("textbox", { name: "Message the session" }); await composer.fill("Keep the current approach"); await page.getByRole("button", { name: "Steer", exact: true }).click(); await expect.poll(() => wire.commands.some((frame) => frame.command === "session.steer")).toBe(true); const helloCount = wire.hellos.length; + const attachCount = wire.commands.filter((frame) => frame.command === "session.attach").length; wire.disconnectClients(); await expect.poll(() => wire.hellos.length).toBeGreaterThan(helloCount); + await expect + .poll(() => wire.commands.filter((frame) => frame.command === "session.attach").length) + .toBeGreaterThan(attachCount); + expect(wire.commands.some((frame) => frame.command === "session.state.get")).toBe(false); await expect(page.locator(`[data-session-row="${VIEW}"]`)).toHaveCount(1); expect(wire.hellos.at(-1)?.requestedFeatures).toContain("cluster.operator"); const latestHello = wire.hellos.at(-1); diff --git a/e2e/site-mobile-docs.spec.ts b/e2e/site-mobile-docs.spec.ts index 7ad34b78..6b8b8cbc 100644 --- a/e2e/site-mobile-docs.spec.ts +++ b/e2e/site-mobile-docs.spec.ts @@ -130,7 +130,7 @@ test("offers the Android APK without hiding desktop downloads", async ({ page }) await expect(androidDownload).toBeVisible(); await expect(androidDownload).toHaveAttribute( "href", - "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.31/T4-Code-0.1.31-android.apk", + "https://github.com/LycaonLLC/t4-code/releases/download/v0.1.32/T4-Code-0.1.32-android.apk", ); await expect(page.getByRole("link", { name: /Linux/u }).first()).toBeVisible(); await expect(page.getByRole("link", { name: /macOS/u }).first()).toBeVisible(); diff --git a/package.json b/package.json index 5c443500..f52a46e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/root", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "scripts": { diff --git a/packages/client/package.json b/packages/client/package.json index 5b207b9a..e3eeb1ff 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/client", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/packages/client/src/omp-client-frames.ts b/packages/client/src/omp-client-frames.ts index 427f0928..14e0e056 100644 --- a/packages/client/src/omp-client-frames.ts +++ b/packages/client/src/omp-client-frames.ts @@ -84,7 +84,7 @@ export function sendClientHello( } catch { fatal(); return; } const encoded = encodeOutgoingMessage(provider, { kind: "hello", - client: options.client ?? { name: "t4-code", version: "0.1.31", build: "client", platform: "electron" }, + client: options.client ?? { name: "t4-code", version: "0.1.32", build: "client", platform: "electron" }, requestedFeatures: [...(options.requestedFeatures ?? ["resume"])], savedCursors, ...(options.capabilities === undefined ? {} : { capabilities: options.capabilities }), diff --git a/packages/client/src/omp-client-runtime.ts b/packages/client/src/omp-client-runtime.ts index 34513a28..3cae4f4b 100644 --- a/packages/client/src/omp-client-runtime.ts +++ b/packages/client/src/omp-client-runtime.ts @@ -189,6 +189,9 @@ export class OmpClient { private granted = new Set(); private previewStateGeneration: number | undefined; private readonly previewStateSessions = new Set(); + private sessionStateGeneration: number | undefined; + private readonly sessionStateSessions = new Set(); + private readonly sessionStateEligible = new Set(); private closedByUser = false; private compatibilityFallbackUsed = false; private connectWaiters: ConnectWaiter[] = []; @@ -442,16 +445,18 @@ export class OmpClient { recovery !== undefined && recovery.generation === generation ? await recovery.promise : await this.sendCommand(intent, options); + if (response.ok && intent.command === "session.create") { + this.rememberCreatedSession(response); + } if ( response.ok && intent.command === "session.attach" && intent.hostId !== undefined && intent.sessionId !== undefined ) { - this.requestPreviewState( - { hostId: hostId(intent.hostId), sessionId: sessionId(intent.sessionId) }, - generation, - ); + const record = { hostId: hostId(intent.hostId), sessionId: sessionId(intent.sessionId) }; + this.requestSessionState(record, generation); + this.requestPreviewState(record, generation); } return response; } @@ -1235,6 +1240,7 @@ export class OmpClient { void recovery.then( (response) => { if (response.ok) { + this.requestSessionState(record, generation); this.requestPreviewState(record, generation); if (!state.awaitsSnapshot && this.attachmentRecoveries.get(key) === state) { this.attachmentRecoveries.delete(key); @@ -1246,12 +1252,59 @@ export class OmpClient { }, (error: unknown) => { if (this.attachmentRecoveries.get(key) === state) this.attachmentRecoveries.delete(key); - this.emitRecoveryError(error); + this.emitRecoveryError(error, "session.attach"); }, ); } } + private requestSessionState( + record: { readonly hostId: HostId; readonly sessionId: SessionId }, + generation: number, + ): void { + if ( + generation !== this.generation || + this.stateValue !== "ready" || + !this.granted.has("sessions.read") + ) + return; + if (this.sessionStateGeneration !== generation) { + this.sessionStateGeneration = generation; + this.sessionStateSessions.clear(); + } + const key = sessionKey(String(record.hostId), String(record.sessionId)); + // Legacy appservers implement session.state.get by starting a supervisor, + // which claims the session lock. Only hydrate sessions this client created; + // attaching an existing session must remain a read-only observer action. + if (!this.sessionStateEligible.has(key)) return; + if (this.sessionStateSessions.has(key)) return; + this.sessionStateSessions.add(key); + void this.sendCommand( + { + hostId: String(record.hostId), + sessionId: String(record.sessionId), + command: "session.state.get", + args: {}, + }, + {}, + ) + .then((response) => { + if (!response.ok && response.error?.code !== "unsupported") + this.emitRecoveryFailure("session.state.get"); + }) + .catch((error: unknown) => this.emitRecoveryError(error, "session.state.get")); + } + + private rememberCreatedSession(response: OmpResponse): void { + const result = response.result; + if (typeof result !== "object" || result === null) return; + const session = (result as Record).session; + if (typeof session !== "object" || session === null) return; + const record = session as Record; + if (typeof record.hostId !== "string" || typeof record.sessionId !== "string") return; + this.sessionStateEligible.add(sessionKey(record.hostId, record.sessionId)); + } + private requestPreviewState( record: { readonly hostId: HostId; readonly sessionId: SessionId }, generation: number, @@ -1273,18 +1326,18 @@ export class OmpClient { .then((response) => { if (!response.ok) this.emitRecoveryFailure("preview.state"); }) - .catch((error: unknown) => this.emitRecoveryError(error)); + .catch((error: unknown) => this.emitRecoveryError(error, "preview.state")); } private emitRecoveryFailure(command: string): void { this.emitError(this.error("protocol", `${command} recovery request failed`, true, { command })); } - private emitRecoveryError(error: unknown): void { + private emitRecoveryError(error: unknown, command: string): void { this.emitError( error instanceof OmpClientError ? error - : this.error("transport", "preview recovery request failed", true), + : this.error("transport", `${command} recovery request failed`, true, { command }), ); } diff --git a/packages/client/test/client-reconnect.test.ts b/packages/client/test/client-reconnect.test.ts index ce1e3bd0..a8f28673 100644 --- a/packages/client/test/client-reconnect.test.ts +++ b/packages/client/test/client-reconnect.test.ts @@ -160,6 +160,55 @@ function responseFor(command: CommandFrame, result: Record = {} }, }; } +function failureFor(command: CommandFrame, code: string): ServerFrame { + return { + v: V, + type: "response", + requestId: command.requestId, + commandId: command.commandId, + hostId: command.hostId, + ...(command.sessionId === undefined ? {} : { sessionId: command.sessionId }), + command: command.command, + ok: false, + error: { code, message: "command is unsupported by the selected runtime" }, + }; +} +function semanticSessionState(): Record { + return { + isStreaming: false, + isCompacting: false, + isPaused: false, + messageCount: 0, + queuedMessageCount: 0, + steeringMode: "one-at-a-time", + followUpMode: "one-at-a-time", + interruptMode: "immediate", + model: { + id: "gpt-5.6-sol", + provider: "openai-codex", + selector: "openai-codex/gpt-5.6-sol", + }, + thinking: "medium", + thinkingLevels: ["low", "medium", "high", "xhigh", "max"], + thinkingSupported: true, + fast: false, + fastAvailable: true, + fastActive: false, + }; +} +function createdSessionResult(): Record { + return { + session: { + hostId: HOST, + sessionId: SESSION, + project: { projectId: "project-fixture" }, + revision: "rev-a", + title: "Created session", + status: "active", + updatedAt: "2026-07-24T00:00:00.000Z", + }, + }; +} function snapshot(seq = 0, session = SESSION): ServerFrame { return { v: V, @@ -1043,15 +1092,208 @@ describe("OmpClient reconnect stability", () => { expect(client.snapshot().attempt).toBe(1); await client.close(); }); + + it("does not claim an existing session merely to hydrate attachment state", async () => { + const transport = new FakeTransport({ + welcome: welcome(), + onSend: (frame, current) => { + if (frame.type === "command" && frame.command === "session.attach") { + current.emit(responseFor(frame)); + } + }, + }); + const client = new OmpClient({ + transport: () => transport, + hostId: HOST, + reconnect: { baseMs: 0, maxMs: 0 }, + }); + + await client.connect(); + await client.attach(HOST, SESSION); + await Promise.resolve(); + await Promise.resolve(); + expect( + transport.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.attach"]); + await client.close(); + }); + + it("hydrates semantic session state after initial and reconnect attachments", async () => { + const clock = new FakeClock(); + const respond = (frame: ClientFrame, transport: FakeTransport): void => { + if (frame.type !== "command") return; + if (frame.command === "session.create") { + transport.emit(responseFor(frame, createdSessionResult())); + } else if (frame.command === "session.attach") { + transport.emit(responseFor(frame)); + } else if (frame.command === "session.state.get") { + transport.emit(responseFor(frame, semanticSessionState())); + } + }; + const first = new FakeTransport({ welcome: welcome(), onSend: respond }); + const second = new FakeTransport({ + welcome: welcome({ resumed: true }), + onSend: respond, + }); + const transports = [first, second]; + const client = new OmpClient({ + transport: () => transports.shift() ?? new FakeTransport(), + hostId: HOST, + clock, + timers: clock, + random: () => 0, + reconnect: { baseMs: 0, maxMs: 0 }, + }); + + await client.connect(); + await client.command({ + hostId: HOST, + command: "session.create", + args: { projectId: "project-fixture" }, + }); + await client.attach(HOST, SESSION); + await Promise.resolve(); + await Promise.resolve(); + expect( + first.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.create", "session.attach", "session.state.get"]); + + first.drop(); + await flushReconnect(clock); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(client.state).toBe("ready"); + expect( + second.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.attach", "session.state.get"]); + await client.close(); + }); + + it("treats unsupported semantic state hydration as an expected no-op", async () => { + const clock = new FakeClock(); + const respond = (frame: ClientFrame, transport: FakeTransport): void => { + if (frame.type !== "command") return; + if (frame.command === "session.create") { + transport.emit(responseFor(frame, createdSessionResult())); + } else if (frame.command === "session.attach") { + transport.emit(responseFor(frame)); + } else if (frame.command === "session.state.get") { + transport.emit(failureFor(frame, "unsupported")); + } + }; + const first = new FakeTransport({ welcome: welcome(), onSend: respond }); + const second = new FakeTransport({ + welcome: welcome({ resumed: true }), + onSend: respond, + }); + const transports = [first, second]; + const client = new OmpClient({ + transport: () => transports.shift() ?? new FakeTransport(), + hostId: HOST, + clock, + timers: clock, + random: () => 0, + reconnect: { baseMs: 0, maxMs: 0 }, + }); + const errors: string[] = []; + client.onError((error) => errors.push(error.message)); + + await client.connect(); + await client.command({ + hostId: HOST, + command: "session.create", + args: { projectId: "project-fixture" }, + }); + await client.attach(HOST, SESSION); + await Promise.resolve(); + await Promise.resolve(); + expect( + first.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.create", "session.attach", "session.state.get"]); + expect(errors).toEqual([]); + + first.drop(); + await flushReconnect(clock); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(client.state).toBe("ready"); + expect( + second.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.attach", "session.state.get"]); + expect(errors).toEqual([]); + await client.close(); + }); + + it("does not delay attachment when semantic state hydration is still pending", async () => { + const transport = new FakeTransport({ + welcome: welcome(), + onSend: (frame, current) => { + if (frame.type !== "command") return; + if (frame.command === "session.create") + current.emit(responseFor(frame, createdSessionResult())); + else if (frame.command === "session.attach") current.emit(responseFor(frame)); + }, + }); + const client = new OmpClient({ + transport: () => transport, + hostId: HOST, + reconnect: { baseMs: 0, maxMs: 0 }, + }); + + await client.connect(); + await client.command({ + hostId: HOST, + command: "session.create", + args: { projectId: "project-fixture" }, + }); + await expect(client.attach(HOST, SESSION)).resolves.toMatchObject({ ok: true }); + expect(client.state).toBe("ready"); + expect( + transport.sent + .map((serialized) => decodeClientFrame(serialized)) + .filter((frame): frame is CommandFrame => frame.type === "command") + .map((frame) => frame.command), + ).toEqual(["session.create", "session.attach", "session.state.get"]); + await client.close(); + }); + it("requests preview state after initial and reconnect attachments without delaying readiness", async () => { const clock = new FakeClock(); const respond = (frame: ClientFrame, transport: FakeTransport): void => { if ( frame.type === "command" && - (frame.command === "session.attach" || frame.command === "preview.state") + ( + frame.command === "session.attach" || + frame.command === "session.state.get" || + frame.command === "preview.state" + ) ) transport.emit( - responseFor(frame, frame.command === "preview.state" ? { previews: [] } : {}), + responseFor( + frame, + frame.command === "preview.state" + ? { previews: [] } + : frame.command === "session.state.get" + ? semanticSessionState() + : {}, + ), ); }; const previewCapabilities = [ diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 792f0c4f..b0e81748 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -196,6 +196,24 @@ function defaultResultFor(command: CommandFrame): Record { if (command.command === "session.attach") { return { attached: true, cursor: { epoch: "epoch-a", seq: 0 } }; } + if (command.command === "session.state.get") { + return { + isStreaming: false, + isCompacting: false, + isPaused: false, + messageCount: 0, + queuedMessageCount: 0, + steeringMode: "one-at-a-time", + followUpMode: "one-at-a-time", + interruptMode: "immediate", + thinking: "medium", + thinkingLevels: ["low", "medium", "high"], + thinkingSupported: true, + fast: false, + fastAvailable: true, + fastActive: false, + }; + } if (command.command === "session.cancel") return { cancelled: true }; return {}; } diff --git a/packages/cluster-server/package.json b/packages/cluster-server/package.json index bb7e6951..e72870b5 100644 --- a/packages/cluster-server/package.json +++ b/packages/cluster-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/cluster-server", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "description": "Stateless Kubernetes-backed omp-app/1 cluster gateway", diff --git a/packages/cluster-server/src/gateway.ts b/packages/cluster-server/src/gateway.ts index 149ec632..0d5df621 100644 --- a/packages/cluster-server/src/gateway.ts +++ b/packages/cluster-server/src/gateway.ts @@ -102,7 +102,7 @@ export class ClusterGateway { this.#connector = options.connector; this.#mutations = options.mutations; this.#ci = options.ciProvider; - this.#version = options.appserverVersion ?? "0.1.31"; + this.#version = options.appserverVersion ?? "0.1.32"; this.#build = options.appserverBuild ?? "cluster"; } get connectionCount(): number { return this.#connections.size; } diff --git a/packages/cluster-server/src/main.ts b/packages/cluster-server/src/main.ts index 01dee33d..59cba3c8 100755 --- a/packages/cluster-server/src/main.ts +++ b/packages/cluster-server/src/main.ts @@ -13,9 +13,9 @@ import { WoodpeckerProvider } from "./woodpecker.ts"; export async function runClusterServer(env: Readonly> = process.env): Promise { const config = clusterServerConfigFromEnv(env); - const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.31", namespace: config.namespace }); + const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.32", namespace: config.namespace }); const health = new ClusterServerHealth(); - const metrics = new ClusterMetrics({ component: "cluster-server", version: "0.1.31", namespace: config.namespace }); + const metrics = new ClusterMetrics({ component: "cluster-server", version: "0.1.32", namespace: config.namespace }); const ca = await loadKubernetesCa(config); const kubernetes = new KubernetesApiClient({ baseUrl: config.kubernetesBaseUrl, @@ -109,7 +109,7 @@ export async function runClusterServer(env: Readonly { try { await runClusterServer(); } catch (error) { - const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.31" }); + const logger = new JsonLogger(undefined, { component: "cluster-server", version: "0.1.32" }); logger.error("cluster server failed", { condition: error instanceof Error ? error.name : "unknown", result: "failure" }); process.exitCode = 1; } diff --git a/packages/cluster-server/src/pod-host-router.ts b/packages/cluster-server/src/pod-host-router.ts index b38b43ea..76aa9cbc 100644 --- a/packages/cluster-server/src/pod-host-router.ts +++ b/packages/cluster-server/src/pod-host-router.ts @@ -61,7 +61,7 @@ export class WebSocketPodHostConnector implements PodHostConnector { socket.send(JSON.stringify({ v: "omp-app/1", type: "hello", protocol: { min: "omp-app/1", max: "omp-app/1" }, - client: { name: "cluster-server", version: "0.1.31", build: "cluster", platform: "linux" }, + client: { name: "cluster-server", version: "0.1.32", build: "cluster", platform: "linux" }, requestedFeatures: PROTOCOL_FEATURES.filter(feature => feature !== "cluster.operator"), savedCursors: [], capabilities: { client: DEVICE_CAPABILITIES.filter(capability => capability !== "ci.trigger") }, diff --git a/packages/cluster-server/src/session-host-main.ts b/packages/cluster-server/src/session-host-main.ts index ab9ec755..b35323ca 100755 --- a/packages/cluster-server/src/session-host-main.ts +++ b/packages/cluster-server/src/session-host-main.ts @@ -55,7 +55,7 @@ export async function runSessionHost( attentionOutcomePath: join(config.stateRoot, "attention-outcomes.json"), ompVersion: ready.ompVersion, ompBuild: ready.ompBuild, - appserverVersion: "0.1.31", + appserverVersion: "0.1.32", appserverBuild: "cluster-session", sessionAuthority: authorities.sessionAuthority, discovery: authorities.discovery, diff --git a/packages/cluster-server/test/pod-host-router.test.ts b/packages/cluster-server/test/pod-host-router.test.ts index 8b620271..8bafd747 100644 --- a/packages/cluster-server/test/pod-host-router.test.ts +++ b/packages/cluster-server/test/pod-host-router.test.ts @@ -11,7 +11,7 @@ const welcome = { hostId: "host-a", ompVersion: "17.0.5", ompBuild: "test", - appserverVersion: "0.1.31", + appserverVersion: "0.1.32", appserverBuild: "cluster-session", epoch: "pod-epoch", grantedCapabilities: [], diff --git a/packages/fixture-server/package.json b/packages/fixture-server/package.json index 2d157d93..2af70e8e 100644 --- a/packages/fixture-server/package.json +++ b/packages/fixture-server/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/fixture-server", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/packages/host-daemon/package.json b/packages/host-daemon/package.json index 544ff7d7..d905c958 100644 --- a/packages/host-daemon/package.json +++ b/packages/host-daemon/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-daemon", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "description": "Standalone T4-owned host daemon with a thin OMP authority bridge", diff --git a/packages/host-daemon/src/cli.ts b/packages/host-daemon/src/cli.ts index 320de0fb..6c841bb9 100644 --- a/packages/host-daemon/src/cli.ts +++ b/packages/host-daemon/src/cli.ts @@ -19,7 +19,7 @@ import { } from "@t4-code/host-service"; import { COMMAND_DESCRIPTORS, type ProjectId, type SessionId } from "@t4-code/protocol"; -export const T4_HOST_VERSION = "0.1.31"; +export const T4_HOST_VERSION = "0.1.32"; export const OFFICIAL_OMP_VERSION = "17.0.9"; export const OFFICIAL_OMP_BUILD = "639bac596d94b5993349f3f6696176cb2bf9b5d3"; const PROFILE = /^[a-z0-9][a-z0-9._-]{0,63}$/u; diff --git a/packages/host-service/package.json b/packages/host-service/package.json index b722bdd3..3241907b 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-service", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "description": "T4-owned host service with runtime adapters over Unix WebSocket", diff --git a/packages/host-wire/package.json b/packages/host-wire/package.json index a286026a..3582046e 100644 --- a/packages/host-wire/package.json +++ b/packages/host-wire/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/host-wire", - "version": "0.1.31", + "version": "0.1.32", "type": "module", "description": "Dependency-free T4 host protocol frames and bounded decoders", "license": "MIT", diff --git a/packages/host-wire/test/app-wire.test.ts b/packages/host-wire/test/app-wire.test.ts index 4fad9a96..68e8c837 100644 --- a/packages/host-wire/test/app-wire.test.ts +++ b/packages/host-wire/test/app-wire.test.ts @@ -723,7 +723,7 @@ describe("app-wire authority", () => { test("exports the bridge schema version independently from the T4 package release", async () => { const metadata = (await Bun.file(new URL("../package.json", import.meta.url)).json()) as { version: string }; expect(APP_WIRE_VERSION).toBe("0.7.0"); - expect(metadata.version).toBe("0.1.31"); + expect(metadata.version).toBe("0.1.32"); }); test("session project wire data is opaque and live state is secret-free", () => { const providerTransport = { diff --git a/packages/host-wire/test/cluster-operator.test.ts b/packages/host-wire/test/cluster-operator.test.ts index e0f5d439..6bb174e7 100644 --- a/packages/host-wire/test/cluster-operator.test.ts +++ b/packages/host-wire/test/cluster-operator.test.ts @@ -50,7 +50,7 @@ describe("cluster operator wire contract", () => { hostId: "cluster-host-uid-1", ompVersion: "17.0.5", ompBuild: "fc0c391334c08ab260057756aa84bd2b07741ee7", - appserverVersion: "0.1.31", + appserverVersion: "0.1.32", appserverBuild: "cluster", epoch: "replica-pod-uid-1", grantedCapabilities: ["sessions.read", "ci.trigger"], diff --git a/packages/model-gateway/package.json b/packages/model-gateway/package.json index 1500518b..5d86e995 100644 --- a/packages/model-gateway/package.json +++ b/packages/model-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/model-gateway", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "description": "Credential-isolating provider gateway for T4 cluster sessions", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 5f8691be..a5b7d92a 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/protocol", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/packages/protocol/test/distribution.test.ts b/packages/protocol/test/distribution.test.ts index d8e1edfa..7c9013d2 100644 --- a/packages/protocol/test/distribution.test.ts +++ b/packages/protocol/test/distribution.test.ts @@ -166,7 +166,7 @@ describe("T4 host-wire distribution", () => { readFileSync(join(installedRoot, "package.json"), "utf8"), ) as Record; expect(installedPackage.name).toBe("@t4-code/host-wire"); - expect(installedPackage.version).toBe("0.1.31"); + expect(installedPackage.version).toBe("0.1.32"); expect(installedPackage.version).not.toBe(manifest.version); expect(installedPackage.dependencies ?? {}).toEqual({}); }); diff --git a/packages/protocol/test/fixtures/platform-boundaries.ts b/packages/protocol/test/fixtures/platform-boundaries.ts index d2762430..7e90288a 100644 --- a/packages/protocol/test/fixtures/platform-boundaries.ts +++ b/packages/protocol/test/fixtures/platform-boundaries.ts @@ -25,7 +25,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.31", + latestVersion: "0.1.32", checkedAt: 1_721_234_567_890, phase: "available", revision: 7, @@ -33,7 +33,7 @@ export const androidUpdateFixtures = Object.freeze({ }, { currentVersion: "0.1.22", - latestVersion: "0.1.31", + latestVersion: "0.1.32", phase: "installer", revision: 8, message: "Installer opened.\nReview Android's prompt.", diff --git a/packages/remote/package.json b/packages/remote/package.json index 4628db2e..395099a2 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/remote", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/packages/service-manager/package.json b/packages/service-manager/package.json index ad1436ac..be0f9ed0 100644 --- a/packages/service-manager/package.json +++ b/packages/service-manager/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/service-manager", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/packages/t4-api-client/package.json b/packages/t4-api-client/package.json index 789eff43..1e4ff183 100644 --- a/packages/t4-api-client/package.json +++ b/packages/t4-api-client/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/t4-api-client", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/packages/t4-api-contract/package.json b/packages/t4-api-contract/package.json index a69bfb23..cd205536 100644 --- a/packages/t4-api-contract/package.json +++ b/packages/t4-api-contract/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/t4-api-contract", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 9b4b6e87..20a2b824 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/ui", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "exports": { diff --git a/scripts/check-host-ownership.test.mjs b/scripts/check-host-ownership.test.mjs index 5a6452a6..8e756da8 100644 --- a/scripts/check-host-ownership.test.mjs +++ b/scripts/check-host-ownership.test.mjs @@ -85,7 +85,7 @@ test("compatibility metadata records the artifact-backed OMP bridge", () => { assert.equal(matrix.t4Host.deploymentState, "standalone-t4-host-thin-omp-bridge"); assert.equal(matrix.t4Host.wireSchemaVersion, "0.7.0"); assert.equal(matrix.t4Host.daemonPackage, "@t4-code/host-daemon"); - assert.equal(matrix.t4Host.daemonPackageVersion, "0.1.31"); + assert.equal(matrix.t4Host.daemonPackageVersion, "0.1.32"); assert.equal(matrix.t4Host.authorityBridgeProtocol, "t4-omp-authority/1"); assert.equal(matrix.verifiedRuntime.artifacts["darwin-arm64"].releaseCodeSignature, "adhoc"); assert.equal( diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 543bc7d4..61e4bbd9 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -173,7 +173,7 @@ test("pins official OMP artifacts and the Gate 0 proof contract", () => { test("rejects a tag that differs from the package version", () => { assert.ok( collectReleaseConsistencyErrors(files, "v9.9.9").some((error) => - error.includes("release tag v9.9.9 does not match v0.1.31"), + error.includes("release tag v9.9.9 does not match v0.1.32"), ), ); }); @@ -202,7 +202,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of appWireCases) { const drifted = changedRuntime("publishedAppWire", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.31").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.32").some((error) => error.includes( `published app-wire ${field} must match current app-wire for tagged releases`, ), @@ -245,7 +245,7 @@ test("tagged releases reject published provenance drift", () => { for (const [field, mutate] of runtimeCases) { const drifted = changedRuntime("publishedRuntime", mutate); assert.ok( - collectReleaseConsistencyErrors(drifted, "v0.1.31").some((error) => + collectReleaseConsistencyErrors(drifted, "v0.1.32").some((error) => error.includes( `published runtime ${field} must match current verified runtime for tagged releases`, ), @@ -257,7 +257,7 @@ test("tagged releases reject published provenance drift", () => { runtime.artifactSha256 = "0".repeat(64); }); assert.ok( - collectReleaseConsistencyErrors(extended, "v0.1.31").some((error) => + collectReleaseConsistencyErrors(extended, "v0.1.32").some((error) => error.includes( "published runtime must exactly match current verified runtime for tagged releases", ), @@ -267,15 +267,15 @@ test("tagged releases reject published provenance drift", () => { test("rejects workspace, site, README, and runtime version drift", () => { const cases = [ - ["apps/web/package.json", (text) => text.replace('"version": "0.1.31"', '"version": "0.1.3"')], + ["apps/web/package.json", (text) => text.replace('"version": "0.1.32"', '"version": "0.1.3"')], [ "apps/site/src/release.ts", - (text) => text.replace('RELEASE_TAG = "v0.1.31"', 'RELEASE_TAG = "v0.1.3"'), + (text) => text.replace('RELEASE_TAG = "v0.1.32"', 'RELEASE_TAG = "v0.1.3"'), ], - ["README.md", (text) => text.replace("Download v0.1.31", "Download v0.1.3")], + ["README.md", (text) => text.replace("Download v0.1.32", "Download v0.1.3")], [ "apps/desktop/src/target-manager.ts", - (text) => text.replace('version: "0.1.31"', 'version: "0.1.3"'), + (text) => text.replace('version: "0.1.32"', 'version: "0.1.3"'), ], [ "apps/site/src/docs/content.ts", @@ -460,8 +460,8 @@ test("historical repair keeps trusted verification code separate from immutable consistencyStep.run, 'node scripts/check-release-consistency.mjs --tag "$RELEASE_TAG" --repo-root .release-source', ); - assert.deepEqual(parseCliArguments(["--tag", "v0.1.31", "--repo-root", ".release-source"]), { - releaseTag: "v0.1.31", + assert.deepEqual(parseCliArguments(["--tag", "v0.1.32", "--repo-root", ".release-source"]), { + releaseTag: "v0.1.32", repoRoot: resolve(".release-source"), }); }); @@ -640,7 +640,7 @@ test("rejects stale README release URLs while allowing historical prose", () => const staleLink = changed("README.md", (text) => `${text}\n[Old release](${oldReleaseUrl})\n`); assert.ok( collectReleaseConsistencyErrors(staleLink).some((error) => - error.includes("release URL for v0.1.3; expected v0.1.31"), + error.includes("release URL for v0.1.3; expected v0.1.32"), ), ); assert.deepEqual(collectReleaseConsistencyErrors(files), []); diff --git a/scripts/ci-paths.mjs b/scripts/ci-paths.mjs index 5c06ac39..9e27c161 100755 --- a/scripts/ci-paths.mjs +++ b/scripts/ci-paths.mjs @@ -14,6 +14,7 @@ const GROUP_PATTERNS = Object.freeze({ /^\.github\/workflows\/ci\.yml$/u, /^\.woodpecker\.yml$/u, /^compat\/omp-app-matrix\.json$/u, + /^packages\/client\/src\//u, /^packages\/host-service\/bin\/current-omp-bridge-proof\.ts$/u, /^packages\/host-service\/src\//u, /^packages\/host-service\/package\.json$/u, diff --git a/scripts/ci-paths.test.mjs b/scripts/ci-paths.test.mjs index 81a2856f..a62b1f3d 100644 --- a/scripts/ci-paths.test.mjs +++ b/scripts/ci-paths.test.mjs @@ -53,6 +53,14 @@ test("host wire changes run every dependent client and continuity gate", () => { }); }); +test("client runtime changes run both bridge continuity gates", () => { + assert.deepEqual(classifyCiPaths(["packages/client/src/omp-client-runtime.ts"]), { + ...none, + continuity: true, + android_debug: true, + }); +}); + test("host daemon changes run its host gates", () => { assert.deepEqual(classifyCiPaths(["packages/host-daemon/src/main.ts"]), { ...none,