diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0ef0b2e4..dfa47dd9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,7 +10,7 @@ body: id: version attributes: label: T4 Code version - placeholder: "0.1.9" + placeholder: "0.1.10" validations: required: true - type: dropdown diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18519151..2a69839c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -356,36 +356,7 @@ jobs: tag_name: ${{ env.RELEASE_TAG }} generate_release_notes: true fail_on_unmatched_files: true - body: | - ## Android app - - This release includes a signed Android APK for direct installation. It is a thin client: the T4 Code interface is bundled in the APK, while OMP sessions and runtime state remain on the host. On first run, enter the host's HTTPS Tailscale address and complete the normal T4 pairing flow. - - Android device credentials are encrypted with an app-specific Android Keystore key. The app does not contain an OMP appserver and does not require a public route; the phone and host should use the same tailnet. - - ## Browser and iOS - - The installable Tailnet web app remains available. iOS TestFlight is coming soon; there is no iOS binary in this release. - - ## Connection security - - The Tailnet gateway accepts only the configured HTTPS web origin and the exact native app origins. It rejects wildcard, opaque, and untrusted origins. Keep the route on Tailscale Serve and do not enable Funnel. - - ## Install notes - - The Android APK is signed and supports Android 7.0 or later. Linux packages are built for x86_64. macOS packages are built for Apple Silicon. - - ## Runtime compatibility - - This release vendors app-wire 0.5.2 from public integration commit [5d4315ee](https://github.com/lyc-aon/oh-my-pi/commit/5d4315eea317260fec030e2b4726f10fed0cd5f6) and was verified with OMP 16.5.0 built from [d4a0b934](https://github.com/lyc-aon/oh-my-pi/commit/d4a0b9344e1796c0e56041cfeea3431a8a728e61), tagged [t4code-16.5.0-appserver-3](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.0-appserver-3). The integration is based on the official upstream [v16.5.0 tag](https://github.com/can1357/oh-my-pi/tree/v16.5.0), commit [3047c27c](https://github.com/can1357/oh-my-pi/commit/3047c27c332c5629c8e063283d349384c10c9a56). It adds bounded growing-session replay, complete session event projection, catalog-backed session lifecycle management, ordered remote outbound frames, cross-client control-state convergence, terminal streaming-state settlement, and restart-safe session teardown. Official upstream OMP v16.5.0 has no `appserver` command and cannot host T4 Code. The verified runtime is built normally from the public `lyc-aon/oh-my-pi` source; it does not require private home-directory files, an auth broker, or a custom Codex CLI fork. - - The macOS build is unsigned and unnotarized. Gatekeeper will block the first launch. After copying T4 Code to Applications, run: - - ```sh - xattr -dr com.apple.quarantine "/Applications/T4 Code.app" - ``` - - Verify downloads with `SHA256SUMS.txt`. + body_path: docs/CURRENT_RELEASE_NOTES.md files: | artifacts/T4-Code-*.apk artifacts/T4-Code-*.deb diff --git a/README.md b/README.md index f5dcdbf4..57c7f65c 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.9**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.9) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) +[**Download v0.1.10**](https://github.com/LycaonLLC/t4-code/releases/tag/v0.1.10) · [**Docs**](https://t4code.net/docs) · [**Get the source**](#build-from-source) ## Requirements -T4 Code needs an OMP build with desktop appserver support. For v0.1.9, use the public integration build below. +T4 Code needs an OMP build with desktop appserver support. For v0.1.10, use the public integration build below. -T4 Code v0.1.9 was verified with OMP 16.5.0 built from [`d4a0b934`](https://github.com/lyc-aon/oh-my-pi/commit/d4a0b9344e1796c0e56041cfeea3431a8a728e61), tagged [`t4code-16.5.0-appserver-3`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.0-appserver-3). That public integration is based on the official upstream [`v16.5.0`](https://github.com/can1357/oh-my-pi/tree/v16.5.0) tag at [`3047c27c`](https://github.com/can1357/oh-my-pi/commit/3047c27c332c5629c8e063283d349384c10c9a56). It adds bounded large-session replay, complete desktop runtime events, catalog-backed session management, ordered remote delivery, cross-client control-state convergence, terminal streaming-state settlement, and restart-safe session teardown. The official upstream v16.5.0 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.2 from integration commit [`5d4315ee`](https://github.com/lyc-aon/oh-my-pi/commit/5d4315eea317260fec030e2b4726f10fed0cd5f6), source tree `713688e8099d4553a0a30b1bf415a7cffb5963f4`. +T4 Code v0.1.10 was verified with OMP 16.5.0 built from [`1ada5fc2`](https://github.com/lyc-aon/oh-my-pi/commit/1ada5fc2f0d6f9026d373cd25e004b974437651e), tagged [`t4code-16.5.0-appserver-4`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.0-appserver-4). That public integration is based on the official upstream [`v16.5.0`](https://github.com/can1357/oh-my-pi/tree/v16.5.0) tag at [`3047c27c`](https://github.com/can1357/oh-my-pi/commit/3047c27c332c5629c8e063283d349384c10c9a56). It adds bounded large-session replay and terminal events, complete desktop runtime projection, catalog-backed session management, deterministic failed-worker reaping, recoverable crash state, settled close state, ordered remote delivery, cross-client control convergence, and restart-safe session teardown. The official upstream v16.5.0 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source; T4 Code does not depend on private home-directory files, an auth broker, or a custom Codex CLI fork. T4 Code vendors `@oh-my-pi/app-wire` 0.5.3 from integration commit [`1ada5fc2`](https://github.com/lyc-aon/oh-my-pi/commit/1ada5fc2f0d6f9026d373cd25e004b974437651e), source tree `4961ea9c522a3bbf9a9900424dd475a48148c729`. | Platform | Arch | Package | | --- | --- | --- | @@ -18,23 +18,21 @@ T4 Code v0.1.9 was verified with OMP 16.5.0 built from [`d4a0b934`](https://gith | Linux | x86_64 | `.deb`, AppImage | | macOS | Apple Silicon (arm64) | `.dmg`, `.zip` (**unsigned, see below**) | -No Windows build and no Intel Mac build in v0.1.9. The iOS TestFlight build is coming soon. +No Windows build and no Intel Mac build in v0.1.10. The iOS TestFlight build is coming soon. -## What changed in v0.1.9 +## What changed in v0.1.10 -- Android now has a signed, installable APK. It is a thin client: the UI ships inside the app, while sessions and runtime state stay on your T4 Code host. -- First run accepts an HTTPS Tailscale hostname, checks the secure WebSocket route, and then uses the normal T4 pairing flow. -- Android device credentials are encrypted with an app-specific Android Keystore key. Only the non-secret Tailnet hostname is kept in web storage. -- The Tailnet gateway accepts the exact Capacitor Android and iOS origins while continuing to reject wildcard, opaque, and untrusted origins. -- The connection screen and titlebar controls are sized for small phone displays. A user can clear the saved host and pair again without reinstalling. -- The browser-installable PWA remains available, and the iOS download slot now reports that TestFlight is coming soon without advertising a build that is not published. +- Host command errors now reach the composer with their reason and redacted details. T4 Code keeps the draft in place so you can respond to the specific problem. +- When prompt delivery has an unknown outcome, the composer asks you to check the transcript before retrying. The retained draft stays ready after that check. +- A new **Terminate runtime** action closes a stuck session worker. T4 Code waits for the host to report the session closed and idle, then refreshes the session list so archive and delete can proceed. +- The matching OMP integration bounds terminal events, reaps failed workers, makes crash-only sessions recoverable, and clears pending work during an explicit close. ## Install ### 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.9-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-android.apk). +2. Download [`T4-Code-0.1.10-android.apk`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.10/T4-Code-0.1.10-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. @@ -43,8 +41,8 @@ The APK does not contain an appserver or expose one to the public internet. It c ### Linux (Debian/Ubuntu) ```sh -wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-linux-amd64.deb -sudo apt install ./T4-Code-0.1.9-linux-amd64.deb +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.10/T4-Code-0.1.10-linux-amd64.deb +sudo apt install ./T4-Code-0.1.10-linux-amd64.deb ``` Use `apt install` rather than `dpkg -i` so system dependencies resolve automatically. @@ -52,17 +50,17 @@ 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.9/T4-Code-0.1.9-linux-x86_64.AppImage -chmod +x T4-Code-0.1.9-linux-x86_64.AppImage -./T4-Code-0.1.9-linux-x86_64.AppImage +wget https://github.com/LycaonLLC/t4-code/releases/download/v0.1.10/T4-Code-0.1.10-linux-x86_64.AppImage +chmod +x T4-Code-0.1.10-linux-x86_64.AppImage +./T4-Code-0.1.10-linux-x86_64.AppImage ``` ### macOS (Apple Silicon) > [!WARNING] -> **The macOS v0.1.9 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. +> **The macOS v0.1.10 build is unsigned and unnotarized.** Apple has not signed or notarized it, so Gatekeeper can report a "damaged" app or an unidentified developer. Only continue if you trust the release from this repository. You can always build from source instead. -1. Download [`T4-Code-0.1.9-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-mac-arm64.dmg) (or [`T4-Code-0.1.9-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.9/T4-Code-0.1.9-mac-arm64.zip)). +1. Download [`T4-Code-0.1.10-mac-arm64.dmg`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.10/T4-Code-0.1.10-mac-arm64.dmg) (or [`T4-Code-0.1.10-mac-arm64.zip`](https://github.com/LycaonLLC/t4-code/releases/download/v0.1.10/T4-Code-0.1.10-mac-arm64.zip)). 2. Drag `T4 Code.app` into `/Applications`. 3. If Gatekeeper blocks the app and you choose to proceed, remove the quarantine attributes from the copied app bundle: @@ -74,7 +72,7 @@ chmod +x T4-Code-0.1.9-linux-x86_64.AppImage ## What the app does -- **Sessions.** Browse sessions grouped by their working folder, create new ones, and switch between them. Rename, archive, restore, or permanently delete a session from its menu. Recently used sessions stay warm, so switching back is instant and nothing is replayed twice. +- **Sessions.** Browse sessions grouped by their working folder, create new ones, and switch between them. Rename, terminate a stuck runtime, archive, restore, or permanently delete a session from its menu. Recently used sessions stay warm, so switching back is instant and nothing is replayed twice. - **Composer.** Send prompts, use slash commands (`/model`, `/compact`, `/retry`, `/review`, `/terminal`, and more), and change the session's model, thinking level, or fast mode inline. - **Panes.** Watch subagents (and cancel them), apply reviews, browse and preview files on the host, and attach to live terminals with real keyboard input and resize. - **Settings.** Edit host settings over the wire. Drafts stage locally and only apply when the host confirms; a dropped connection never silently writes anything. diff --git a/SECURITY.md b/SECURITY.md index 791cad49..121144d2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. -- The macOS v0.1.9 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. +- The macOS v0.1.10 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 84035b69..89393c74 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,7 +6,7 @@ T3 Code is selectively referenced for future ports from https://github.com/pingd ## Oh My Pi -Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.2` package is packed from the public `lyc-aon/oh-my-pi` integration commit `5d4315eea317260fec030e2b4726f10fed0cd5f6`, source tree `713688e8099d4553a0a30b1bf415a7cffb5963f4`; tarball SHA-256 `fb9b608d7a2245001c334808475fabd9e05603729f48cff88b21a5165b4fb63a`; golden corpus SHA-256 `36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. +Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.3` package is packed from the public `lyc-aon/oh-my-pi` integration commit `1ada5fc2f0d6f9026d373cd25e004b974437651e`, source tree `4961ea9c522a3bbf9a9900424dd475a48148c729`; tarball SHA-256 `5ae8833b963a6a56b65a9e2184358906a8717a6c8f57617875a91d4771791050`; golden corpus SHA-256 `36811f39241c6c491c967a8f969f14c43431366289750538a40893d0dc267324`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. ## Oh My Pi icon diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4e1cb75b..88f01514 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/desktop", - "version": "0.1.9", + "version": "0.1.10", "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 372afea6..0e208052 100644 --- a/apps/desktop/src/target-manager.ts +++ b/apps/desktop/src/target-manager.ts @@ -1,5 +1,5 @@ import { createOmpClient, isConfirmationDecisionConsumed, OmpClientError, type CommandIntent, type CursorStore, type OmpClient, type PublicServerFrame } from "@t4-code/client"; -import type { CommandResult, ConnectionStateEvent, RuntimeErrorEvent } from "@t4-code/protocol/desktop-ipc"; +import { commandResultError, type CommandResult, type ConnectionStateEvent, type RuntimeErrorEvent } from "@t4-code/protocol/desktop-ipc"; import type { ConfirmRequest, ConfirmResult, TerminalCloseRequest, TerminalInputRequest, TerminalResizeRequest, TerminalResult } from "@t4-code/protocol/desktop-ipc"; import { ADDITIVE_FEATURES, DEVICE_CAPABILITIES, type DeviceCapability } from "@t4-code/protocol"; import type { PairOkFrame, WelcomeFrame } from "@t4-code/protocol"; @@ -65,6 +65,12 @@ function safeError(error: unknown): { readonly code: RuntimeErrorEvent["code"]; return { code: "transport", message: "target operation failed" }; } +function safePublicFrame(frame: PublicServerFrame): PublicServerFrame { + if (frame.type !== "response" || frame.error === undefined) return frame; + const error = commandResultError(frame.error) ?? { code: "internal", message: "command failed" }; + return { ...frame, error }; +} + export class DesktopTargetManager { private readonly cursorStore: CursorStore; private readonly cursorStoreFactory: (targetId: string) => CursorStore; @@ -175,12 +181,14 @@ export class DesktopTargetManager { const generation = runtime.generation; try { const result = await runtime.client.command(intent); + const error = commandResultError(result.error); return { targetId, requestId: String(result.requestId), commandId: String(result.commandId), accepted: result.ok, ...(result.result === undefined ? {} : { result: result.result }), + ...(error === undefined ? {} : { error }), }; } catch (error) { if (this.generations.get(targetId) !== generation && error instanceof OmpClientError && error.code === "closed") @@ -324,7 +332,7 @@ export class DesktopTargetManager { cursorStore: this.cursorStoreFactory(targetId), capabilities: requestedCapabilities, requestedFeatures: ADDITIVE_FEATURES, - client: { name: "T4 Code", version: "0.1.9", build: "desktop", platform: process.platform }, + client: { name: "T4 Code", version: "0.1.10", build: "desktop", platform: process.platform }, reconnect: { attemptCap: 12, baseMs: 250, maxMs: 10_000 }, }; const client = createOmpClient(clientOptions); @@ -333,7 +341,7 @@ export class DesktopTargetManager { client.onFrame((frame) => { if (this.generations.get(targetId) !== generation) return; if (frame.type === "welcome") this.latestWelcomes.set(targetId, frame); - this.events.onFrame(targetId, frame); + this.events.onFrame(targetId, safePublicFrame(frame)); }); client.onState((snapshot) => { if (this.generations.get(targetId) !== generation) return; diff --git a/apps/desktop/test/target-manager.test.ts b/apps/desktop/test/target-manager.test.ts index cd0bebab..0a805166 100644 --- a/apps/desktop/test/target-manager.test.ts +++ b/apps/desktop/test/target-manager.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { DESKTOP_IPC_CHANNELS, decodeDesktopInvokeRequest } from "@t4-code/protocol/desktop-ipc"; -import type { CursorStore, OmpTransport } from "@t4-code/client"; -import { commandId, confirmationId, DEVICE_CAPABILITIES, hostId, sessionId, type WelcomeFrame } from "@t4-code/protocol"; +import type { CursorStore, OmpTransport, PublicServerFrame } from "@t4-code/client"; +import { + commandId, + confirmationId, + DEVICE_CAPABILITIES, + hostId, + sessionId, + type WelcomeFrame, +} from "@t4-code/protocol"; import { validEvent } from "../src/ipc.ts"; import { DesktopTargetManager } from "../src/target-manager.ts"; import type { RemoteTargetRecord, RemoteTargetRegistry } from "../src/remote-runtime/registry.ts"; @@ -9,10 +16,25 @@ import type { RemoteTargetRecord, RemoteTargetRegistry } from "../src/remote-run const V = "omp-app/1" as const; function welcome(host = "host-fixture"): WelcomeFrame { return { - v: V, type: "welcome", selectedProtocol: V, hostId: hostId(host), ompVersion: "fixture", ompBuild: "test", - appserverVersion: "fixture", appserverBuild: "test", epoch: "epoch-a", - grantedCapabilities: ["sessions.read", "sessions.prompt", "sessions.control", "sessions.manage"], grantedFeatures: ["resume"], - negotiatedLimits: { maxInputBytes: 1_048_576 }, authentication: "local", resumed: false, + v: V, + type: "welcome", + selectedProtocol: V, + hostId: hostId(host), + ompVersion: "fixture", + ompBuild: "test", + appserverVersion: "fixture", + appserverBuild: "test", + epoch: "epoch-a", + grantedCapabilities: [ + "sessions.read", + "sessions.prompt", + "sessions.control", + "sessions.manage", + ], + grantedFeatures: ["resume"], + negotiatedLimits: { maxInputBytes: 1_048_576 }, + authentication: "local", + resumed: false, }; } function pairingWelcome(host = "host-fixture"): WelcomeFrame { @@ -20,7 +42,9 @@ function pairingWelcome(host = "host-fixture"): WelcomeFrame { } class Transport implements OmpTransport { private readonly helloFrame: WelcomeFrame; - constructor(helloFrame: WelcomeFrame = welcome()) { this.helloFrame = helloFrame; } + constructor(helloFrame: WelcomeFrame = welcome()) { + this.helloFrame = helloFrame; + } readonly sent: string[] = []; closed = false; private readonly messages = new Set<(data: string) => void>(); @@ -31,11 +55,13 @@ class Transport implements OmpTransport { send(data: string): void { this.sent.push(data); for (const listener of this.sentListeners) listener(); - if (JSON.parse(data).type === "hello") for (const listener of this.messages) listener(JSON.stringify(this.helloFrame)); + if (JSON.parse(data).type === "hello") + for (const listener of this.messages) listener(JSON.stringify(this.helloFrame)); } waitForSent(index: number): Promise> { const existing = this.sent[index]; - if (existing !== undefined) return Promise.resolve(JSON.parse(existing) as Record); + if (existing !== undefined) + return Promise.resolve(JSON.parse(existing) as Record); const { promise, resolve } = Promise.withResolvers>(); const listener = () => { const data = this.sent[index]; @@ -46,29 +72,83 @@ class Transport implements OmpTransport { this.sentListeners.add(listener); return promise; } - receive(frame: Record): void { for (const listener of this.messages) listener(JSON.stringify(frame)); } - close(): void { if (this.closed) return; this.closed = true; for (const listener of this.closes) listener(1000, "closed"); this.messages.clear(); this.closes.clear(); this.errors.clear(); this.sentListeners.clear(); } - onMessage(listener: (data: string | Uint8Array) => void): () => void { const wrapped = (data: string) => listener(data); this.messages.add(wrapped); return () => this.messages.delete(wrapped); } - onClose(listener: (code?: number, reason?: string) => void): () => void { this.closes.add(listener); return () => this.closes.delete(listener); } - onError(listener: (error: unknown) => void): () => void { this.errors.add(listener); return () => this.errors.delete(listener); } + receive(frame: Record): void { + for (const listener of this.messages) listener(JSON.stringify(frame)); + } + close(): void { + if (this.closed) return; + this.closed = true; + for (const listener of this.closes) listener(1000, "closed"); + this.messages.clear(); + this.closes.clear(); + this.errors.clear(); + this.sentListeners.clear(); + } + onMessage(listener: (data: string | Uint8Array) => void): () => void { + const wrapped = (data: string) => listener(data); + this.messages.add(wrapped); + return () => this.messages.delete(wrapped); + } + onClose(listener: (code?: number, reason?: string) => void): () => void { + this.closes.add(listener); + return () => this.closes.delete(listener); + } + onError(listener: (error: unknown) => void): () => void { + this.errors.add(listener); + return () => this.errors.delete(listener); + } +} +class Store implements CursorStore { + load(): readonly never[] { + return []; + } + save(): void {} } -class Store implements CursorStore { load(): readonly never[] { return []; } save(): void {} } class Registry implements RemoteTargetRegistry { private readonly values = new Map(); - async list(): Promise { return [...this.values.values()]; } - async get(id: string): Promise { return this.values.get(id) ?? null; } - async put(value: RemoteTargetRecord): Promise { this.values.set(value.targetId, value); } - async remove(id: string): Promise { this.values.delete(id); } + async list(): Promise { + return [...this.values.values()]; + } + async get(id: string): Promise { + return this.values.get(id) ?? null; + } + async put(value: RemoteTargetRecord): Promise { + this.values.set(value.targetId, value); + } + async remove(id: string): Promise { + this.values.delete(id); + } } -const target = (targetId: string): RemoteTargetRecord => ({ targetId, label: targetId, mode: "direct", address: "100.64.0.1", port: 4210, requestedCapabilities: ["sessions.read"], grantedCapabilities: [], status: "unknown" }); -function manager(transports: Transport[], registry = new Registry()): DesktopTargetManager { +const target = (targetId: string): RemoteTargetRecord => ({ + targetId, + label: targetId, + mode: "direct", + address: "100.64.0.1", + port: 4210, + requestedCapabilities: ["sessions.read"], + grantedCapabilities: [], + status: "unknown", +}); +function manager( + transports: Transport[], + registry = new Registry(), + onFrame: (frame: PublicServerFrame) => void = () => undefined, +): DesktopTargetManager { return new DesktopTargetManager({ cursorStore: new Store(), registry, - transportFactory: () => { const next = new Transport(); transports.push(next); return next as never; }, - remoteTransportFactory: () => { const next = new Transport(); transports.push(next); return next as never; }, + transportFactory: () => { + const next = new Transport(); + transports.push(next); + return next as never; + }, + remoteTransportFactory: () => { + const next = new Transport(); + transports.push(next); + return next as never; + }, capabilities: ["sessions.read"], - events: { onFrame: () => {}, onState: () => {}, onError: () => {} }, + events: { onFrame: (_targetId, frame) => onFrame(frame), onState: () => {}, onError: () => {} }, }); } @@ -91,20 +171,36 @@ describe("desktop target manager boundaries", () => { const localTransports: Transport[] = []; const local = new DesktopTargetManager({ cursorStore: new Store(), - transportFactory: () => { const next = new Transport(); localTransports.push(next); return next as never; }, + transportFactory: () => { + const next = new Transport(); + localTransports.push(next); + return next as never; + }, events: { onFrame: () => {}, onState: () => {}, onError: () => {} }, }); await local.connect(); const localHello = JSON.parse(localTransports[0]?.sent[0] ?? "{}") as Record; - expect(localHello).toMatchObject({ type: "hello", capabilities: { client: [...DEVICE_CAPABILITIES] } }); + expect(localHello).toMatchObject({ + type: "hello", + capabilities: { client: [...DEVICE_CAPABILITIES] }, + }); await local.close(); const transports: Transport[] = []; const registry = new Registry(); - const observe = { ...target("observe"), requestedCapabilities: ["sessions.read", "catalog.read"] }; + const observe = { + ...target("observe"), + requestedCapabilities: ["sessions.read", "catalog.read"], + }; const settingsControl = { ...target("settings-control"), - requestedCapabilities: ["sessions.read", "config.read", "config.write", "sessions.prompt", "sessions.control"], + requestedCapabilities: [ + "sessions.read", + "config.read", + "config.write", + "sessions.prompt", + "sessions.control", + ], }; await registry.put(observe); await registry.put(settingsControl); @@ -112,32 +208,52 @@ describe("desktop target manager boundaries", () => { await Promise.all([runtime.connect("observe"), runtime.connect("settings-control")]); const observeHello = JSON.parse(transports[0]?.sent[0] ?? "{}") as Record; const settingsHello = JSON.parse(transports[1]?.sent[0] ?? "{}") as Record; - expect(observeHello).toMatchObject({ type: "hello", capabilities: { client: observe.requestedCapabilities } }); - expect(settingsHello).toMatchObject({ type: "hello", capabilities: { client: settingsControl.requestedCapabilities } }); + expect(observeHello).toMatchObject({ + type: "hello", + capabilities: { client: observe.requestedCapabilities }, + }); + expect(settingsHello).toMatchObject({ + type: "hello", + capabilities: { client: settingsControl.requestedCapabilities }, + }); const firstObserveTransport = transports[0]; await runtime.disconnect("observe"); await runtime.connect("observe"); const reconnectHello = JSON.parse(transports[2]?.sent[0] ?? "{}") as Record; expect(firstObserveTransport?.closed).toBe(true); - expect(reconnectHello).toMatchObject({ type: "hello", capabilities: { client: observe.requestedCapabilities } }); + expect(reconnectHello).toMatchObject({ + type: "hello", + capabilities: { client: observe.requestedCapabilities }, + }); - const changedSettings = { ...settingsControl, requestedCapabilities: ["sessions.read", "config.read"] }; + const changedSettings = { + ...settingsControl, + requestedCapabilities: ["sessions.read", "config.read"], + }; await registry.put(changedSettings); const firstSettingsTransport = transports[1]; await runtime.connect("settings-control"); const changedHello = JSON.parse(transports[3]?.sent[0] ?? "{}") as Record; expect(firstSettingsTransport?.closed).toBe(true); - expect(changedHello).toMatchObject({ type: "hello", capabilities: { client: changedSettings.requestedCapabilities } }); + expect(changedHello).toMatchObject({ + type: "hello", + capabilities: { client: changedSettings.requestedCapabilities }, + }); await runtime.close(); }); it("uses the remote scope for pairing and never asks for a broad grant", async () => { const transports: Transport[] = []; const registry = new Registry(); - const remote = { ...target("observe-pair"), requestedCapabilities: ["sessions.read", "catalog.read"] }; + const remote = { + ...target("observe-pair"), + requestedCapabilities: ["sessions.read", "catalog.read"], + }; await registry.put(remote); const credentials = { - withCredential(): T { throw new Error("credential unavailable"); }, + withCredential(): T { + throw new Error("credential unavailable"); + }, set: async () => {}, revoke: async () => {}, }; @@ -160,7 +276,10 @@ describe("desktop target manager boundaries", () => { const pairRequestPromise = pairTransport.waitForSent(1); const pairing = runtime.pairStart("observe-pair", "123456"); const pairRequest = await pairRequestPromise; - expect(pairRequest).toMatchObject({ type: "pair.start", requestedCapabilities: remote.requestedCapabilities }); + expect(pairRequest).toMatchObject({ + type: "pair.start", + requestedCapabilities: remote.requestedCapabilities, + }); expect(pairRequest).not.toMatchObject({ requestedCapabilities: [...DEVICE_CAPABILITIES] }); pairTransport.receive({ v: V, @@ -185,18 +304,42 @@ describe("desktop target manager boundaries", () => { await registry.put(target("commands")); const runtime = manager(transports, registry); await runtime.connect("commands"); - const first = runtime.command("commands", { hostId: "host-fixture", command: "host.list", args: {} }); - const second = runtime.command("commands", { hostId: "host-fixture", command: "host.list", args: {} }); + const first = runtime.command("commands", { + hostId: "host-fixture", + command: "host.list", + args: {}, + }); + const second = runtime.command("commands", { + hostId: "host-fixture", + command: "host.list", + args: {}, + }); await runtime.disconnect("commands"); - const outcomes = await Promise.all([first.then(() => "resolved", (error: { code?: string }) => error.code), second.then(() => "resolved", (error: { code?: string }) => error.code)]); + const outcomes = await Promise.all([ + first.then( + () => "resolved", + (error: { code?: string }) => error.code, + ), + second.then( + () => "resolved", + (error: { code?: string }) => error.code, + ), + ]); expect(outcomes).toEqual(["outcome_unknown", "outcome_unknown"]); await runtime.close(); }); it("forwards decoded command payloads needed by lease consumers", async () => { const transports: Transport[] = []; - const runtime = manager(transports); + const frames: PublicServerFrame[] = []; + const runtime = manager(transports, new Registry(), (frame) => frames.push(frame)); await runtime.connect(); - const pending = runtime.command({ hostId: "host-fixture", sessionId: "session-a", command: "controller.lease.acquire", expectedRevision: "revision-a", args: { ownerId: "desktop" } }); + const pending = runtime.command({ + hostId: "host-fixture", + sessionId: "session-a", + command: "controller.lease.acquire", + expectedRevision: "revision-a", + args: { ownerId: "desktop" }, + }); const command = JSON.parse(transports[0]?.sent.at(-1) ?? "{}") as Record; transports[0]?.receive({ v: V, @@ -216,7 +359,10 @@ describe("desktop target manager boundaries", () => { result: { accepted: true, leaseId: "lease-live", expiresAt: "2030-01-01T00:00:00.000Z" }, }); const rejected = runtime.command({ hostId: "host-fixture", command: "host.list", args: {} }); - const rejectedCommand = JSON.parse(transports[0]?.sent.at(-1) ?? "{}") as Record; + const rejectedCommand = JSON.parse(transports[0]?.sent.at(-1) ?? "{}") as Record< + string, + unknown + >; transports[0]?.receive({ v: V, type: "response", @@ -225,9 +371,35 @@ describe("desktop target manager boundaries", () => { hostId: "host-fixture", ok: false, command: "host.list", - error: { code: "outcome_unknown", message: "command failed" }, + error: { + code: "outcome_unknown", + message: "command failed; Bearer live-message-token", + details: { + recovery: "inspect transcript", + diagnostic: "token=live-detail-token", + accessToken: "must-not-cross-renderer-ipc", + }, + }, }); - expect((await rejected).accepted).toBe(false); + expect(await rejected).toMatchObject({ + accepted: false, + error: { + code: "outcome_unknown", + message: "command failed; [redacted]", + details: { recovery: "inspect transcript", diagnostic: "token=[redacted]" }, + }, + }); + const responseFrame = frames.find((frame) => frame.type === "response" && !frame.ok); + expect(responseFrame).toMatchObject({ + error: { + code: "outcome_unknown", + message: "command failed; [redacted]", + details: { recovery: "inspect transcript", diagnostic: "token=[redacted]" }, + }, + }); + expect(JSON.stringify(responseFrame)).not.toContain("live-message-token"); + expect(JSON.stringify(responseFrame)).not.toContain("live-detail-token"); + expect(JSON.stringify(responseFrame)).not.toContain("must-not-cross-renderer-ipc"); await runtime.close(); }); it("treats a host-acknowledged denial as a consumed confirmation decision", async () => { @@ -297,7 +469,10 @@ describe("desktop target manager boundaries", () => { let reads = 0; const token = "A".repeat(43); const credentials = { - withCredential(_targetId: string, provider: (value: { token: string; deviceId: string }) => T): T { + withCredential( + _targetId: string, + provider: (value: { token: string; deviceId: string }) => T, + ): T { reads += 1; return provider({ token, deviceId: "device-auth" }); }, @@ -305,26 +480,50 @@ describe("desktop target manager boundaries", () => { revoke: async () => {}, }; const runtime = new DesktopTargetManager({ - cursorStore: new Store(), registry, credentials, - remoteTransportFactory: () => { const next = new Transport(); transports.push(next); return next as never; }, - events: { onFrame: () => {}, onState: () => {}, onError: () => {} }, capabilities: ["sessions.read"], + cursorStore: new Store(), + registry, + credentials, + remoteTransportFactory: () => { + const next = new Transport(); + transports.push(next); + return next as never; + }, + events: { onFrame: () => {}, onState: () => {}, onError: () => {} }, + capabilities: ["sessions.read"], }); await runtime.connect("auth"); - const internals = runtime as unknown as { runtimes: Map unknown } } }> }; + const internals = runtime as unknown as { + runtimes: Map unknown } } }>; + }; expect(reads).toBe(2); expect(JSON.stringify(internals)).toContain("runtimes"); expect(JSON.stringify(internals).includes(token)).toBe(false); await runtime.close(); }); it("rejects malformed invoke channels, targets, and pairing credentials at the IPC schema boundary", () => { - expect(() => decodeDesktopInvokeRequest({ channel: "omp:targets:list", payload: { extra: true } })).toThrow(); - expect(() => decodeDesktopInvokeRequest({ channel: "omp:connect", payload: { targetId: "../secret" } })).toThrow(); - expect(() => decodeDesktopInvokeRequest({ channel: "omp:pair", payload: { targetId: "local", code: "token!" } })).toThrow(); - expect(JSON.stringify({ targetId: "remote", paired: true }).includes("deviceToken")).toBe(false); + expect(() => + decodeDesktopInvokeRequest({ channel: "omp:targets:list", payload: { extra: true } }), + ).toThrow(); + expect(() => + decodeDesktopInvokeRequest({ channel: "omp:connect", payload: { targetId: "../secret" } }), + ).toThrow(); + expect(() => + decodeDesktopInvokeRequest({ + channel: "omp:pair", + payload: { targetId: "local", code: "token!" }, + }), + ).toThrow(); + expect(JSON.stringify({ targetId: "remote", paired: true }).includes("deviceToken")).toBe( + false, + ); }); it("uses canonical frozen pairing capabilities and ignores caller mutation", () => { const supplied = ["sessions.read"] as const; - const custom = new DesktopTargetManager({ cursorStore: new Store(), capabilities: supplied, events: { onFrame: () => {}, onState: () => {}, onError: () => {} } }); + const custom = new DesktopTargetManager({ + cursorStore: new Store(), + capabilities: supplied, + events: { onFrame: () => {}, onState: () => {}, onError: () => {} }, + }); const values = custom as unknown as { capabilities: readonly string[] }; expect(values.capabilities).toEqual(["sessions.read"]); expect(Object.isFrozen(values.capabilities)).toBe(true); diff --git a/apps/mobile/capacitor.config.json b/apps/mobile/capacitor.config.json index 0e97a78d..966d30b2 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.9", + "appendUserAgent": " T4CodeMobile/0.1.10", "android": { "path": "android", "minWebViewVersion": 60, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a63c0076..9e1f12ba 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/mobile", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "scripts": { diff --git a/apps/site/package.json b/apps/site/package.json index 40fa8028..cc56f38d 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/site", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "scripts": { diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts index 4b26f46f..b73cee18 100644 --- a/apps/site/src/docs/content.ts +++ b/apps/site/src/docs/content.ts @@ -202,7 +202,11 @@ const localSessions: DocTopic = { kind: "note", text: "This release does not independently alias, pin, reorder, or hide working-folder groups. Those controls need a server-owned workspace registry so desktop and phone agree; a browser-only preference would drift between clients.", }, - { kind: "h2", id: "local-sessions-lifecycle", text: "Rename, archive, restore, or delete" }, + { + kind: "h2", + id: "local-sessions-lifecycle", + text: "Terminate, rename, archive, restore, or delete", + }, { kind: "p", text: "The rail has Current and Archived views. Rename changes a session title. Archive is reversible and keeps the transcript and artifacts. Restore returns the session to Current.", @@ -211,6 +215,10 @@ const localSessions: DocTopic = { kind: "p", text: "Permanent delete removes the session transcript and its artifact directory. T4 Code asks you to type the exact session title, and OMP refuses the operation if the session is busy or its revision changed during confirmation.", }, + { + kind: "p", + text: "Use **Terminate runtime** when a session worker needs to close before archive or delete. The action has its own confirmation, asks OMP to close the worker, then waits for the authoritative session list to report closed and idle. It stays available for a current session even when an earlier status already says closed.", + }, { kind: "h2", id: "local-sessions-switching", text: "Switching stays instant" }, { kind: "p", @@ -474,6 +482,15 @@ const troubleshooting: DocTopic = { kind: "p", text: `First confirm that \`omp appserver status --json\` succeeds. Official upstream OMP v${OMP_RUNTIME_VERSION} cannot answer that command and cannot host T4 Code. On older public appserver integration builds, a large, actively growing transcript can exceed the replay limit during attach. T4 Code v${RELEASE_VERSION} stops the resulting reconnect loop, but the client cannot repair a snapshot the host never delivered. Use the [verified OMP integration tag](${OMP_RUNTIME_URL}) or a later public or upstream build that includes appserver support and the same bounded replay behavior.`, }, + { + kind: "h2", + id: "troubleshooting-prompt-delivery", + text: "Prompt stays in the composer", + }, + { + kind: "p", + text: "T4 Code keeps the draft and shows the command code, host message, and redacted details. Follow that reason before sending again. If the host reports an unknown delivery outcome, check the transcript first; the prompt may already be there.", + }, { kind: "h2", id: "troubleshooting-declined", text: "\u201cThe host declined…\u201d" }, { kind: "p", diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts index 07b62c79..cbdf6796 100644 --- a/apps/site/src/release.ts +++ b/apps/site/src/release.ts @@ -6,15 +6,15 @@ export const DOCS_URL = `${SITE_URL}/docs`; export const REPO_URL = "https://github.com/LycaonLLC/t4-code"; export const OMP_URL = "https://github.com/can1357/oh-my-pi"; export const OMP_RUNTIME_VERSION = "16.5.0"; -export const OMP_RUNTIME_COMMIT = "d4a0b9344e1796c0e56041cfeea3431a8a728e61"; -export const OMP_RUNTIME_TAG = "t4code-16.5.0-appserver-3"; +export const OMP_RUNTIME_COMMIT = "1ada5fc2f0d6f9026d373cd25e004b974437651e"; +export const OMP_RUNTIME_TAG = "t4code-16.5.0-appserver-4"; export const OMP_RUNTIME_URL = `https://github.com/lyc-aon/oh-my-pi/tree/${OMP_RUNTIME_TAG}`; export const OMP_UPSTREAM_TAG = "v16.5.0"; export const OMP_UPSTREAM_COMMIT = "3047c27c332c5629c8e063283d349384c10c9a56"; export const OMP_UPSTREAM_URL = `${OMP_URL}/tree/${OMP_UPSTREAM_TAG}`; -export const APP_WIRE_VERSION = "0.5.2"; -export const RELEASE_TAG = "v0.1.9"; -export const RELEASE_VERSION = "0.1.9"; +export const APP_WIRE_VERSION = "0.5.3"; +export const RELEASE_TAG = "v0.1.10"; +export const RELEASE_VERSION = "0.1.10"; export const RELEASES_URL = `${REPO_URL}/releases/tag/${RELEASE_TAG}`; export type Platform = "android" | "linux" | "mac"; @@ -48,11 +48,11 @@ function asset( } export const RELEASE_ASSETS: readonly ReleaseAsset[] = [ - asset("android", "apk", "universal", "T4-Code-0.1.9-android.apk", "Android APK"), - asset("linux", "deb", "x86_64", "T4-Code-0.1.9-linux-amd64.deb", "Linux .deb"), - asset("linux", "appimage", "x86_64", "T4-Code-0.1.9-linux-x86_64.AppImage", "Linux AppImage"), - asset("mac", "dmg", "arm64", "T4-Code-0.1.9-mac-arm64.dmg", "macOS .dmg"), - asset("mac", "zip", "arm64", "T4-Code-0.1.9-mac-arm64.zip", "macOS .zip"), + asset("android", "apk", "universal", "T4-Code-0.1.10-android.apk", "Android APK"), + asset("linux", "deb", "x86_64", "T4-Code-0.1.10-linux-amd64.deb", "Linux .deb"), + asset("linux", "appimage", "x86_64", "T4-Code-0.1.10-linux-x86_64.AppImage", "Linux AppImage"), + asset("mac", "dmg", "arm64", "T4-Code-0.1.10-mac-arm64.dmg", "macOS .dmg"), + asset("mac", "zip", "arm64", "T4-Code-0.1.10-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 5e1fd4d2..6885e0ab 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.9 asset names and URLs, and the +// Release contract guard: exact v0.1.10 asset names and URLs, and the // platform-detection rule the hero download button relies on. import { describe, expect, it } from "vite-plus/test"; import { @@ -19,13 +19,13 @@ import { } from "../src/release.ts"; describe("release assets", () => { - it("carries the five contracted v0.1.9 filenames", () => { + it("carries the five contracted v0.1.10 filenames", () => { expect(RELEASE_ASSETS.map((a) => a.filename)).toEqual([ - "T4-Code-0.1.9-android.apk", - "T4-Code-0.1.9-linux-amd64.deb", - "T4-Code-0.1.9-linux-x86_64.AppImage", - "T4-Code-0.1.9-mac-arm64.dmg", - "T4-Code-0.1.9-mac-arm64.zip", + "T4-Code-0.1.10-android.apk", + "T4-Code-0.1.10-linux-amd64.deb", + "T4-Code-0.1.10-linux-x86_64.AppImage", + "T4-Code-0.1.10-mac-arm64.dmg", + "T4-Code-0.1.10-mac-arm64.zip", ]); }); @@ -37,8 +37,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.9"); - expect(RELEASE_VERSION).toBe("0.1.9"); + expect(RELEASE_TAG).toBe("v0.1.10"); + expect(RELEASE_VERSION).toBe("0.1.10"); }); it("splits assets by platform with correct architectures", () => { @@ -59,15 +59,15 @@ describe("release assets", () => { describe("OMP integration contract", () => { it("pins the verified runtime tag, commit, and app-wire package", () => { - expect(OMP_RUNTIME_TAG).toBe("t4code-16.5.0-appserver-3"); - expect(OMP_RUNTIME_COMMIT).toBe("d4a0b9344e1796c0e56041cfeea3431a8a728e61"); + expect(OMP_RUNTIME_TAG).toBe("t4code-16.5.0-appserver-4"); + expect(OMP_RUNTIME_COMMIT).toBe("1ada5fc2f0d6f9026d373cd25e004b974437651e"); expect(OMP_RUNTIME_URL).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.0-appserver-3", + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-16.5.0-appserver-4", ); expect(OMP_UPSTREAM_TAG).toBe("v16.5.0"); expect(OMP_UPSTREAM_COMMIT).toBe("3047c27c332c5629c8e063283d349384c10c9a56"); expect(OMP_UPSTREAM_URL).toBe("https://github.com/can1357/oh-my-pi/tree/v16.5.0"); - expect(APP_WIRE_VERSION).toBe("0.5.2"); + expect(APP_WIRE_VERSION).toBe("0.5.3"); }); }); diff --git a/apps/web/package.json b/apps/web/package.json index 6aa84e7c..c520c0c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t4-code/web", - "version": "0.1.9", + "version": "0.1.10", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index f1b5cf08..ce566fab 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -25,6 +25,7 @@ import { Archive, Cable, ChevronRight, + CircleStop, MoreHorizontal, Pencil, Plus, @@ -50,6 +51,7 @@ import { renameLiveSession, restoreLiveSession, sessionCreateSupport, + terminateLiveSession, } from "../features/session-runtime/session-management.ts"; import { resolveSessionManagementNavigation } from "../features/session-runtime/session-navigation.ts"; import { desktopRuntime, useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts"; @@ -67,8 +69,8 @@ function describeSessionState(session: WorkspaceSession): string { return session.status === null ? "Idle" : ""; } -type SessionDialog = "rename" | "delete" | null; -type SessionAction = "rename" | "archive" | "restore" | "delete"; +type SessionDialog = "rename" | "terminate" | "delete" | null; +type SessionAction = "rename" | "terminate" | "archive" | "restore" | "delete"; function SessionRowItem({ row, @@ -100,19 +102,25 @@ function SessionRowItem({ const archived = session.archivedAt !== undefined; const support = ( - command: "session.rename" | "session.archive" | "session.restore" | "session.delete", + command: + | "session.rename" + | "session.close" + | "session.archive" + | "session.restore" + | "session.delete", ) => snapshot === null || address === null ? { supported: false, reason: "Connect to this host to manage the session" } : managementCommandSupport(snapshot, address, command); const renameSupport = support("session.rename"); + const terminateSupport = support("session.close"); const archiveSupport = support("session.archive"); const restoreSupport = support("session.restore"); const deleteSupport = support("session.delete"); const workingReason = - archiveSupport.reason === "Stop the session before archiving or deleting it" || - deleteSupport.reason === "Stop the session before archiving or deleting it" - ? "Stop the session before archiving or deleting it" + archiveSupport.reason === "Terminate the runtime before archiving or deleting it" || + deleteSupport.reason === "Terminate the runtime before archiving or deleting it" + ? "Terminate the runtime before archiving or deleting it" : null; const runAction = useCallback( @@ -123,21 +131,24 @@ function SessionRowItem({ setError(null); try { if (action === "rename") await renameLiveSession(controller, address, renameValue); + else if (action === "terminate") await terminateLiveSession(controller, address); else if (action === "archive") await archiveLiveSession(controller, address); else if (action === "restore") await restoreLiveSession(controller, address); else await deleteLiveSession(controller, address); const verb = action === "rename" ? "renamed" - : action === "archive" - ? "archived" - : action === "restore" - ? "restored" - : "permanently deleted"; + : action === "terminate" + ? "runtime terminated" + : action === "archive" + ? "archived" + : action === "restore" + ? "restored" + : "permanently deleted"; onAnnounce(`${session.title} ${verb}.`); setMenuOpen(false); setDialog(null); - if (action !== "rename") { + if (action !== "rename" && action !== "terminate") { const navigation = resolveSessionManagementNavigation( action, session, @@ -186,6 +197,10 @@ function SessionRowItem({ setRenameValue(session.title); setDialog("rename"); setMenuOpen(false); + } else if (action === "terminate") { + setError(null); + setDialog("terminate"); + setMenuOpen(false); } else if (action === "delete") { setDeleteValue(""); setDialog("delete"); @@ -287,6 +302,13 @@ function SessionRowItem({