Merge branch 'main' into OrcaWin/win-edr-commandline-no-peb

This commit is contained in:
Neil
2026-09-01 21:00:57 -07:00
committed by GitHub
1141 changed files with 95010 additions and 81404 deletions
+27 -3
View File
@@ -401,27 +401,51 @@ jobs:
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune expired adhoc releases
# Only after a live publish: $TAG is then a non-draft this step must not
# delete, and a run that failed before publishing has nothing to retire.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
# Protect the tag this run just shipped, so no filter mistake can delete
# a build minutes after the person who cut it was told it exists.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why compute the cutoff in bash rather than with jq's `now`: this runs
# once per dispatch, and a fixed epoch makes the threshold visible in the
# log when someone asks where their build went.
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
echo "Pruning adhoc releases published before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
# --cleanup-tag so pruning does not leave orphan tags with no release or
# assets attached. Drafts are excluded: a stale draft is the failure
# path's business, not the retention window's.
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")"
#
# Age comes from publishedAt, never createdAt. GitHub reports createdAt
# as the date of the *commit* a release's tag points at, and every tag
# here is cut from this repo's one seed commit — so all of them carry
# that same createdAt, and the day the window rolled past it the entire
# channel expired at once and a single run deleted it. A release with no
# publishedAt is kept rather than aged by guesswork.
jq_filter='map(select(.isDraft | not))'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | map(select(.tagName != \"${TAG//\"/\\\"}\"))"
fi
jq_filter+=" | map(select((.publishedAt // \"\") != \"\"))"
jq_filter+=" | map(select((.publishedAt | fromdateiso8601) < $cutoff)) | .[].tagName"
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,publishedAt,isDraft \
--jq "$jq_filter")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
# Belt-and-suspenders: the filter above should already exclude $TAG.
if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then
echo "::warning::Prune list still included just-published $tag after protect; skipping delete."
continue
fi
echo "Pruning $tag"
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
+27 -4
View File
@@ -105,6 +105,11 @@ const winSpeechNativeResource = {
to: 'node_modules/sherpa-onnx-win-x64'
}
// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build.
// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with
// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows.
const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx']
/** @type {import('electron-builder').Configuration} */
module.exports = {
appId,
@@ -376,12 +381,24 @@ module.exports = {
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always',
// Why: on a real uninstall, stop and remove the relocated terminal daemon
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
// Why: electron-builder allows one include, so both Windows installer hooks live in it -
// the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an
// update's uninstallOldVersion) and the additive markdown "Open with" registration.
// Windows markdown association is deliberately NOT done via `fileAssociations`; see the
// header comment in that file for why that would steal the user's default .md handler.
include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh')
},
mac: {
// Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming
// LSHandlerRank ownership, so whichever editor the user already prefers stays the default.
// Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break.
fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({
ext,
name: 'Markdown Document',
description: 'Markdown Document',
role: 'Editor',
rank: 'Alternate'
})),
icon: 'resources/build/icon.icns',
entitlements: 'resources/build/entitlements.mac.plist',
entitlementsInherit: 'resources/build/entitlements.mac.plist',
@@ -468,6 +485,12 @@ module.exports = {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
// Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to
// text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob
// override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the
// default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to
// application/x-genesis-32x-rom, so claiming it here would need a glob override.
mimeTypes: ['text/markdown'],
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
-19
View File
@@ -2,32 +2,13 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline src/main/agent-hooks/server.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/index.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/main/updater.ts
inline src/preload/index.ts
inline src/relay/pty-handler.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/ui.ts
inline src/shared/keybindings.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/index.tsx
-23
View File
@@ -1,23 +0,0 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+79
View File
@@ -0,0 +1,79 @@
; electron-builder NSIS hooks for the Orca Windows installer.
;
; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall /
; customUnInstall hook Orca needs lives here.
; ---------------------------------------------------------------------------
; Markdown "Open with Orca" (issue #10138)
;
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "<ProgID>"
; That overwrites whichever editor currently owns .md, with no backup, for every
; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so
; uninstalling Orca would leave .md pointing at a deleted ProgID.
;
; These writes are additive only. Registering a ProgID plus an OpenWithProgids
; hint and an Applications\<exe>\SupportedTypes entry puts Orca in Explorer's
; "Open with" list and in "Choose another app", while the default handler stays
; exactly where the user left it. Never add a `Software\Classes\.<ext>` default
; value here.
;
; MARKDOWN_PROGID must stay in sync with the extension list handled by
; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts.
; ---------------------------------------------------------------------------
!define MARKDOWN_PROGID "Orca.Markdown"
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" ""
!macroend
!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
!macroend
!macro customInstall
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"'
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx"
; Why: Explorer caches the association list until told otherwise.
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
; ---------------------------------------------------------------------------
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so
; dropping them during uninstallOldVersion is correct and keeps the pair symmetric.
DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx"
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
+18 -11
View File
@@ -2493,30 +2493,31 @@
"https://github.com/stablyai/orca/issues/11298",
"https://github.com/stablyai/orca/pull/11300"
],
"invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. With uninterrupted timer delivery, the first socket that arms an idle heartbeat is probed immediately and an unresponsive socket is reaped within one interval. Later sockets join the existing shared cadence without another timer or immediate sweep and are reaped within two intervals. Responsive sockets survive, pause recovery grants a fresh probe, and close or error-to-close releases connection listeners and timers.",
"oracle": "With one fake clock and exact socket identities, accept the first socket at 0 ms and require an immediate owned probe plus reaping at 100 ms when unresponsive. Keep a responsive first socket, accept an unresponsive later socket at 50 ms, require the same shared timer, its first probe at 100 ms, no early reap, and termination at 200 ms. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.",
"invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. Unauthenticated sockets receive no heartbeat control frames during E2EE and are bounded by the pre-auth timeout; authenticated sockets share one periodic cadence, tolerate missed probes and event-loop pause, and release listeners and timers on close or error.",
"oracle": "With one fake clock and exact socket identities, accept an authenticated first socket at 0 ms and require its first probe on the 100 ms tick. Accept an unauthenticated later socket at 50 ms and require no probe at the 100 ms tick; authenticate it, then require its first probe on the next shared tick and termination only after the configured consecutive-miss budget. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts"
],
"testFiles": [
"src/main/runtime/rpc/ws-transport-accept-order.test.ts",
"src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"src/main/runtime/rpc/ws-transport.test.ts"
"src/main/runtime/rpc/ws-transport.test.ts",
"src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/rpc/ws-transport-accept-order.test.ts",
"assertions": [
"the first synchronous probe observes message, pong, close, and error ownership",
"the first unresponsive socket is reaped at one interval",
"a later socket keeps the original shared timer, is first probed on the shared tick, and is reaped within two intervals",
"the first periodic probe observes message, pong, close, and error ownership",
"an authenticated unresponsive socket is reaped on the configured consecutive-miss budget",
"an unauthenticated later socket is not probed before authentication, then joins the original shared timer",
"final close releases heartbeat membership, listeners, and timers"
]
},
{
"file": "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"assertions": [
"one missed probe reaps only the unresponsive client",
"a single missed probe does not reap the unresponsive client",
"event-loop resume grants clients a fresh probe"
]
},
@@ -2527,6 +2528,12 @@
"pre-auth, raw TCP, and accepted WebSocket resource bounds remain enforced",
"error and close races finalize membership once"
]
},
{
"file": "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts",
"assertions": [
"an authenticated real WebSocket survives one swallowed pong and responds to later probes"
]
}
],
"evidenceRuns": [
@@ -2534,10 +2541,10 @@
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts",
"result": "passed",
"durationSeconds": 0.91,
"summary": "Three runtime transport files and 35 tests passed with deterministic first- and later-socket cadence, exact listener/timer ownership, pause recovery, security bounds, and cleanup."
"summary": "Four runtime transport files and 42 tests passed with deterministic authenticated heartbeat cadence, handshake grace for later sockets, exact listener/timer ownership, pause recovery, transient packet-loss tolerance, security bounds, and cleanup."
}
],
"runtimeBudget": {
@@ -2550,7 +2557,7 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On latest main and with the listener-order fix disabled, the first synchronous probe observed no message, pong, close, or error owner. The structural listener-order fix passed those assertions. The published delayed-first-sweep alternative missed the first-socket one-interval cleanup bound. A later-socket oracle now separately pins the intended shared cadence at a 100 ms first probe and 200 ms reap after acceptance at 50 ms."
"evidence": "On the candidate before this review fix, an authenticated first socket kept the shared timer alive while an unauthenticated socket accepted at 50 ms was pinged at the 100 ms tick; the new oracle failed. After filtering heartbeat clients to the authenticated transport map, the same byte-identical oracle passes: no pre-auth ping, first post-auth probe on the next shared tick, and bounded cleanup."
},
"performanceBudget": {
"required": true,
@@ -0,0 +1,14 @@
# Files allowed to construct a `ws` server that binds a port without pinning `host`.
#
# `ws` accepts `{ port }` alone and silently binds the wildcard address. A server
# reached over 127.0.0.1 must pin `host: '127.0.0.1'`, or a foreign loopback
# listener can hold the same port and answer in its place -- which is how
# relay-control-client.test.ts came to fail with a real HTTP 401 in a test that
# was simulating silence.
#
# This list only shrinks. Adding a line also requires raising the pin in
# websocket-server-loopback-bind.test.ts, which is deliberate friction.
# Deliberate, not drift: this mock is dialled by a phone on the LAN, so it has to
# be reachable on a real interface. A loopback bind would make it unreachable.
mobile/scripts/mock-server.ts
+204
View File
@@ -0,0 +1,204 @@
/**
* Read the top-level option keys of a call's object-literal argument out of raw
* source text.
*
* Text rather than an AST because typescript@7 no longer ships the classic
* compiler API and every installed parser is a transitive dependency. The
* tradeoff is handled by refusing to guess: any shape this cannot read comes
* back as `unreadable` with a reason, and callers must treat that as a failure
* rather than as an absence of keys.
*/
export type CallOptionKeys =
| { readonly readable: true; readonly keys: readonly string[] }
| { readonly readable: false; readonly reason: string }
type ScanState = 'code' | 'line' | 'block' | 'single' | 'double' | 'template'
function closesString(state: ScanState, current: string): boolean {
return (
(state === 'single' && current === "'") ||
(state === 'double' && current === '"') ||
(state === 'template' && current === '`')
)
}
function opensNonCode(current: string, next: string | undefined): ScanState | null {
if (current === '/' && next === '/') {
return 'line'
}
if (current === '/' && next === '*') {
return 'block'
}
if (current === "'") {
return 'single'
}
if (current === '"') {
return 'double'
}
if (current === '`') {
return 'template'
}
return null
}
/**
* Text between an open paren and its match, tracking strings and comments so a
* brace inside either cannot unbalance the count. Null when it never closes.
*/
function balancedArguments(text: string, openIndex: number): string | null {
let depth = 0
let state: ScanState = 'code'
for (let index = openIndex; index < text.length; index++) {
const current = text[index]
const next = text[index + 1]
if (state === 'code') {
const opened = opensNonCode(current, next)
if (opened) {
state = opened
if (opened === 'line' || opened === 'block') {
index++
}
} else if (current === '(' || current === '{' || current === '[') {
depth++
} else if (current === ')' || current === '}' || current === ']') {
depth--
if (depth === 0) {
return text.slice(openIndex + 1, index)
}
if (depth < 0) {
return null
}
}
continue
}
if (state === 'line') {
if (current === '\n') {
state = 'code'
}
continue
}
if (state === 'block') {
if (current === '*' && next === '/') {
state = 'code'
index++
}
continue
}
if (current === '\\') {
index++
continue
}
// Brace tracking inside `${}` would need its own depth; templates never
// appear as options, so report one as unreadable instead of guessing.
if (state === 'template' && current === '$' && next === '{') {
return null
}
if (closesString(state, current)) {
state = 'code'
}
}
return null
}
/** Keys at depth 0 of an object literal body, with anything non-identifier kept verbatim. */
function objectLiteralKeys(body: string): string[] {
const keys: string[] = []
let depth = 0
let state: ScanState = 'code'
let inValue = false
let token = ''
const flush = (): void => {
const name = token.trim()
token = ''
if (name && depth === 0) {
keys.push(name)
}
}
for (let index = 0; index < body.length; index++) {
const current = body[index]
const next = body[index + 1]
if (state === 'code') {
const opened = opensNonCode(current, next)
if (opened) {
state = opened
if (opened === 'line' || opened === 'block') {
index++
}
} else if (current === '(' || current === '{' || current === '[') {
depth++
if (!inValue) {
token += current
}
} else if (current === ')' || current === '}' || current === ']') {
depth--
if (!inValue) {
token += current
}
} else if (current === ':' && depth === 0 && !inValue) {
flush()
inValue = true
} else if (current === ',' && depth === 0) {
// A shorthand or a spread ends here having never seen a colon.
if (inValue) {
inValue = false
token = ''
} else {
flush()
}
} else if (!inValue) {
token += current
}
continue
}
if (state === 'line') {
if (current === '\n') {
state = 'code'
}
continue
}
if (state === 'block') {
if (current === '*' && next === '/') {
state = 'code'
index++
}
continue
}
if (current === '\\') {
index++
continue
}
if (closesString(state, current)) {
state = 'code'
}
}
if (!inValue) {
flush()
}
return keys
}
/**
* Option keys of the call whose argument list opens at `parenIndex`, or the
* reason the shape could not be read. Spreads and computed keys land in the
* latter: either can carry a key this would otherwise report as absent.
*/
export function readCallOptionKeys(text: string, parenIndex: number): CallOptionKeys {
const args = balancedArguments(text, parenIndex)
if (args === null) {
return { readable: false, reason: 'argument list never closes' }
}
if (!args.trim()) {
return { readable: false, reason: 'called with no options argument' }
}
const trimmed = args.trim()
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return { readable: false, reason: 'options are not an object literal' }
}
const keys = objectLiteralKeys(trimmed.slice(1, -1))
const unreadable = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key))
if (unreadable !== undefined) {
return { readable: false, reason: `unreadable option key \`${unreadable}\`` }
}
return { readable: true, keys }
}
@@ -0,0 +1,116 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("")
// value of Software\Classes\.<ext>. Additive `WriteRegNone ...\OpenWithProgids` must not
// match, or the guard below would be unfalsifiable.
const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i
// The hooks file documents the forbidden line in prose, so match executable script only.
const stripNsisCommentLines = (source) =>
source
.split('\n')
.filter((line) => !/^\s*[;#]/.test(line))
.join('\n')
const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8')
describe('electron-builder markdown file associations', () => {
// Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS
// packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value
// — silently taking .md from whichever editor owns it, for every existing user on their
// next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot
// prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must
// stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks.
it('never claims the Windows default markdown handler', () => {
expect(electronBuilderConfig.fileAssociations).toBeUndefined()
expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined()
})
it('joins the macOS Open With list for every markdown extension without owning it', () => {
const associations = electronBuilderConfig.mac.fileAssociations
// One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob.
expect([...associations].map((association) => association.ext).sort()).toEqual(
[...MARKDOWN_EXTENSIONS].sort()
)
for (const association of associations) {
expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' })
}
})
// Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to
// text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning
// the default. A fileAssociations entry would ship a redundant glob override instead.
it('reuses the existing shared-mime-info markdown type on Linux', () => {
expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown')
expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined()
})
it('points the single NSIS include at the installer hooks file on disk', () => {
const includePath = electronBuilderConfig.nsis.include
expect(existsSync(includePath)).toBe(true)
expect(basename(includePath)).toBe('orca-installer-hooks.nsh')
})
// Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so
// the assertion below is a live check rather than a regex that can never fire.
it('recognizes an APP_ASSOCIATE-style default-handler write', () => {
for (const takeover of [
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"',
'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"'
]) {
expect(takeover).toMatch(DEFAULT_HANDLER_WRITE)
}
expect(
'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"'
).not.toMatch(DEFAULT_HANDLER_WRITE)
// Comment stripping must drop prose that quotes the bad line without swallowing a real
// one that happens to carry a trailing comment.
const stripped = stripNsisCommentLines(
[
'; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "<ProgID>"',
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops'
].join('\n')
)
expect(stripped.split('\n')).toHaveLength(1)
expect(stripped).toMatch(DEFAULT_HANDLER_WRITE)
})
it('registers Windows markdown Open With additively, never as the default', async () => {
const hooks = await readInstallerHooks()
expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE)
// The additive hint that puts Orca in Explorer's "Open with" list.
expect(hooks).toMatch(
/WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/
)
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/)
for (const ext of MARKDOWN_EXTENSIONS) {
expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
}
expect(hooks).toMatch(/!macro\s+customInstall\b/)
expect(hooks).toMatch(/!macro\s+customUnInstall\b/)
})
// Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown
// hooks too. electron-builder allows only one include, so a merge that drops the daemon
// sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall.
it('keeps the daemon-host uninstall sweep across the include rename', async () => {
const hooks = await readInstallerHooks()
expect(hooks).toContain('orca-terminal-daemon.exe')
expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
// Without this guard, uninstallOldVersion would kill the daemon on every update —
// defeating the relocation that keeps terminals alive across updates.
expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
})
})
@@ -87,7 +87,7 @@ const parent = `${head}~1`
const CANDIDATES = [
'src/main/git/status.ts',
'src/shared/agent-hook-listener.ts',
'src/renderer/src/components/TaskPage.tsx'
'src/renderer/src/components/task-page/TaskPage.tsx'
]
const files = []
@@ -363,6 +363,10 @@ describe('Electron runtime package contract', () => {
expect(afterInstallScript).toContain('chrome-sandbox')
expect(afterInstallScript).toContain('chmod 4755 "$sandbox"')
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
expect(afterInstallScript).toContain('is_owned_link()')
expect(afterInstallScript).toContain('readlink -f -- "$link"')
expect(afterInstallScript).toContain('[ ! -e "$link" ] && [ ! -L "$link" ]')
expect(afterInstallScript).not.toContain('[ ! -e "$link" ] || [ -L "$link" ]')
})
it('advances only the skill release ledger in a taggable release-cut commit', () => {
+39 -11
View File
@@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
chmod(targetPath, 0o755)
}
/**
* Restore owner write permission across a private copy.
*
* Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode
* across, so a tree copied from the write-protected shared cache lands read-only and every patch
* the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit
* comes back; group and other stay as the source left them.
*/
export function makeTreeWritable(targetPath, chmod = chmodSync) {
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
const entryPath = join(targetPath, entry.name)
if (entry.isDirectory()) {
makeTreeWritable(entryPath, chmod)
} else if (!entry.isSymbolicLink()) {
const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode
chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200)
}
}
chmod(targetPath, 0o755)
}
/**
* Share storage when possible, otherwise copy the bytes.
*
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
* through into the source.
* through into the source. The copy is unprotected on the way out for the same reason -- a private
* tree the caller cannot write to is useless to it.
*/
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const copy = options.copy ?? copyTreeVerbatim
const unprotect = options.unprotect ?? makeTreeWritable
const privateMechanisms = new Set(['clone', 'reflink'])
let result = { mechanism: null, copyError: null }
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
try {
const mechanism = shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
})
return { mechanism, copyError: null }
result = {
mechanism: shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
}),
copyError: null
}
} catch (copyError) {
copy(sourcePath, destinationPath)
return { mechanism: null, copyError }
result = { mechanism: null, copyError }
}
} else {
copy(sourcePath, destinationPath)
}
copy(sourcePath, destinationPath)
return { mechanism: null, copyError: null }
unprotect(destinationPath)
return result
}
function copyTreeVerbatim(sourcePath, destinationPath) {
+35
View File
@@ -19,6 +19,7 @@ import {
copyPrivateTree,
hardlinkTree,
makeTreeReadOnly,
makeTreeWritable,
shareTree
} from './space-sharing-copy.mjs'
@@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => {
)
})
describe('makeTreeWritable', () => {
it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => {
const { source } = makeTree()
makeTreeReadOnly(source)
makeTreeWritable(source)
const file = path.join(source, 'nested', 'file')
expect(statSync(file).mode & 0o200).toBe(0o200)
expect(() => writeFileSync(file, 'mutated')).not.toThrow()
})
it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => {
const { source } = makeTree()
const executable = path.join(source, 'electron')
writeFileSync(executable, 'binary')
chmodSync(executable, 0o555)
makeTreeWritable(source)
expect(statSync(executable).mode & 0o777).toBe(0o755)
})
})
describe('copyPrivateTree', () => {
it.runIf(process.platform !== 'win32')(
'hands back a tree the caller can patch, even from a write-protected source',
() => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
makeTreeReadOnly(source)
copyPrivateTree(source, destination)
// The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync
// all carry that across, and `pn dev` then died patching the copied bundle's Info.plist.
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow()
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
}
)
it('never hardlinks, because the caller patches what it gets back', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
@@ -0,0 +1,172 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { readCallOptionKeys } from './call-site-option-keys'
/**
* Locate every `new WebSocketServer(...)` in the tree and say, for each, whether
* it pins a bind address.
*
* `ws` accepts `{ port }` alone and silently binds the wildcard address, so a
* server the caller then dials on 127.0.0.1 sits at a port a foreign loopback
* listener can also hold -- and the more specific listener wins the connection,
* answering in that server's place.
*
* Anything unreadable is reported as `opaque` rather than skipped. A matcher
* that silently exempts the shapes it fails to parse is worse than no matcher,
* because it reads as coverage.
*/
export type BindSite = { path: string; line: number }
export type OpaqueSite = BindSite & { reason: string }
export type WebSocketServerBindScan = {
filesScanned: number
/** Every construction recognized, however it was then classified. */
constructions: number
/** Binds a port with no `host`: reachable at an address the dialer never named. */
wildcardBound: BindSite[]
/** Shape that could not be read; never treated as safe. */
opaque: OpaqueSite[]
/** Binds a port and pins `host`. */
loopbackBound: BindSite[]
/** No `port`: attaches to a server that owns the bind itself. */
attached: BindSite[]
}
const IGNORED_DIRECTORIES = new Set([
'node_modules',
'dist',
'out',
'build',
'.git',
'__fixtures__',
'coverage',
// Full snapshots of older releases; their bind sites are not this tree's to fix.
'.cross-version-checkouts'
])
const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts)$/
const SCANNED_ROOTS = ['src', 'mobile', 'config', 'tests']
const WS_IMPORT_HINT = /from\s*['"]ws['"]/
function collectSourceFiles(root: string, found: string[] = []): string[] {
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>
try {
entries = readdirSync(root, { withFileTypes: true })
} catch {
return found
}
for (const entry of entries) {
if (IGNORED_DIRECTORIES.has(entry.name)) {
continue
}
const full = join(root, entry.name)
if (entry.isDirectory()) {
collectSourceFiles(full, found)
} else if (SCANNED_EXTENSIONS.test(entry.name)) {
found.push(full)
}
}
return found
}
/** Local names bound to ws's server class, following `as` aliases and namespace imports. */
function webSocketServerNames(text: string): { direct: Set<string>; namespaces: Set<string> } {
const direct = new Set<string>()
const namespaces = new Set<string>()
// One statement at a time: a pattern reaching for `from 'ws'` would swallow
// every import above it and lose the specifier names in the blob.
for (const match of text.matchAll(/\bimport\b([\s\S]*?)\bfrom\s*(['"])([^'"]+)\2/g)) {
if (match[3] !== 'ws') {
continue
}
const clause = match[1]
if (/^\s*type\b/.test(clause)) {
continue
}
const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/)
if (namespace) {
namespaces.add(namespace[1])
}
const named = clause.match(/\{([\s\S]*)\}/)
if (!named) {
continue
}
for (const specifier of named[1].split(',')) {
const trimmed = specifier.trim()
if (!trimmed || /^type\s/.test(trimmed)) {
continue
}
const parts = trimmed.split(/\s+as\s+/)
// `Server` is ws's own alias for WebSocketServer.
if (parts[0].trim() === 'WebSocketServer' || parts[0].trim() === 'Server') {
direct.add((parts[1] ?? parts[0]).trim())
}
}
}
return { direct, namespaces }
}
function classify(
scan: WebSocketServerBindScan,
site: BindSite,
text: string,
paren: number
): void {
const options = readCallOptionKeys(text, paren)
if (!options.readable) {
scan.opaque.push({ ...site, reason: options.reason })
return
}
if (!options.keys.includes('port')) {
scan.attached.push(site)
return
}
if (!options.keys.includes('host')) {
scan.wildcardBound.push(site)
return
}
scan.loopbackBound.push(site)
}
export function scanWebSocketServerBinds(repoRoot: string): WebSocketServerBindScan {
const files = SCANNED_ROOTS.flatMap((directory) => collectSourceFiles(join(repoRoot, directory)))
const scan: WebSocketServerBindScan = {
filesScanned: files.length,
constructions: 0,
wildcardBound: [],
opaque: [],
loopbackBound: [],
attached: []
}
for (const file of files) {
const text = readFileSync(file, 'utf8')
// Filter on the import, not on the class name: `Server as Wss` never spells
// WebSocketServer, and keying on that name silently skipped the whole alias.
if (!WS_IMPORT_HINT.test(text)) {
continue
}
const { direct, namespaces } = webSocketServerNames(text)
if (!direct.size && !namespaces.size) {
continue
}
const path = relative(repoRoot, file).split('\\').join('/')
const patterns = [
...[...direct].map((name) => new RegExp(`\\bnew\\s+${name}\\s*\\(`, 'g')),
...[...namespaces].map(
(name) => new RegExp(`\\bnew\\s+${name}\\.(?:WebSocketServer|Server)\\s*\\(`, 'g')
)
]
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
scan.constructions++
const line = text.slice(0, match.index).split('\n').length
classify(scan, { path, line }, text, match.index + match[0].length - 1)
}
}
}
return scan
}
export function formatSites(sites: readonly BindSite[]): string[] {
return sites.map((site) => `${site.path}:${site.line}`)
}
@@ -0,0 +1,106 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { formatSites, scanWebSocketServerBinds } from './websocket-server-bind-scan'
/**
* Hold the bind address at the tree level rather than per call site.
*
* Every one of the ~30 `.listen(0, ...)` calls in this repo already passes
* '127.0.0.1'; 7 of 7 `new WebSocketServer({ port })` calls did not. Authors know
* the convention -- `ws` just never asks, because `{ port }` alone binds the
* wildcard without a word. That silence is what this test replaces.
*
* The allowlist only shrinks. A new wildcard bind fails here even where it looks
* harmless today, because harmless-looking is exactly what the seven were.
*/
/** The ratchet, held as data so it reads as the list it is. */
const WILDCARD_BIND_ALLOWLIST: readonly string[] = readFileSync(
join(__dirname, '__fixtures__', 'websocket-server-wildcard-bind-allowlist.txt'),
'utf8'
)
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
/**
* The true count of constructions that bind a port without pinning a host.
*
* May only ever be DECREASED, and only by pinning a host. Raising it is never
* the fix.
*/
const WILDCARD_BIND_PIN = 1
/**
* A floor under the constructions the scanner still recognizes.
*
* This is the guard against the scanner going blind: an import pattern it stops
* following reports zero offenders and reads exactly like a clean tree. During
* development a single wrong regex dropped this from 24 to 3.
*/
const RECOGNIZED_CONSTRUCTION_FLOOR = 20
describe('WebSocketServer loopback bind boundary', () => {
const repoRoot = resolve(__dirname, '..', '..')
const scan = scanWebSocketServerBinds(repoRoot)
const offenders = scan.wildcardBound.map((site) => site.path)
it('scans a plausible number of files', () => {
// A broken root or extension list would make the guard silently vacuous.
expect(scan.filesScanned).toBeGreaterThan(5_000)
})
it('still recognizes the known construction sites', () => {
expect(
scan.constructions,
`Only ${scan.constructions} WebSocketServer constructions were recognized; the floor is ` +
`${RECOGNIZED_CONSTRUCTION_FLOOR}. The scanner has probably stopped following an import ` +
'shape rather than the tree having lost that many servers.'
).toBeGreaterThanOrEqual(RECOGNIZED_CONSTRUCTION_FLOOR)
})
it('can read the options of every construction it found', () => {
// An unreadable shape is never assumed safe: it could be hiding a host, or
// hiding the absence of one. Rewrite it as a plain object literal.
expect(
scan.opaque.map((site) => `${site.path}:${site.line} -- ${site.reason}`),
'WebSocketServer options that this guard cannot read.'
).toEqual([])
})
it('has no wildcard-bound server outside the allowlist', () => {
const unlisted = scan.wildcardBound.filter(
(site) => !WILDCARD_BIND_ALLOWLIST.includes(site.path)
)
expect(
formatSites(unlisted),
"New WebSocketServer that binds a port without a host. Pass host: '127.0.0.1' so a foreign " +
'loopback listener cannot claim the port and answer in its place.'
).toEqual([])
})
it('has no stale allowlist entry', () => {
// Why this direction matters too: an entry left behind after the file was
// fixed hides the next regression in that same path.
const stale = WILDCARD_BIND_ALLOWLIST.filter((path) => !offenders.includes(path))
expect(stale, 'Allowlist entry no longer binds the wildcard — delete the line.').toEqual([])
})
it('holds the wildcard-bind count at the pin', () => {
// Bounding by the allowlist's own length would prove nothing: the two move
// together, so appending a line to silence a failure would keep the bound
// satisfied. The pin is a literal so that widening takes a second edit.
expect(
scan.wildcardBound.length,
`${scan.wildcardBound.length} constructions bind the wildcard; the pin is ` +
`${WILDCARD_BIND_PIN}. Never raise the pin -- pass host: '127.0.0.1' instead.`
).toBeLessThanOrEqual(WILDCARD_BIND_PIN)
// A pin left above reality is how a ratchet rots: it re-opens room for the
// next wildcard bind to land for free.
expect(
scan.wildcardBound.length,
`Only ${scan.wildcardBound.length} constructions bind the wildcard. Lower ` +
`WILDCARD_BIND_PIN to ${scan.wildcardBound.length} to keep the ground you just took.`
).toBeGreaterThanOrEqual(WILDCARD_BIND_PIN)
})
})
+11
View File
@@ -6,6 +6,17 @@
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/preload/api/**/*",
"../src/preload/browser-client-page-renderer-requests.ts",
"../src/preload/browser-find-subscriptions.ts",
"../src/preload/close-active-tab-payload-admission.ts",
"../src/preload/e2e-config.ts",
"../src/preload/gitlab.ts",
"../src/preload/preload-runtime-support.ts",
"../src/preload/renderer-heap-statistics-reader.ts",
"../src/preload/renderer-process-memory-reader.ts",
"../src/preload/renderer-restart-wiring.ts",
"../src/preload/runtime-environment-subscriptions.ts",
"../src/preload/usage-provider-api.ts",
"../src/shared/**/*",
"../src/main/gitlab/mappers.ts",
"../src/main/ipc/worktree-branch-name.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 34m">
<title>downloads: 34m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 35m">
<title>downloads: 35m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">34m</text>
<text x="90" y="14">34m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">35m</text>
<text x="90" y="14">35m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+11
View File
@@ -42,6 +42,17 @@ authority.
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
### Placeholders That Fail Open
`GitCapabilityCache` records commands Git *rejects*. A `git log --format`
placeholder Git does not know is not rejected: Git echoes it verbatim and exits
zero, so there is no error to remember and no probe to cache. Ask for both forms
in one record and pick at parse time.
| Placeholder | Preferred behavior | Compatibility behavior |
| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting |
## Why Not `simple-git`
`simple-git` is a process wrapper around the installed Git binary. Its custom
+16 -11
View File
@@ -56,11 +56,25 @@ of the libraries installed.
On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
`libfuse2` name also resolves there because nothing else provides it. FUSE is
optional: without it, use the AppImage's supported extraction path:
optional: without it, use the AppImage's supported extraction path. CLI
registration does this once automatically, so registered commands do not need
FUSE.
Download and make the AppImage executable:
```bash
sudo mkdir -p /opt/orca
sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \
-o /opt/orca/orca-linux.AppImage
sudo chmod +x /opt/orca/orca-linux.AppImage
```
To extract it without FUSE, run the extraction as root because the installation
directory is root-owned:
```bash
cd /opt/orca
./orca-linux.AppImage --appimage-extract
sudo ./orca-linux.AppImage --appimage-extract
sudo chmod -R a+rX /opt/orca/squashfs-root
/opt/orca/squashfs-root/AppRun serve --port 6768
```
@@ -76,15 +90,6 @@ extract-and-run wrapper can print extracted paths before Orca starts, so
automation that requires stdout to contain only the ready JSON should extract
once and invoke `squashfs-root/AppRun`.
Download and make the AppImage executable:
```bash
sudo mkdir -p /opt/orca
sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \
-o /opt/orca/orca-linux.AppImage
sudo chmod +x /opt/orca/orca-linux.AppImage
```
If `Xvfb` was installed somewhere other than `/usr/bin`, confirm systemd can
find it later:
+101
View File
@@ -2,6 +2,8 @@
Two properties of `wsl.exe` decide how every guest invocation has to be written. Both are silent
when you get them wrong: the command still runs and still exits 0, it just returns the wrong bytes.
Those are sections 1 and 2. A closing section answers the question that running Orca's writes
inside a distro raises next: what happens to the distro's disk image.
## 1. Always `--exec`, never `--`
@@ -70,3 +72,102 @@ wsl.exe -d <distro> --exec /usr/bin/env PATH=… HOME=… /usr/bin/git -C <dir>
This is what the direct-git read path does. It is immune to both problems above by construction and
avoids paying login-shell startup on every call, which also sidesteps profiles that block or print.
Resolve the PATH/HOME once through a fenced probe, cache it per distro, then use this form.
## Disk: the distro VHDX only grows
Everything above puts Orca's writes inside the distro, which raises a separate question. WSL2 keeps
the entire guest filesystem in a single dynamically-expanding `ext4.vhdx`. Deleting files inside
the distro does free the blocks — for ext4 to reuse — but the host-visible `.vhdx` does not shrink
on its own.
### Finding the file
The path depends on how the distro was installed, so do not assume one:
| Install method | `ext4.vhdx` lives under |
| ---------------------- | --------------------------------------------------------- |
| recent `wsl --install` | `%LOCALAPPDATA%\wsl\{guid}\` |
| Microsoft Store | `%LOCALAPPDATA%\Packages\<PackageFamilyName>\LocalState\` |
| `wsl --import` | wherever the operator pointed it |
The install-agnostic answer is the registry, which records every distro's directory as `BasePath`:
```powershell
Get-ChildItem HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss |
ForEach-Object { Get-ItemProperty $_.PSPath } |
Select-Object DistributionName, BasePath
```
### Measured behavior
WSL 2.7.11.0 / Ubuntu-24.04, one machine. Sizes are **size on disk** — allocated bytes, via
`GetCompressedFileSize`, not the logical file length. That distinction matters below: on this
machine the vhdx was not sparse, so the two numbers were identical, but on a sparse vhdx the
logical size stays pinned at the high-water mark while only size on disk falls when space is
reclaimed. Measure the wrong one and reclaim looks like it did nothing.
| Step | `ext4.vhdx` size on disk (bytes) |
| ---------------------------------- | -------------------------------- |
| baseline | 21,673,017,344 |
| write 1 GiB | 22,746,759,168 |
| delete it | 22,746,759,168 |
| write a fresh incompressible 1 GiB | 22,746,759,168 |
| hold 3 GiB live at once | 24,894,242,816 |
The fourth row is the point: the second gigabyte cost zero growth, because ext4 handed it the
blocks the first one freed. Only exceeding the previous peak moved the file.
### Reclaiming space
Sparse mode lets the guest hand freed blocks back to the host, so the file can shrink instead of
only growing. Two preconditions, both easy to miss: the distro has to be stopped (the vhdx cannot
be converted while it is mounted), and `wsl --manage` exists only on WSL 2.5 and newer — check with
`wsl --version`.
```
wsl --terminate <distro>
wsl --manage <distro> --set-sparse true
```
The equivalent for distros not yet created is `sparseVhd = true` under `[experimental]` in
`%UserProfile%\.wslconfig` — that file lives in the Windows user profile, **not** inside the distro
and not at `~/.wslconfig`, and does not exist until you create it.
Neither touches slack that already exists. For that, shut WSL down and compact the file by hand
from an **elevated** prompt. `compact vdisk` on its own fails because no virtual disk is selected,
so the `select` and the read-only `attach` are required, not optional:
```
wsl --shutdown
diskpart
DISKPART> select vdisk file="C:\path\to\ext4.vhdx"
DISKPART> attach vdisk readonly
DISKPART> compact vdisk
DISKPART> detach vdisk
DISKPART> exit
```
Two caveats, neither verified here: field reports say `compact vdisk` is a no-op on a vhdx that is
already sparse (convert back with `--set-sparse false` first), and sparse mode's runtime cost was
not measured. Microsoft's [disk-space guide](https://learn.microsoft.com/windows/wsl/disk-space)
carries the current locate/expand/compact procedure and the `--manage` version floor;
[`.wslconfig`](https://learn.microsoft.com/windows/wsl/wsl-config) carries `sparseVhd`. Enabling
sparse mode and compacting are both per-machine decisions; Orca does not make either.
On the measured machine the vhdx was **not** sparse: `fsutil sparse queryflag` reported "NOT set as
sparse", and no `%UserProfile%\.wslconfig` existed to opt in. Microsoft documents `sparseVhd` as
defaulting to `false`, so that is the expected state rather than a local quirk — but the flag is
per-vhdx, set when the disk is created or by an explicit conversion, so check your own distro
rather than assuming either way.
### What this means for Orca
A vhdx that grows as speculative worktree preparation and mirrored worktrees write into the distro
is expected. Its size is monotonically non-decreasing and roughly tracks peak concurrent usage —
but it can drift above peak, and the measurement above is the best case for reuse: the second
gigabyte was allocated immediately after the first was freed, out of the same block group. Under
sustained churn — many worktrees created and removed over weeks, no `fstrim`/discard, sparse off —
allocation spreads and the file settles higher than live peak.
So growth on its own is not evidence of a leak. Growth well above live peak usage is worth
investigating, and is the case the reclaim steps above address.
+11 -4
View File
@@ -18,7 +18,7 @@
"fumadocs-mdx": "^14.3.1",
"fumadocs-ui": "^16.8.4",
"lucide-react": "^1.6.0",
"next": "16.2.1",
"next": "16.3.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0",
@@ -32,10 +32,10 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint-config-next": "16.3.4",
"tailwindcss": "^4",
"typescript": "^5",
"vercel": "50.37.0"
"vercel": "59.11.1"
},
"engines": {
"node": "22.x"
@@ -46,6 +46,13 @@
"esbuild",
"sharp",
"unrs-resolver"
]
],
"overrides": {
"@vercel/fun>tar": "7.5.22",
"@vercel/fun>@tootallnate/once": "2.0.1",
"@vercel/node>undici": "5.29.0",
"@vercel/python-analysis>js-yaml": "4.3.2",
"@vercel/python-analysis>minimatch": "10.2.6"
}
}
}
+1255 -737
View File
File diff suppressed because it is too large Load Diff
+179 -152
View File
@@ -93,7 +93,7 @@ importers:
version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3)
expo-router:
specifier: ^55.0.18
version: 55.0.18(2f99795f9796def5cdb1516a493dc117)
version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
expo-secure-store:
specifier: ^55.0.18
version: 55.0.18(expo@55.0.30)
@@ -178,7 +178,7 @@ importers:
version: 0.25.4
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
happy-dom:
specifier: ^20.11.8
version: 20.11.8
@@ -199,10 +199,10 @@ importers:
version: 6.0.3
vite:
specifier: ^8.0.16
version: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -2897,6 +2897,9 @@ packages:
'@types/node@26.1.2':
resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
'@types/node@26.4.0':
resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==}
'@types/react-native@0.73.0':
resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==}
deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed.
@@ -3356,8 +3359,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
baseline-browser-mapping@2.11.20:
resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3398,8 +3401,8 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
browserslist@4.28.8:
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3448,8 +3451,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
@@ -3941,8 +3944,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.352:
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
electron-to-chromium@1.5.416:
resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==}
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
@@ -5228,6 +5231,10 @@ packages:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
js-yaml@4.3.2:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
jsc-safe-url@0.2.4:
resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==}
@@ -5492,8 +5499,8 @@ packages:
resolution: {integrity: sha512-tnn0J5wzgTgTx2OJy3Cwr1y79bJz4eNgFQd+2HENOs5Vz6QOMnt05z7J+BedIo9wIbpEa0iN9U1nerxyvMRE9g==}
engines: {node: '>=20.19.4'}
metro-babel-transformer@0.84.4:
resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==}
metro-babel-transformer@0.84.5:
resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-cache-key@0.83.7:
@@ -5504,8 +5511,8 @@ packages:
resolution: {integrity: sha512-I38PtcjT4crS5HY9UQ8i6z8S7tJ2WewtPGr/OwS6FcLKfy5T/1hlTaFw+wozUZkEtNpR6Gc0oJuvuKCbSoSN5A==}
engines: {node: '>=20.19.4'}
metro-cache-key@0.84.4:
resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==}
metro-cache-key@0.84.5:
resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-cache@0.83.7:
@@ -5516,8 +5523,8 @@ packages:
resolution: {integrity: sha512-aogMG5WbKzW5000otNjYrS9hIoORzkCI1faPJK+vxQLaf2BorJKBBFe/jl2Tfsi1mZUglS2EBuBt8B3JB7MYDQ==}
engines: {node: '>=20.19.4'}
metro-cache@0.84.4:
resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==}
metro-cache@0.84.5:
resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-config@0.83.7:
@@ -5528,8 +5535,8 @@ packages:
resolution: {integrity: sha512-crNbNy+/B4tCne2+HjUshwvC57gNBQj+V9fFSy3lHH2RlKcLHib3Mil/SfTX8Pfh2fCY5pPuSu2OKa7YiadB+Q==}
engines: {node: '>=20.19.4'}
metro-config@0.84.4:
resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==}
metro-config@0.84.5:
resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-core@0.83.7:
@@ -5540,8 +5547,8 @@ packages:
resolution: {integrity: sha512-NTyOUOQaQKvQgJG9VI2ymN6KTM7gHEqkVFhkPc9bK4BsHSTz/EaXuFBXtC+wtwINLeH9tbusL/jfsSmdEmuU7A==}
engines: {node: '>=20.19.4'}
metro-core@0.84.4:
resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==}
metro-core@0.84.5:
resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-file-map@0.83.7:
@@ -5552,8 +5559,8 @@ packages:
resolution: {integrity: sha512-+W++EUuzEXIfWQEFTWQMVThzhWbnJL4gRNJ9WSHzIAM5pT7gsprUxo9+2hrfirURm/TLvrKwhq37oCECJcDSyQ==}
engines: {node: '>=20.19.4'}
metro-file-map@0.84.4:
resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==}
metro-file-map@0.84.5:
resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-minify-terser@0.83.7:
@@ -5564,8 +5571,8 @@ packages:
resolution: {integrity: sha512-7tU0J5/c7LZaZJwTlOb1xq0NepTFvGzRxigDhZOq4jSE6g0BRHmBqe7XvLH3WcRiJNGy+eshcZr34RpMjb6mmg==}
engines: {node: '>=20.19.4'}
metro-minify-terser@0.84.4:
resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==}
metro-minify-terser@0.84.5:
resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-resolver@0.83.7:
@@ -5576,8 +5583,8 @@ packages:
resolution: {integrity: sha512-piU0NVTI9i37YztDVF5rtn9uxP3NebVT0xZM9NKJ9z0jbigrctUQksi3NoYIeJMvL6Wn2dgAehpcmmnfn+gUwA==}
engines: {node: '>=20.19.4'}
metro-resolver@0.84.4:
resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==}
metro-resolver@0.84.5:
resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-runtime@0.83.7:
@@ -5588,8 +5595,8 @@ packages:
resolution: {integrity: sha512-f7FfeM0pamq8vrvs8aO9KvIUabbUKe0WkHFpLt6Q9yIIIsORqNFwlgJeHGraOFPU7Cxqj5yLXkJu5bT1uwDXvw==}
engines: {node: '>=20.19.4'}
metro-runtime@0.84.4:
resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==}
metro-runtime@0.84.5:
resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-source-map@0.83.7:
@@ -5600,8 +5607,8 @@ packages:
resolution: {integrity: sha512-60Uor7bM+KsVewLkLCcZfkPFCbqjPDdoSmeBuT3+ye+ac80BuLWbbR8DpyxWpUqQeZPdaAT5ZqFgDIs8BNEcVA==}
engines: {node: '>=20.19.4'}
metro-source-map@0.84.4:
resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==}
metro-source-map@0.84.5:
resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-symbolicate@0.83.7:
@@ -5614,8 +5621,8 @@ packages:
engines: {node: '>=20.19.4'}
hasBin: true
metro-symbolicate@0.84.4:
resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==}
metro-symbolicate@0.84.5:
resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
hasBin: true
@@ -5627,8 +5634,8 @@ packages:
resolution: {integrity: sha512-9JRPkvi+m0QH2Y/w5RjCF9mHqOUNFpMFLDDLhKUjhcKESh8Wm3HKDdHXHVldTN1lTToCIabv81nF0zyJzKEJ5g==}
engines: {node: '>=20.19.4'}
metro-transform-plugins@0.84.4:
resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==}
metro-transform-plugins@0.84.5:
resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-transform-worker@0.83.7:
@@ -5639,8 +5646,8 @@ packages:
resolution: {integrity: sha512-Pa2hOfhUmWpI/dmkhsLq8uGyFHK2opoEK7j/YCiRtqNSe0YzzxYguXTNgg8AMiuZfc9LPcB1AhGENS10O5wcIw==}
engines: {node: '>=20.19.4'}
metro-transform-worker@0.84.4:
resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==}
metro-transform-worker@0.84.5:
resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro@0.83.7:
@@ -5653,8 +5660,8 @@ packages:
engines: {node: '>=20.19.4'}
hasBin: true
metro@0.84.4:
resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==}
metro@0.84.5:
resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
hasBin: true
@@ -5763,8 +5770,9 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
node-releases@2.0.54:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -5795,8 +5803,8 @@ packages:
resolution: {integrity: sha512-pk7el+eTOzfSKMAY4QBiiwKzegXn633JQj13y+pW5E5IdS+yV2CfDJ9Hf20K/LKy8nCrP9dAmd7XOk52OJxifA==}
engines: {node: '>=20.19.4'}
ob1@0.84.4:
resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==}
ob1@0.84.5:
resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
object-assign@4.1.1:
@@ -6677,6 +6685,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
terser@5.51.2:
resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==}
engines: {node: '>=10'}
hasBin: true
test-exclude@6.0.0:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
@@ -6862,8 +6875,8 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
update-browserslist-db@1.3.2:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -7301,7 +7314,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.3
'@babel/helper-validator-option': 7.27.1
browserslist: 4.28.2
browserslist: 4.28.8
lru-cache: 5.1.1
semver: 6.3.1
@@ -7309,7 +7322,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.2
browserslist: 4.28.8
lru-cache: 5.1.1
semver: 6.3.1
@@ -8694,7 +8707,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.1
js-yaml: 4.3.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -8773,7 +8786,7 @@ snapshots:
ws: 8.21.3
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117)
expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -8985,7 +8998,7 @@ snapshots:
'@expo/json-file': 10.0.16
'@expo/metro': 55.1.2
'@expo/spawn-async': 1.8.0
browserslist: 4.28.2
browserslist: 4.28.8
chalk: 4.1.2
debug: 4.4.3
getenv: 2.0.0
@@ -9116,7 +9129,7 @@ snapshots:
react: 19.2.8
optionalDependencies:
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117)
expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
react-dom: 19.2.8(react@19.2.8)
transitivePeerDependencies:
- supports-color
@@ -9201,14 +9214,14 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -9281,7 +9294,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.31
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit: 0.1.2
@@ -9975,8 +9988,8 @@ snapshots:
dependencies:
'@react-native/js-polyfills': 0.85.2
'@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7)
metro-config: 0.84.4
metro-runtime: 0.84.4
metro-config: 0.84.5
metro-runtime: 0.84.5
transitivePeerDependencies:
- '@babel/core'
- bufferutil
@@ -10126,7 +10139,7 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)':
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -10136,7 +10149,7 @@ snapshots:
react-test-renderer: 19.2.8(react@19.2.8)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
'@tootallnate/once@2.0.1': {}
@@ -10345,6 +10358,10 @@ snapshots:
dependencies:
undici-types: 8.3.0
'@types/node@26.4.0':
dependencies:
undici-types: 8.3.0
'@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)':
dependencies:
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
@@ -10525,13 +10542,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))':
'@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -10934,7 +10951,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.27: {}
baseline-browser-mapping@2.11.20: {}
better-opn@3.0.2:
dependencies:
@@ -10972,13 +10989,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.28.2:
browserslist@4.28.8:
dependencies:
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001792
electron-to-chromium: 1.5.352
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
baseline-browser-mapping: 2.11.20
caniuse-lite: 1.0.30001810
electron-to-chromium: 1.5.416
node-releases: 2.0.54
update-browserslist-db: 1.3.2(browserslist@4.28.8)
bs-logger@0.2.6:
dependencies:
@@ -11024,7 +11041,7 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001792: {}
caniuse-lite@1.0.30001810: {}
chai@6.2.2: {}
@@ -11174,7 +11191,7 @@ snapshots:
core-js-compat@3.49.0:
dependencies:
browserslist: 4.28.2
browserslist: 4.28.8
cose-base@1.0.3:
dependencies:
@@ -11184,13 +11201,13 @@ snapshots:
dependencies:
layout-base: 2.0.1
create-jest@29.7.0(@types/node@26.1.2):
create-jest@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -11554,7 +11571,7 @@ snapshots:
ee-first@1.1.1: {}
electron-to-chromium@1.5.352: {}
electron-to-chromium@1.5.416: {}
emittery@0.13.1: {}
@@ -12169,7 +12186,7 @@ snapshots:
expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8):
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -12177,7 +12194,7 @@ snapshots:
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@expo/npm-proofread': 1.0.1
'@expo/spawn-async': 1.7.2
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@tsconfig/node18': 18.2.6
'@types/jest': 29.5.14
babel-plugin-dynamic-import-node: 2.3.3
@@ -12185,11 +12202,11 @@ snapshots:
commander: 12.1.0
eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3)
glob: 13.0.6
jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
jest-snapshot-prettier: prettier@2.8.8
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2))
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0))
resolve-workspace-root: 2.0.1
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3)
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- '@babel/core'
@@ -12252,7 +12269,7 @@ snapshots:
- supports-color
- typescript
expo-router@55.0.18(2f99795f9796def5cdb1516a493dc117):
expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e):
dependencies:
'@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
@@ -12289,7 +12306,7 @@ snapshots:
use-latest-callback: 0.2.6(react@19.2.8)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
optionalDependencies:
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
@@ -12950,7 +12967,7 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.2
@@ -12970,16 +12987,16 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-cli@29.7.0(@types/node@26.1.2):
jest-cli@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@26.1.2)
create-jest: 29.7.0(@types/node@26.4.0)
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.3
@@ -12989,7 +13006,7 @@ snapshots:
- supports-color
- ts-node
jest-config@29.7.0(@types/node@26.1.2):
jest-config@29.7.0(@types/node@26.4.0):
dependencies:
'@babel/core': 7.29.7
'@jest/test-sequencer': 29.7.0
@@ -13014,7 +13031,7 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -13069,7 +13086,7 @@ snapshots:
jest-mock: 29.7.0
jest-util: 29.7.0
jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
dependencies:
'@expo/config': 55.0.16(typescript@5.9.3)
'@expo/json-file': 10.0.14
@@ -13080,7 +13097,7 @@ snapshots:
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2))
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0))
json5: 2.2.3
lodash: 4.18.1
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
@@ -13184,7 +13201,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -13212,7 +13229,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
cjs-module-lexer: 1.4.3
collect-v8-coverage: 1.0.3
@@ -13279,11 +13296,11 @@ snapshots:
chalk: 3.0.0
prompts: 2.4.2
jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.1.2)):
jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)):
dependencies:
ansi-escapes: 6.2.1
chalk: 4.1.2
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
jest-regex-util: 29.6.3
jest-watcher: 29.7.0
slash: 5.1.0
@@ -13308,12 +13325,12 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
jest@29.7.0(@types/node@26.1.2):
jest@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@26.1.2)
jest-cli: 29.7.0(@types/node@26.4.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -13333,6 +13350,10 @@ snapshots:
dependencies:
argparse: 2.0.1
js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
jsc-safe-url@0.2.4: {}
jsdom@20.0.3:
@@ -13604,12 +13625,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-babel-transformer@0.84.4:
metro-babel-transformer@0.84.5:
dependencies:
'@babel/core': 7.29.7
flow-enums-runtime: 0.0.6
hermes-parser: 0.35.0
metro-cache-key: 0.84.4
metro-cache-key: 0.84.5
nullthrows: 1.1.1
transitivePeerDependencies:
- supports-color
@@ -13622,7 +13643,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
metro-cache-key@0.84.4:
metro-cache-key@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -13644,12 +13665,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-cache@0.84.4:
metro-cache@0.84.5:
dependencies:
exponential-backoff: 3.1.3
flow-enums-runtime: 0.0.6
https-proxy-agent: 7.0.6
metro-core: 0.84.4
metro-core: 0.84.5
transitivePeerDependencies:
- supports-color
@@ -13683,15 +13704,15 @@ snapshots:
- supports-color
- utf-8-validate
metro-config@0.84.4:
metro-config@0.84.5:
dependencies:
connect: 3.7.0
flow-enums-runtime: 0.0.6
jest-validate: 29.7.0
metro: 0.84.4
metro-cache: 0.84.4
metro-core: 0.84.4
metro-runtime: 0.84.4
metro: 0.84.5
metro-cache: 0.84.5
metro-core: 0.84.5
metro-runtime: 0.84.5
yaml: 2.9.0
transitivePeerDependencies:
- bufferutil
@@ -13710,11 +13731,11 @@ snapshots:
lodash.throttle: 4.1.1
metro-resolver: 0.83.8
metro-core@0.84.4:
metro-core@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
lodash.throttle: 4.1.1
metro-resolver: 0.84.4
metro-resolver: 0.84.5
metro-file-map@0.83.7:
dependencies:
@@ -13744,7 +13765,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-file-map@0.84.4:
metro-file-map@0.84.5:
dependencies:
debug: 4.4.3
fb-watchman: 2.0.2
@@ -13768,10 +13789,10 @@ snapshots:
flow-enums-runtime: 0.0.6
terser: 5.49.1
metro-minify-terser@0.84.4:
metro-minify-terser@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
terser: 5.49.1
terser: 5.51.2
metro-resolver@0.83.7:
dependencies:
@@ -13781,7 +13802,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
metro-resolver@0.84.4:
metro-resolver@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -13795,7 +13816,7 @@ snapshots:
'@babel/runtime': 7.29.7
flow-enums-runtime: 0.0.6
metro-runtime@0.84.4:
metro-runtime@0.84.5:
dependencies:
'@babel/runtime': 7.29.7
flow-enums-runtime: 0.0.6
@@ -13828,15 +13849,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-source-map@0.84.4:
metro-source-map@0.84.5:
dependencies:
'@babel/traverse': 7.29.8
'@babel/types': 7.29.8
flow-enums-runtime: 0.0.6
invariant: 2.2.4
metro-symbolicate: 0.84.4
metro-symbolicate: 0.84.5
nullthrows: 1.1.1
ob1: 0.84.4
ob1: 0.84.5
source-map: 0.5.7
vlq: 1.0.1
transitivePeerDependencies:
@@ -13864,11 +13885,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-symbolicate@0.84.4:
metro-symbolicate@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
invariant: 2.2.4
metro-source-map: 0.84.4
metro-source-map: 0.84.5
nullthrows: 1.1.1
source-map: 0.5.7
vlq: 1.0.1
@@ -13897,7 +13918,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-transform-plugins@0.84.4:
metro-transform-plugins@0.84.5:
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.8
@@ -13948,20 +13969,20 @@ snapshots:
- supports-color
- utf-8-validate
metro-transform-worker@0.84.4:
metro-transform-worker@0.84.5:
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.8
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
flow-enums-runtime: 0.0.6
metro: 0.84.4
metro-babel-transformer: 0.84.4
metro-cache: 0.84.4
metro-cache-key: 0.84.4
metro-minify-terser: 0.84.4
metro-source-map: 0.84.4
metro-transform-plugins: 0.84.4
metro: 0.84.5
metro-babel-transformer: 0.84.5
metro-cache: 0.84.5
metro-cache-key: 0.84.5
metro-minify-terser: 0.84.5
metro-source-map: 0.84.5
metro-transform-plugins: 0.84.5
nullthrows: 1.1.1
transitivePeerDependencies:
- bufferutil
@@ -14059,7 +14080,7 @@ snapshots:
- supports-color
- utf-8-validate
metro@0.84.4:
metro@0.84.5:
dependencies:
'@babel/code-frame': 7.29.7
'@babel/core': 7.29.7
@@ -14076,23 +14097,22 @@ snapshots:
flow-enums-runtime: 0.0.6
graceful-fs: 4.2.11
hermes-parser: 0.35.0
image-size: 1.2.1
invariant: 2.2.4
jest-worker: 29.7.0
jsc-safe-url: 0.2.4
lodash.throttle: 4.1.1
metro-babel-transformer: 0.84.4
metro-cache: 0.84.4
metro-cache-key: 0.84.4
metro-config: 0.84.4
metro-core: 0.84.4
metro-file-map: 0.84.4
metro-resolver: 0.84.4
metro-runtime: 0.84.4
metro-source-map: 0.84.4
metro-symbolicate: 0.84.4
metro-transform-plugins: 0.84.4
metro-transform-worker: 0.84.4
metro-babel-transformer: 0.84.5
metro-cache: 0.84.5
metro-cache-key: 0.84.5
metro-config: 0.84.5
metro-core: 0.84.5
metro-file-map: 0.84.5
metro-resolver: 0.84.5
metro-runtime: 0.84.5
metro-source-map: 0.84.5
metro-symbolicate: 0.84.5
metro-transform-plugins: 0.84.5
metro-transform-worker: 0.84.5
mime-types: 3.0.2
nullthrows: 1.1.1
serialize-error: 2.1.0
@@ -14175,7 +14195,7 @@ snapshots:
node-int64@0.4.0: {}
node-releases@2.0.38: {}
node-releases@2.0.54: {}
normalize-path@3.0.0: {}
@@ -14206,7 +14226,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
ob1@0.84.4:
ob1@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -15212,6 +15232,13 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
terser@5.51.2:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@6.0.0:
dependencies:
'@istanbuljs/schema': 0.1.6
@@ -15267,11 +15294,11 @@ snapshots:
ts-dedent@2.3.0: {}
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3):
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
json5: 2.2.3
lodash.memoize: 4.1.2
@@ -15381,9 +15408,9 @@ snapshots:
unpipe@1.0.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
update-browserslist-db@1.3.2(browserslist@4.28.8):
dependencies:
browserslist: 4.28.2
browserslist: 4.28.8
escalade: 3.2.0
picocolors: 1.1.1
@@ -15442,7 +15469,7 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0):
vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -15450,17 +15477,17 @@ snapshots:
rolldown: 1.1.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
esbuild: 0.25.4
fsevents: 2.3.3
terser: 5.49.1
terser: 5.51.2
tsx: 4.22.4
yaml: 2.9.0
vitest@4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)):
vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -15477,10 +15504,10 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
happy-dom: 20.11.8
jsdom: 20.0.3
transitivePeerDependencies:
@@ -1,16 +1,31 @@
import { readFileSync } from 'node:fs'
const SOURCE_FILES = [
'./terminal-webview-html.ts',
...Array.from(
{ length: 10 },
(_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`
)
] as const
const COMPOSER_FILE = './terminal-webview-html.ts'
const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm
const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm
/** Reads the TypeScript source that assembles the in-WebView document. */
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
/**
* Reads the TypeScript source that assembles the in-WebView document.
*
* Why: the slice list is derived from the composer's own imports rather than duplicated, so a
* new slice cannot join the emitted document while staying invisible to the tests that search
* this source. The count cross-check catches an import shape the regex cannot see.
*/
export function readTerminalWebViewHtmlSource(): string {
return SOURCE_FILES.map((relativePath) =>
readFileSync(new URL(relativePath, import.meta.url), 'utf8')
).join('\n')
const composer = readSource(COMPOSER_FILE)
const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`)
const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length
if (composedCount === 0) {
throw new Error('no composed WebView document slices found')
}
if (slices.length !== composedCount) {
throw new Error(
`WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})`
)
}
return [composer, ...slices.map(readSource)].join('\n')
}
+31 -21
View File
@@ -1,28 +1,38 @@
import { TERMINAL_HTML_FRAGMENT_01 } from './terminal-webview-html/fragment-01'
import { TERMINAL_HTML_FRAGMENT_02 } from './terminal-webview-html/fragment-02'
import { TERMINAL_HTML_FRAGMENT_03 } from './terminal-webview-html/fragment-03'
import { TERMINAL_HTML_FRAGMENT_04 } from './terminal-webview-html/fragment-04'
import { TERMINAL_HTML_FRAGMENT_05 } from './terminal-webview-html/fragment-05'
import { TERMINAL_HTML_FRAGMENT_06 } from './terminal-webview-html/fragment-06'
import { TERMINAL_HTML_FRAGMENT_07 } from './terminal-webview-html/fragment-07'
import { TERMINAL_HTML_FRAGMENT_08 } from './terminal-webview-html/fragment-08'
import { TERMINAL_HTML_FRAGMENT_09 } from './terminal-webview-html/fragment-09'
import { TERMINAL_HTML_FRAGMENT_10 } from './terminal-webview-html/fragment-10'
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling'
import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale'
import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan'
import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue'
import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write'
import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router'
import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction'
import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring'
import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing'
import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry'
import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay'
import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures'
import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close'
export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
// Why: keep the document source stable while each script/style concern remains independently reviewable.
// Why: keep the document source stable while each script/style concern remains independently
// reviewable. Boundaries can only fall where the emitted document allows, so a few modules
// carry a second concern noted at the top of the file.
export const XTERM_HTML = [
TERMINAL_HTML_FRAGMENT_01,
TERMINAL_HTML_FRAGMENT_02,
TERMINAL_HTML_FRAGMENT_03,
TERMINAL_HTML_FRAGMENT_04,
TERMINAL_HTML_FRAGMENT_05,
TERMINAL_HTML_FRAGMENT_06,
TERMINAL_HTML_FRAGMENT_07,
TERMINAL_HTML_FRAGMENT_08,
TERMINAL_HTML_FRAGMENT_09,
TERMINAL_HTML_FRAGMENT_10
TERMINAL_HTML_DOCUMENT_SHELL,
TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING,
TERMINAL_HTML_FIT_SCALE,
TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN,
TERMINAL_HTML_WRITE_QUEUE,
TERMINAL_HTML_INIT_AND_WRITE,
TERMINAL_HTML_HOST_MESSAGE_ROUTER,
TERMINAL_HTML_SELECTION_STATE_AND_EVICTION,
TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING,
TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING,
TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY,
TERMINAL_HTML_SELECTION_OVERLAY,
TERMINAL_HTML_SURFACE_TOUCH_GESTURES,
TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE
].join('')
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
@@ -1,7 +1,7 @@
import { colors } from '../../theme/mobile-theme'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
export const TERMINAL_HTML_FRAGMENT_01 = `<!DOCTYPE html>
export const TERMINAL_HTML_DOCUMENT_SHELL = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
@@ -1,287 +0,0 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
export const TERMINAL_HTML_FRAGMENT_03 = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -1,132 +0,0 @@
export const TERMINAL_HTML_FRAGMENT_10 = ` updateTransform();
}
var deltaY = ts.lastY - y;
ts.lastTime = now;
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt);
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt);
} else {
ts.velY = 0;
}
}
ts.lastX = x;
ts.lastY = y;
}
}, { capture: true, passive: false });
targetSurface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
// Why: a finished pinch snaps to the nearest preset and becomes the new
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
// size change reflows columns and RN persists + resizes the PTY to match.
var target = snapToTextScalePreset(currentTextScale * userScale);
var changed = target !== currentTextScale;
userScale = 1;
panX = 0; panY = 0;
applyTextScale(target);
updateTransform();
notify({ type: 'font-scale-changed', fontScale: target });
if (changed) notify({ type: 'haptic', kind: 'selection' });
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
if (e.touches.length === 0) {
var vel = ts.velY;
var FRICTION = 0.972;
var MIN_VEL = 0.012;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var delta = vel * 16;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += delta;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null;
return;
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}
}
}, { capture: true, passive: true });
}
attachSurfaceEventHandlers(surface);
function handleIncomingMessage(e) {
var msg;
try {
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
} catch (ex) {
return;
}
try {
handleMsg(msg);
} catch(ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
ex,
msg && msg.type === 'init' && !everReady
);
}
}
window.addEventListener('message', handleIncomingMessage);
document.addEventListener('message', handleIncomingMessage);
window.addEventListener('resize', function() {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize');
adjustRowsForViewport();
repositionOverlay();
clampPan();
updateTransform();
});
if (window.Terminal) {
notify({ type: 'web-ready' });
} else {
reportEngineError('terminal engine missing', 'xterm failed to load', true);
}
})();
</script>
</body>
</html>`
@@ -1,6 +1,6 @@
import { TERMINAL_REFLOW_JS } from '../terminal-webview-reflow-injected'
export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS}
export const TERMINAL_HTML_HOST_MESSAGE_ROUTER = ` ${TERMINAL_REFLOW_JS}
function notify(msg) {
if (window.ReactNativeWebView) {
@@ -186,45 +186,4 @@ export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS}
}
}
// ============================================================
// SELECTION MODE (long-press → handles → Copy)
// ============================================================
var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u;
var LONG_PRESS_MS = 500;
var LONG_PRESS_SLOP = 10;
// Why: a tap that opens a link/path must survive small finger jitter. The
// long-press slop (10px) only cancels the press-to-select timer; reusing it
// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
// window so deliberate scrolls/pans still don't fire a tap.
var TAP_SLOP = 24;
var TAP_MAX_MS = 700;
var EDGE_SCROLL_PX = 40;
var EDGE_SCROLL_INTERVAL = 60;
var selectionOverlay = document.getElementById('selection-overlay');
var handleStart = document.getElementById('sel-handle-start');
var handleEnd = document.getElementById('sel-handle-end');
var selMenu = document.getElementById('sel-menu');
var btnCopy = document.getElementById('sel-menu-copy');
var btnSelAll = document.getElementById('sel-menu-all');
// mode: 'navigate' | 'select'
var selMode = 'navigate';
var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
var longPressTimer = null;
var longPressOrigin = null; // {x,y, identifier}
// Why: tap detection is tracked separately from the long-press timer so a
// small jitter that cancels the press-to-select timer does not also cancel
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
// gesture is disqualified as a tap (moved too far or held too long).
var tapCandidate = null;
var edgeScrollTimer = null;
var edgeScrollDir = 0;
var edgeScrollClientX = 0;
var edgeScrollClientY = 0;
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
`
@@ -0,0 +1,43 @@
export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handleIncomingMessage(e) {
var msg;
try {
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
} catch (ex) {
return;
}
try {
handleMsg(msg);
} catch(ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
ex,
msg && msg.type === 'init' && !everReady
);
}
}
window.addEventListener('message', handleIncomingMessage);
document.addEventListener('message', handleIncomingMessage);
window.addEventListener('resize', function() {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize');
adjustRowsForViewport();
repositionOverlay();
clampPan();
updateTransform();
});
if (window.Terminal) {
notify({ type: 'web-ready' });
} else {
reportEngineError('terminal engine missing', 'xterm failed to load', true);
}
})();
</script>
</body>
</html>`
@@ -0,0 +1,52 @@
export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
`
@@ -1,100 +1,6 @@
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected'
import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected'
export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0;
function resetEvictionCounter() { linesEverWritten = 0; }
function isBufferFull() {
if (!term) return false;
return linesEverWritten >= 5000 + (term.rows || 0);
}
function checkEviction() {
if (selMode !== 'select' || !sel) return;
var oldest = Math.min(sel.anchor.row, sel.focus.row);
if (oldest < 0) {
notify({ type: 'selection-evicted' });
cancelSelect();
}
}
function logFeedAndEvict() {
linesEverWritten++;
if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1;
if (selMode === 'select' && sel && isBufferFull()) {
sel.anchor.row -= 1;
sel.focus.row -= 1;
checkEviction();
repositionOverlay();
}
}
function emitModesIfChanged() {
if (!term) return;
var bp = !!(term.modes && term.modes.bracketedPasteMode);
var alt = false;
var mouseTrackingMode = getMouseTrackingMode();
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
if (
bp !== lastEmittedModes.bracketedPasteMode ||
alt !== lastEmittedModes.altScreen ||
mouseTrackingMode !== lastEmittedModes.mouseTrackingMode ||
sgrMouseMode !== lastEmittedModes.sgrMouseMode ||
sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode
) {
lastEmittedModes = {
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
};
notify({
type: 'modes',
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
});
}
}
var lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}
function attachTermObservers() {
if (!term) return;
disposeTermObservers();
try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {}
try {
termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); }));
} catch (e) {}
// Why: emit modes on every parsed write so RN's mirror stays current
// without round-trip; covers \\x1b[?2004h/l and alt-screen toggles.
try {
if (term.onWriteParsed) {
termObserverDisposables.push(term.onWriteParsed(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
}));
}
} catch (e) {}
// Initial emit once buffer settles.
afterWritesDrained(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
});
}
function viewportToCell(clientX, clientY) {
export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
@@ -201,4 +107,82 @@ export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0;
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M';
if (mouseTrackingMode === 'x10') return sgrPress;
return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm';
}
// Why: non-SGR click coordinates use printable ASCII bytes on the mobile
// bridge; unsafe wide-terminal cells must not turn into corrupted input.
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
if (col > 126 || row > 126) return '';
var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row);
if (mouseTrackingMode === 'x10') return press;
return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isClickMouseTrackingMode(mode) {
return mode !== 'none';
}
function isWheelMouseTrackingMode(mode) {
return mode !== 'none' && mode !== 'x10';
}
function shouldRouteScrollToTerminalInput() {
return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();
}
function buildMouseWheelScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var sequence = buildMouseWheelSequence(lines, clientX, clientY);
if (!sequence) return '';
return repeatSequence(sequence, count);
}
function buildTuiScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var mouseTrackingMode = getMouseTrackingMode();
var sequence = '';
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY);
}
if (!sequence) sequence = buildArrowScrollSequence(lines);
return repeatSequence(sequence, count);
}
function routeScrollLines(lines, clientX, clientY) {
if (!term || lines === 0) return;
var mouseTrackingMode = getMouseTrackingMode();
var alternateBufferActive = isAlternateBufferActive();
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY);
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput });
return;
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
var fallbackInput = buildTuiScrollInput(lines, clientX, clientY);
if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput });
return;
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
var input = buildTuiScrollInput(lines, clientX, clientY);
if (input) notify({ type: 'terminal-input', bytes: input });
return;
}
term.scrollLines(lines);
}
`
@@ -3,7 +3,8 @@ import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injec
import { TERMINAL_TEXT_SCALES } from '../../storage/preferences'
import { DEFAULT_TERMINAL_THEME } from './theme'
export const TERMINAL_HTML_FRAGMENT_02 = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
// Also carries the scroll-indicator painter, which reads the scale state declared here.
export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
${TERMINAL_SURFACE_SWAP_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
@@ -1,7 +1,8 @@
import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected'
import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap'
export const TERMINAL_HTML_FRAGMENT_08 = ` ${TERMINAL_PATH_TAP_JS}
// Opens with the path/url tap matchers: they land at this point in the emitted document.
export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS}
${URL_TAP_WEBVIEW_JS}
function seedWordSelection(col, absRow) {
@@ -0,0 +1,71 @@
export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================
// SELECTION MODE (long-press → handles → Copy)
// ============================================================
var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u;
var LONG_PRESS_MS = 500;
var LONG_PRESS_SLOP = 10;
// Why: a tap that opens a link/path must survive small finger jitter. The
// long-press slop (10px) only cancels the press-to-select timer; reusing it
// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
// window so deliberate scrolls/pans still don't fire a tap.
var TAP_SLOP = 24;
var TAP_MAX_MS = 700;
var EDGE_SCROLL_PX = 40;
var EDGE_SCROLL_INTERVAL = 60;
var selectionOverlay = document.getElementById('selection-overlay');
var handleStart = document.getElementById('sel-handle-start');
var handleEnd = document.getElementById('sel-handle-end');
var selMenu = document.getElementById('sel-menu');
var btnCopy = document.getElementById('sel-menu-copy');
var btnSelAll = document.getElementById('sel-menu-all');
// mode: 'navigate' | 'select'
var selMode = 'navigate';
var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
var longPressTimer = null;
var longPressOrigin = null; // {x,y, identifier}
// Why: tap detection is tracked separately from the long-press timer so a
// small jitter that cancels the press-to-select timer does not also cancel
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
// gesture is disqualified as a tap (moved too far or held too long).
var tapCandidate = null;
var edgeScrollTimer = null;
var edgeScrollDir = 0;
var edgeScrollClientX = 0;
var edgeScrollClientY = 0;
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
var linesEverWritten = 0;
function resetEvictionCounter() { linesEverWritten = 0; }
function isBufferFull() {
if (!term) return false;
return linesEverWritten >= 5000 + (term.rows || 0);
}
function checkEviction() {
if (selMode !== 'select' || !sel) return;
var oldest = Math.min(sel.anchor.row, sel.focus.row);
if (oldest < 0) {
notify({ type: 'selection-evicted' });
cancelSelect();
}
}
function logFeedAndEvict() {
linesEverWritten++;
if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1;
if (selMode === 'select' && sel && isBufferFull()) {
sel.anchor.row -= 1;
sel.focus.row -= 1;
checkEviction();
repositionOverlay();
}
}
`
@@ -1,82 +1,4 @@
export const TERMINAL_HTML_FRAGMENT_07 = ` var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M';
if (mouseTrackingMode === 'x10') return sgrPress;
return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm';
}
// Why: non-SGR click coordinates use printable ASCII bytes on the mobile
// bridge; unsafe wide-terminal cells must not turn into corrupted input.
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
if (col > 126 || row > 126) return '';
var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row);
if (mouseTrackingMode === 'x10') return press;
return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isClickMouseTrackingMode(mode) {
return mode !== 'none';
}
function isWheelMouseTrackingMode(mode) {
return mode !== 'none' && mode !== 'x10';
}
function shouldRouteScrollToTerminalInput() {
return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();
}
function buildMouseWheelScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var sequence = buildMouseWheelSequence(lines, clientX, clientY);
if (!sequence) return '';
return repeatSequence(sequence, count);
}
function buildTuiScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var mouseTrackingMode = getMouseTrackingMode();
var sequence = '';
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY);
}
if (!sequence) sequence = buildArrowScrollSequence(lines);
return repeatSequence(sequence, count);
}
function routeScrollLines(lines, clientX, clientY) {
if (!term || lines === 0) return;
var mouseTrackingMode = getMouseTrackingMode();
var alternateBufferActive = isAlternateBufferActive();
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY);
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput });
return;
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
var fallbackInput = buildTuiScrollInput(lines, clientX, clientY);
if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput });
return;
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
var input = buildTuiScrollInput(lines, clientX, clientY);
if (input) notify({ type: 'terminal-input', bytes: input });
return;
}
term.scrollLines(lines);
}
function clampNormalScrollLines(lines) {
export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) {
if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0;
var buffer = term.buffer.active;
if (lines > 0) {
@@ -2,7 +2,8 @@ import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injec
import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected'
import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected'
export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS}
// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document.
export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS}
// External mouse / trackpad scroll: see
// terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
@@ -135,4 +136,93 @@ export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS}
if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) {
panX += x - ts.lastX;
clampPan();
updateTransform();
}
var deltaY = ts.lastY - y;
ts.lastTime = now;
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt);
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt);
} else {
ts.velY = 0;
}
}
ts.lastX = x;
ts.lastY = y;
}
}, { capture: true, passive: false });
targetSurface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
// Why: a finished pinch snaps to the nearest preset and becomes the new
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
// size change reflows columns and RN persists + resizes the PTY to match.
var target = snapToTextScalePreset(currentTextScale * userScale);
var changed = target !== currentTextScale;
userScale = 1;
panX = 0; panY = 0;
applyTextScale(target);
updateTransform();
notify({ type: 'font-scale-changed', fontScale: target });
if (changed) notify({ type: 'haptic', kind: 'selection' });
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
if (e.touches.length === 0) {
var vel = ts.velY;
var FRICTION = 0.972;
var MIN_VEL = 0.012;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var delta = vel * 16;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += delta;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null;
return;
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}
}
}, { capture: true, passive: true });
}
attachSurfaceEventHandlers(surface);
`
@@ -0,0 +1,67 @@
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected'
export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() {
if (!term) return;
var bp = !!(term.modes && term.modes.bracketedPasteMode);
var alt = false;
var mouseTrackingMode = getMouseTrackingMode();
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
if (
bp !== lastEmittedModes.bracketedPasteMode ||
alt !== lastEmittedModes.altScreen ||
mouseTrackingMode !== lastEmittedModes.mouseTrackingMode ||
sgrMouseMode !== lastEmittedModes.sgrMouseMode ||
sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode
) {
lastEmittedModes = {
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
};
notify({
type: 'modes',
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
});
}
}
var lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}
function attachTermObservers() {
if (!term) return;
disposeTermObservers();
try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {}
try {
termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); }));
} catch (e) {}
// Why: emit modes on every parsed write so RN's mirror stays current
// without round-trip; covers \\x1b[?2004h/l and alt-screen toggles.
try {
if (term.onWriteParsed) {
termObserverDisposables.push(term.onWriteParsed(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
}));
}
} catch (e) {}
// Initial emit once buffer settles.
afterWritesDrained(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
});
}
`
@@ -0,0 +1,130 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
// Opens with the injected theme block: it lands at this point in the emitted document.
export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
`
@@ -1,7 +1,7 @@
import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected'
import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme'
export const TERMINAL_HTML_FRAGMENT_04 = `${TERMINAL_WEBGL_RECOVERY_JS}
export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS}
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
@@ -0,0 +1,110 @@
// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to
// other concerns, but emitted-document order pins them inside this queue.
export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -0,0 +1,17 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
// Why: every other WebView test exercises one slice of the document, so an edit to an
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608'
const EXPECTED_LENGTH = 729776
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
expect(XTERM_HTML.length).toBe(EXPECTED_LENGTH)
expect(createHash('sha256').update(XTERM_HTML, 'utf8').digest('hex')).toBe(EXPECTED_SHA256)
})
})
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in
// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file.
@@ -8,16 +9,7 @@ const source =
readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') +
Array.from({ length: 10 }, (_, index) =>
readFileSync(
new URL(
`./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`,
import.meta.url
),
'utf8'
)
).join('')
readTerminalWebViewHtmlSource()
const sessionSource = readFileSync(
new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url),
'utf8'
@@ -11,8 +11,8 @@ const terminalHtmlModuleSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
const terminalHtmlFragmentSource = readFileSync(
new URL('./terminal-webview-html/fragment-01.ts', import.meta.url),
const terminalHtmlDocumentShellSource = readFileSync(
new URL('./terminal-webview-html/document-shell.ts', import.meta.url),
'utf8'
)
// Read behavior from the assembled document; the module source only contains
@@ -150,7 +150,7 @@ describe('TerminalWebView text zoom', () => {
})
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
@@ -59,7 +59,8 @@ function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null {
// fail with EADDRINUSE; the full scenario restarts on the captured port
// because the client keeps reconnecting to its original URL.
function startServer(port = 0): Promise<WebSocketServer> {
const wss = new WebSocketServer({ port })
// host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here.
const wss = new WebSocketServer({ host: '127.0.0.1', port })
wss.on('connection', (ws: ServerSocket) => {
let sharedKey: Uint8Array | null = null
let authenticated = false
+15 -2
View File
@@ -11,6 +11,19 @@ set -e
link="/usr/bin/orca-ide"
is_owned_link() {
[ -L "$link" ] || return 1
local link_target candidate candidate_target
link_target="$(readlink -f -- "$link" 2>/dev/null || true)"
for candidate in /opt/Orca/resources/bin/orca-ide /opt/orca-ide/resources/bin/orca-ide /opt/orca/resources/bin/orca-ide; do
candidate_target="$(readlink -f -- "$candidate" 2>/dev/null || true)"
if [ -n "$candidate_target" ] && [ "$link_target" = "$candidate_target" ]; then
return 0
fi
done
return 1
}
for dir in /opt/Orca /opt/orca-ide /opt/orca; do
sandbox="$dir/chrome-sandbox"
if [ -f "$sandbox" ]; then
@@ -22,8 +35,8 @@ for dir in /opt/Orca /opt/orca-ide /opt/orca; do
shim="$dir/resources/bin/orca-ide"
if [ -x "$shim" ]; then
# Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide.
if [ ! -e "$link" ] || [ -L "$link" ]; then
ln -sf "$shim" "$link"
if { [ ! -e "$link" ] && [ ! -L "$link" ]; } || is_owned_link; then
ln -sfn -- "$shim" "$link"
fi
break
fi
+54 -18
View File
@@ -1,5 +1,5 @@
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { join, relative, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch'
@@ -9,6 +9,32 @@ import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest'
// group. These tests are the only thing standing between that trust and a
// silently unreachable command, so they load every group for real.
// Why: __dirname works under both Vitest and the CommonJS tsc emit that
// build:cli type-checks this file against; import.meta.dirname does not.
const HANDLERS_DIR = join(__dirname, 'handlers')
// Why: both the plural records and the single-command `*_HANDLER` ones that
// nested modules export get spread into a group, so both must route.
const HANDLER_RECORD_EXPORT = /_HANDLERS?$/
function listHandlerModules(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
return listHandlerModules(path)
}
return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : []
})
}
function isHandlerRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
Object.values(value).every((entry) => typeof entry === 'function')
)
}
describe('handler group manifest', () => {
it('lists a loadable group for every entry', async () => {
for (const group of HANDLER_GROUPS) {
@@ -54,25 +80,35 @@ describe('handler group manifest', () => {
})
// Why: dropping a group from the manifest silently unregisters its commands —
// scan the directory so a new or forgotten handler file fails here, not in prod.
it('registers every handler module that exports a handler group', async () => {
// Why: __dirname works under both Vitest and the CommonJS tsc emit that
// build:cli type-checks this file against; import.meta.dirname does not.
const dir = join(__dirname, 'handlers')
const modules = readdirSync(dir).filter(
(file) => file.endsWith('.ts') && !file.endsWith('.test.ts')
)
const registered = new Set(HANDLER_GROUPS.map((group) => group.name))
const missing: string[] = []
for (const file of modules) {
const name = file.slice(0, -'.ts'.length)
const exports: Record<string, unknown> = await import(join(dir, file))
const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS'))
if (exportsGroup && !registered.has(name)) {
missing.push(name)
// walk the tree so a new or forgotten handler file fails here, not in prod.
// Nested modules are spread into a parent group rather than registered under
// their own name, so routability, not file name, is the invariant that holds.
it('routes every command exported by a handler module', async () => {
const routes = buildHandlerRoutes(HANDLER_GROUPS)
const unroutable: string[] = []
for (const file of listHandlerModules(HANDLERS_DIR)) {
const exports: Record<string, unknown> = await import(file)
for (const [name, value] of Object.entries(exports)) {
if (!HANDLER_RECORD_EXPORT.test(name) || !isHandlerRecord(value)) {
continue
}
for (const key of Object.keys(value)) {
if (!routes.has(key)) {
unroutable.push(`${relative(HANDLERS_DIR, file)} ${name}: ${key}`)
}
}
}
}
expect(missing).toEqual([])
expect(unroutable).toEqual([])
})
it('finds the modules it is meant to guard', () => {
// Why: a walk that missed the tree would make the guard above vacuously pass.
const modules = listHandlerModules(HANDLERS_DIR)
expect(modules.length).toBeGreaterThanOrEqual(40)
expect(
modules.filter((file) => relative(HANDLERS_DIR, file).includes(sep)).length
).toBeGreaterThanOrEqual(7)
})
})
+5 -2
View File
@@ -57,8 +57,11 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite',
// both ends of the contract are only readable statically. Without this leg the rewrite could
// emit a name nothing reads and every behavioural assertion above would still pass.
const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8')
const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const start = mainSource.indexOf('function getServeOptions(')
const mainSource = readFileSync(
join(process.cwd(), 'src/main/startup/main-process-serve.ts'),
'utf8'
)
const start = mainSource.indexOf('export function getServeOptions(')
// Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously.
expect(start).toBeGreaterThanOrEqual(0)
const end = mainSource.indexOf('\n}', start)
+5
View File
@@ -117,6 +117,11 @@ export class AgentAwakeService {
}
}
/** Agents this runtime has seen working recently, independent of the awake setting. */
getWorkingAgentCount(): number {
return this.getEligibleRunningStatusCount()
}
subscribe(listener: (status: ComputerAwakeStatus) => void): () => void {
this.statusListeners.add(listener)
return () => this.statusListeners.delete(listener)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,222 @@
import { movePaneCacheState } from '../../../shared/agent-hook-listener/listener-state'
import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../../shared/pane-key-alias'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { PANE_KEY_ALIASES_MAX } from './server-constants'
import type { EnrichedAgentHookEventPayload, PaneKeyAliasPersistenceListener } from './server-types'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import { isValidPaneKey } from './server-status-identity'
import { AgentHookServerAuthorityEvidence } from './server-authority-evidence'
export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAuthorityEvidence {
setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void {
this.paneKeyAliasPersistenceListener = listener
}
protected getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] {
return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) =>
entry.ptyId
? [
{
ptyId: entry.ptyId,
legacyPaneKey,
stablePaneKey: entry.stablePaneKey,
updatedAt: entry.updatedAt
}
]
: []
)
}
protected notifyPaneKeyAliasPersistenceListener(): void {
this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases())
}
protected boundPaneKeyAliases(): void {
while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) {
// Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup.
const oldestKey = this.legacyPaneKeyAliases.keys().next().value
if (!oldestKey) {
break
}
this.legacyPaneKeyAliases.delete(oldestKey)
}
}
protected getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
let fallbackPaneKey = paneKey
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
if (
entry.stablePaneKey === ownerPaneKey &&
(!ptyId || !entry.ptyId || entry.ptyId === ptyId)
) {
if (entry.authorityVerified) {
return physicalPaneKey
}
fallbackPaneKey = physicalPaneKey
}
}
return fallbackPaneKey
}
canTransferPaneAuthority(
fromPaneKey: string,
ptyId: string | undefined,
ownsPty: (physicalPaneKey: string, ptyId: string) => boolean
): boolean {
if (!isValidPaneKey(fromPaneKey)) {
return false
}
const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey)
const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId)
const alias = this.legacyPaneKeyAliases.get(physicalPaneKey)
if (ptyId) {
return Boolean(
(alias?.authorityVerified && alias.ptyId === ptyId) ||
ownsPty(physicalPaneKey, ptyId) ||
(ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId))
)
}
// Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias.
return alias?.authorityVerified === true
}
registerPaneKeyAlias(
legacyPaneKey: string,
stablePaneKey: string,
ptyId?: string,
updatedAt = Date.now(),
options?: { overwriteExisting?: boolean; authorityVerified?: boolean }
): void {
const fromPaneKey = legacyPaneKey.trim()
const toPaneKey = stablePaneKey.trim()
if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) {
return
}
const existing = this.legacyPaneKeyAliases.get(fromPaneKey)
if (existing && options?.overwriteExisting === false) {
return
}
// Why: remint tokens have no embedded tab id; first pane wins so a later spawn
// cannot steal leftover $$…:L$$ posts onto a different tab:leaf.
if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) {
return
}
const normalizedPtyId =
typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId
const normalizedUpdatedAt =
Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now())
const authorityVerified = options?.authorityVerified ?? false
if (
existing &&
existing.stablePaneKey === toPaneKey &&
existing.ptyId === (normalizedPtyId ?? null) &&
existing.updatedAt === normalizedUpdatedAt &&
existing.authorityVerified === authorityVerified
) {
return
}
this.legacyPaneKeyAliases.set(fromPaneKey, {
stablePaneKey: toPaneKey,
ptyId: normalizedPtyId ?? null,
updatedAt: normalizedUpdatedAt,
authorityVerified
})
this.boundPaneKeyAliases()
if (normalizedPtyId) {
this.notifyPaneKeyAliasPersistenceListener()
}
}
transferPaneAuthority(
fromPaneKey: string,
toPaneKey: string,
ptyId?: string,
updatedAt = Date.now(),
options?: { authorityVerified?: boolean }
): void {
if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) {
return
}
const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey)
const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId)
const existing = this.legacyPaneKeyAliases.get(physicalPaneKey)
const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null
const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey)
movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey)
const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (movedStatus) {
const owner = parsePaneKey(toPaneKey)
this.state.lastStatusByPaneKey.set(toPaneKey, {
...movedStatus,
paneKey: toPaneKey,
tabId: owner?.tabId
})
}
const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
if (hydratedLaunchTokenHash) {
this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash)
}
const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey)
if (persistedAuthority) {
const owner = parsePaneKey(toPaneKey)
this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey)
this.persistedAuthorityCommitmentsByPaneKey.set(
toPaneKey,
Object.freeze({
...persistedAuthority,
paneKey: toPaneKey,
...(owner?.tabId ? { tabId: owner.tabId } : {})
})
)
}
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
}
const restartedTokenHash =
this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey)
if (restartedTokenHash) {
this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash)
}
const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey)
if (activeTurnCompletedAt !== undefined) {
this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey)
this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt)
}
const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey)
if (authorityObservation) {
const owner = parsePaneKey(toPaneKey)
this.currentAuthorityObservations.delete(previousOwnerPaneKey)
this.currentAuthorityObservations.set(
toPaneKey,
Object.freeze({ ...authorityObservation, paneKey: toPaneKey, tabId: owner?.tabId })
)
}
const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey)
if (promptDedupe !== undefined) {
this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey)
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
}
this.clearAssistantMessageRetry(previousOwnerPaneKey)
this.clearCodexSubagentPoll(previousOwnerPaneKey)
// Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner.
this.legacyPaneKeyAliases.set(physicalPaneKey, {
stablePaneKey: toPaneKey,
ptyId: normalizedPtyId,
updatedAt,
authorityVerified: options?.authorityVerified ?? true
})
this.boundPaneKeyAliases()
this.closedAgentStatusPaneKeys.delete(toPaneKey)
this.notifyPaneKeyAliasPersistenceListener()
if (hadStatus || persistedAuthority) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
}
}
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type {
AgentHookAuthorityAttestation,
AgentHookAuthorityEvidence,
EnrichedAgentHookEventPayload
} from './server-types'
import { AgentHookServerStatusRetries } from './server-status-retries'
export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerStatusRetries {
attestCompatibilityAuthority(candidate: {
paneKey: string
launchTokenHash: string
connectionId: string | null
terminalProvenance: 'current_runtime' | 'restored'
}): AgentHookAuthorityAttestation | null {
const paneKey = this.resolvePaneKeyAlias(candidate.paneKey)
const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean =>
entry.launchTokenHash === candidate.launchTokenHash &&
entry.connectionId === candidate.connectionId
const commitments = this.hydratedAuthorityCommitments.filter(
(entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry)
)
const current = Array.from(this.currentAuthorityObservations.values())
const observations = current.filter(matchesCandidate)
const paneObservations = current.filter(
(entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey
)
const hasUniqueCurrentObservation =
observations.length === 1 &&
paneObservations.length === 1 &&
this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey
if (candidate.terminalProvenance === 'current_runtime') {
return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null
}
if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) {
return null
}
if (observations.length === 0 && paneObservations.length === 0) {
return Object.freeze({ paneKey, source: 'hydrated_commitment' })
}
if (!hasUniqueCurrentObservation) {
return null
}
return Object.freeze({ paneKey, source: 'current_hook' })
}
protected captureHydratedAuthorityCommitments(): void {
this.revokedHydratedAuthorityCommitments = new WeakSet()
for (const entry of this.state.lastStatusByPaneKey.values()) {
const evidence = this.toAuthorityEvidence(
entry as EnrichedAgentHookEventPayload,
this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey)
)
if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) {
this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence)
}
}
this.hydratedAuthorityCommitments = Object.freeze(
Array.from(this.persistedAuthorityCommitmentsByPaneKey.values())
)
}
protected recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void {
const evidence = this.toAuthorityEvidence(payload)
if (evidence) {
this.currentAuthorityObservations.set(evidence.paneKey, evidence)
this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence)
this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash)
}
}
protected toAuthorityEvidence(
payload: AgentHookEventPayload | EnrichedAgentHookEventPayload,
launchTokenHashOverride?: string
): AgentHookAuthorityEvidence | null {
const launchToken = payload.launchToken?.trim()
const launchTokenHash =
launchTokenHashOverride ??
(launchToken ? createHash('sha256').update(launchToken).digest('hex') : null)
if (!launchTokenHash) {
return null
}
return Object.freeze({
paneKey: payload.paneKey,
launchTokenHash,
connectionId: payload.connectionId,
...(payload.tabId ? { tabId: payload.tabId } : {}),
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}),
observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now()
})
}
}
@@ -0,0 +1,193 @@
import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { AgentHookServerAuthorityAliases } from './server-authority-aliases'
import type { RetiredPaneAlias, RetiredPaneFence } from './server-types'
export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases {
// Why: retirement fences a pane and every alias of it, then deletes those aliases.
retirePaneAuthority(paneKey: string): void {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
const paneKeys = new Set([paneKey, ownerPaneKey])
const retiredAliases: RetiredPaneAlias[] = []
let aliasChanged = false
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) {
this.legacyPaneKeyAliases.delete(physicalPaneKey)
retiredAliases.push({ physicalPaneKey, entry })
paneKeys.add(physicalPaneKey)
paneKeys.add(entry.stablePaneKey)
aliasChanged = true
}
}
this.recordRetiredPaneFence(paneKeys, retiredAliases)
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
for (const key of paneKeys) {
this.markPaneClosedForAgentStatus(key)
this.restartedStatusLaunchTokenHashByPaneKey.delete(key)
this.clearAssistantMessageRetry(key)
this.clearCodexSubagentPoll(key)
clearPaneCacheState(this.state, key)
this.activeHookTurnCompletedAtByPaneKey.delete(key)
this.runtimeObservedStatusPaneKeys.delete(key)
this.currentAuthorityObservations.delete(key)
this.promptSentDedupeByPaneKey.delete(key)
this.observations.forget(key)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (hadStatus || authorityChanged) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
}
// Why: retirement fences a pane and every alias of it, then deletes those aliases.
// Lifting only the key we are handed strands the rest — a detached pane's process
// keeps posting the key it launched under, so it would stay suppressed forever with
// the fence apparently lifted. Replay the recorded fence instead: same key set, same
// aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger
// claim survives and a live process is never routed back into a closed tab.
protected restoreRetiredPaneFence(fence: RetiredPaneFence): void {
let aliasChanged = false
for (const { physicalPaneKey, entry } of fence.aliases) {
if (
this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) ||
this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) ||
// Why: the pane was rebound in the meantime; the newer alias is the truth.
this.legacyPaneKeyAliases.has(physicalPaneKey)
) {
continue
}
this.legacyPaneKeyAliases.set(physicalPaneKey, entry)
aliasChanged = true
}
for (const key of fence.paneKeys) {
if (this.retiredPaneFencesByKey.get(key) === fence) {
this.retiredPaneFencesByKey.delete(key)
}
}
if (aliasChanged) {
this.boundPaneKeyAliases()
this.notifyPaneKeyAliasPersistenceListener()
}
}
restorePaneAuthority(paneKey: string): boolean {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) {
return false
}
// Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that
// exact pane disproves the claim at the moment it stops being true, so the fence
// lifts here instead of waiting for the agent to speak again — an agent re-attached
// mid-turn or left idle would otherwise stay suppressed for the rest of its life
// (STA-4114). A closed *tab* is a separate, stronger claim and is left standing.
const fence =
this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey)
let restored = false
for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) {
if (this.isClosedAgentStatusTabForPaneKey(key)) {
continue
}
if (this.closedAgentStatusPaneKeys.delete(key)) {
restored = true
}
}
if (fence) {
this.restoreRetiredPaneFence(fence)
}
return restored
}
clearPaneKeyAliasesForPty(
ptyId: string,
options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean }
): void {
let aliasChanged = false
let statusChanged = false
const clearedStatusPaneKeys = new Set<string>()
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
if (entry.ptyId !== ptyId) {
continue
}
const shouldClearStablePaneKey =
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
const revokedPaneKeys = new Set([legacyPaneKey])
if (shouldClearStablePaneKey) {
revokedPaneKeys.add(entry.stablePaneKey)
}
if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) {
statusChanged = true
}
this.legacyPaneKeyAliases.delete(legacyPaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey)
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
statusChanged = true
clearedStatusPaneKeys.add(entry.stablePaneKey)
}
if (shouldClearStablePaneKey) {
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
clearPaneCacheState(this.state, entry.stablePaneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey)
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
this.currentAuthorityObservations.delete(entry.stablePaneKey)
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
}
aliasChanged = true
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (statusChanged) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
for (const paneKey of clearedStatusPaneKeys) {
this.emitPaneStatusCleared({ paneKey })
}
}
}
protected resolvePaneKeyAlias(paneKey: string): string {
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
}
protected revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet<string>): boolean {
let changed = false
for (const commitment of this.hydratedAuthorityCommitments) {
if (
paneKeys.has(commitment.paneKey) ||
paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey))
) {
this.revokedHydratedAuthorityCommitments.add(commitment)
changed = true
}
}
for (const paneKey of paneKeys) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed
changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed
}
return changed
}
protected normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
if (typeof body !== 'object' || body === null) {
return body
}
const record = body as Record<string, unknown>
const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : ''
const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey
if (!stablePaneKey) {
return body
}
// Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner.
return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId }
}
}
@@ -0,0 +1,191 @@
import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster'
import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { EnrichedAgentHookEventPayload } from './server-types'
export function attachClaudeChildOnlyBoundary(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } {
const establishesBoundary =
next.payload.agentType === 'claude' &&
(next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') &&
!next.toolAgentId &&
next.payload.state === 'working' &&
next.payload.subagents?.some((subagent) => subagent.state === 'working') === true &&
next.claudeRunningNonAgentTask === false
const carriesBoundary =
previous?.claudeLeadBoundaryChildOnly === true &&
next.payload.agentType === 'claude' &&
next.claudeRunningNonAgentTask === false &&
(next.toolAgentId !== undefined ||
next.hookEventName === 'SubagentStart' ||
next.hookEventName === 'SubagentStop' ||
next.hookEventName === 'TeammateIdle')
return establishesBoundary || carriesBoundary
? { ...next, claudeLeadBoundaryChildOnly: true }
: next
}
export function invalidateClaudeChildOnlyBoundary(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): EnrichedAgentHookEventPayload | undefined {
if (
previous?.claudeLeadBoundaryChildOnly !== true ||
attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true
) {
return previous
}
const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous
return withoutBoundary
}
export function shouldKeepClaudePermissionVisible(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): boolean {
if (previous?.restoredUnconfirmed) {
return false
}
if (
previous?.payload.agentType !== 'claude' ||
previous.payload.state !== 'waiting' ||
previous.hookEventName !== 'PermissionRequest' ||
next.payload.agentType !== 'claude' ||
next.payload.state !== 'working'
) {
return false
}
if (next.hasExplicitPrompt === true) {
return false
}
if (isClaudePermissionOwningChildEnding(previous, next)) {
return false
}
if (isClaudePermissionResumingApprovedTool(previous, next)) {
return false
}
// Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides.
if (isAskUserQuestionTool(previous.payload.toolName)) {
return false
}
return true
}
function isClaudePermissionOwningChildEnding(
previous: EnrichedAgentHookEventPayload,
next: AgentHookEventPayload
): boolean {
const ownerId = previous.toolAgentId?.trim()
if (!ownerId) {
return false
}
if (next.hookEventName === 'SubagentStop') {
return ownerId === next.toolAgentId?.trim()
}
return (
next.hookEventName === 'TeammateIdle' &&
next.teammateName !== undefined &&
claudeTeammateIdMatchesName(ownerId, next.teammateName)
)
}
function isClaudePermissionResumingApprovedTool(
previous: EnrichedAgentHookEventPayload,
next: AgentHookEventPayload
): boolean {
const previousToolUseId = previous.toolUseId?.trim() || undefined
const nextToolUseId = next.toolUseId?.trim() || undefined
const previousAgentId = previous.toolAgentId?.trim() || undefined
const nextAgentId = next.toolAgentId?.trim() || undefined
const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined
const previousAgentType = previous.toolAgentType?.trim() || undefined
const nextAgentType = next.toolAgentType?.trim() || undefined
const hasMatchingConcreteAgentId =
previousAgentId !== undefined && previousAgentId === nextAgentId
const hasSameExplicitAgentType =
!hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType
const sameToolName =
previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName
const sameKnownToolInput =
previous.payload.toolInput !== undefined &&
previous.payload.toolInput === next.payload.toolInput
const sameUnknownInputFromConcreteAgent =
hasMatchingConcreteAgentId &&
previous.payload.toolInput === undefined &&
next.payload.toolInput === undefined
const hasMatchingToolUseId =
previousToolUseId !== undefined && previousToolUseId === nextToolUseId
const hasConflictingToolUseId =
previousToolUseId !== undefined &&
nextToolUseId !== undefined &&
previousToolUseId !== nextToolUseId
const sameUnknownInputFromToolUseId =
hasMatchingToolUseId &&
previous.payload.toolInput === undefined &&
next.payload.toolInput === undefined
return (
(next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') &&
nextToolUseId !== undefined &&
!hasConflictingToolUseId &&
// Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal.
(hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) &&
sameToolName &&
(sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId)
)
}
export function shouldInheritClaudeToolUseIdForPermission(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): boolean {
if (
previous?.restoredUnconfirmed ||
previous?.payload.agentType !== 'claude' ||
previous.payload.state !== 'working' ||
previous.hookEventName !== 'PreToolUse' ||
typeof previous.toolUseId !== 'string' ||
previous.toolUseId.trim().length === 0 ||
next.payload.agentType !== 'claude' ||
next.payload.state !== 'waiting' ||
next.hookEventName !== 'PermissionRequest' ||
next.toolUseId !== undefined
) {
return false
}
const sameKnownToolInput =
previous.payload.toolInput !== undefined &&
previous.payload.toolInput === next.payload.toolInput
const sameUnknownToolInput =
previous.payload.toolInput === undefined && next.payload.toolInput === undefined
if (
previous.toolAgentId !== next.toolAgentId ||
previous.toolAgentType !== next.toolAgentType ||
previous.payload.toolName === undefined ||
previous.payload.toolName !== next.payload.toolName ||
(!sameKnownToolInput && !sameUnknownToolInput)
) {
return false
}
return true
}
export function attachClaudePermissionToolUseId(
previous: EnrichedAgentHookEventPayload | undefined,
next: AgentHookEventPayload
): AgentHookEventPayload {
const inheritedToolUseId = previous?.toolUseId
if (
!shouldInheritClaudeToolUseIdForPermission(previous, next) ||
typeof inheritedToolUseId !== 'string'
) {
return next
}
return {
...next,
// Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id.
toolUseId: inheritedToolUseId
}
}
@@ -0,0 +1,167 @@
import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AgentHookServerAuthorityFences } from './server-authority-fences'
export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFences {
/** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state
* claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live
* claims. Returns null when the row has no resumable session to keep. */
protected toRetainedProviderSessionRow(
entry: EnrichedAgentHookEventPayload | null | undefined
): EnrichedAgentHookEventPayload | null {
if (
!entry?.providerSession ||
!entry.payload.agentType ||
entry.payload.agentType === 'unknown'
) {
return null
}
const { launchToken: _launchToken, ...resumeIdentity } = entry
return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true }
}
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
dropStatusEntry(paneKey: string): void {
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
if (!deleted) {
return
}
const retained = this.toRetainedProviderSessionRow(deleted)
if (retained) {
this.state.lastStatusByPaneKey.set(deleted.paneKey, retained)
}
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.emitStatusDropped(deleted.paneKey)
}
/** Retire panes whose owning process is certifiably dead.
*
* The ordinary teardown already does this: every attributable PTY exit reaches
* `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that
* resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may
* never rebuild — so those panes keep a `working` row and its latches for good, with no hook left
* to retire them. This is the same operation reached from the runtime's own pane-key knowledge,
* so a dead pane is cleaned up identically however its keys were resolved. */
reconcileEndedProcessForPaneKeys(
paneKeys: Iterable<string>,
options?: {
/** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still
* be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop`
* minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to
* resume into, and dropping it matches what `clearProviderPtyState` already does. */
preserveResumeIdentity?: boolean
}
): number {
// A certified PTY exit passes no resume identity; a surviving shell may opt into the remnant.
let cleared = 0
for (const paneKey of paneKeys) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) {
continue
}
const retained = options?.preserveResumeIdentity
? this.toRetainedProviderSessionRow(
this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
)
: null
this.clearPaneState(resolvedPaneKey)
if (retained) {
this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
cleared += 1
}
return cleared
}
/** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through
* `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list
* itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a
* different file. */
protected hasLiveClaimsForPaneKey(paneKey: string): boolean {
return paneHasStateClaims(this.state, paneKey)
}
/** Clear statuses proven to belong to one lost SSH transport. */
clearStatusEntriesForConnection(connectionId: string): void {
const normalizedConnectionId = connectionId.trim()
if (normalizedConnectionId.length === 0) {
return
}
const clearedAt = Math.max(
Date.now(),
(this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1
)
this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt)
let statusChanged = false
for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) {
const entry = rawEntry as EnrichedAgentHookEventPayload
// Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown.
if (entry.connectionId !== normalizedConnectionId) {
continue
}
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
if (deleted) {
statusChanged = true
if (deleted.payload.agentType === 'codex') {
// Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children.
this.state.codexSubagentRosterByPaneKey.delete(paneKey)
this.state.codexLeadStateByPaneKey.delete(paneKey)
} else if (deleted.payload.agentType === 'claude') {
this.state.claudeSubagentRosterByPaneKey.delete(paneKey)
this.state.claudeLeadStateByPaneKey.delete(paneKey)
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
this.state.claudeActiveSessionCronPaneKeys.delete(paneKey)
this.state.claudeSessionOwnerByPaneKey.delete(paneKey)
}
}
}
for (const [paneKey, evidence] of this.currentAuthorityObservations) {
if (evidence.connectionId === normalizedConnectionId) {
this.currentAuthorityObservations.delete(paneKey)
}
}
if (statusChanged) {
// Why: persist/notify once — one disconnect can own many panes.
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
// Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row.
this.emitPaneStatusCleared({
transient: true,
connectionId: normalizedConnectionId,
clearedAt
})
}
protected deleteStatusEntry(
paneKey: string,
options?: { preserveAuthority?: boolean }
): EnrichedAgentHookEventPayload | null {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (!existing) {
return null
}
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
if (!options?.preserveAuthority) {
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
}
this.clearAssistantMessageRetry(resolvedPaneKey)
this.clearCodexSubagentPoll(resolvedPaneKey)
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
this.currentAuthorityObservations.delete(resolvedPaneKey)
if (existing.payload.state === 'done') {
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
}
return existing
}
}
@@ -0,0 +1,29 @@
import { AGENT_KIND_VALUES, type AgentKind } from '../../../shared/telemetry-events'
// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together.
export const LAST_STATUS_FILE_NAME = 'last-status.json'
export const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
export const ASSISTANT_MESSAGE_RETRY_MS = 50
export const CODEX_SUBAGENT_POLL_MS = 1_000
export const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt).
export const LAST_STATUS_FILE_VERSION = 2
// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush.
export const STATUS_PERSIST_DEBOUNCE_MS = 250
export const TOOL_PROGRESS_HOOK_EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'PostToolUseFailure'
])
export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate.
export const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session.
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
export const PANE_KEY_ALIASES_MAX = 1024
export const RETIRED_PANE_FENCES_MAX = 1024
@@ -0,0 +1,162 @@
import { readFileSync } from 'node:fs'
import {
seedClaudeLeadTurnFromPersistedStatus,
seedClaudeSubagentRosterFromSnapshots
} from '../../../shared/agent-hook-listener/providers/claude-roster-state'
import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state'
import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants'
import type { LastStatusFile } from './server-types'
import {
authorityCommitmentsMatch,
dropHydratedIdleClaudeSubagents,
readPersistedLaunchTokenHash,
sanitizeHydratedEntry,
sanitizePersistedAuthorityCommitment
} from './server-persistence-validation'
import { AgentHookServerReaping } from './server-reaping'
export abstract class AgentHookServerHydration extends AgentHookServerReaping {
/** Hydrate the durable cache, validating every row before it reaches the live listener state. */
protected hydrateLastStatusFromDisk(): void {
if (!this.lastStatusFilePath) {
return
}
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
this.state.lastStatusByPaneKey.clear()
this.hydratedLaunchTokenHashByPaneKey.clear()
this.persistedAuthorityCommitmentsByPaneKey.clear()
let raw: string
try {
raw = readFileSync(this.lastStatusFilePath, 'utf8')
} catch (err) {
// Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[agent-hooks] failed to read last-status file:', err)
}
return
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
console.warn('[agent-hooks] last-status file is not valid JSON; ignoring')
return
}
if (typeof parsed !== 'object' || parsed === null) {
console.warn('[agent-hooks] last-status file is not an object; ignoring')
return
}
const file = parsed as Partial<LastStatusFile>
if (file.version !== LAST_STATUS_FILE_VERSION) {
console.warn(
`[agent-hooks] last-status file version mismatch (${String(
file.version
)} != ${LAST_STATUS_FILE_VERSION}); ignoring`
)
return
}
const entries = file.entries
if (typeof entries !== 'object' || entries === null) {
console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring')
return
}
let hydrated = 0
let dropped = 0
let prunedLegacyClaudeSubagents = 0
let scrubbedLegacyLaunchTokens = 0
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
for (const [paneKey, rawEntry] of Object.entries(entries)) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const rawResolvedEntry =
resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null
? rawEntry
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
if (entry && entry.receivedAt >= ttlCutoff) {
const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry)
if (launchTokenHash) {
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash)
const evidence = this.toAuthorityEvidence(entry, launchTokenHash)
if (evidence) {
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence)
}
}
if (
typeof rawResolvedEntry === 'object' &&
rawResolvedEntry !== null &&
typeof (rawResolvedEntry as Record<string, unknown>).launchToken === 'string'
) {
scrubbedLegacyLaunchTokens += 1
}
const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload)
if (hydratedPayload !== entry.payload) {
prunedLegacyClaudeSubagents +=
(entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0)
entry.payload = hydratedPayload
}
if (entry.payload.state !== 'done') {
// Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth.
entry.restoredUnconfirmed = true
}
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
if (entry.connectionId) {
// Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state.
const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId)
this.connectionTimestampWatermarkById.set(
entry.connectionId,
Math.max(previousWatermark ?? -1, entry.receivedAt)
)
}
// Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds.
if (entry.payload.agentType === 'codex') {
seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload)
} else if (entry.payload.agentType === 'claude') {
seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, {
childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true
})
if (entry.payload.subagents) {
seedClaudeSubagentRosterFromSnapshots(
this.state,
resolvedPaneKey,
entry.payload.subagents
)
}
}
hydrated += 1
} else {
dropped += 1
}
}
for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment)
if (!commitment || commitment.observedAt < ttlCutoff) {
dropped += 1
continue
}
const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey)
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
dropped += 1
continue
}
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment)
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash)
}
if (dropped > 0) {
console.warn(
`[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})`
)
}
if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) {
// Why: persist load-time pruning and bearer scrubbing once.
this.runStatusPersist()
} else if (hydrated > 0) {
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
this.lastWrittenJson = raw
}
}
}
@@ -0,0 +1,81 @@
import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { normalizeHookPayload } from '../../../shared/agent-hook-listener'
import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay'
import type { NormalizedLocalHook } from './server-types'
import { AgentHookServerPersistence } from './server-persistence'
export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence {
protected setClaudeBackgroundEvidence(
paneKey: string,
hasRunningTask: boolean,
hasActiveCron: boolean
): void {
if (hasRunningTask) {
this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey)
} else {
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
if (hasActiveCron) {
this.state.claudeActiveSessionCronPaneKeys.add(paneKey)
} else {
this.state.claudeActiveSessionCronPaneKeys.delete(paneKey)
}
}
protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook {
if (source !== 'claude' || typeof body !== 'object' || body === null) {
return { event: normalizeHookPayload(this.state, source, body, this.env) }
}
const rawPaneKey = (body as Record<string, unknown>).paneKey
const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : ''
if (!paneKey) {
return { event: normalizeHookPayload(this.state, source, body, this.env) }
}
const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)
const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
const event = normalizeHookPayload(this.state, source, body, this.env)
const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey)
const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey)
this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron)
if (!event || event.paneKey !== paneKey) {
return { event }
}
// Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate.
return {
event,
onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron)
}
}
// Spool records are durable replay evidence, not a live observation.
protected ingestSpoolRecord(record: SpoolRecord): void {
if (!isAgentHookSource(record.source)) {
return
}
const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record))
const normalized = this.normalizeLocalHookPayload(record.source, body)
if (!normalized.event) {
return
}
const replay = { ...normalized.event, isReplay: true as const }
const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, {
source: record.source,
hookEventName: replay.hookEventName,
isReplay: true,
hasExplicitPrompt: replay.hasExplicitPrompt,
launchToken: replay.launchToken
})
if (statusDisposition === 'suppress') {
return
}
const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay
if (statusDisposition === 'restart') {
this.observations.rebind(event.paneKey)
}
this.recordCurrentAuthorityObservation(event)
this.applyNormalizedStatus(event, normalized.onAccepted)
if (event.payload.state !== 'done') {
this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey))
}
}
}
@@ -0,0 +1,282 @@
import { track } from '../../telemetry/client'
import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types'
import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume'
import { isAgentHookSource, restoreShedStatusFields } from '../../../shared/agent-hook-relay'
import {
MAX_PANE_KEY_LEN,
normalizeClaudePromptId,
warnOnHookEnvOrVersionMismatch
} from '../../../shared/agent-hook-listener/listener-limits'
import {
canAcceptClaudeCompactCompletion,
isClaudeCompactCompletionConsumed,
markClaudeCompactCompletionConsumed,
resolveLegacyCompactTrigger
} from '../../../shared/claude-compact-completion'
import { launchTokenHash } from '../../../shared/agent-hook-spool'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import { isValidPiProviderSessionOnly } from './server-status-identity'
import { AgentHookServerIngestTerminal } from './server-ingest-terminal'
export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestTerminal {
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
ingestRemote(
envelope: {
paneKey: string
tabId?: string
worktreeId?: string
env?: string
version?: string
launchToken?: string
hasExplicitPrompt?: boolean
promptInteractionKey?: string
hookEventName?: string
source?: unknown
providerPromptId?: unknown
compactTrigger?: unknown
toolUseId?: string
toolAgentId?: string
teammateName?: string
toolAgentType?: string
providerSession?: unknown
providerSessionOnly?: unknown
isReplay?: boolean
/** Payload fields the relay dropped to fit an oversized frame; validated below. */
shedFields?: unknown
claudeRunningNonAgentTask?: unknown
payload: unknown
},
connectionId: string | null
): void {
// Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches.
if (connectionId !== null && typeof connectionId !== 'string') {
return
}
const trimmedConnectionId = connectionId?.trim() ?? null
if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) {
return
}
if (!envelope || typeof envelope.paneKey !== 'string') {
return
}
// Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge.
const physicalPaneKey = envelope.paneKey.trim()
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
const parsedPaneKey = parsePaneKey(paneKey)
if (paneKey.length === 0) {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
return
}
if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) {
return
}
// Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state.
if (envelope.isReplay === true) {
const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
const actualLaunchTokenHash = launchTokenHash(envelope.launchToken)
if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) {
return
}
}
if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') {
return
}
if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') {
return
}
// Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined.
const reportedTabId =
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
? envelope.tabId.trim()
: undefined
if (
paneKey === physicalPaneKey &&
reportedTabId !== undefined &&
reportedTabId !== parsedPaneKey.tabId
) {
return
}
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
const hookEventName =
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
const providerPromptId =
source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined
const compactTrigger =
source === 'claude' &&
(envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto')
? envelope.compactTrigger
: undefined
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
source,
rawSource: envelope.source,
hookEventName,
isReplay: envelope.isReplay === true,
hasExplicitPrompt: envelope.hasExplicitPrompt === true,
launchToken: envelope.launchToken
})
if (statusDisposition === 'suppress') {
return
}
if (statusDisposition === 'restart') {
// Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session.
// Why paneKey, not envelope.paneKey: alias resolution already mapped it to the
// stable pane, so the rebind cannot land on a legacy key.
this.observations.rebind(paneKey)
}
const worktreeId =
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
? envelope.worktreeId.trim()
: undefined
const promptInteractionKey =
typeof envelope.promptInteractionKey === 'string' &&
envelope.promptInteractionKey.trim().length > 0
? envelope.promptInteractionKey.trim()
: undefined
const toolUseId =
typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0
? envelope.toolUseId.trim()
: undefined
const toolAgentId =
typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0
? envelope.toolAgentId.trim()
: undefined
const teammateName =
typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0
? envelope.teammateName.trim()
: undefined
const toolAgentType =
typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0
? envelope.toolAgentType.trim()
: undefined
const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined
// Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed).
const validatedPayload = normalizeAgentStatusPayload(envelope.payload)
if (!validatedPayload) {
return
}
// Why: restore a shed roster only when its digest and turn identity still match the cache.
let normalizedPayload = restoreShedStatusFields(
validatedPayload,
envelope.shedFields,
this.state.lastStatusByPaneKey.get(paneKey)?.payload
)
const previousStatus = this.state.lastStatusByPaneKey.get(paneKey)
let acceptedCompactCompletion = false
if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') {
// Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone);
// reject it here too so a host on any version cannot drive pane state from it.
if (hookEventName === 'PreCompact' || source !== 'claude') {
return
}
// Why: a relay predating this change strips `compactTrigger` from its cached PostCompact
// before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is
// fixed and known — manual produced `done`, auto produced `working` — so the payload state
// stands in for the missing trigger. Trigger substitution only; ownership is still checked.
const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state)
// Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older
// relay maps it to `working`, and this ingest applies the relay's payload verbatim — so
// without this drop, every auto compact on such a host mints exactly the stuck `working` this
// change removes.
if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) {
return
}
if (
isClaudeCompactCompletionConsumed(
this.state.claudeConsumedCompactPromptIdByPaneKey,
paneKey,
providerPromptId
) ||
!canAcceptClaudeCompactCompletion(previousStatus, {
source,
connectionId: trimmedConnectionId,
providerPromptId,
providerSession
})
) {
return
}
markClaudeCompactCompletionConsumed(
this.state.claudeConsumedCompactPromptIdByPaneKey,
paneKey,
providerPromptId
)
// Why: an older relay built this payload before the boundary flag existed, so it arrives as a
// plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the
// boundary here so a compact stays silent regardless of which relay normalized it.
if (normalizedPayload.sessionBoundary !== true) {
normalizedPayload = { ...normalizedPayload, sessionBoundary: true }
}
acceptedCompactCompletion = true
}
// Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the
// trigger-stripped replay is exactly the shape that arrives without one, and it is still the
// compact's own promptless event, so it still needs the summarized turn's label.
if (
source === 'claude' &&
(compactTrigger !== undefined || acceptedCompactCompletion) &&
normalizedPayload.prompt.length === 0 &&
previousStatus?.payload.prompt
) {
normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt }
}
if (
envelope.providerSessionOnly === true &&
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
) {
return
}
const applyClaudeBackgroundWork =
normalizedPayload.agentType === 'claude' &&
typeof envelope.claudeRunningNonAgentTask === 'boolean' &&
// Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime.
(envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey))
// Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected.
warnOnHookEnvOrVersionMismatch(this.state, {
version: envelope.version,
env: envelope.env,
expectedEnv: this.env
})
const event = {
paneKey,
source,
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
tabId,
worktreeId,
connectionId: trimmedConnectionId,
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
promptInteractionKey,
hookEventName,
providerPromptId,
compactTrigger,
toolUseId,
toolAgentId,
teammateName,
toolAgentType,
providerSession,
providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
claudeRunningNonAgentTask:
typeof envelope.claudeRunningNonAgentTask === 'boolean'
? envelope.claudeRunningNonAgentTask
: undefined,
payload: normalizedPayload
} as AgentHookEventPayload
this.recordCurrentAuthorityObservation(event)
this.applyNormalizedStatus(
event,
applyClaudeBackgroundWork
? () => {
if (envelope.claudeRunningNonAgentTask) {
this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey)
} else {
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
}
: undefined
)
}
}
@@ -0,0 +1,104 @@
import { track } from '../../telemetry/client'
import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence'
import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AgentHookServerIngestNormalization } from './server-ingest-normalization'
export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization {
ingestTerminalStatus(event: {
paneKey: string
tabId?: string
worktreeId?: string
connectionId?: string | null
payload: ParsedAgentStatusPayload
}): void {
const physicalPaneKey = event.paneKey.trim()
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
const parsedPaneKey = parsePaneKey(paneKey)
if (paneKey.length === 0) {
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
return
}
if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) {
return
}
const reportedTabId =
event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined
if (
paneKey === physicalPaneKey &&
reportedTabId !== undefined &&
reportedTabId !== parsedPaneKey.tabId
) {
return
}
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
if (this.getAgentStatusDisposition(paneKey) !== 'accept') {
return
}
const worktreeId =
event.worktreeId !== undefined && event.worktreeId.trim().length > 0
? event.worktreeId.trim()
: undefined
const connectionId =
typeof event.connectionId === 'string' && event.connectionId.trim().length > 0
? event.connectionId.trim()
: null
const previous = this.state.lastStatusByPaneKey.get(paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (
previous?.claudeLeadBoundaryChildOnly === true &&
previous.payload.agentType === 'claude' &&
event.payload.agentType === 'claude'
) {
// Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives.
return
}
// Why: preserve the hook-completed turn stamp while OSC repaints the current state.
const preserveActiveTurnStamp =
previous?.payload.turnCompletedAt !== undefined &&
previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey)
if (
!previous?.restoredUnconfirmed &&
previous?.connectionId === connectionId &&
previous.tabId === tabId &&
previous.worktreeId === worktreeId &&
terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp)
) {
return
}
// Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is
// never evidence that the session ended — yet overwriting the row dropped the cached identity.
// That erased it from persisted rows (lost across restart) and from headless `orca serve`, which
// serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630).
// A new turn after `done` still starts clean so a reused pane cannot inherit a finished session.
// Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an
// omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so
// it must not be read as a mismatch and strip the session the renderer would have kept.
const claimedAgentType =
event.payload.agentType && event.payload.agentType !== 'unknown'
? event.payload.agentType
: undefined
const preservedProviderSession =
previous?.providerSession &&
(claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) &&
(previous.payload.state !== 'done' || event.payload.state === 'done')
? previous.providerSession
: undefined
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
this.applyNormalizedStatus(
{
paneKey,
tabId,
worktreeId,
connectionId,
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
payload: event.payload
},
undefined,
'osc'
)
}
}
@@ -0,0 +1,197 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { randomUUID } from 'node:crypto'
import {
CLAUDE_STATUSLINE_PATHNAME,
parseClaudeStatusLineBody
} from '../../../shared/claude-statusline-rate-limits'
import { mergeAgentHookRequestHeaders } from '../../../shared/agent-hook-listener/hook-envelope'
import { readRequestBody } from '../../../shared/agent-hook-listener/request-body'
import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing'
import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits'
import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference'
import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state'
import { trackEmptyPaneKeyHook } from './server-transport-rules'
import { AgentHookServerRuntimeEnv } from './server-runtime-env'
export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv {
/** Start the loopback listener after hydration and spool replay have settled. */
async start(options?: {
env?: string
userDataPath?: string
endpointNamespace?: string
}): Promise<void> {
if (this.server) {
return
}
if (options?.env) {
this.env = options.env
}
if (options?.userDataPath) {
// Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect.
this.configureEndpointPaths(options.userDataPath, options.endpointNamespace)
}
this.token = randomUUID()
this.endpointFileWritten = false
this.lastWrittenJson = null
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.captureHydratedAuthorityCommitments()
// Drain before binding the listener so replay cannot race a live hook during startup.
if (this.endpointDir) {
drainAgentHookSpool({
endpointDir: this.endpointDir,
getPersistedLaunchTokenHash: (paneKey) =>
this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)),
ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record)
})
}
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'POST') {
res.writeHead(404)
res.end()
return
}
// Why: authenticate before spending work reading an untrusted body.
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
res.writeHead(403)
res.end()
return
}
// Why: bound request time so a stalled client can't hold a socket open (slowloris).
// Why: track our own destroy so the slowloris cap can't be misread as outside interference.
let destroyedBySlowlorisCap = false
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
destroyedBySlowlorisCap = true
req.destroy()
})
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
try {
const body = await readRequestBody(req)
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
const statusLineEvent = parseClaudeStatusLineBody(body)
if (statusLineEvent) {
this.onClaudeStatusLine?.(statusLineEvent)
}
res.writeHead(204)
res.end()
return
}
const source = resolveHookSource(pathname)
if (!source) {
res.writeHead(404)
res.end()
return
}
// Why: merge transport headers before normalization so relay-compatible fields have one canonical path.
const hookBody = mergeAgentHookRequestHeaders(body, req.headers)
trackEmptyPaneKeyHook(hookBody)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody)
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
source,
hookEventName: normalized.event.hookEventName,
isReplay: normalized.event.isReplay,
hasExplicitPrompt: normalized.event.hasExplicitPrompt,
launchToken: normalized.event.launchToken
})
: 'suppress'
if (normalized.event && statusDisposition !== 'suppress') {
const event =
statusDisposition === 'restart'
? { ...normalized.event, launchToken: undefined }
: normalized.event
if (statusDisposition === 'restart') {
// Why: a retired pane accepting a new turn is a different agent session behind the
// same key — later observations must not be ordered against the retired one.
this.observations.rebind(event.paneKey)
}
this.recordCurrentAuthorityObservation(event)
const enriched = this.applyNormalizedStatus(event, normalized.onAccepted)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
}
res.writeHead(204)
res.end()
} catch (error) {
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
// this is the one failure mode that silently stops status for every runtime at once.
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error })
}
// Why: fail open — return success on malformed payloads so a broken hook never blocks the agent.
res.writeHead(204)
res.end()
}
}
// Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects.
this.server = createServer((req, res) => {
void handleRequest(req, res)
})
await new Promise<void>((resolve, reject) => {
const onStartupError = (err: Error): void => {
// Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event.
this.server?.off('listening', onListening)
reject(err)
}
const onListening = (): void => {
this.server?.off('error', onStartupError)
this.server?.on('error', (err) => {
console.error('[agent-hooks] server error', err)
})
const address = this.server!.address()
if (address && typeof address === 'object') {
this.port = address.port
}
this.maybeWriteEndpointFile()
resolve()
}
this.server!.once('error', onStartupError)
this.server!.listen(0, '127.0.0.1', onListening)
})
}
stop(): void {
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
this.flushStatusPersistSync()
this.server?.close()
this.server = null
this.port = 0
this.token = ''
this.env = 'production'
this.onAgentStatus = null
this.onPaneStatusCleared = null
for (const timer of this.assistantMessageRetryTimers.values()) {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
this.clearAllCodexSubagentPolls()
this.endpointDir = null
this.endpointFilePathCache = null
this.endpointFileWritten = false
this.lastStatusFilePath = null
this.lastWrittenJson = null
this.runtimeObservedStatusPaneKeys.clear()
this.hydratedAuthorityCommitments = Object.freeze([])
this.hydratedLaunchTokenHashByPaneKey.clear()
this.persistedAuthorityCommitmentsByPaneKey.clear()
this.revokedHydratedAuthorityCommitments = new WeakSet()
this.currentAuthorityObservations.clear()
this.promptSentDedupeByPaneKey.clear()
this.closedAgentStatusTabIds.clear()
this.closedAgentStatusPaneKeys.clear()
this.restartedStatusLaunchTokenHashByPaneKey.clear()
this.retiredPaneFencesByKey.clear()
this.connectionTimestampWatermarkById.clear()
this.legacyPaneKeyAliases.clear()
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
clearAllListenerCaches(this.state)
this.notifyStatusChangeListeners()
}
}
@@ -0,0 +1,218 @@
import type {
AgentStatusClearIpcPayload,
AgentStatusIpcPayload
} from '../../../shared/agent-status-types'
import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits'
import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference'
import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state'
import type {
AgentHookAuthorityEvidence,
AgentHookProviderSessionIdentity,
AgentHookStatusChangeEntry,
EnrichedAgentHookEventPayload,
StatusDropListener
} from './server-types'
import { toAgentStatusIpcPayload } from './server-status-identity'
import { AgentHookServerState } from './server-state'
export abstract class AgentHookServerListeners extends AgentHookServerState {
/**
* Notified once per process when repeated hook POSTs are cut off mid-body (#11217).
* Why: the listener fails open on every request error, so without this the only symptom is
* agent status quietly going stale — for every runtime at once, since they share this transport.
*/
setTransportInterferenceListener(
listener: ((report: HookTransportInterferenceReport) => void) | null
): void {
this.onTransportInterference = listener
}
setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void {
this.onAgentStatus = listener
if (!listener) {
return
}
// Why: replay is best-effort per pane so one throwing listener can't starve the rest.
for (const payload of this.state.lastStatusByPaneKey.values()) {
try {
// Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it.
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
} catch (err) {
console.error('[agent-hooks] replay listener threw', err)
}
}
}
// Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly.
setClaudeStatusLineListener(
listener: ((event: ClaudeStatusLineRateLimits) => void) | null
): void {
this.onClaudeStatusLine = listener
}
subscribeStatusChanges(listener: (statuses: AgentHookStatusChangeEntry[]) => void): () => void {
this.statusChangeListeners.add(listener)
return () => {
this.statusChangeListeners.delete(listener)
}
}
subscribeProviderSessionChanges(
listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void
): () => void {
this.providerSessionChangeListeners.add(listener)
return () => {
this.providerSessionChangeListeners.delete(listener)
}
}
/** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user
* dismissal, so it never routes through the pane-status-clear fan-out — pane-owned
* cleanup (synthetic spinners) still has to retire with the row it was driving. */
subscribeStatusDrop(listener: StatusDropListener): () => void {
this.statusDropListeners.add(listener)
return () => {
this.statusDropListeners.delete(listener)
}
}
protected emitStatusDropped(paneKey: string): void {
for (const listener of this.statusDropListeners) {
// Why: matches every other fan-out here — one throwing subscriber must not strand the rest.
try {
listener(paneKey)
} catch (err) {
console.error('[agent-hooks] status-drop listener threw', err)
}
}
}
/** Multi-subscriber tap on every enriched status change (no replay). */
subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void {
this.enrichedStatusListeners.add(listener)
return () => {
this.enrichedStatusListeners.delete(listener)
}
}
/** Replay is durable evidence from a prior runtime, not a live observation. */
protected withdrawReplayObservation(paneKey: string): void {
if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) {
this.notifyStatusChangeListeners()
}
}
setPaneStatusClearListener(listener: ((clear: AgentStatusClearIpcPayload) => void) | null): void {
this.onPaneStatusCleared = listener
}
/** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener`
* (a single slot the main window owns and drops on close) this survives window
* teardown and exists at all under headless serve, which never opens one. */
subscribePaneStatusClear(listener: (clear: AgentStatusClearIpcPayload) => void): () => void {
this.paneStatusClearListeners.add(listener)
return () => {
this.paneStatusClearListeners.delete(listener)
}
}
protected emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void {
this.onPaneStatusCleared?.(clear)
for (const listener of this.paneStatusClearListeners) {
// Why: callers are pane/connection teardown paths; one throwing subscriber must
// not strand the rest, matching every other fan-out here.
try {
listener(clear)
} catch (err) {
console.error('[agent-hooks] pane-status-clear listener threw', err)
}
}
}
/** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
* dashboard catches up on hook events that fired during startup. */
getStatusSnapshot(): AgentStatusIpcPayload[] {
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
)
}
/** Provider-session identities, including Pi's metadata-only rows. */
getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] {
return this.buildStatusChangeNotification().providerSessions
}
getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] {
const entry = this.state.lastStatusByPaneKey.get(paneKey)
return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : []
}
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
return this.hydratedAuthorityCommitments
}
getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] {
return Object.freeze(
Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry }))
)
}
protected buildStatusChangeNotification(): {
statuses: AgentHookStatusChangeEntry[]
providerSessions: AgentHookProviderSessionIdentity[]
} {
const statuses: AgentHookStatusChangeEntry[] = []
const providerSessions: AgentHookProviderSessionIdentity[] = []
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
const enriched = entry as EnrichedAgentHookEventPayload
if (enriched.providerSession) {
providerSessions.push({
paneKey,
sessionId: enriched.providerSession.id,
...(enriched.providerSession.transcriptPath
? { transcriptPath: enriched.providerSession.transcriptPath }
: {}),
...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {})
})
}
if (!enriched.providerSessionOnly) {
statuses.push({
state: enriched.payload.state,
receivedAt: enriched.receivedAt,
observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey)
})
}
}
return { statuses, providerSessions }
}
protected notifyStatusChangeListeners(): void {
if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) {
return
}
const { statuses, providerSessions } = this.buildStatusChangeNotification()
for (const listener of this.statusChangeListeners) {
try {
listener(statuses)
} catch (err) {
console.error('[agent-hooks] status-change listener threw', err)
}
}
for (const listener of this.providerSessionChangeListeners) {
try {
listener(providerSessions)
} catch (err) {
console.error('[agent-hooks] provider-session listener threw', err)
}
}
}
getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] {
return this.buildStatusChangeNotification().statuses
}
/** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */
_getStateForTests(): HookListenerState {
return this.state
}
}
@@ -0,0 +1,183 @@
import { createHash } from 'node:crypto'
import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume'
import {
normalizeAgentStatusPayload,
type ParsedAgentStatusPayload
} from '../../../shared/agent-status-types'
import { isAgentHookSource } from '../../../shared/agent-hook-relay'
import { normalizeClaudePromptId } from '../../../shared/agent-hook-listener/listener-limits'
import { parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentHookAuthorityEvidence, EnrichedAgentHookEventPayload } from './server-types'
import { isValidPaneKey, isValidPiProviderSessionOnly } from './server-status-identity'
export function dropHydratedIdleClaudeSubagents(
payload: ParsedAgentStatusPayload
): ParsedAgentStatusPayload {
if (
payload.agentType !== 'claude' ||
!payload.subagents?.some((subagent) => subagent.state === 'idle')
) {
return payload
}
const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle')
// Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart.
return {
...payload,
subagents: activeSubagents.length > 0 ? activeSubagents : undefined
}
}
export function sanitizeHydratedEntry(
paneKey: string,
rawEntry: unknown
): EnrichedAgentHookEventPayload | null {
const parsedPaneKey = parsePaneKey(paneKey)
if (!parsedPaneKey) {
return null
}
if (typeof rawEntry !== 'object' || rawEntry === null) {
return null
}
const record = rawEntry as Record<string, unknown>
if (record.paneKey !== paneKey) {
return null
}
const tabId = record.tabId
if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) {
return null
}
// Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row.
if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) {
return null
}
const worktreeId = record.worktreeId
if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) {
return null
}
const receivedAt = record.receivedAt
if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) {
return null
}
const stateStartedAt = record.stateStartedAt
if (
typeof stateStartedAt !== 'number' ||
!Number.isFinite(stateStartedAt) ||
stateStartedAt <= 0
) {
return null
}
// Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest.
const connectionIdRaw = record.connectionId
let connectionId: string | null
if (connectionIdRaw === null || connectionIdRaw === undefined) {
connectionId = null
} else if (typeof connectionIdRaw === 'string') {
connectionId = connectionIdRaw
} else {
return null
}
const payload = normalizeAgentStatusPayload(record.payload)
if (!payload) {
return null
}
const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined
const providerSessionOnly = record.providerSessionOnly === true
const retainedForLiveness = record.retainedForLiveness === true
const validRetainedIdentity = Boolean(
retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown'
)
if (
providerSessionOnly &&
!isValidPiProviderSessionOnly(providerSession, payload.agentType) &&
!validRetainedIdentity
) {
return null
}
const source = isAgentHookSource(record.source) ? record.source : undefined
const providerPromptId =
source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined
const compactTrigger =
source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto')
? record.compactTrigger
: undefined
return {
paneKey,
source,
tabId: typeof tabId === 'string' ? tabId : undefined,
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
connectionId,
hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined,
hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined,
providerPromptId,
compactTrigger,
toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined,
toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined,
teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined,
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined,
providerSession,
providerSessionOnly: providerSessionOnly ? true : undefined,
retainedForLiveness: retainedForLiveness ? true : undefined,
payload,
receivedAt,
stateStartedAt
}
}
export function readPersistedLaunchTokenHash(rawEntry: unknown): string | null {
if (typeof rawEntry !== 'object' || rawEntry === null) {
return null
}
const record = rawEntry as Record<string, unknown>
const launchTokenHash =
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
if (/^[a-f0-9]{64}$/.test(launchTokenHash)) {
return launchTokenHash
}
const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : ''
return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null
}
export function sanitizePersistedAuthorityCommitment(
paneKey: string,
value: unknown
): AgentHookAuthorityEvidence | null {
if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) {
return null
}
const record = value as Record<string, unknown>
const launchTokenHash =
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
const connectionId = record.connectionId
const observedAt = record.observedAt
if (
!/^[a-f0-9]{64}$/.test(launchTokenHash) ||
(connectionId !== null && typeof connectionId !== 'string') ||
typeof observedAt !== 'number' ||
!Number.isFinite(observedAt)
) {
return null
}
return Object.freeze({
paneKey,
launchTokenHash,
connectionId: connectionId as string | null,
...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}),
...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}),
observedAt
})
}
export function authorityCommitmentsMatch(
left: AgentHookAuthorityEvidence,
right: AgentHookAuthorityEvidence
): boolean {
return (
left.paneKey === right.paneKey &&
left.launchTokenHash === right.launchTokenHash &&
left.connectionId === right.connectionId &&
left.tabId === right.tabId &&
left.worktreeId === right.worktreeId
)
}
@@ -0,0 +1,141 @@
import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { isValidPaneKey } from './server-status-identity'
import { LAST_STATUS_FILE_VERSION, STATUS_PERSIST_DEBOUNCE_MS } from './server-constants'
import type {
EnrichedAgentHookEventPayload,
LastStatusFile,
PersistedAgentHookAuthorityCommitment,
PersistedAgentHookEventPayload
} from './server-types'
import { authorityCommitmentsMatch } from './server-persistence-validation'
import { AgentHookServerHydration } from './server-hydration'
export abstract class AgentHookServerPersistence extends AgentHookServerHydration {
protected serializeStatusFile(): string {
const entries: Record<string, PersistedAgentHookEventPayload> = {}
const authorityCommitments: Record<string, PersistedAgentHookAuthorityCommitment> = {}
const conflictedCommitments = new Set<string>()
for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) {
authorityCommitments[paneKey] = { ...commitment }
}
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
// Why: never persist invalid keys (matches the hydrate-path invariant).
if (!isValidPaneKey(paneKey)) {
continue
}
const enrichedPayload = payload as EnrichedAgentHookEventPayload
const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true
const {
claudeRunningNonAgentTask: _claudeRunningNonAgentTask,
promptInteractionKey: _promptInteractionKey,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
restoredUnconfirmed: _restoredUnconfirmed,
// Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload).
observation: _observation,
// Replay provenance is runtime-only and must not survive another restart.
isReplay: _isReplay,
launchToken,
...persistedPayload
} = enrichedPayload
const launchTokenHash = launchToken?.trim()
? createHash('sha256').update(launchToken.trim()).digest('hex')
: this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
entries[paneKey] = {
...persistedPayload,
...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}),
...(launchTokenHash ? { launchTokenHash } : {})
}
const commitment = this.toAuthorityEvidence(payload, launchTokenHash)
if (commitment && !conflictedCommitments.has(paneKey)) {
const existing = authorityCommitments[paneKey]
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
delete authorityCommitments[paneKey]
conflictedCommitments.add(paneKey)
} else {
authorityCommitments[paneKey] = { ...commitment }
}
}
}
const file: LastStatusFile = {
version: LAST_STATUS_FILE_VERSION,
entries,
authorityCommitments
}
return JSON.stringify(file)
}
protected scheduleStatusPersist(): void {
if (!this.lastStatusFilePath) {
return
}
// Why: reset the timer each call so the write fires only after the last event in a burst.
if (this.statusPersistTimer) {
clearTimeout(this.statusPersistTimer)
}
this.statusPersistTimer = setTimeout(() => {
this.statusPersistTimer = null
this.runStatusPersist()
}, STATUS_PERSIST_DEBOUNCE_MS)
// Why: don't keep the event loop alive just for a status flush — quit already flushes sync.
if (typeof this.statusPersistTimer.unref === 'function') {
this.statusPersistTimer.unref()
}
}
flushStatusPersistSync(): void {
if (this.statusPersistTimer) {
clearTimeout(this.statusPersistTimer)
this.statusPersistTimer = null
}
if (!this.lastStatusFilePath) {
return
}
this.runStatusPersist()
}
protected runStatusPersist(): void {
if (!this.lastStatusFilePath || !this.endpointDir) {
return
}
const json = this.serializeStatusFile()
if (json === this.lastWrittenJson) {
return
}
const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`)
let tmpWritten = false
try {
mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 })
if (process.platform !== 'win32') {
try {
chmodSync(this.endpointDir, 0o700)
} catch {
// best-effort
}
}
writeFileSync(tmpPath, json, { mode: 0o600 })
tmpWritten = true
renameSync(tmpPath, this.lastStatusFilePath)
this.lastWrittenJson = json
} catch (err) {
console.warn('[agent-hooks] failed to write last-status file:', err)
if (tmpWritten) {
try {
unlinkSync(tmpPath)
} catch {
// tmp already gone
}
}
}
}
_resetPromptSentDedupeForTests(): void {
this.promptSentDedupeByPaneKey.clear()
}
_resetConnectionTimestampWatermarksForTests(): void {
this.connectionTimestampWatermarkById.clear()
}
}
@@ -0,0 +1,124 @@
import {
claudeRosterHasRestoredSnapshotSubagent,
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../../shared/claude-subagent-roster'
import { reapRestoredClaudeSubagentsForDeadPane } from '../../../shared/agent-hook-listener/providers/claude-roster-state'
import { AgentHookServerTabCleanup } from './server-tab-cleanup'
import type { EnrichedAgentHookEventPayload } from './server-types'
export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup {
/** Second reap path for restored Claude subagent rows: drop the ones whose pane
* has no live local agent process behind it any more. A PTY that dies while Orca
* is down never runs the teardown that clears pane state, so hydrate rebuilds a
* roster nothing can ever retire — the inventory reap needs the parent to emit a
* complete `background_tasks` list and an idle parent never does. The row then
* gates the pane 'working' for the rest of its life and hibernation, which
* requires 'done', can never reclaim the agent's heap.
*
* Both the execution host and relay binding must prove local ownership before
* targeted PTY liveness is consulted. Panes that reported in this runtime are
* also skipped. Returns the number of panes changed. */
async reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
): Promise<number> {
const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = []
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
const enriched = entry as EnrichedAgentHookEventPayload
if (
enriched.payload.agentType === 'claude' &&
enriched.connectionId === null &&
isLocalExecutionHost(enriched.worktreeId) &&
// Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal,
// or a background-task/cron latch nothing will refresh, strands the pane just as
// permanently — and unlike the roster case there is no child event left to reap it.
(claudeRosterHasRestoredSnapshotSubagent(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
) ||
enriched.payload.state !== 'done' ||
this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) &&
!this.runtimeObservedStatusPaneKeys.has(paneKey)
) {
candidates.push({ paneKey, entry: enriched })
}
}
const liveness = await Promise.all(
candidates.map(async (candidate) => {
try {
return await isLocalPaneAgentLive(candidate.paneKey)
} catch {
return true
}
})
)
let changedPanes = 0
for (const [index, candidate] of candidates.entries()) {
const { paneKey, entry: enriched } = candidate
if (
liveness[index] ||
!isLocalPaneLivenessEvidenceCurrent(paneKey) ||
this.state.lastStatusByPaneKey.get(paneKey) !== enriched ||
this.runtimeObservedStatusPaneKeys.has(paneKey) ||
!isLocalExecutionHost(enriched.worktreeId)
) {
continue
}
if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) {
// Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably
// gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the
// pane the same way an observed exit would — otherwise the widened candidate set is inert.
//
// Why delete rather than downgrade to `done` like the reap branch below: that branch has a
// real turn to describe — a parent whose children it just reaped — while these panes' only
// claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would
// invent a completion that never happened, and leaving it non-terminal keeps the bug. This
// sweep stands in for the exit Orca never observed, so it does what that exit does:
// `clearProviderPtyState` -> `clearPaneState`.
if (this.hasLiveClaimsForPaneKey(paneKey)) {
this.clearPaneState(paneKey)
changedPanes += 1
}
continue
}
changedPanes += 1
const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey)
const subagents = claudeRosterToSnapshots(roster)
// Why: the pane's persisted 'working' was the child gate holding a finished
// lead open (subagent events never set lead state). With the last working row
// gone and no process left to report, 'done' is the only truthful state — and
// the one hibernation needs once this pane's agent is restored.
const state =
enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster)
? 'done'
: enriched.payload.state
const stateChanged = state !== enriched.payload.state
const reconciledAt = stateChanged
? Math.max(Date.now(), enriched.receivedAt + 1)
: enriched.receivedAt
// Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying
// restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion.
const { restoredUnconfirmed, ...reconciledBase } = enriched
const reconciled: EnrichedAgentHookEventPayload = {
...reconciledBase,
...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
receivedAt: reconciledAt,
stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt,
payload: {
...enriched.payload,
state,
workingMode: state === 'working' ? enriched.payload.workingMode : undefined,
subagents
}
}
this.state.lastStatusByPaneKey.set(paneKey, reconciled)
}
if (changedPanes > 0) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
return changedPanes
}
}
@@ -0,0 +1,63 @@
import { join } from 'node:path'
import {
getEndpointFileName,
writeEndpointFile
} from '../../../shared/agent-hook-listener/endpoint-publication'
import {
ORCA_HOOK_PROTOCOL_VERSION,
ORCA_HOOK_RAW_JSON_TRANSPORT
} from '../../../shared/agent-hook-types'
import { AgentHookServerIngestRemote } from './server-ingest-remote'
export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote {
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
return {}
}
const env: Record<string, string> = {
ORCA_AGENT_HOOK_PORT: String(this.port),
ORCA_AGENT_HOOK_TOKEN: this.token,
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION,
ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT
}
// Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
if (this.endpointFileWritten && this.endpointFilePathCache) {
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
}
return env
}
get endpointFilePath(): string | null {
return this.endpointFilePathCache
}
/** Test/diagnostic accessor for the on-disk last-status file path. */
get lastStatusPath(): string | null {
return this.lastStatusFilePath
}
protected maybeWriteEndpointFile(): void {
if (!this.endpointDir || !this.endpointFilePathCache) {
return
}
this.endpointFileWritten = false
const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, {
port: this.port,
token: this.token,
env: this.env,
version: ORCA_HOOK_PROTOCOL_VERSION,
transport: ORCA_HOOK_RAW_JSON_TRANSPORT
})
this.endpointFileWritten = ok
}
protected configureEndpointPaths(userDataPath: string, endpointNamespace?: string): void {
// Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect.
this.endpointDir = endpointNamespace
? join(userDataPath, 'agent-hooks', endpointNamespace)
: join(userDataPath, 'agent-hooks')
this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName())
this.lastStatusFilePath = join(this.endpointDir, 'last-status.json')
}
}
+217
View File
@@ -0,0 +1,217 @@
import type { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import {
createHookListenerState,
type HookListenerState
} from '../../../shared/agent-hook-listener/listener-state'
import {
createHookTransportInterferenceTracker,
describeHookTransportInterference,
type HookTransportInterferenceReport
} from '../../../shared/agent-hook-transport-interference'
import {
AgentStatusObservationSequencer,
createAgentStatusAuthorityId,
type AgentStatusObservation,
type AgentStatusObservationOrigin
} from '../../../shared/agent-status-observation'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { AgentHookSource } from '../../../shared/agent-hook-relay'
import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { SpoolRecord } from '../../../shared/agent-hook-spool'
import type {
AgentHookAuthorityEvidence,
AgentHookProviderSessionIdentity,
AgentHookStatusChangeEntry,
AgentPromptSentDedupeEntry,
EnrichedAgentHookEventPayload,
NormalizedLocalHook,
PaneKeyAliasEntry,
PaneKeyAliasPersistenceListener,
PaneStatusClearListener,
ProviderSessionChangeListener,
RetiredPaneAlias,
RetiredPaneFence,
ServerAgentStatusListener,
ServerStatusLineListener,
StatusChangeListener,
StatusDropListener
} from './server-types'
/** Shared mutable state for the layered hook-server implementation. */
export abstract class AgentHookServerState {
protected server: ReturnType<typeof createServer> | null = null
protected port = 0
protected token = ''
// Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
protected env = 'production'
protected onAgentStatus: ServerAgentStatusListener = null
protected onClaudeStatusLine: ServerStatusLineListener = null
protected onPaneStatusCleared: PaneStatusClearListener | null = null
protected paneStatusClearListeners = new Set<PaneStatusClearListener>()
protected statusDropListeners = new Set<StatusDropListener>()
protected statusChangeListeners = new Set<StatusChangeListener>()
protected providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
// Why: setListener is a single slot owned by the main-window fanout; the
// plugin event bus (and future consumers) need an additive subscription
// that also works in headless serve, where no window listener exists.
protected enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>()
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
protected endpointDir: string | null = null
protected endpointFilePathCache: string | null = null
protected endpointFileWritten = false
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
protected state: HookListenerState = createHookListenerState()
protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null =
null
protected transportInterference = createHookTransportInterferenceTracker(
(report: HookTransportInterferenceReport) => {
console.warn(describeHookTransportInterference(report))
this.onTransportInterference?.(report)
}
)
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
protected runtimeObservedStatusPaneKeys = new Set<string>()
protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
protected hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
protected persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
protected revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
protected currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
protected legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
// Why: indexed by every key the retirement fenced, so a re-attach on any of them
// (owner, physical, or a deleted alias) finds the same record. Bounded like the maps
// it mirrors; an evicted record simply degrades to lifting the key it was handed.
protected retiredPaneFencesByKey = new Map<string, RetiredPaneFence>()
protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
protected lastStatusFilePath: string | null = null
// Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state.
protected statusPersistTimer: ReturnType<typeof setTimeout> | null = null
protected assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
protected promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
protected activeHookTurnCompletedAtByPaneKey = new Map<string, number>()
protected promptSentHashSalt = randomBytes(16).toString('hex')
protected closedAgentStatusTabIds = new Set<string>()
protected closedAgentStatusPaneKeys = new Set<string>()
protected restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
protected connectionTimestampWatermarkById = new Map<string, number>()
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
protected lastWrittenJson: string | null = null
// Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own
// OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here.
protected readonly observations = new AgentStatusObservationSequencer(
createAgentStatusAuthorityId('main-agent-hooks')
)
protected abstract withdrawReplayObservation(paneKey: string): void
protected abstract ingestSpoolRecord(record: SpoolRecord): void
protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void
protected abstract buildStatusChangeNotification(): {
statuses: AgentHookStatusChangeEntry[]
providerSessions: AgentHookProviderSessionIdentity[]
}
protected abstract notifyStatusChangeListeners(): void
protected abstract markTabClosedForAgentStatus(tabId: string): void
protected abstract getAgentStatusDisposition(
paneKey: string,
event?: {
source?: AgentHookSource
rawSource?: unknown
hookEventName?: string
isReplay?: boolean
hasExplicitPrompt?: boolean
launchToken?: string
}
): 'accept' | 'restart' | 'suppress'
protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean
protected abstract recordRetiredPaneFence(
paneKeys: ReadonlySet<string>,
aliases: readonly RetiredPaneAlias[]
): void
protected abstract markPaneClosedForAgentStatus(paneKey: string): void
protected abstract attachStatusTiming(
payload: AgentHookEventPayload,
now?: number
): EnrichedAgentHookEventPayload
protected abstract hashPromptForTelemetryDedupe(prompt: string): string
protected abstract maybeTrackAgentPromptSent(
payload: AgentHookEventPayload,
previousStatus: EnrichedAgentHookEventPayload | undefined
): void
protected abstract stampObservation(
payload: AgentHookEventPayload,
origin: AgentStatusObservationOrigin,
observedAt: number
): AgentStatusObservation
protected abstract applyNormalizedStatus(
payload: AgentHookEventPayload,
onAccepted?: () => void,
origin?: AgentStatusObservationOrigin
): EnrichedAgentHookEventPayload
protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void
protected abstract clearAssistantMessageRetry(paneKey: string): void
protected abstract clearCodexSubagentPoll(paneKey: string): void
protected abstract clearAllCodexSubagentPolls(): void
protected abstract scheduleCodexSubagentPoll(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload
): void
protected abstract scheduleAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload,
attempt?: number,
discoveryReady?: boolean
): void
protected abstract applyAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload,
nextAttempt: number,
requireExactOriginal: boolean
): void
protected abstract getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[]
protected abstract notifyPaneKeyAliasPersistenceListener(): void
protected abstract boundPaneKeyAliases(): void
protected abstract getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string
protected abstract restoreRetiredPaneFence(fence: RetiredPaneFence): void
protected abstract revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet<string>): boolean
protected abstract resolvePaneKeyAlias(paneKey: string): string
protected abstract normalizeHookBodyPaneKeyAlias(body: unknown): unknown
protected abstract normalizeLocalHookPayload(
source: AgentHookSource,
body: unknown
): NormalizedLocalHook
protected abstract setClaudeBackgroundEvidence(
paneKey: string,
hasRunningTask: boolean,
hasActiveCron: boolean
): void
protected abstract toRetainedProviderSessionRow(
entry: EnrichedAgentHookEventPayload | null | undefined
): EnrichedAgentHookEventPayload | null
protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean
protected abstract clearPaneState(paneKey: string): void
protected abstract deleteStatusEntry(
paneKey: string,
options?: { preserveAuthority?: boolean }
): EnrichedAgentHookEventPayload | null
protected abstract maybeWriteEndpointFile(): void
protected abstract hydrateLastStatusFromDisk(): void
protected abstract captureHydratedAuthorityCommitments(): void
protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void
protected abstract toAuthorityEvidence(
payload: AgentHookEventPayload | EnrichedAgentHookEventPayload,
launchTokenHashOverride?: string
): AgentHookAuthorityEvidence | null
protected abstract serializeStatusFile(): string
protected abstract scheduleStatusPersist(): void
protected abstract runStatusPersist(): void
abstract _getStateForTests(): HookListenerState
abstract _resetPromptSentDedupeForTests(): void
abstract _resetConnectionTimestampWatermarksForTests(): void
}
@@ -0,0 +1,141 @@
import { createHash } from 'node:crypto'
import { getCohortAtEmit } from '../../telemetry/cohort-classifier'
import { track } from '../../telemetry/client'
import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary'
import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type {
AgentStatusObservation,
AgentStatusObservationOrigin
} from '../../../shared/agent-status-observation'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { agentTypeToPromptSentAgentKind } from './server-status-identity'
import { AgentHookServerStatusDisposition } from './server-status-disposition'
export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition {
protected attachStatusTiming(
payload: AgentHookEventPayload,
now = Date.now()
): EnrichedAgentHookEventPayload {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
const commandCodeNewTurn =
previous !== undefined &&
isCommandCodeNewTurnWhileWorking({
agentType: payload.payload.agentType,
previousState: previous.payload.state,
incomingState: payload.payload.state,
previousPrompt: previous.payload.prompt,
incomingPrompt: payload.payload.prompt,
hasExplicitPrompt: payload.hasExplicitPrompt,
previousPromptInteractionKey: previous.promptInteractionKey,
incomingPromptInteractionKey: payload.promptInteractionKey
})
const stateStartedAt =
previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn
? previous.stateStartedAt
: now
// Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival.
return {
...payload,
receivedAt: now,
stateStartedAt
}
}
protected hashPromptForTelemetryDedupe(prompt: string): string {
return createHash('sha256')
.update(this.promptSentHashSalt)
.update('\0')
.update(prompt)
.digest('hex')
}
protected maybeTrackAgentPromptSent(
payload: AgentHookEventPayload,
previousStatus: EnrichedAgentHookEventPayload | undefined
): void {
if (payload.isReplay === true || payload.hasExplicitPrompt !== true) {
return
}
const prompt = payload.payload.prompt?.trim() ?? ''
if (prompt.length === 0) {
return
}
const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType)
const promptHash = this.hashPromptForTelemetryDedupe(prompt)
const promptInteractionKey =
typeof payload.promptInteractionKey === 'string' &&
payload.promptInteractionKey.trim().length > 0
? payload.promptInteractionKey.trim()
: undefined
const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey)
const isCompletedTurnBoundary =
previousStatus?.payload.state === 'done' && payload.payload.state === 'working'
if (
previousDedupe?.agentKind === agentKind &&
previousDedupe.promptInteractionKey !== undefined &&
previousDedupe.promptInteractionKey === promptInteractionKey &&
(agentKind === 'opencode' || previousDedupe.promptHash === promptHash)
) {
return
}
if (
previousDedupe?.agentKind === agentKind &&
previousDedupe.promptHash === promptHash &&
!(
previousStatus?.payload.state === 'done' &&
payload.payload.state === 'done' &&
previousDedupe.promptInteractionKey !== undefined &&
promptInteractionKey !== undefined &&
previousDedupe.promptInteractionKey !== promptInteractionKey
) &&
!isCompletedTurnBoundary
) {
return
}
this.promptSentDedupeByPaneKey.set(payload.paneKey, {
agentKind,
promptHash,
promptInteractionKey
})
try {
// Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality.
track('agent_prompt_sent', {
agent_kind: agentKind,
launch_source: 'unknown',
request_kind: 'followup',
...getCohortAtEmit()
})
} catch (err) {
console.error('[agent-hooks] prompt-sent telemetry failed', err)
}
}
/** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet
* (STA-4293) — it is stamped here because every main-side ingress funnels through
* applyNormalizedStatus, so no origin can silently arrive untagged. */
protected stampObservation(
payload: AgentHookEventPayload,
origin: AgentStatusObservationOrigin,
observedAt: number
): AgentStatusObservation {
return this.observations.observe(payload.paneKey, {
origin,
observedAt,
// Why: reuse the listener's own per-provider classifier; a second list of raw event-name
// literals here would strand the providers whose boundary event is named anything else.
boundary:
payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName),
kind: payload.providerSessionOnly
? 'identity-only'
: // Why: a replay restates a turn that already happened, and OSC 9999 repaints the
// current state rather than announcing a change — neither is a fresh transition.
payload.isReplay === true || origin === 'osc'
? 'snapshot'
: 'transition'
})
}
}
@@ -0,0 +1,161 @@
import { createHash } from 'node:crypto'
import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentHookSource } from '../../../shared/agent-hook-relay'
import {
CLOSED_AGENT_STATUS_PANE_KEYS_MAX,
CLOSED_AGENT_STATUS_TAB_IDS_MAX,
RETIRED_PANE_FENCES_MAX
} from './server-constants'
import type { RetiredPaneAlias, RetiredPaneFence } from './server-types'
import { AgentHookServerStatusInference } from './server-status-inference'
export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference {
protected markTabClosedForAgentStatus(tabId: string): void {
// Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids.
this.closedAgentStatusTabIds.delete(tabId)
this.closedAgentStatusTabIds.add(tabId)
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
const oldest = this.closedAgentStatusTabIds.keys().next().value
if (oldest === undefined) {
break
}
this.closedAgentStatusTabIds.delete(oldest)
}
}
protected getAgentStatusDisposition(
paneKey: string,
event?: {
source?: AgentHookSource
/** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */
rawSource?: unknown
hookEventName?: string
isReplay?: boolean
hasExplicitPrompt?: boolean
launchToken?: string
}
): 'accept' | 'restart' | 'suppress' {
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
const paneRetired =
this.closedAgentStatusPaneKeys.has(paneKey) ||
this.closedAgentStatusPaneKeys.has(ownerPaneKey)
const tabId = parsePaneKey(ownerPaneKey)?.tabId
if (tabId && this.closedAgentStatusTabIds.has(tabId)) {
return 'suppress'
}
if (!paneRetired) {
const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey)
// Why: deferred retirement lets a new process start in a still-authorized pane, so
// its tokened SessionStart re-fences; prompts recur, so a stale process would win.
if (
event?.hookEventName === 'SessionStart' &&
event.isReplay !== true &&
tokenFence !== undefined
) {
const startedLaunchToken = event.launchToken?.trim()
if (startedLaunchToken) {
this.restartedStatusLaunchTokenHashByPaneKey.set(
ownerPaneKey,
createHash('sha256').update(startedLaunchToken).digest('hex')
)
return 'accept'
}
}
if (event && tokenFence) {
const launchToken = event.launchToken?.trim()
if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) {
return 'suppress'
}
}
return 'accept'
}
// Why: command completion retires launch authority but leaves its shell pane reusable.
// A live new-turn event proves a new agent process owns the retired pane just like a
// fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386).
// Why the classifier, not literals: only 5 of 18 sources name their boundary
// `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever.
// Why four branches: `source` collapses to undefined when an older relay omits the field,
// when a newer host sends an unknown string, and when the wire value is malformed. Only an
// unknown string is valid future-provider evidence. Unreachable from the local path, which
// 404s an unresolvable source.
const isNewTurn =
event?.source !== undefined
? isNewTurnEvent(event.source, event.hookEventName)
: typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0
? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and
// the costs are asymmetric — a stranded pane is invisible and permanent with no user
// recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS.
true
: event?.rawSource === undefined
? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it
// cannot revive a provider whose boundary event is named anything else.
event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart'
: false
// Why in addition to the classifier: the OpenCode family carries its mid-session boundary in
// an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no
// SessionStart at all, so without this its retired panes never come back.
const freshOpenCodeFamilyPrompt =
(event?.source === 'opencode' || event?.source === 'mimo-code') &&
event.hookEventName === 'MessagePart' &&
event.hasExplicitPrompt === true
// Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up
// status on that launch token stops a stale process reclaiming the pane's row without
// restoring retired orchestration authority.
if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) {
this.closedAgentStatusPaneKeys.delete(paneKey)
this.closedAgentStatusPaneKeys.delete(ownerPaneKey)
const launchToken = event?.launchToken?.trim()
if (launchToken) {
this.restartedStatusLaunchTokenHashByPaneKey.set(
ownerPaneKey,
createHash('sha256').update(launchToken).digest('hex')
)
} else {
this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey)
}
return 'restart'
}
return 'suppress'
}
// Why: a fence can span tabs (a pane detached into another tab), and legacy numeric
// keys never parse as stable ones — resolve both forms so neither slips the tab check.
protected isClosedAgentStatusTabForPaneKey(paneKey: string): boolean {
const tabId =
parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined
return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId)
}
protected recordRetiredPaneFence(
paneKeys: ReadonlySet<string>,
aliases: readonly RetiredPaneAlias[]
): void {
const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases }
for (const key of paneKeys) {
// Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest.
this.retiredPaneFencesByKey.delete(key)
this.retiredPaneFencesByKey.set(key, fence)
}
while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) {
const oldest = this.retiredPaneFencesByKey.keys().next().value
if (oldest === undefined) {
break
}
this.retiredPaneFencesByKey.delete(oldest)
}
}
protected markPaneClosedForAgentStatus(paneKey: string): void {
this.closedAgentStatusPaneKeys.delete(paneKey)
this.closedAgentStatusPaneKeys.add(paneKey)
while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) {
const oldest = this.closedAgentStatusPaneKeys.keys().next().value
if (oldest === undefined) {
break
}
this.closedAgentStatusPaneKeys.delete(oldest)
}
}
}
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto'
import type { AgentKind } from '../../../shared/telemetry-events'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import {
getAgentResumeArgv,
type AgentProviderSessionMetadata
} from '../../../shared/agent-session-resume'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { AGENT_PROMPT_SENT_AGENT_KINDS, TOOL_PROGRESS_HOOK_EVENTS } from './server-constants'
import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits'
export function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind {
const normalized = agentType?.trim().toLowerCase()
if (!normalized || normalized === 'unknown') {
return 'other'
}
if (normalized === 'claude') {
return 'claude-code'
}
return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind)
? (normalized as AgentKind)
: 'other'
}
export function equivalentInterruptAgentType(
actual: AgentType | undefined,
baseline: AgentType | undefined
): boolean {
const normalizedActual = actual === 'unknown' ? undefined : actual
const normalizedBaseline = baseline === 'unknown' ? undefined : baseline
return normalizedActual === normalizedBaseline
}
// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed.
export function isValidPaneKey(value: unknown): value is string {
return (
typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null
)
}
// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead.
export function isValidPiProviderSessionOnly(
providerSession: AgentProviderSessionMetadata | undefined,
agentType: AgentType | undefined
): boolean {
return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession))
}
export function toAgentStatusIpcPayload(
entry: EnrichedAgentHookEventPayload
): AgentStatusIpcPayload {
return {
paneKey: entry.paneKey,
...(entry.launchToken ? { launchToken: entry.launchToken } : {}),
tabId: entry.tabId,
worktreeId: entry.worktreeId,
connectionId: entry.connectionId,
receivedAt: entry.receivedAt,
stateStartedAt: entry.stateStartedAt,
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
...(entry.observation ? { observation: entry.observation } : {}),
...entry.payload
}
}
export function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean {
if (next.payload.state !== 'working') {
return false
}
if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') {
return false
}
// Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work.
return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName)
}
export function paneCacheKeyTabId(key: string): string | null {
const paneKey = key.split('\0', 1)[0] ?? key
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
}
export function paneCacheKeyMatchesTab(key: string, tabId: string): boolean {
return paneCacheKeyTabId(key) === tabId
}
export function hashLaunchToken(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
@@ -0,0 +1,175 @@
import {
markClaudeLeadTurnInterrupted,
clearClaudeAnsweredQuestionWait
} from '../../../shared/agent-hook-listener/providers/claude-roster-state'
import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state'
import {
isAgentInterruptInputIntent,
type AgentInterruptInferenceRequest
} from '../../../shared/agent-interrupt-intent'
import {
isAskUserQuestionTool,
type AgentQuestionAnsweredInferenceRequest
} from '../../../shared/agent-question-answered-intent'
import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types'
import type { EnrichedAgentHookEventPayload } from './server-types'
import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity'
import { AgentHookServerListeners } from './server-listeners'
export abstract class AgentHookServerStatusInference extends AgentHookServerListeners {
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
}
if (!isAgentInterruptInputIntent(request.intent)) {
return false
}
const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (!existing) {
return false
}
if (existing.providerSessionOnly) {
return false
}
// Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime.
if (existing.restoredUnconfirmed) {
return false
}
const payload = existing.payload
const agentType: AgentType | undefined = payload.agentType
// Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn.
if (agentType === 'droid' && request.intent === 'ctrl-c') {
return false
}
// Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt.
if (
(agentType === 'opencode' || agentType === 'copilot') &&
request.intent === 'plain-escape' &&
request.inputCount !== 2
) {
return false
}
const dismissesClaudeQuestion =
agentType === 'claude' &&
request.intent === 'plain-escape' &&
payload.state === 'waiting' &&
isAskUserQuestionTool(payload.toolName)
if (dismissesClaudeQuestion) {
return this.inferQuestionAnswered(request)
}
// Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
if (
payload.state !== 'working' ||
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
payload.prompt !== request.baselinePrompt ||
existing.receivedAt !== request.baselineUpdatedAt ||
existing.stateStartedAt !== request.baselineStateStartedAt ||
Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS
) {
return false
}
// Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows.
if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) {
return false
}
// Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons.
if (
agentType === 'claude' &&
(this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) ||
this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey))
) {
return false
}
// Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
if (agentType === 'claude') {
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
}
if (agentType === 'codex') {
markCodexLeadTurnInterrupted(this.state, existing.paneKey)
}
const inferred = this.applyNormalizedStatus({
paneKey: existing.paneKey,
tabId: existing.tabId,
worktreeId: existing.worktreeId,
connectionId: existing.connectionId,
providerSession: existing.providerSession,
payload: {
state: 'done',
prompt: payload.prompt,
agentType,
...(payload.model ? { model: payload.model } : {}),
interrupted: true,
// Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore.
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
console.debug('[agent-hooks] inferred interrupted agent status', {
paneKey: inferred.paneKey,
agentType,
intent: request.intent
})
return true
}
/** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
}
const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (!existing) {
return false
}
// Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime.
if (existing.restoredUnconfirmed) {
return false
}
const payload = existing.payload
// Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky.
if (
payload.agentType !== 'claude' ||
payload.state !== 'waiting' ||
!isAskUserQuestionTool(payload.toolName)
) {
return false
}
if (
payload.agentType !== request.baselineAgentType ||
payload.prompt !== request.baselinePrompt ||
existing.receivedAt !== request.baselineUpdatedAt ||
existing.stateStartedAt !== request.baselineStateStartedAt ||
Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS
) {
return false
}
// Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card.
const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey)
const inferred = this.applyNormalizedStatus({
paneKey: existing.paneKey,
tabId: existing.tabId,
worktreeId: existing.worktreeId,
connectionId: existing.connectionId,
providerSession: existing.providerSession,
payload: {
state: restored.state,
...(restored.workingMode ? { workingMode: restored.workingMode } : {}),
prompt: payload.prompt,
agentType: payload.agentType,
...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}),
...(restored.turnCompletedAt !== undefined
? { turnCompletedAt: restored.turnCompletedAt }
: {}),
...(payload.subagents ? { subagents: payload.subagents } : {})
}
})
console.debug('[agent-hooks] inferred resolved question status', {
paneKey: inferred.paneKey,
state: inferred.payload.state
})
return true
}
}
@@ -0,0 +1,155 @@
import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state'
import { normalizeHookPayload } from '../../../shared/agent-hook-listener'
import {
hasPendingAgentResultText,
preparePendingGrokResultDiscovery
} from '../../../shared/agent-hook-listener/grok-result-discovery'
import type { AgentHookSource } from '../../../shared/agent-hook-relay'
import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler'
import type { EnrichedAgentHookEventPayload } from './server-types'
import {
ASSISTANT_MESSAGE_RETRY_ATTEMPTS,
ASSISTANT_MESSAGE_RETRY_MS,
CODEX_SUBAGENT_POLL_MS
} from './server-constants'
import { AgentHookServerStatusUpdate } from './server-status-update'
type CodexSubagentPoll = {
source: AgentHookSource
body: unknown
original: EnrichedAgentHookEventPayload
}
export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate {
private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler<CodexSubagentPoll>(
CODEX_SUBAGENT_POLL_MS,
(paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll)
)
protected clearAllCodexSubagentPolls(): void {
this.codexSubagentPollScheduler.clearAll()
}
protected clearAssistantMessageRetry(paneKey: string): void {
const timer = this.assistantMessageRetryTimers.get(paneKey)
if (!timer) {
return
}
clearTimeout(timer)
this.assistantMessageRetryTimers.delete(paneKey)
}
protected clearCodexSubagentPoll(paneKey: string): void {
this.codexSubagentPollScheduler.clear(paneKey)
}
protected scheduleCodexSubagentPoll(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload
): void {
// Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll.
if (source !== 'codex') {
return
}
this.codexSubagentPollScheduler.clear(original.paneKey)
if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) {
return
}
this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original })
}
private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void {
const { source, body, original } = poll
// Keep the identity check at callback time: a newer event supersedes this
// payload even when its pane still has transcript children.
if (
paneKey !== original.paneKey ||
!this.server ||
this.state.lastStatusByPaneKey.get(original.paneKey) !== original
) {
return
}
const normalized = normalizeHookPayload(this.state, source, body, this.env)
if (!normalized) {
return
}
const subagentsChanged =
JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents)
const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original
this.scheduleCodexSubagentPoll(source, body, next)
}
protected scheduleAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload,
attempt = 1,
discoveryReady = false
): void {
if (
original.payload.lastAssistantMessage ||
!hasPendingAgentResultText(source, body) ||
attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS
) {
return
}
this.clearAssistantMessageRetry(original.paneKey)
if (!discoveryReady) {
const discovery = preparePendingGrokResultDiscovery(source, body)
if (discovery) {
// Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically.
void discovery
.then(() => {
if (this.server) {
this.applyAssistantMessageRetry(source, body, original, 1, true)
}
})
.catch((err) => {
console.error('[agent-hooks] Grok result discovery failed:', err)
})
return
}
}
const timer = setTimeout(() => {
try {
this.assistantMessageRetryTimers.delete(original.paneKey)
this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady)
} catch (err) {
console.error('[agent-hooks] assistant message retry failed:', err)
}
}, ASSISTANT_MESSAGE_RETRY_MS)
this.assistantMessageRetryTimers.set(original.paneKey, timer)
if (typeof timer.unref === 'function') {
timer.unref()
}
}
protected applyAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload,
nextAttempt: number,
requireExactOriginal: boolean
): void {
const current = this.state.lastStatusByPaneKey.get(original.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (
!current ||
(requireExactOriginal && current !== original) ||
current.payload.agentType !== original.payload.agentType ||
current.payload.prompt !== original.payload.prompt ||
current.payload.lastAssistantMessage
) {
return
}
const normalized = this.normalizeLocalHookPayload(source, body)
if (!normalized.event?.payload.lastAssistantMessage) {
this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal)
return
}
// Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed.
this.applyNormalizedStatus(normalized.event, normalized.onAccepted)
}
}
@@ -0,0 +1,219 @@
import {
reconcileRemoteCodexState,
markCodexLeadTurnInterrupted
} from '../../../shared/agent-hook-listener/providers/codex-state'
import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
} from '../../../shared/agent-status-identity'
import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants'
import type { EnrichedAgentHookEventPayload } from './server-types'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation'
import {
attachClaudeChildOnlyBoundary,
attachClaudePermissionToolUseId,
invalidateClaudeChildOnlyBoundary,
shouldKeepClaudePermissionVisible
} from './server-claude-status-rules'
import { isToolProgressWorkingAfterInterrupt } from './server-status-identity'
import { AgentHookServerStatusApplication } from './server-status-application'
export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication {
protected applyNormalizedStatus(
payload: AgentHookEventPayload,
onAccepted?: () => void,
origin: AgentStatusObservationOrigin = 'hook'
): EnrichedAgentHookEventPayload {
if (payload.hookEventName === 'UserPromptSubmit') {
// Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp.
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
}
let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
const connectionClearWatermark = payload.connectionId
? this.connectionTimestampWatermarkById.get(payload.connectionId)
: undefined
// Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback.
const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined
const now = Math.max(
Date.now(),
(connectionClearWatermark ?? -1) + 1,
(restoredStatusWatermark ?? -1) + 1
)
if (payload.connectionId) {
this.connectionTimestampWatermarkById.set(payload.connectionId, now)
}
if (payload.providerSessionOnly) {
// Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status.
onAccepted?.()
const enriched = {
...this.attachStatusTiming(payload, now),
observation: this.stampObservation(payload, origin, now)
}
this.clearAssistantMessageRetry(enriched.paneKey)
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.emitEnrichedStatus(enriched)
return enriched
}
const stateReconciledPayload =
payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName
? {
...payload,
payload: reconcileRemoteCodexState(
this.state,
payload.paneKey,
payload.hookEventName,
payload.toolAgentId,
payload.payload,
previous?.payload
)
}
: payload
const previousCodexRoot =
stateReconciledPayload.payload.agentType === 'codex' &&
stateReconciledPayload.toolAgentId &&
previous?.payload.agentType === 'codex'
? previous
: undefined
const preservedProviderSession = !stateReconciledPayload.providerSession
? previousCodexRoot?.providerSession
: undefined
const preservedRootModel = !stateReconciledPayload.payload.model
? previousCodexRoot?.payload.model
: undefined
// Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity.
const rootContextPreservingPayload =
preservedProviderSession || preservedRootModel
? {
...stateReconciledPayload,
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
payload: preservedRootModel
? { ...stateReconciledPayload.payload, model: preservedRootModel }
: stateReconciledPayload.payload
}
: stateReconciledPayload
const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary(
previous,
rootContextPreservingPayload
)
if (boundaryReconciledPrevious !== previous) {
previous = boundaryReconciledPrevious
if (previous) {
this.state.lastStatusByPaneKey.set(previous.paneKey, previous)
this.scheduleStatusPersist()
}
}
const identity = resolveAgentStatusIdentity({
existing: previous
? {
agentType: previous.payload.agentType,
state: previous.payload.state,
updatedAt: previous.receivedAt,
restoredUnconfirmed: previous.restoredUnconfirmed
}
: undefined,
incoming: rootContextPreservingPayload.payload.agentType,
now
})
if (
previous &&
shouldSuppressInheritedTerminalStatus({
inheritedFromActivePane: identity.inheritedFromActivePane,
incomingState: rootContextPreservingPayload.payload.state
})
) {
return previous
}
const identityResolvedPayload =
identity.agentType === rootContextPreservingPayload.payload.agentType
? rootContextPreservingPayload
: {
...rootContextPreservingPayload,
payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType }
}
const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload)
const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload)
if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) {
return previous
}
// Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row.
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
effectivePayload.payload.state === 'done' &&
previous.payload.agentType === effectivePayload.payload.agentType &&
previous.payload.prompt === effectivePayload.payload.prompt &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS
) {
return previous
}
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
effectivePayload.payload.state === 'working' &&
previous.payload.agentType === effectivePayload.payload.agentType &&
previous.payload.prompt === effectivePayload.payload.prompt &&
(effectivePayload.isReplay === true ||
isToolProgressWorkingAfterInterrupt(effectivePayload) ||
(effectivePayload.hasExplicitPrompt !== true &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
) {
if (effectivePayload.payload.agentType === 'codex') {
markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey)
}
return previous
}
if (
effectivePayload.payload.state !== 'done' ||
effectivePayload.payload.lastAssistantMessage
) {
this.clearAssistantMessageRetry(effectivePayload.paneKey)
}
onAccepted?.()
if (!identity.inheritedFromActivePane) {
this.maybeTrackAgentPromptSent(effectivePayload, previous)
}
const enriched = {
...this.attachStatusTiming(boundaryAwarePayload, now),
observation: this.stampObservation(boundaryAwarePayload, origin, now)
}
if (
typeof enriched.payload.turnCompletedAt === 'number' &&
Number.isFinite(enriched.payload.turnCompletedAt)
) {
this.activeHookTurnCompletedAtByPaneKey.set(
enriched.paneKey,
enriched.payload.turnCompletedAt
)
}
// Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible.
if (enriched.restoredUnconfirmed) {
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
} else {
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
}
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.emitEnrichedStatus(enriched)
return enriched
}
// Why: every status emit must reach plugins too, so a new early-return path
// upstream cannot silently leave the plugin tap behind the main-window fanout.
protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
this.onAgentStatus?.(enriched)
for (const listener of this.enrichedStatusListeners) {
try {
listener(enriched)
} catch (err) {
console.error('[agent-hooks] enriched status listener threw', err)
}
}
}
}
@@ -0,0 +1,122 @@
import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state'
import { paneCacheKeyMatchesTab } from './server-status-identity'
import { AgentHookServerCleanup } from './server-cleanup'
export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
/** Drop every status/cache claim attributable to a closed tab prefix. */
dropStatusEntriesByTabPrefix(tabId: string): void {
this.markTabClosedForAgentStatus(tabId)
const paneKeysToClear = new Set<string>()
for (const key of this.state.lastStatusByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key)
}
}
for (const key of this.state.lastPromptByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.lastToolByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const key of this.state.ampCompletedCacheKeys) {
if (paneCacheKeyMatchesTab(key, tabId)) {
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
}
}
for (const paneKey of this.runtimeObservedStatusPaneKeys) {
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
paneKeysToClear.add(paneKey)
}
}
for (const paneKey of this.promptSentDedupeByPaneKey.keys()) {
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
paneKeysToClear.add(paneKey)
}
}
for (const commitment of this.hydratedAuthorityCommitments) {
if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) {
paneKeysToClear.add(commitment.paneKey)
}
}
let aliasChanged = false
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
if (paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
paneKeysToClear.add(legacyPaneKey)
paneKeysToClear.add(entry.stablePaneKey)
this.markPaneClosedForAgentStatus(legacyPaneKey)
this.markPaneClosedForAgentStatus(entry.stablePaneKey)
aliasChanged = true
}
}
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear)
let statusChanged = false
for (const paneKey of paneKeysToClear) {
if (this.state.lastStatusByPaneKey.has(paneKey)) {
statusChanged = true
}
this.clearAssistantMessageRetry(paneKey)
this.clearCodexSubagentPoll(paneKey)
clearPaneCacheState(this.state, paneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.currentAuthorityObservations.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey)
}
if (aliasChanged) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (statusChanged || authorityChanged) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
}
clearPaneState(paneKey: string): void {
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
const paneKeys = new Set([paneKey, resolvedPaneKey])
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
this.clearCodexSubagentPoll(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
this.currentAuthorityObservations.delete(resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) {
if (alias.stablePaneKey === resolvedPaneKey) {
this.legacyPaneKeyAliases.delete(legacyPaneKey)
paneKeys.add(legacyPaneKey)
paneKeys.add(alias.stablePaneKey)
clearPaneCacheState(this.state, legacyPaneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey)
this.currentAuthorityObservations.delete(legacyPaneKey)
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey)
clearedAlias = true
}
}
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
if (clearedAlias) {
this.notifyPaneKeyAliasPersistenceListener()
}
if (hadStatus || authorityChanged) {
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.emitPaneStatusCleared({ paneKey: resolvedPaneKey })
}
}
}
@@ -0,0 +1,13 @@
import { track } from '../../telemetry/client'
/** Keep unattributed hook deliveries visible in telemetry without rejecting the request. */
export function trackEmptyPaneKeyHook(body: unknown): void {
if (typeof body !== 'object' || body === null) {
return
}
const paneKey = (body as Record<string, unknown>).paneKey
if (typeof paneKey === 'string' && paneKey.trim().length > 0) {
return
}
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
}
+113
View File
@@ -0,0 +1,113 @@
import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type {
AgentStatusClearIpcPayload,
AgentStatusState
} from '../../../shared/agent-status-types'
import type { AgentStatusObservation } from '../../../shared/agent-status-observation'
import type { AgentKind } from '../../../shared/telemetry-events'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
receivedAt: number
stateStartedAt: number
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
observation?: AgentStatusObservation
/** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */
restoredUnconfirmed?: true
/** User-hidden resume identity retained solely for destructive liveness checks. */
retainedForLiveness?: true
/** Persisted proof that a lead boundary was held working only by child agents. */
claudeLeadBoundaryChildOnly?: true
}
export type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
| 'claudeRunningNonAgentTask'
| 'launchToken'
| 'promptInteractionKey'
| 'restoredUnconfirmed'
// Why: revision counters are in-memory and the authority id is regenerated per process, so
// a stored observation could only rehydrate as a stale ordering claim from a dead authority.
| 'observation'
> & {
launchTokenHash?: string
}
export type PersistedAgentHookAuthorityCommitment = {
paneKey: string
launchTokenHash: string
connectionId: string | null
tabId?: string
worktreeId?: string
observedAt: number
}
export type AgentHookStatusChangeEntry = {
state: AgentStatusState
receivedAt: number
observedInCurrentRuntime: boolean
}
export type AgentHookProviderSessionIdentity = {
paneKey: string
sessionId: string
transcriptPath?: string
worktreeId?: string
}
export type AgentHookAuthorityEvidence = Readonly<{
paneKey: string
launchTokenHash: string
connectionId: string | null
tabId?: string
worktreeId?: string
observedAt: number
}>
export type AgentHookAuthorityAttestation = Readonly<{
paneKey: string
source: 'current_hook' | 'hydrated_commitment'
}>
export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
export type ProviderSessionChangeListener = (
providerSessions: AgentHookProviderSessionIdentity[]
) => void
export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void
export type StatusDropListener = (paneKey: string) => void
export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void
export type PaneKeyAliasEntry = {
stablePaneKey: string
ptyId: string | null
updatedAt: number
authorityVerified: boolean
}
export type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry }
/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */
export type RetiredPaneFence = {
paneKeys: readonly string[]
aliases: readonly RetiredPaneAlias[]
}
export type LastStatusFile = {
version: number
entries: Record<string, PersistedAgentHookEventPayload>
authorityCommitments?: Record<string, PersistedAgentHookAuthorityCommitment>
}
export type AgentPromptSentDedupeEntry = {
agentKind: AgentKind
promptHash: string
promptInteractionKey?: string
}
export type NormalizedLocalHook = {
event: AgentHookEventPayload | null
onAccepted?: () => void
}
export type ServerStatusLineListener = ((event: ClaudeStatusLineRateLimits) => void) | null
export type ServerAgentStatusListener = ((payload: EnrichedAgentHookEventPayload) => void) | null
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import {
dedupeCodexRolloutCopyAliases,
dedupeCodexRolloutFileAliases,
dedupeCodexSessionsBySessionId
} from './codex-session-root-dedup'
@@ -204,6 +205,106 @@ describe('dedupeCodexRolloutFileAliases', () => {
})
})
describe('dedupeCodexRolloutCopyAliases', () => {
type Candidate = {
agent: string
path: string
codexHome: string | null
}
const accessors = {
isCodex: (candidate: Candidate) => candidate.agent === 'codex',
getFilePath: (candidate: Candidate) => candidate.path,
getCodexHome: (candidate: Candidate) => candidate.codexHome
}
it('collapses cross-volume copies only after session_meta proves the same id', async () => {
const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null }
const managed = {
agent: 'codex',
path: MANAGED_HOME_ROLLOUT,
codexHome: MANAGED_HOME
}
const readSessionMetaId = vi.fn(async () => 'shared-session-id')
await expect(
dedupeCodexRolloutCopyAliases([managed, real], accessors, readSessionMetaId)
).resolves.toEqual([real])
expect(readSessionMetaId).toHaveBeenCalledTimes(2)
})
it('keeps same-name files when ids differ or metadata cannot prove identity', async () => {
const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null }
const managed = {
agent: 'codex',
path: MANAGED_HOME_ROLLOUT,
codexHome: MANAGED_HOME
}
await expect(
dedupeCodexRolloutCopyAliases([real, managed], accessors, async (path) =>
path === REAL_HOME_ROLLOUT ? 'real-id' : 'managed-id'
)
).resolves.toEqual([real, managed])
await expect(
dedupeCodexRolloutCopyAliases([real, managed], accessors, async () => null)
).resolves.toEqual([real, managed])
})
it('does not compare copies across native and WSL execution namespaces', async () => {
const rolloutName = REAL_HOME_ROLLOUT.split('/').at(-1)
const native = {
agent: 'codex',
path: `C:\\Users\\ada\\.codex\\sessions\\${rolloutName}`,
codexHome: null
}
const wsl = {
agent: 'codex',
path: `\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\${rolloutName}`,
codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex'
}
const readSessionMetaId = vi.fn(async () => 'shared-session-id')
await expect(
dedupeCodexRolloutCopyAliases([native, wsl], accessors, readSessionMetaId)
).resolves.toEqual([native, wsl])
expect(readSessionMetaId).not.toHaveBeenCalled()
})
it('proves only contested same-name candidates', async () => {
const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null }
const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME }
const lone = {
agent: 'codex',
path: '/Users/ada/.codex/sessions/rollout-2026-08-20T09-00-00-lone.jsonl',
codexHome: null
}
const readSessionMetaId = vi.fn(async () => 'shared-session-id')
await expect(
dedupeCodexRolloutCopyAliases([real, managed, lone], accessors, readSessionMetaId)
).resolves.toEqual([real, lone])
expect(readSessionMetaId).toHaveBeenCalledTimes(2)
})
// Why: the proof reads run inside the scan's 130s deadline, so a superseded
// scan must stop rather than drain a whole second history copy (#17888).
it('stops proving copies once the scan is cancelled', async () => {
const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null }
const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME }
const controller = new AbortController()
controller.abort()
await expect(
dedupeCodexRolloutCopyAliases(
[real, managed],
accessors,
async () => 'shared-session-id',
controller.signal
)
).rejects.toThrow()
})
})
describe('dedupeCodexSessionsBySessionId', () => {
it('collapses a both-roots session to the real-home row', () => {
const managed = codexSession({
@@ -1,5 +1,7 @@
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { sessionSortTime } from './session-scanner-accumulator'
// Why: the session bridge and the real-home backfill hardlink one physical
@@ -120,6 +122,107 @@ export function dedupeCodexRolloutFileAliases<T>(
})
}
/** Applies cheap hardlink proof before bounded cross-volume copy proof. */
export async function dedupeCodexRolloutAliases<T>(
candidates: readonly T[],
accessors: {
isCodex: (candidate: T) => boolean
getFilePath: (candidate: T) => string
getCodexHome: (candidate: T) => string | null
getHardlinkIdentity: (candidate: T) => string | null
},
readSessionMetaId: (filePath: string) => Promise<string | null>,
signal?: AbortSignal
): Promise<T[]> {
const hardlinkDeduped = dedupeCodexRolloutFileAliases(candidates, accessors)
return dedupeCodexRolloutCopyAliases(hardlinkDeduped, accessors, readSessionMetaId, signal)
}
// Matches the scan's own parse batch width. UNC candidates are additionally
// serialized by the WSL transcript gate, so this only widens native reads.
const COPY_PROOF_READ_CONCURRENCY = 8
/**
* Drops cross-volume rollout copies only when bounded session metadata proves
* the same Codex session id. Unreadable or ambiguous candidates remain for the
* full parser and its existing post-parse identity check.
*/
export async function dedupeCodexRolloutCopyAliases<T>(
candidates: readonly T[],
accessors: {
isCodex: (candidate: T) => boolean
getFilePath: (candidate: T) => string
getCodexHome: (candidate: T) => string | null
},
readSessionMetaId: (filePath: string) => Promise<string | null>,
signal?: AbortSignal
): Promise<T[]> {
const groups = new Map<string, T[]>()
for (const candidate of candidates) {
if (!accessors.isCodex(candidate)) {
continue
}
const filePath = accessors.getFilePath(candidate)
const fileName = lastPathSegment(filePath)
if (!CODEX_ROLLOUT_FILE_NAME_PATTERN.test(fileName)) {
continue
}
const key = `${codexPathExecutionNamespace(filePath)}\0${fileName}`
const group = groups.get(key)
if (group) {
group.push(candidate)
} else {
groups.set(key, [candidate])
}
}
// Only same-name groups can alias, so the read fans out across every
// contested candidate at once rather than one group at a time — a corpus
// with a full second history copy has thousands of two-file groups.
const contested = [...groups.values()].filter((group) => group.length > 1).flat()
if (contested.length === 0) {
return [...candidates]
}
const identifiedIds = await mapWithConcurrency(
contested,
COPY_PROOF_READ_CONCURRENCY,
async (candidate) => {
throwIfAiVaultScanCancelled(signal)
return readSessionMetaId(accessors.getFilePath(candidate))
}
)
const idByCandidate = new Map<T, string | null>(
contested.map((candidate, index) => [candidate, identifiedIds[index]])
)
const aliasesToDrop = new Set<T>()
for (const group of groups.values()) {
if (group.length < 2) {
continue
}
const bestById = new Map<string, { candidate: T; rank: number; filePath: string }>()
for (const candidate of group) {
const id = idByCandidate.get(candidate)
if (!id) {
continue
}
const filePath = accessors.getFilePath(candidate)
const rank = codexSessionRootRank(accessors.getCodexHome(candidate))
const best = bestById.get(id)
if (!best || rank < best.rank || (rank === best.rank && filePath < best.filePath)) {
bestById.set(id, { candidate, rank, filePath })
}
}
for (const candidate of group) {
const id = idByCandidate.get(candidate)
if (id && bestById.get(id)?.candidate !== candidate) {
aliasesToDrop.add(candidate)
}
}
}
return candidates.filter((candidate) => !aliasesToDrop.has(candidate))
}
/**
* Collapses parsed Codex sessions that share a rollout name and session id on
* one execution host, keeping the canonical root's row. Requiring both the
@@ -14,6 +14,7 @@ import * as fsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import {
ensureSessionParseCacheLoaded,
flushSessionParseCachePersistForTests,
@@ -126,6 +127,53 @@ async function coldParseStats(path: string): Promise<SessionParseStats> {
return stats
}
/**
* Schema 2 dropped the `appVersion` equality gate: a cache written by any
* release with this schema is now replayed straight into Agent Session
* History without re-reading the transcript (#17888 — the gate forced a
* multi-gigabyte cold scan after every update). The cost of that reuse is
* that nothing else invalidates a stale row, so `SCHEMA_VERSION` is the only
* remaining compatibility signal and forgetting to bump it ships wrong data.
*
* This table is the reminder. Changing the persisted session shape — or the
* meaning of a field the parsers fill — breaks this `satisfies` and the fix
* is to bump `SCHEMA_VERSION` in session-parse-cache-persistence.ts, not to
* silently extend the list.
*/
const CACHED_SESSION_FIELDS = {
id: true,
executionHostId: true,
executionHostPlatform: true,
agent: true,
sessionId: true,
title: true,
cwd: true,
branch: true,
model: true,
filePath: true,
codexHome: true,
createdAt: true,
updatedAt: true,
modifiedAt: true,
messageCount: true,
totalTokens: true,
previewMessages: true,
previewMessagesTruncated: true,
firstUserPrompt: true,
lastUserPrompt: true,
queuedMessageCount: true,
subagentTranscriptCount: true,
resumeCommand: true,
subagent: true,
structuredSession: true
} satisfies Record<keyof AiVaultSession, true>
describe('cached session compatibility', () => {
it('pins the persisted session shape to the current parse-cache schema', () => {
expect(Object.keys(CACHED_SESSION_FIELDS).length).toBeGreaterThan(0)
})
})
describe('session parse cache persistence', () => {
it('exposes a copy of the active configuration for background scanners', () => {
const configured = { filePath: '/tmp/ai-vault-cache.json', appVersion: APP_VERSION }
@@ -197,19 +245,45 @@ describe('session parse cache persistence', () => {
expect(stats.reused).toBe(0)
})
it('ignores a cache file written by a different app version', async () => {
it('rejects the legacy schema 1 cache after parser semantics changed', async () => {
const root = await makeTempDir()
const cacheFile = join(root, 'session-parse-cache.json')
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
const transcript = await writeTranscript(root)
await parseAndPersist(transcript)
simulateRestart(cacheFile, '9.9.9-other')
const persisted = JSON.parse(await readFile(cacheFile, 'utf-8'))
persisted.schemaVersion = 1
await writeFile(cacheFile, JSON.stringify(persisted))
simulateRestart(cacheFile)
const stats = await coldParseStats(transcript)
expect(stats.fullParses).toBe(1)
expect(stats.reused).toBe(0)
})
it('reuses a schema-compatible cache written by a different app version', async () => {
const root = await makeTempDir()
const cacheFile = join(root, 'session-parse-cache.json')
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION })
const transcript = await writeTranscript(root)
const candidate = await claudeCandidate(transcript)
await parseAndPersist(transcript)
simulateRestart(cacheFile, '9.9.9-other')
await ensureSessionParseCacheLoaded()
// Deleting the transcript proves the cross-version result comes entirely
// from the schema-compatible cache and performs no transcript read.
await rm(transcript)
const stats = createSessionParseStats()
const session = await parseAgentSessionFileCached(candidate, process.platform, stats)
expect(session).not.toBeNull()
expect(stats.reused).toBe(1)
expect(stats.fullParses).toBe(0)
expect(stats.bytesRead).toBe(0)
})
it('seeding never clobbers a live in-memory entry', async () => {
const root = await makeTempDir()
const transcript = await writeTranscript(root)
@@ -266,7 +340,13 @@ describe('session parse cache persistence', () => {
vi.clearAllMocks()
await ensureSessionParseCacheLoaded()
scheduleSessionParseCachePersist({ reused: 0, incremental: 2, fullParses: 5, bytesRead: 10 })
scheduleSessionParseCachePersist({
reused: 0,
incremental: 2,
fullParses: 5,
earlyStopped: 0,
bytesRead: 10
})
await flushSessionParseCachePersistForTests()
expect(fsPromises.readFile).not.toHaveBeenCalled()
@@ -12,8 +12,9 @@ import {
type SessionParseStats
} from './session-scanner-parse-cache'
// Bump when the persisted entry layout changes; a mismatched file is discarded whole.
const SCHEMA_VERSION = 1
// Bump when the persisted entry layout or cached session semantics change; a
// mismatched file is discarded whole.
const SCHEMA_VERSION = 2
// Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write.
const SAVE_DEBOUNCE_MS = 1_500
// The payload contains transcript-derived preview text; keep it user-only
@@ -63,11 +64,13 @@ export function ensureSessionParseCacheLoaded(): Promise<void> {
}
/**
* Schedule a debounced snapshot write after a scan that parsed something.
* Reused-only scans schedule no write (the file already reflects the cache).
* Schedule a debounced snapshot write after a scan that parsed something. An
* early-stopped transcript counts: its stat key moved, so the entry must be
* re-persisted or the next launch re-reads it. Reused-only scans schedule no
* write (the file already reflects the cache).
*/
export function scheduleSessionParseCachePersist(stats: SessionParseStats): void {
if (options === null || stats.incremental + stats.fullParses <= 0) {
if (options === null || stats.incremental + stats.fullParses + stats.earlyStopped <= 0) {
return
}
const current = options
@@ -105,7 +108,7 @@ async function loadPersistedEntries(current: SessionParseCachePersistenceOptions
await sweepOrphanedTempFiles(current.filePath)
try {
const raw = await readFile(current.filePath, 'utf-8')
const entries = parsePersistedFile(JSON.parse(raw), current.appVersion)
const entries = parsePersistedFile(JSON.parse(raw))
if (entries) {
seedSessionParseCache(entries)
}
@@ -132,17 +135,14 @@ async function sweepOrphanedTempFiles(filePath: string): Promise<void> {
}
}
function parsePersistedFile(
parsed: unknown,
appVersion: string
): [string, PersistedSessionParseCacheEntry][] | null {
function parsePersistedFile(parsed: unknown): [string, PersistedSessionParseCacheEntry][] | null {
if (typeof parsed !== 'object' || parsed === null) {
return null
}
const file = parsed as Record<string, unknown>
// Why: parser output shape/semantics may change between app versions, so a
// cross-version file is discarded — one cold scan per update is the price.
if (file.schemaVersion !== SCHEMA_VERSION || file.appVersion !== appVersion) {
// Why: application releases that keep this schema promise compatible cached
// session semantics, so an update does not force a multi-gigabyte cold scan.
if (file.schemaVersion !== SCHEMA_VERSION || typeof file.appVersion !== 'string') {
return null
}
if (!Array.isArray(file.entries)) {
@@ -0,0 +1,269 @@
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseCodexSessionFile } from './session-scanner-codex-parser'
import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path'
import {
createSessionParseStats,
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from './session-scanner-parse-cache'
import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types'
// Codex serializes `RolloutLine` as `timestamp` + the adjacently tagged
// `RolloutItem`; payload enums (`ResponseItem`, `EventMsg`) are internally
// tagged, so their own `type` leads. Every fixture below matches that spelling.
const PAD = 'x'.repeat(2048)
function record(type: string, payload: Record<string, unknown>, timestamp: string): string {
return JSON.stringify({ timestamp, type, payload })
}
// Padded past the fast path's prefix limit: an undersized record is parsed
// exactly either way, so only oversized fixtures exercise the prefix match.
function paddedPayload(payloadType: string, extra: Record<string, unknown> = {}) {
return { type: payloadType, ...extra, pad: PAD }
}
let tempRoots: string[] = []
async function writeTranscript(lines: string[], name: string): Promise<FileWithMtime> {
const root = await mkdtemp(join(tmpdir(), 'orca-codex-fast-path-'))
tempRoots.push(root)
const path = join(root, 'sessions', '2026', '08', '20', name)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, `${lines.join('\n')}\n`)
const fileStat = await stat(path)
return {
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString(),
sizeBytes: fileStat.size
}
}
beforeEach(() => {
resetSessionParseCacheForTests()
})
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('readCodexTimelineOnlyRecord', () => {
// The fast path is the complement of what `consumeCodexRecordLine` reads, so
// every record that feeds a visible field must fail the prefix match even
// when it is large — a >1KiB opening prompt is ordinary, and it is the title.
it.each([
['session_meta', record('session_meta', { id: 'a', pad: PAD }, '2026-08-20T10:00:00.000Z')],
[
'turn_context',
record('turn_context', { cwd: '/repo', pad: PAD }, '2026-08-20T10:00:00.000Z')
],
[
'response_item/message',
record(
'response_item',
paddedPayload('message', { role: 'user' }),
'2026-08-20T10:00:00.000Z'
)
],
[
'event_msg/user_message',
record('event_msg', paddedPayload('user_message'), '2026-08-20T10:00:00.000Z')
],
[
'event_msg/agent_message',
record('event_msg', paddedPayload('agent_message'), '2026-08-20T10:00:00.000Z')
],
[
'event_msg/item_completed',
record('event_msg', paddedPayload('item_completed'), '2026-08-20T10:00:00.000Z')
],
[
'event_msg/token_count',
record('event_msg', paddedPayload('token_count'), '2026-08-20T10:00:00.000Z')
]
])('keeps %s on the full parser', (_label, line) => {
expect(readCodexTimelineOnlyRecord(Buffer.from(line))).toBeNull()
})
it.each([
['compacted', record('compacted', { message: PAD }, '2026-08-20T10:00:00.000Z')],
[
'response_item/function_call_output',
record('response_item', paddedPayload('function_call_output'), '2026-08-20T10:00:00.000Z')
],
[
'response_item/image_generation_call',
record('response_item', paddedPayload('image_generation_call'), '2026-08-20T10:00:00.000Z')
],
[
'response_item/reasoning',
record('response_item', paddedPayload('reasoning'), '2026-08-20T10:00:00.000Z')
],
[
'event_msg/task_complete',
record('event_msg', paddedPayload('task_complete'), '2026-08-20T10:00:00.000Z')
],
[
'event_msg/exec_command_output_delta',
record('event_msg', paddedPayload('exec_command_output_delta'), '2026-08-20T10:00:00.000Z')
]
])('takes %s off the full parser', (_label, line) => {
expect(readCodexTimelineOnlyRecord(Buffer.from(line))).toEqual({
timestamp: '2026-08-20T10:00:00.000Z'
})
})
it('falls back for small, reordered, or prefix-ambiguous records', () => {
const small = record('compacted', { message: 'short' }, '2026-08-20T10:00:00.000Z')
expect(readCodexTimelineOnlyRecord(Buffer.from(small))).toBeNull()
const reordered = `${JSON.stringify({ type: 'compacted', timestamp: '2026-08-20T10:00:00.000Z', payload: { message: PAD } })}`
expect(readCodexTimelineOnlyRecord(Buffer.from(reordered))).toBeNull()
// `type` pushed past the bounded prefix leaves the payload unidentified.
const lateType = `{"timestamp":"2026-08-20T10:00:00.000Z","type":"response_item","payload":{"pad":"${PAD}","type":"reasoning"}}`
expect(readCodexTimelineOnlyRecord(Buffer.from(lateType))).toBeNull()
})
})
describe('Codex resumable parser fast path', () => {
it('matches the one-shot parser without JSON-parsing proven irrelevant large records', async () => {
// Distinct fillers so the assertion can name which large records the fast
// path skipped and which correctly fell back to the full parser.
const skippedFiller = `skipped-${'x'.repeat(2 * 1024 * 1024)}`
const fallbackFiller = `fallback-${'x'.repeat(2 * 1024 * 1024)}`
const file = await writeTranscript(
[
record(
'session_meta',
{ id: 'fast-path-session', cwd: '/repo/app' },
'2026-08-20T10:00:00.000Z'
),
// A long opening prompt is the session title; it must survive the size gate.
record(
'response_item',
{
type: 'message',
role: 'user',
content: [{ type: 'text', text: `Keep visible messages exact ${PAD}` }]
},
'2026-08-20T10:00:01.000Z'
),
record(
'response_item',
{ type: 'function_call_output', output: skippedFiller },
'2026-08-20T10:00:02.000Z'
),
record('compacted', { message: skippedFiller }, '2026-08-20T10:00:03.000Z'),
record(
'event_msg',
{ type: 'token_count', info: { total_token_usage: { total_tokens: 120 } } },
'2026-08-20T10:00:04.000Z'
),
record(
'response_item',
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Visible answer' }]
},
'2026-08-20T10:00:05.000Z'
),
// A nested `payload.type` must not be mistaken for the record's own.
record(
'event_msg',
{
metadata: { payload: { type: 'turn_aborted' } },
type: 'token_count',
info: { total_token_usage: { total_tokens: 150 } }
},
'2026-08-20T10:00:06.000Z'
),
record('event_msg', { type: 'future_event', value: 1 }, '2026-08-20T10:00:07.000Z'),
// Reordered envelopes take the compatibility fallback.
JSON.stringify({
type: 'future_record',
timestamp: '2026-08-20T10:00:08.000Z',
payload: { value: fallbackFiller }
}),
record('world_state', { state: skippedFiller }, '2026-08-20T10:00:09.000Z')
],
'rollout-2026-08-20T10-00-00-fast-path.jsonl'
)
const expected = await parseCodexSessionFile(file, process.platform, null)
const candidate: SessionFileCandidate = { agent: 'codex', file, codexHome: null }
const parseSpy = vi.spyOn(JSON, 'parse')
const actual = await parseAgentSessionFileCached(candidate, process.platform)
expect(actual).toEqual(expected)
expect(actual).toMatchObject({
messageCount: 2,
totalTokens: 150,
updatedAt: '2026-08-20T10:00:09.000Z'
})
const parsedInputs = parseSpy.mock.calls
.map(([input]) => input)
.filter((input): input is string => typeof input === 'string')
expect(parsedInputs.some((input) => input.includes('skipped-xxx'))).toBe(false)
// A reordered envelope is not proven irrelevant, so it still parses whole.
expect(parsedInputs.some((input) => input.includes('fallback-xxx'))).toBe(true)
})
it('stops reading as soon as session_meta rejects a worker transcript', async () => {
const file = await writeTranscript(
[
record(
'session_meta',
{ id: 'worker-session', source: { subagent: { thread_spawn: true } } },
'2026-08-20T10:00:00.000Z'
),
record('compacted', { message: 'x'.repeat(4 * 1024 * 1024) }, '2026-08-20T10:00:01.000Z')
],
'rollout-2026-08-20T10-00-00-worker.jsonl'
)
const stats = createSessionParseStats()
const session = await parseAgentSessionFileCached(
{ agent: 'codex', file, codexHome: null },
process.platform,
stats
)
expect(session).toBeNull()
expect(stats.bytesRead).toBeLessThan((file.sizeBytes ?? 0) / 2)
})
it('never re-reads a worker transcript that later grew', async () => {
const file = await writeTranscript(
[
record(
'session_meta',
{ id: 'worker-session', thread_source: 'subagent' },
'2026-08-20T10:00:00.000Z'
),
record('compacted', { message: 'x'.repeat(1024 * 1024) }, '2026-08-20T10:00:01.000Z')
],
'rollout-2026-08-20T10-00-00-worker-grown.jsonl'
)
const candidate: SessionFileCandidate = { agent: 'codex', file, codexHome: null }
await parseAgentSessionFileCached(candidate, process.platform)
const grown = createSessionParseStats()
const session = await parseAgentSessionFileCached(
{ ...candidate, file: { ...file, mtimeMs: file.mtimeMs + 1, sizeBytes: 99_000_000 } },
process.platform,
grown
)
expect(session).toBeNull()
expect(grown.bytesRead).toBe(0)
// Reported apart from `incremental` so the scan span shows a dismissal, not
// an incremental parse that happened to read nothing.
expect(grown).toMatchObject({ earlyStopped: 1, incremental: 0, fullParses: 0 })
})
})
@@ -34,6 +34,7 @@ import {
subtractCodexUsage
} from './session-scanner-values'
import { remoteSessionContentLines } from './remote-session-content-lines'
import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path'
export async function parseCodexSessionFile(
file: FileWithMtime,
@@ -270,6 +271,15 @@ function codexResumeStateFromParseState(
): ResumableSessionParseState {
return {
consumeLine: (line) => consumeCodexRecordLine(state, line),
consumeLineBytes: (line) => {
const timelineOnlyRecord = readCodexTimelineOnlyRecord(line)
if (timelineOnlyRecord) {
updateTimeline(state.accumulator, timelineOnlyRecord.timestamp)
} else {
consumeCodexRecordLine(state, line.toString('utf8'))
}
},
shouldStop: () => state.rejectedWorkerSession,
clone: () =>
codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader),
touchFile: (file) => {
@@ -305,12 +315,8 @@ async function parseCodexSessionLines(args: {
})
}
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
const threadSource = extractCodexThreadSource(payload)
const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource)
if (threadSource) {
return threadSource.toLowerCase() !== 'user'
}
@@ -0,0 +1,47 @@
// Records below this size are decoded and parsed exactly: JSON.parse on a
// kilobyte costs less than the risk of a prefix heuristic, and the scan cost
// this path exists to remove is entirely in megabyte-scale records.
const CODEX_RECORD_PREFIX_LIMIT = 1024
// serde emits `RolloutLine` as `timestamp` then the adjacently tagged
// `RolloutItem` (`type`, `payload`), and every tagged payload enum writes its
// own `type` first. Anything spelled differently takes the full parser.
const CODEX_RECORD_ENVELOPE_PATTERN = /^\{"timestamp":"([^"]+)","type":"([^"]+)","payload":/
const CODEX_PAYLOAD_TYPE_PATTERN = /^\{"type":"([^"]+)"/
// Every record `consumeCodexRecordLine` reads beyond the timeline clock; the
// fast path is the complement, so teaching the parser a new record means
// adding it here or the scanner silently stops seeing it.
// `session-scanner-codex-fast-path.test.ts` pins each entry.
const PARSED_RECORD_TYPES = new Set(['session_meta', 'turn_context'])
const PARSED_RESPONSE_ITEM_TYPES = new Set(['message'])
const PARSED_EVENT_TYPES = new Set([
'item_completed',
'user_message',
'agent_message',
'token_count'
])
/** Returns the timestamp only when the record cannot affect other visible session fields. */
export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } | null {
if (line.length <= CODEX_RECORD_PREFIX_LIMIT) {
return null
}
const prefix = line.toString('utf8', 0, CODEX_RECORD_PREFIX_LIMIT)
const envelope = CODEX_RECORD_ENVELOPE_PATTERN.exec(prefix)
const timestamp = envelope?.[1]
const recordType = envelope?.[2]
if (!timestamp || !recordType || PARSED_RECORD_TYPES.has(recordType)) {
return null
}
if (recordType !== 'response_item' && recordType !== 'event_msg') {
return { timestamp }
}
// A payload whose type is unreadable from the bounded prefix stays ambiguous.
const payloadType = CODEX_PAYLOAD_TYPE_PATTERN.exec(prefix.slice(envelope[0].length))?.[1]
if (!payloadType) {
return null
}
const parsedPayloadTypes =
recordType === 'response_item' ? PARSED_RESPONSE_ITEM_TYPES : PARSED_EVENT_TYPES
return parsedPayloadTypes.has(payloadType) ? null : { timestamp }
}
@@ -0,0 +1,83 @@
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
const NEWLINE_BYTE = 0x0a
const CARRIAGE_RETURN_BYTE = 0x0d
type JsonlReadResult = {
consumedThrough: number
trailingPartialLine: string | null
bytesRead: number
}
// Byte-accurate JSONL fold: offsets count bytes rather than decoded UTF-8
// characters, so an incremental read resumes at an exact line boundary.
export async function consumeCompleteJsonlLines(args: {
path: string
start: number
onLine: (line: string) => void
onLineBytes?: (line: Buffer) => void
shouldStop?: () => boolean
}): Promise<JsonlReadResult> {
if (args.shouldStop?.()) {
return { consumedThrough: args.start, trailingPartialLine: null, bytesRead: 0 }
}
let consumedThrough = args.start
let bytesRead = 0
// A piece list avoids O(record^2) copying when one record spans many chunks.
let remainderParts: Buffer[] = []
let remainderLength = 0
let stopped = false
const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan')
for await (const chunk of stream as AsyncIterable<Buffer>) {
bytesRead += chunk.length
if (!chunk.includes(NEWLINE_BYTE)) {
remainderParts.push(chunk)
remainderLength += chunk.length
continue
}
const data =
remainderLength > 0
? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length)
: chunk
remainderParts = []
remainderLength = 0
let lineStart = 0
let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
while (newlineIndex !== -1) {
let lineEnd = newlineIndex
if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) {
lineEnd--
}
if (args.onLineBytes) {
args.onLineBytes(data.subarray(lineStart, lineEnd))
} else {
args.onLine(data.toString('utf-8', lineStart, lineEnd))
}
lineStart = newlineIndex + 1
if (args.shouldStop?.()) {
stopped = true
break
}
newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
}
consumedThrough += lineStart
if (stopped) {
remainderParts = []
remainderLength = 0
break
}
if (lineStart < data.length) {
// Copy the tail so retaining it does not pin the whole chunk buffer.
remainderParts = [Buffer.from(data.subarray(lineStart))]
remainderLength = data.length - lineStart
}
}
return {
consumedThrough,
trailingPartialLine:
remainderLength > 0 ? Buffer.concat(remainderParts, remainderLength).toString('utf-8') : null,
bytesRead
}
}
@@ -163,7 +163,9 @@ describe('codex-specific resume behavior', () => {
process.platform,
stats
)
expect(stats.incremental).toBe(1)
// The append is dismissed without a read, so it is an early stop rather
// than an incremental parse.
expect(stats).toMatchObject({ earlyStopped: 1, incremental: 0 })
expect(grown).toBeNull()
})
@@ -1,7 +1,4 @@
import {
openTranscriptReadStream,
readTranscriptSlice
} from '../native-chat/wsl-transcript-fs-access'
import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
@@ -16,13 +13,12 @@ import { countSubagentTranscripts } from './session-scanner-subagent-transcripts
import { countOmpSubagentTranscripts } from './session-scanner-omp-subagent-transcripts'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
import { refreshCachedCodexTitle } from './session-scanner-codex-cached-title'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a
// full steady-state result set stays resident between forced rescans.
const MAX_CACHE_ENTRIES = 4096
const NEWLINE_BYTE = 0x0a
const CARRIAGE_RETURN_BYTE = 0x0d
type ResumePoint = {
state: ResumableSessionParseState
@@ -88,11 +84,15 @@ export type SessionParseStats = {
reused: number
incremental: number
fullParses: number
// Transcripts the parser already excluded (Codex workers), re-listed after a
// write and dismissed without reading. Counted apart from `incremental` so a
// scan span still shows how much work the early stop actually removed.
earlyStopped: number
bytesRead: number
}
export function createSessionParseStats(): SessionParseStats {
return { reused: 0, incremental: 0, fullParses: 0, bytesRead: 0 }
return { reused: 0, incremental: 0, fullParses: 0, earlyStopped: 0, bytesRead: 0 }
}
const cache = new Map<string, SessionParseCacheEntry>()
@@ -264,8 +264,13 @@ async function parseResumableCandidate(args: {
// or the next resume would double-count the lines applied before the error.
const state = canResume ? resume.state.clone() : args.stateFactory()
const startOffset = canResume ? resume.byteOffset : 0
// Mirrors the reader's entry guard so a dismissed transcript is not reported
// as an incremental parse that read nothing.
const stoppedBeforeRead = state.shouldStop?.() === true
if (args.stats) {
if (canResume) {
if (stoppedBeforeRead) {
args.stats.earlyStopped++
} else if (canResume) {
args.stats.incremental++
} else {
args.stats.fullParses++
@@ -275,7 +280,11 @@ async function parseResumableCandidate(args: {
const readResult = await consumeCompleteJsonlLines({
path: file.path,
start: startOffset,
onLine: (line) => state.consumeLine(line)
onLine: (line) => state.consumeLine(line),
// Bound: the optional hooks are declared as methods, so a parser written
// with method syntax must not lose `this` on the way into the reader.
onLineBytes: state.consumeLineBytes?.bind(state),
shouldStop: state.shouldStop?.bind(state)
})
if (args.stats) {
args.stats.bytesRead += readResult.bytesRead
@@ -311,70 +320,3 @@ async function endsWithNewlineAt(path: string, offset: number): Promise<boolean>
const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan')
return slice.length === 1 && slice[0] === NEWLINE_BYTE
}
type JsonlReadResult = {
consumedThrough: number
trailingPartialLine: string | null
bytesRead: number
}
// Byte-accurate replacement for readline: offsets must count bytes (not
// UTF-8-decoded characters) so a resumed read starts exactly where the last
// complete line ended.
async function consumeCompleteJsonlLines(args: {
path: string
start: number
onLine: (line: string) => void
}): Promise<JsonlReadResult> {
let consumedThrough = args.start
let bytesRead = 0
// Why a piece list: re-joining the partial line with every chunk made one
// oversized record (a big tool result) cost O(record^2). Joining once, when a
// newline finally arrives, keeps it linear.
let remainderParts: Buffer[] = []
let remainderLength = 0
const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan')
for await (const chunk of stream as AsyncIterable<Buffer>) {
bytesRead += chunk.length
// Why check the chunk alone: the pieces held over are all mid-line, so none
// of them contains a newline.
if (!chunk.includes(NEWLINE_BYTE)) {
remainderParts.push(chunk)
remainderLength += chunk.length
continue
}
const data =
remainderLength > 0
? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length)
: chunk
remainderParts = []
remainderLength = 0
let lineStart = 0
let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
while (newlineIndex !== -1) {
let lineEnd = newlineIndex
if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) {
lineEnd--
}
args.onLine(data.toString('utf-8', lineStart, lineEnd))
lineStart = newlineIndex + 1
newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart)
}
consumedThrough += lineStart
if (lineStart < data.length) {
// Copy the tail so retaining it doesn't pin the whole chunk buffer.
remainderParts = [Buffer.from(data.subarray(lineStart))]
remainderLength = data.length - lineStart
}
}
const trailingPartialLine =
remainderLength > 0 ? Buffer.concat(remainderParts, remainderLength).toString('utf-8') : null
return {
consumedThrough,
trailingPartialLine,
bytesRead
}
}
@@ -92,6 +92,11 @@ export type ResumableParseFinalizeOptions = {
// read or a display-only trailing line can never corrupt the cached fold.
export type ResumableSessionParseState = {
consumeLine(line: string): void
// Optional zero-copy path for parsers that can reject irrelevant records
// from a bounded byte prefix before decoding a potentially huge JSONL line.
consumeLineBytes?(line: Buffer): void
// Lets a parser terminate an excluded transcript without draining the file.
shouldStop?(): boolean
clone(): ResumableSessionParseState
// Refresh per-scan file metadata (mtime display string) without re-parsing.
touchFile(file: FileWithMtime): void
+7 -3
View File
@@ -8,9 +8,10 @@ import { withSpan } from '../observability/tracer'
import { sessionSortTime } from './session-scanner-accumulator'
import {
codexRolloutHardlinkIdentity,
dedupeCodexRolloutFileAliases,
dedupeCodexRolloutAliases,
dedupeCodexSessionsBySessionId
} from './codex-session-root-dedup'
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
import {
createAntigravityWorkspaceResolver,
readLocalAntigravityHistory,
@@ -82,7 +83,7 @@ export async function scanAiVaultSessions(
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
throwIfAiVaultScanCancelled(options.signal)
const candidates = dedupeCodexRolloutFileAliases(
const candidates = await dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map((file): SessionFileCandidate => ({
@@ -107,7 +108,9 @@ export async function scanAiVaultSessions(
getFilePath: (candidate) => candidate.file.path,
getCodexHome: (candidate) => candidate.codexHome,
getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file)
}
},
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
const parsedSessions = await parseSessionCandidates({
@@ -144,6 +147,7 @@ export async function scanAiVaultSessions(
span.setAttribute('reused', parseStats.reused)
span.setAttribute('incremental', parseStats.incremental)
span.setAttribute('fullParses', parseStats.fullParses)
span.setAttribute('earlyStopped', parseStats.earlyStopped)
span.setAttribute('bytesRead', parseStats.bytesRead)
span.setAttribute('issues', issues.length)
+240
View File
@@ -0,0 +1,240 @@
import {
chmodSync,
mkdirSync,
mkdtempSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
hasAppImagePathEnvironment,
resolveAppImageRuntimeIdentity
} from './appimage-runtime-identity'
const fixtureRoots: string[] = []
function appImageHeader(machine: number): Buffer {
const header = Buffer.alloc(64)
header.set([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01])
header.set([0x41, 0x49, 0x02], 8)
header.writeUInt16LE(machine, 18)
return header
}
function createFixture(appDirName = '.mount_Orca123', machine = 0x3e) {
const root = mkdtempSync(join(tmpdir(), 'orca-appimage-identity-'))
const appImagePath = join(root, 'Applications', 'Orca.AppImage')
const appDirPath = join(root, appDirName)
const execPath = join(appDirPath, 'orca-ide')
const resourcesPath = join(appDirPath, 'resources')
const packageTypePath = join(resourcesPath, 'package-type')
const packageMarkerPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json')
fixtureRoots.push(root)
mkdirSync(dirname(appImagePath), { recursive: true })
mkdirSync(dirname(packageMarkerPath), { recursive: true })
writeFileSync(appImagePath, appImageHeader(machine), { mode: 0o755 })
writeFileSync(join(appDirPath, 'AppRun'), '#!/bin/sh\n', { mode: 0o755 })
writeFileSync(execPath, appImageHeader(machine), { mode: 0o755 })
writeFileSync(
packageMarkerPath,
JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })
)
return {
root,
appImagePath,
appDirPath,
execPath,
resourcesPath,
packageTypePath,
packageMarkerPath,
identity: {
platform: 'linux' as const,
environment: { APPIMAGE: appImagePath, APPDIR: appDirPath },
execPath,
resourcesPath
}
}
}
afterEach(() => {
for (const root of fixtureRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe.skipIf(process.platform === 'win32')('resolveAppImageRuntimeIdentity', () => {
it.each([
['x64', 0x3e, '.mount_Orca123'],
['ARM64 extract-and-run', 0xb7, 'appimage_extracted_123']
])('accepts a complete %s AppImage runtime', (_architecture, machine, appDirName) => {
const fixture = createFixture(appDirName, machine)
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toEqual({
appImagePath: fixture.appImagePath
})
})
it('accepts an AppImage moved independently of its runtime directory', () => {
const fixture = createFixture()
const movedPath = join(fixture.root, 'Moved Apps', 'Orca current.AppImage')
mkdirSync(dirname(movedPath), { recursive: true })
renameSync(fixture.appImagePath, movedPath)
fixture.identity.environment.APPIMAGE = movedPath
expect(resolveAppImageRuntimeIdentity(fixture.identity)?.appImagePath).toBe(movedPath)
})
it('accepts the exact AppImage package-type marker', () => {
const fixture = createFixture()
rmSync(fixture.packageMarkerPath)
writeFileSync(fixture.packageTypePath, 'AppImage')
expect(resolveAppImageRuntimeIdentity(fixture.identity)).not.toBeNull()
})
it.each([
['APPIMAGE', undefined],
['APPIMAGE', 'relative/Orca.AppImage'],
['APPDIR', undefined],
['APPDIR', 'relative/mount'],
['APPIMAGE', '/tmp/Orca\0.AppImage']
] as const)('rejects an unusable %s value', (key, value) => {
const fixture = createFixture()
expect(
resolveAppImageRuntimeIdentity({
...fixture.identity,
environment: { ...fixture.identity.environment, [key]: value }
})
).toBeNull()
})
it.each([
['an ordinary executable', (path: string) => writeFileSync(path, '#!/bin/sh\n')],
[
'bad ELF magic',
(path: string) => {
const header = appImageHeader(0x3e)
header[0] = 0
writeFileSync(path, header)
}
],
[
'bad AppImage type magic',
(path: string) => {
const header = appImageHeader(0x3e)
header[10] = 1
writeFileSync(path, header)
}
],
['a non-executable file', (path: string) => chmodSync(path, 0o644)],
[
'a directory',
(path: string) => {
rmSync(path)
mkdirSync(path)
}
]
])('rejects APPIMAGE pointing to %s', (_case, mutate) => {
const fixture = createFixture()
mutate(fixture.appImagePath)
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull()
})
it.each([
[
'AppRun',
(fixture: ReturnType<typeof createFixture>) => rmSync(join(fixture.appDirPath, 'AppRun'))
],
[
'an executable AppRun',
(fixture: ReturnType<typeof createFixture>) =>
chmodSync(join(fixture.appDirPath, 'AppRun'), 0o644)
],
[
'the Orca package marker',
(fixture: ReturnType<typeof createFixture>) => rmSync(fixture.packageMarkerPath)
]
])('rejects a runtime missing %s', (_case, mutate) => {
const fixture = createFixture()
mutate(fixture)
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull()
})
it('rejects forged AppImage variables around an ordinary packaged layout', () => {
const fixture = createFixture()
rmSync(join(fixture.appDirPath, 'AppRun'))
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull()
})
it('rejects a package marker for a different application', () => {
const fixture = createFixture()
writeFileSync(
fixture.packageMarkerPath,
JSON.stringify({ name: 'foreign-compiled-output', type: 'commonjs' })
)
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull()
})
it('rejects an inexact package-type marker without the Orca fallback', () => {
const fixture = createFixture()
rmSync(fixture.packageMarkerPath)
writeFileSync(fixture.packageTypePath, 'appimage')
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull()
})
it('rejects payload evidence that resolves outside APPDIR', () => {
const fixture = createFixture()
const externalAppRun = join(fixture.root, 'foreign-AppRun')
writeFileSync(externalAppRun, '#!/bin/sh\n', { mode: 0o755 })
rmSync(join(fixture.appDirPath, 'AppRun'))
symlinkSync(externalAppRun, join(fixture.appDirPath, 'AppRun'))
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull()
})
it('rejects a package marker that resolves outside APPDIR', () => {
const fixture = createFixture()
const externalMarker = join(fixture.root, 'foreign-package.json')
writeFileSync(
externalMarker,
JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs' })
)
rmSync(fixture.packageMarkerPath)
symlinkSync(externalMarker, fixture.packageMarkerPath)
expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull()
})
it('rejects inherited AppImage variables around a non-AppImage executable', () => {
const fixture = createFixture()
expect(
resolveAppImageRuntimeIdentity({
...fixture.identity,
execPath: join(fixture.root, 'opt', 'Orca', 'orca-ide'),
resourcesPath: join(fixture.root, 'opt', 'Orca', 'resources')
})
).toBeNull()
})
it('rejects a resources path outside the runtime root', () => {
const fixture = createFixture()
expect(
resolveAppImageRuntimeIdentity({
...fixture.identity,
resourcesPath: `${fixture.appDirPath}-other/resources`
})
).toBeNull()
})
it('rejects the identity off Linux', () => {
const fixture = createFixture()
expect(resolveAppImageRuntimeIdentity({ ...fixture.identity, platform: 'darwin' })).toBeNull()
})
})
describe('hasAppImagePathEnvironment', () => {
it('requires the AppImage file path before treating the runtime as verifiable', () => {
expect(hasAppImagePathEnvironment({ APPIMAGE: '/tmp/Orca.AppImage' })).toBe(true)
expect(hasAppImagePathEnvironment({ APPDIR: '/tmp/.mount_Orca123' })).toBe(false)
expect(hasAppImagePathEnvironment({ APPIMAGE: '', APPDIR: '/tmp/.mount_Orca123' })).toBe(false)
})
})
+196
View File
@@ -0,0 +1,196 @@
import {
closeSync,
constants,
fstatSync,
openSync,
readSync,
realpathSync,
statSync,
type Stats
} from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
const APPIMAGE_HEADER_LENGTH = 11
const ORCA_PACKAGE_MARKER_MAX_BYTES = 1_024
const PACKAGE_TYPE_MARKER_MAX_BYTES = 32
export type AppImageRuntimeIdentity = {
appImagePath: string
}
export type AppImageRuntimeIdentityInput = {
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
execPath?: unknown
resourcesPath?: unknown
}
function isAbsolutePath(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && !value.includes('\0') && isAbsolute(value)
}
function readExact(fd: number, length: number): Buffer | null {
const bytes = Buffer.alloc(length)
let offset = 0
while (offset < length) {
const count = readSync(fd, bytes, offset, length - offset, offset)
if (count === 0) {
return null
}
offset += count
}
return bytes
}
function inspectRegularFile<T>(
filePath: string,
inspect: (fd: number, stats: Stats) => T
): T | null {
let fd: number | undefined
try {
fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK)
const stats = fstatSync(fd)
return stats.isFile() ? inspect(fd, stats) : null
} catch {
return null
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
}
function hasAppImageHeader(appImagePath: string): boolean {
return (
inspectRegularFile(appImagePath, (fd, stats) => {
const header = readExact(fd, APPIMAGE_HEADER_LENGTH)
return (
(stats.mode & 0o111) !== 0 &&
header?.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) === true &&
header.subarray(8, 11).equals(Buffer.from([0x41, 0x49, 0x02]))
)
}) === true
)
}
function isInside(rootPath: string, candidatePath: string): boolean {
const candidateRelative = relative(rootPath, candidatePath)
return (
candidateRelative.length > 0 &&
candidateRelative !== '..' &&
!candidateRelative.startsWith(`..${sep}`) &&
!isAbsolute(candidateRelative)
)
}
function isExecutablePayloadFile(runtimeRoot: string, filePath: string): boolean {
try {
const canonicalPath = realpathSync(filePath)
const stats = statSync(canonicalPath)
return stats.isFile() && (stats.mode & 0o111) !== 0 && isInside(runtimeRoot, canonicalPath)
} catch {
return false
}
}
function readPayloadMarker(
runtimeRoot: string,
markerPath: string,
maxBytes: number
): string | null {
try {
const canonicalPath = realpathSync(markerPath)
if (!isInside(runtimeRoot, canonicalPath)) {
return null
}
return inspectRegularFile(canonicalPath, (fd, stats) =>
stats.size === 0 || stats.size > maxBytes
? null
: (readExact(fd, stats.size)?.toString('utf8') ?? null)
)
} catch {
return null
}
}
function hasAppImagePackageEvidence(runtimeRoot: string, resourcesPath: string): boolean {
const packageType = readPayloadMarker(
runtimeRoot,
join(resourcesPath, 'package-type'),
PACKAGE_TYPE_MARKER_MAX_BYTES
)
if (packageType === 'AppImage') {
return true
}
const content = readPayloadMarker(
runtimeRoot,
join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json'),
ORCA_PACKAGE_MARKER_MAX_BYTES
)
try {
const marker: unknown = JSON.parse(content ?? '')
return (
typeof marker === 'object' &&
marker !== null &&
'name' in marker &&
marker.name === 'orca-compiled-output' &&
'type' in marker &&
marker.type === 'commonjs'
)
} catch {
return false
}
}
/** Returns whether the process inherited an AppImage file path to validate. */
export function hasAppImagePathEnvironment(environment: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(environment.APPIMAGE)
}
export function resolveAppImageRuntimeIdentity(
input: AppImageRuntimeIdentityInput = {}
): AppImageRuntimeIdentity | null {
if ((input.platform ?? process.platform) !== 'linux') {
return null
}
const environment = input.environment ?? process.env
const appImagePath = environment.APPIMAGE
const appDirPath = environment.APPDIR
const execPath = input.execPath ?? process.execPath
const resourcesPath = input.resourcesPath ?? process.resourcesPath
if (
!isAbsolutePath(appImagePath) ||
!isAbsolutePath(appDirPath) ||
!isAbsolutePath(execPath) ||
!isAbsolutePath(resourcesPath)
) {
return null
}
const runtimeRoot = resolve(appDirPath)
if (
resolve(dirname(execPath)) !== runtimeRoot ||
resolve(resourcesPath) !== resolve(join(runtimeRoot, 'resources')) ||
!hasAppImageHeader(appImagePath)
) {
return null
}
try {
const realRuntimeRoot = realpathSync(runtimeRoot)
if (
realpathSync(resourcesPath) !== join(realRuntimeRoot, 'resources') ||
!isExecutablePayloadFile(realRuntimeRoot, execPath) ||
!isExecutablePayloadFile(realRuntimeRoot, join(runtimeRoot, 'AppRun')) ||
!hasAppImagePackageEvidence(realRuntimeRoot, resourcesPath)
) {
return null
}
} catch {
return null
}
return { appImagePath }
}
@@ -274,9 +274,12 @@ describe('artifact create intent store', () => {
).toThrow(/unsupported format/)
})
it('persists a 5 MiB escaped artifact within the recovery limit', async () => {
it('persists an escaped artifact within the recovery limit', async () => {
const userDataPath = await createUserDataPath()
const nearLimitBody = { ...body, content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES) }
const nearLimitBody = {
...body,
content: '"'.repeat(Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 2))
}
expect(
artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody })
).toBeLessThanOrEqual(ARTIFACT_MAX_REQUEST_BYTES)
@@ -307,7 +310,7 @@ describe('artifact create intent store', () => {
'key-a',
{ ...body, content: 'x'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1) }
)
).toThrow(/5 MiB limit/)
).toThrow(/10 MiB limit/)
})
it('rejects a recovery body whose escaped request exceeds the transport budget', async () => {
@@ -207,7 +207,7 @@ export function getOrCreateArtifactCreateIntent(
body: ArtifactWriteBody
): ArtifactCreateIntent {
if (artifactContentByteLength(body.content) > ARTIFACT_MAX_CONTENT_BYTES) {
throw new Error('Artifact content exceeds the 5 MiB limit.')
throw new Error('Artifact content exceeds the 10 MiB limit.')
}
if (artifactIntentRequestByteLength(sourceKey, body) > ARTIFACT_MAX_REQUEST_BYTES) {
throw new Error('Artifact create recovery record exceeds the supported size.')
@@ -0,0 +1,185 @@
import { existsSync, readFileSync } from 'node:fs'
import type { BrowserScreenshotResult, BrowserEvalResult } from '../../shared/runtime-types'
import { BrowserError } from './cdp-bridge'
import { captureFullPageScreenshot } from './cdp-screenshot'
import { acquireElectronDebugger } from './electron-debugger-lease'
import { AgentBrowserBridgeUtilityCommands } from './agent-browser-bridge-utility-commands'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBridgeUtilityCommands {
async screenshot(
format?: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserScreenshotResult> {
// Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64.
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName) => {
return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format)
},
{ ensureVisible: false }
)
}
async fullPageScreenshot(
format?: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserScreenshotResult> {
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName, target) => {
return this.captureFullPageScreenshotCommand(
sessionName,
target.webContentsId,
500,
format === 'jpeg' ? 'jpeg' : 'png'
)
},
{ ensureVisible: false }
)
}
private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult {
const parsed = raw as { path?: string } | undefined
if (!parsed?.path) {
throw new BrowserError('browser_error', 'Screenshot returned no file path')
}
if (!existsSync(parsed.path)) {
throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`)
}
const data = readFileSync(parsed.path).toString('base64')
return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult
}
private async captureScreenshotCommand(
sessionName: string,
commandArgs: string[],
settleMs: number,
format?: string
): Promise<BrowserScreenshotResult> {
return this.withSerializedScreenshotAccess(async () => {
const session = this.sessions.get(sessionName)
const restore = session
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
: () => {}
try {
// Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first.
await new Promise((r) => setTimeout(r, settleMs))
const raw = await this.execAgentBrowser(sessionName, commandArgs)
return this.readScreenshotFromResult(raw, format)
} finally {
restore()
}
})
}
private async captureFullPageScreenshotCommand(
sessionName: string,
webContentsId: number,
settleMs: number,
format: 'png' | 'jpeg'
): Promise<BrowserScreenshotResult> {
return this.withSerializedScreenshotAccess(async () => {
const session = this.sessions.get(sessionName)
const restore = session
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
: () => {}
try {
// Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface.
await new Promise((r) => setTimeout(r, settleMs))
const wc = this.getWebContents(webContentsId)
if (!wc) {
throw new BrowserError('browser_tab_not_found', 'Tab is no longer available')
}
return await captureFullPageScreenshot(wc, format)
} catch (error) {
throw new BrowserError('browser_error', (error as Error).message)
} finally {
restore()
}
})
}
private async withSerializedScreenshotAccess<T>(execute: () => Promise<T>): Promise<T> {
const previousTurn = this.screenshotTurn.catch(() => {})
let releaseTurn!: () => void
this.screenshotTurn = new Promise<void>((resolve) => {
releaseTurn = resolve
})
await previousTurn
try {
return await execute()
} finally {
releaseTurn()
}
}
async evaluate(
expression: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserEvalResult> {
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (_sessionName, target) => {
const wc = this.requireTargetWebContents(target)
let releaseDebugger = (): void => {}
try {
releaseDebugger = acquireElectronDebugger(wc).release
const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true
})) as {
result: { value?: unknown; description?: string }
exceptionDetails?: { text: string; exception?: { description?: string } }
}
if (exceptionDetails) {
throw new BrowserError(
'browser_eval_error',
exceptionDetails.exception?.description ?? exceptionDetails.text
)
}
const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId)
if (currentTarget.webContentsId !== target.webContentsId) {
throw new BrowserError(
'browser_tab_changed',
`Browser page ${target.browserPageId} changed while evaluating; retry the command`
)
}
return {
result:
result.value !== undefined
? typeof result.value === 'object' && result.value !== null
? JSON.stringify(result.value)
: String(result.value)
: (result.description ?? ''),
origin: wc.getURL()
}
} catch (error) {
if (error instanceof BrowserError) {
throw error
}
if (!this.getWebContents(target.webContentsId)) {
throw this.createPageUnavailableError(
`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`
)
}
throw new BrowserError(
'browser_error',
`Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
releaseDebugger()
}
},
{ ensureSession: false }
)
}
}
@@ -0,0 +1,186 @@
import type {
BrowserSnapshotResult,
BrowserClickResult,
BrowserGotoResult,
BrowserFillResult
} from '../../shared/runtime-types'
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
import { BrowserError } from './cdp-bridge'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
import { focusedValueSetExpression } from './agent-browser-bridge-input'
import {
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES,
EMBEDDED_NAVIGATION_TIMEOUT_MS
} from './agent-browser-bridge-types'
import {
isAbortedNavigationError,
waitForAbortedNavigationReplacement
} from './agent-browser-bridge-process'
import { AgentBrowserBridgeQueue } from './agent-browser-bridge-queue'
export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQueue {
async snapshot(worktreeId?: string, browserPageId?: string): Promise<BrowserSnapshotResult> {
// Why: snapshot creates fresh refs so it must bypass the stale-ref guard
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => {
const result = (await this.execAgentBrowser(sessionName, [
'snapshot'
])) as BrowserSnapshotResult
return {
...result,
browserPageId: target.browserPageId
}
})
}
async click(
element: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserClickResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult
})
}
async dblclick(
element: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserClickResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult
})
}
async goto(url: string, worktreeId?: string, browserPageId?: string): Promise<BrowserGotoResult> {
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (_sessionName, target) => {
const wc = this.requireTargetWebContents(target)
const navigationUrl = normalizeBrowserNavigationUrl(url)
if (!navigationUrl) {
throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`)
}
const navigationState: { preventUnloadEvent: Electron.Event | null } = {
preventUnloadEvent: null
}
const onWillPreventUnload = (event: Electron.Event): void => {
navigationState.preventUnloadEvent = event
}
wc.on('will-prevent-unload', onWillPreventUnload)
let navigationAborted = false
const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS
let navigationTimeout: ReturnType<typeof setTimeout> | null = null
try {
await Promise.race([
wc.loadURL(navigationUrl),
new Promise<never>((_resolve, reject) => {
navigationTimeout = setTimeout(
() =>
reject(
new Error(
`Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms`
)
),
EMBEDDED_NAVIGATION_TIMEOUT_MS
)
navigationTimeout.unref?.()
})
])
} catch (error) {
if (navigationTimeout) {
clearTimeout(navigationTimeout)
navigationTimeout = null
}
if (!this.getWebContents(target.webContentsId)) {
throw this.createPageUnavailableError(
`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`
)
}
// Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed.
if (
!isAbortedNavigationError(error) ||
(navigationState.preventUnloadEvent !== null &&
!navigationState.preventUnloadEvent.defaultPrevented)
) {
throw new BrowserError(
'browser_error',
`Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
)
}
navigationAborted = true
// Why: a superseding navigation rejects the first load before its replacement has landed.
await waitForAbortedNavigationReplacement(
wc,
target.browserPageId,
Math.max(0, navigationDeadline - Date.now())
)
} finally {
wc.removeListener('will-prevent-unload', onWillPreventUnload)
if (navigationTimeout) {
clearTimeout(navigationTimeout)
}
}
// Why: cross-process navigation can replace the guest while retaining the same authoritative page id.
const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId)
const navigatedWebContents = this.requireTargetWebContents(navigatedTarget)
const loadError = navigationAborted
? this.browserManager.getBrowserPageLoadError(target.browserPageId)
: null
if (loadError) {
throw new BrowserError(
'browser_error',
`Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})`
)
}
return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() }
},
{ ensureSession: false }
)
}
async fill(
element: string,
value: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserFillResult> {
await assertClipboardTextWriteWithinLimitWithYield(value)
// Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead.
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName) => {
if (!(await this.isExplicitContentEditableTarget(sessionName, element))) {
await this.execAgentBrowser(sessionName, ['focus', element])
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(''))
])
for (const chunk of iterateBrowserTextInsertionChunks(
value,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
)) {
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(chunk), { append: true })
])
}
await this.execAgentBrowser(sessionName, [
'eval',
focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true })
])
return { filled: element } as BrowserFillResult
}
await this.fillExplicitContentEditable(sessionName, element, value)
return { filled: element } as BrowserFillResult
},
{ requireScopedTarget: true }
)
}
}
@@ -0,0 +1,263 @@
import { execFile } from 'node:child_process'
import type { WebContents } from 'electron'
import { BrowserError } from './cdp-bridge'
import {
focusedRichTextEditExpression,
isExplicitContentEditableResult
} from './agent-browser-bridge-input'
import {
isTabClosedTransportError,
pageUnavailableMessageForSession
} from './agent-browser-bridge-process'
import { translateResult } from './agent-browser-bridge-result'
import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
import {
STALE_SESSION_CLOSE_TIMEOUT_MS,
type AgentBrowserExecOptions,
type SessionState,
type ResolvedBrowserCommandTarget
} from './agent-browser-bridge-types'
export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs {
protected abstract destroySession(
sessionName: string,
options?: { closeTimeoutMs?: number }
): Promise<void>
protected abstract runAgentBrowserRaw(
sessionName: string,
args: string[],
execOptions?: AgentBrowserExecOptions
): Promise<string>
protected requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents {
const wc = this.getWebContents(target.webContentsId)
if (!wc || wc.isDestroyed()) {
throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`)
}
return wc
}
/**
* Notice that the daemon retired itself between two commands.
*
* A replacement daemon still serves the page (every call reasserts `--cdp`)
* but carries none of the session's network routes, so without this the
* interception the caller configured is silently gone (#16367).
*/
protected reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void {
if (
this.agentBrowserIdleTimeoutMs === null ||
Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs
) {
return
}
session.initialized = false
if (session.activeInterceptPatterns.length > 0) {
this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns])
}
}
protected assertCommandAdmission(): void {
if (this.shutdownStarted) {
throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down')
}
}
protected async execAgentBrowser(
sessionName: string,
commandArgs: string[],
execOptions?: AgentBrowserExecOptions
): Promise<unknown> {
const session = this.sessions.get(sessionName)
if (!session) {
// Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure.
throw this.createPageUnavailableError(sessionName)
}
// Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors.
if (!this.getWebContents(session.webContentsId)) {
await this.destroySession(sessionName)
throw this.createPageUnavailableError(sessionName)
}
this.reinitializeIfDaemonIdledOut(sessionName, session)
session.lastCommandAt = Date.now()
const args = ['--session', sessionName]
const managesInterceptRoutes =
commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute')
const needsInit = !session.initialized
// Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner.
args.push('--cdp', String(session.proxy.getPort()))
// Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits.
for (const commandArg of commandArgs) {
args.push(commandArg)
}
args.push('--json')
const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions)
const translated = translateResult(stdout)
if (!translated.ok) {
throw this.createCommandError(
sessionName,
translated.error.message,
translated.error.code,
session.webContentsId
)
}
// Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp.
if (needsInit) {
session.initialized = true
// Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing.
const pendingPatterns = managesInterceptRoutes
? undefined
: this.pendingInterceptRestore.get(sessionName)
if (pendingPatterns && pendingPatterns.length > 0) {
this.pendingInterceptRestore.delete(sessionName)
try {
const urlPattern = pendingPatterns[0] ?? '**/*'
await this.runAgentBrowserRaw(sessionName, [
'--session',
sessionName,
'--cdp',
String(session.proxy.getPort()),
'network',
'route',
urlPattern,
'--json'
])
session.activeInterceptPatterns = pendingPatterns
} catch {
// Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it.
}
}
}
return translated.result
}
protected async isExplicitContentEditableTarget(
sessionName: string,
element: string
): Promise<boolean> {
const result = await this.execAgentBrowser(sessionName, [
'get',
'attr',
element,
'contenteditable'
])
return isExplicitContentEditableResult(result)
}
protected async fillExplicitContentEditable(
sessionName: string,
element: string,
value: string
): Promise<void> {
await this.execAgentBrowser(sessionName, ['focus', element])
// Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls.
await this.execAgentBrowser(sessionName, ['eval', '--stdin'], {
stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true })
})
}
protected createPageUnavailableError(sessionName: string): BrowserError {
return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName))
}
protected closeStaleAgentBrowserSession(sessionName: string): Promise<void> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
reject(error)
} else {
resolve()
}
}
// Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser.
const timeout = setTimeout(() => {
child?.kill()
finish(
new BrowserError(
'browser_owner_unavailable',
`Could not reset stale helper session ${sessionName}; retry after agent-browser exits`
)
)
}, STALE_SESSION_CLOSE_TIMEOUT_MS)
try {
child = execFile(
this.agentBrowserBin,
['--session', sessionName, 'close'],
// Why windowsHide: agent-browser is console-subsystem and Orca's main
// process owns no console, so each spawn gets a fresh visible conhost
// that takes foreground -- keystrokes typed into a terminal at that
// moment land in the black box (#14543).
{
env: this.agentBrowserEnv,
timeout: STALE_SESSION_CLOSE_TIMEOUT_MS,
windowsHide: true
},
(error) =>
finish(
error
? new BrowserError(
'browser_owner_unavailable',
`Could not reset stale helper session ${sessionName}: ${error.message}`
)
: undefined
)
)
} catch (error) {
finish(
new BrowserError(
'browser_owner_unavailable',
`Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}`
)
)
}
})
}
protected createCommandError(
sessionName: string,
message: string,
fallbackCode: string,
webContentsId?: number
): BrowserError {
// Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone.
if (
fallbackCode === 'browser_error' &&
isTabClosedTransportError(message) &&
this.isSessionTargetClosed(sessionName, webContentsId)
) {
return this.createPageUnavailableError(sessionName)
}
return new BrowserError(fallbackCode, message)
}
protected isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean {
const session = this.sessions.get(sessionName)
if (!session) {
return true
}
const targetWebContentsId = webContentsId ?? session.webContentsId
return !this.getWebContents(targetWebContentsId)
}
}
@@ -0,0 +1,124 @@
import type {
BrowserTypeResult,
BrowserSelectResult,
BrowserScrollResult
} from '../../shared/runtime-types'
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
import { AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES } from './agent-browser-bridge-types'
import { AgentBrowserBridgeCoreCommands } from './agent-browser-bridge-core-commands'
export abstract class AgentBrowserBridgeInputCommands extends AgentBrowserBridgeCoreCommands {
async type(
input: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserTypeResult> {
await assertClipboardTextWriteWithinLimitWithYield(input)
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName) => {
for (const chunk of iterateBrowserTextInsertionChunks(
input,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
)) {
await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk])
}
return { typed: true } as BrowserTypeResult
},
{ requireScopedTarget: true }
)
}
async select(
element: string,
value: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserSelectResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, [
'select',
element,
value
])) as BrowserSelectResult
})
}
async scroll(
direction: string,
amount?: number,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserScrollResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['scroll', direction]
if (amount != null) {
args.push(String(amount))
}
return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult
})
}
async scrollIntoView(
element: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['scrollintoview', element])
})
}
async get(
what: string,
selector?: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['get', what]
if (selector) {
args.push(selector)
}
return await this.execAgentBrowser(sessionName, args)
})
}
async is(
what: string,
selector: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['is', what, selector])
})
}
// ── Keyboard commands ──
async keyboardInsertText(
text: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
await assertClipboardTextWriteWithinLimitWithYield(text)
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName) => {
let result: unknown = { inserted: true }
for (const chunk of iterateBrowserTextInsertionChunks(
text,
AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
)) {
result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk])
}
return result
},
{ requireScopedTarget: true }
)
}
}
@@ -0,0 +1,67 @@
export function focusedValueSetExpression(
valueExpression: string,
options?: { append?: boolean; dispatchEvents?: boolean }
): string {
const nextValue = options?.append
? ["String(target.value ?? '') + ", valueExpression].join('')
: valueExpression
const dispatchEvents = options?.dispatchEvents
? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));"
: ''
return [
'(() => { const el = document.activeElement; if (el) {',
// Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value.
" const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";",
" const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));",
' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;',
' let target = el;',
" if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {",
" const controls = target.getAttribute('aria-controls');",
' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }',
' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }',
' }',
" const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;",
' const nextValue = ',
nextValue,
'; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }',
dispatchEvents,
' } })()'
].join('')
}
// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale.
export function focusedRichTextEditExpression(
valueExpression: string,
options?: { selectAll?: boolean }
): string {
const selectAll = options?.selectAll ? 'true' : 'false'
return [
'(() => {',
' const target = document.activeElement;',
' const value = ',
valueExpression,
';',
` const selectAll = ${selectAll};`,
" const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');",
" if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }",
' if (selectAll) {',
" if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }",
' const selection = window.getSelection();',
" if (!selection) { throw new Error('Rich-text selection is unavailable'); }",
' selection.selectAllChildren(target);',
' }',
" const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';",
' let edited = false;',
' try {',
' edited = document.execCommand(editCommand, false, value) === true;',
' } catch { edited = false; }',
" if (!edited) { throw new Error('Browser rich-text editing command failed'); }",
' })()'
].join('')
}
export function isExplicitContentEditableResult(result: unknown): boolean {
const value =
result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined
return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value)
}
@@ -0,0 +1,192 @@
import type {
BrowserHoverResult,
BrowserDragResult,
BrowserUploadResult,
BrowserWaitResult,
BrowserCheckResult,
BrowserFocusResult,
BrowserClearResult,
BrowserSelectAllResult,
BrowserKeypressResult,
BrowserPdfResult
} from '../../shared/runtime-types'
import { BrowserError } from './cdp-bridge'
import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types'
import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands'
export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands {
async hover(
element: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserHoverResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult
})
}
async drag(
from: string,
to: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserDragResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult
})
}
async upload(
element: string,
filePaths: string[],
worktreeId?: string,
browserPageId?: string
): Promise<BrowserUploadResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, [
'upload',
element,
...filePaths
])) as BrowserUploadResult
})
}
async wait(
options?: {
selector?: string
timeout?: number
text?: string
url?: string
load?: string
fn?: string
state?: string
},
worktreeId?: string,
browserPageId?: string
): Promise<BrowserWaitResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['wait']
const hasCondition =
!!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn
if (options?.selector) {
args.push(options.selector)
} else if (options?.timeout != null && !hasCondition) {
args.push(String(options.timeout))
}
if (options?.text) {
args.push('--text', options.text)
}
if (options?.url) {
args.push('--url', options.url)
}
if (options?.load) {
args.push('--load', options.load)
}
if (options?.fn) {
args.push('--fn', options.fn)
}
const normalizedState = options?.state === 'visible' ? undefined : options?.state
if (normalizedState) {
args.push('--state', normalizedState)
}
// Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang.
return (await this.execAgentBrowser(sessionName, args, {
timeoutMs:
options?.timeout != null && hasCondition
? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS
: undefined,
timeoutError:
options?.timeout != null && hasCondition
? new BrowserError(
'browser_timeout',
`Timed out waiting for browser condition after ${options.timeout}ms.`
)
: undefined
})) as BrowserWaitResult
})
}
async check(
element: string,
checked: boolean,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserCheckResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = checked ? ['check', element] : ['uncheck', element]
return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult
})
}
async focus(
element: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserFocusResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult
})
}
async clear(
element: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserClearResult> {
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (sessionName) => {
if (!(await this.isExplicitContentEditableTarget(sessionName, element))) {
// Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields.
await this.execAgentBrowser(sessionName, ['fill', element, ''])
return { cleared: element }
}
await this.fillExplicitContentEditable(sessionName, element, '')
return { cleared: element }
},
{ requireScopedTarget: true }
)
}
async selectAll(
element: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserSelectAllResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
// Why: agent-browser has no select-all command — implement as focus + Ctrl+A
await this.execAgentBrowser(sessionName, ['focus', element])
return (await this.execAgentBrowser(sessionName, [
'press',
'Control+a'
])) as BrowserSelectAllResult
})
}
async keypress(
key: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserKeypressResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult
})
}
async pdf(worktreeId?: string, browserPageId?: string): Promise<BrowserPdfResult> {
// Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF().
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => {
const wc = this.getWebContents(target.webContentsId)
if (!wc) {
throw new BrowserError('browser_no_tab', 'Tab is no longer available')
}
const buffer = await wc.printToPDF({
printBackground: true,
preferCSSPageSize: true
})
return { data: buffer.toString('base64') }
})
}
}
@@ -0,0 +1,266 @@
import { CdpWsProxy } from './cdp-ws-proxy'
import { BrowserError } from './cdp-bridge'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
import { AgentBrowserBridgeRawProcess } from './agent-browser-bridge-raw-process'
import type { AgentBrowserCleanupOptions } from './agent-browser-bridge-types'
import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types'
export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawProcess {
async onTabClosed(webContentsId: number): Promise<void> {
const browserPageId = this.resolveTabIdSafe(webContentsId)
const owningWorktreeId = browserPageId
? this.browserManager.getWorktreeIdForTab(browserPageId)
: undefined
let nextWorktreeActiveWebContentsId: number | null = null
if (
owningWorktreeId &&
this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId
) {
nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents(
owningWorktreeId,
webContentsId
)
}
if (this.activeWebContentsId === webContentsId) {
this.activeWebContentsId = nextWorktreeActiveWebContentsId
}
if (browserPageId) {
await this.onPageClosed(browserPageId)
}
this.options.onTabsChanged?.(owningWorktreeId)
}
/**
* Retire a page's daemon by page id.
*
* The headless offscreen backend owns pages by id and unregisters the guest
* itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it
* has to say which page closed (#16367).
*/
async onPageClosed(browserPageId: string): Promise<void> {
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}`
await this.destroySession(sessionName)
this.pendingInterceptRestore.delete(sessionName)
}
async onProcessSwap(
browserPageId: string,
newWebContentsId: number,
previousWebContentsId?: number
): Promise<void> {
// Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it.
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}`
const session = this.sessions.get(sessionName)
const oldWebContentsId = previousWebContentsId ?? session?.webContentsId
const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId)
// Why: save intercept patterns before destroy so the new session can restore them after init.
if (session && session.activeInterceptPatterns.length > 0) {
this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns])
}
await this.destroySession(sessionName)
if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) {
this.activeWebContentsId = newWebContentsId
}
if (
owningWorktreeId &&
oldWebContentsId != null &&
this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId
) {
this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId)
}
this.options.onTabsChanged?.(owningWorktreeId ?? undefined)
}
protected async ensureSession(
sessionName: string,
browserPageId: string,
webContentsId: number
): Promise<void> {
const pendingDestruction = this.pendingSessionDestruction.get(sessionName)
if (pendingDestruction) {
await pendingDestruction
}
this.assertCommandAdmission()
if (this.sessions.has(sessionName)) {
return
}
// Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger.
const pending = this.pendingSessionCreation.get(sessionName)
if (pending) {
await pending
this.assertCommandAdmission()
return
}
const createSession = async (): Promise<void> => {
const wc = this.getWebContents(webContentsId)
if (!wc) {
// Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape.
throw new BrowserError(
'browser_tab_not_found',
`Browser page ${browserPageId} is no longer available`
)
}
// Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port.
await this.closeStaleAgentBrowserSession(sessionName)
const proxy = new CdpWsProxy(wc)
const cdpEndpoint = await proxy.start()
this.sessions.set(sessionName, {
proxy,
cdpEndpoint,
initialized: false,
consecutiveTimeouts: 0,
activeInterceptPatterns: [],
activeCapture: false,
lastCommandAt: Date.now(),
webContentsId,
activeProcess: null
})
}
const promise = createSession()
this.pendingSessionCreation.set(sessionName, promise)
try {
await promise
} finally {
this.pendingSessionCreation.delete(sessionName)
}
}
protected async restartSessionForTarget(
sessionName: string,
browserPageId: string,
webContentsId: number,
options: { recreate: boolean } = { recreate: true }
): Promise<void> {
const pendingCreation = this.pendingSessionCreation.get(sessionName)
if (pendingCreation) {
await pendingCreation.catch(() => {})
}
const session = this.sessions.get(sessionName)
if (session) {
if (session.activeInterceptPatterns.length > 0) {
this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns])
}
this.sessions.delete(sessionName)
this.pendingSessionCreation.delete(sessionName)
if (session.activeProcess) {
this.cancelledProcesses.add(session.activeProcess)
try {
session.activeProcess.kill()
} catch {
// Process may already be exiting.
}
session.activeProcess = null
}
const destroy = (async (): Promise<void> => {
try {
await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], {
timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS
})
} catch {
// Session may already be dead.
}
await session.proxy.stop()
})()
this.pendingSessionDestruction.set(sessionName, destroy)
try {
await destroy
} finally {
this.pendingSessionDestruction.delete(sessionName)
}
}
if (options.recreate) {
await this.ensureSession(sessionName, browserPageId, webContentsId)
}
}
protected async destroySession(
sessionName: string,
options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS }
): Promise<void> {
const pendingDestruction = this.pendingSessionDestruction.get(sessionName)
if (pendingDestruction) {
await pendingDestruction
return
}
const pendingCreation = this.pendingSessionCreation.get(sessionName)
if (pendingCreation) {
// Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close.
try {
await pendingCreation
} catch {
// Creation failures are handled by the original caller; teardown still rejects queued work below.
}
}
const session = this.sessions.get(sessionName)
if (!session) {
this.rejectQueuedCommandsForClosedSession(sessionName)
return
}
this.sessions.delete(sessionName)
this.pendingSessionCreation.delete(sessionName)
// Why: queued commands would hang forever if we just delete the queue — drain and reject them.
this.rejectQueuedCommandsForClosedSession(sessionName)
if (session.activeProcess) {
// Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout.
this.cancelledProcesses.add(session.activeProcess)
try {
session.activeProcess.kill()
} catch {
// Process may already be exiting.
}
session.activeProcess = null
}
const destroy = (async (): Promise<void> => {
try {
// Why: each tab has its own named session — close without --session leaves this tab's daemon running.
// Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout.
await this.runAgentBrowserRaw(
sessionName,
['--session', sessionName, 'close'],
options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs }
)
} catch {
// Session may already be dead
}
await session.proxy.stop()
})()
this.pendingSessionDestruction.set(sessionName, destroy)
try {
await destroy
} finally {
this.pendingSessionDestruction.delete(sessionName)
}
}
protected rejectQueuedCommandsForClosedSession(sessionName: string): void {
const queue = this.commandQueues.get(sessionName)
this.commandQueues.delete(sessionName)
this.processingQueues.delete(sessionName)
if (queue) {
const err = new BrowserError(
'browser_tab_closed',
'Tab was closed while commands were queued'
)
for (const cmd of queue) {
cmd.reject(err)
}
queue.length = 0
}
}
}
@@ -0,0 +1,206 @@
import type { BrowserMouseModifier } from './agent-browser-bridge-types'
import { BrowserError } from './cdp-bridge'
import {
normalizeCdpMouseButton,
cdpMouseButtonMask,
cdpMouseModifierMask,
resolveMobileTouchClickPoint
} from './agent-browser-bridge-mouse'
import { acquireElectronDebugger } from './electron-debugger-lease'
import { AgentBrowserBridgeInputCommands } from './agent-browser-bridge-input-commands'
export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridgeInputCommands {
// ── Mouse commands ──
async mouseMove(
x: number,
y: number,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)])
})
}
async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['mouse', 'down']
if (button) {
args.push(button)
}
return await this.execAgentBrowser(sessionName, args)
})
}
async mouseClick(
x: number,
y: number,
button?: string,
worktreeId?: string,
browserPageId?: string,
radius?: number,
modifiers?: BrowserMouseModifier[]
): Promise<unknown> {
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (_sessionName, target) => {
const wc = this.getWebContents(target.webContentsId)
if (!wc || wc.isDestroyed()) {
throw new BrowserError(
'browser_tab_not_found',
`Browser page ${target.browserPageId} is no longer available`
)
}
const cdpButton = normalizeCdpMouseButton(button)
const buttons = cdpMouseButtonMask(cdpButton)
const cdpModifiers = cdpMouseModifierMask(modifiers)
const lease = acquireElectronDebugger(wc)
try {
wc.focus()
const point =
cdpButton === 'left'
? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event.
await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0)
: { x, y, adjusted: false, handled: false }
// Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls.
// Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls.
if (!point.handled) {
await wc.debugger.sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
x: point.x,
y: point.y,
button: cdpButton,
buttons,
modifiers: cdpModifiers,
clickCount: 1
})
await wc.debugger.sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: point.x,
y: point.y,
button: cdpButton,
buttons: 0,
modifiers: cdpModifiers,
clickCount: 1
})
}
return {
clicked: {
x: point.x,
y: point.y,
button: cdpButton,
adjusted: point.adjusted,
handled: point.handled
}
}
} finally {
lease.release()
}
},
{ ensureSession: false }
)
}
async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['mouse', 'up']
if (button) {
args.push(button)
}
return await this.execAgentBrowser(sessionName, args)
})
}
async mouseWheel(
dy: number,
dx?: number,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['mouse', 'wheel', String(dy)]
if (dx != null) {
args.push(String(dx))
}
return await this.execAgentBrowser(sessionName, args)
})
}
// ── Find (semantic locators) ──
async find(
locator: string,
value: string,
action: string,
text?: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['find', locator, value, action]
if (text) {
args.push(text)
}
return await this.execAgentBrowser(sessionName, args)
})
}
// ── Set commands ──
async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['set', 'device', name])
})
}
async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['set', 'offline']
if (state) {
args.push(state)
}
return await this.execAgentBrowser(sessionName, args)
})
}
async setHeaders(
headersJson: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson])
})
}
async setCredentials(
user: string,
pass: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass])
})
}
async setMedia(
colorScheme?: string,
reducedMotion?: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
const args = ['set', 'media']
if (colorScheme) {
args.push(colorScheme)
}
if (reducedMotion) {
args.push(reducedMotion)
}
return await this.execAgentBrowser(sessionName, args)
})
}
}

Some files were not shown because too many files have changed in this diff Show More