Merge branch 'main' of https://github.com/stablyai/orca into deleting-skill

This commit is contained in:
Jinjing
2026-08-24 23:52:39 -07:00
1402 changed files with 132035 additions and 91441 deletions
+45
View File
@@ -475,6 +475,44 @@ jobs:
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
# would pay for it 32 times to run one file in whichever shard it landed in.
orcad_browser:
name: orcad browser provider
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
# Why no native-runtime: the provider drives the prebuilt agent-browser binary
# shipped in node_modules and never touches node-pty.
- uses: ./.github/actions/install-node-dependencies
# Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an
# AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable
# sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an
# unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long.
- name: Resolve Chrome for the browser provider
run: |
set -euo pipefail
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
if [ -z "$chrome" ]; then
echo "::error::No Google Chrome on the runner; the browser provider test would silently skip."
exit 1
fi
"$chrome" --version
echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV"
- name: Test external Chromium browser provider
run: |
pnpm exec vitest run --config config/vitest.config.ts \
src/main/orcad/external-chromium-browser-process.integration.test.ts
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
@@ -642,6 +680,7 @@ jobs:
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
config/scripts/rebuild-native-deps.test.mjs
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
src/shared/child-process/windows-command-line.win32.test.ts
@@ -658,6 +697,8 @@ jobs:
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
src/shared/secure-file-fsync-flags.test.ts
src/main/ipc/pty-codex-account-attribution.test.ts
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
@@ -744,6 +785,7 @@ jobs:
- xterm_patch_sync
- shell_contracts
- test
- orcad_browser
- managed_hook_node18
- package
- package_windows
@@ -769,6 +811,7 @@ jobs:
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
@@ -788,6 +831,7 @@ jobs:
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
@@ -807,6 +851,7 @@ jobs:
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
+10
View File
@@ -14,6 +14,9 @@ const {
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const {
verifyPackagedNodePtyJobOwnership
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
// Why: dev-channel builds must carry the *release* identity — same bundle id,
@@ -266,6 +269,13 @@ module.exports = {
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
const hostArchEnum = archEnumByNodeArch[process.arch]
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
if (context.electronPlatformName === 'win32') {
if (process.platform === 'win32' && canExecuteTargetArch) {
verifyPackagedNodePtyJobOwnership(resourcesDir)
} else {
console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package')
}
}
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
executeCommands: canExecuteTargetArch
})
-44
View File
@@ -3,7 +3,6 @@
# 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/cli/handlers/automations.ts
inline src/cli/handlers/orchestration.ts
inline src/cli/help.ts
inline src/main/agent-hooks/server.ts
inline src/main/automations/external-manager.ts
@@ -14,47 +13,29 @@ inline src/main/browser/browser-manager.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/browser/grab-guest-script.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/claude-accounts/service.ts
inline src/main/cli/cli-installer.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.ts
inline src/main/daemon/daemon-init.ts
inline src/main/daemon/daemon-pty-adapter.ts
inline src/main/daemon/daemon-server.ts
inline src/main/daemon/pty-subprocess.ts
inline src/main/git/repo.ts
inline src/main/git/runner.ts
inline src/main/git/status.ts
inline src/main/git/worktree.ts
inline src/main/github/pr-refresh-coordinator.ts
inline src/main/github/project-view.ts
inline src/main/github/project-view/mutations.ts
inline src/main/github/work-item-details.ts
inline src/main/gitlab/client.ts
inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/github.ts
inline src/main/ipc/pty.ts
inline src/main/ipc/repos.ts
inline src/main/ipc/ssh.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/ipc/worktrees.ts
inline src/main/jira/issues.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence/loading-store/store.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/providers/ssh-git-provider.ts
inline src/main/rate-limits/claude-fetcher.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/codex-fetcher.ts
@@ -65,7 +46,6 @@ inline src/main/runtime/orca-runtime-git.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/rpc/methods/terminal.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/speech/model-manager.ts
@@ -78,22 +58,16 @@ inline src/main/text-generation/commit-message-text-generation.ts
inline src/main/updater.ts
inline src/main/window/attach-main-window-services.ts
inline src/main/window/createMainWindow.ts
inline src/main/workspace-space-analysis.ts
inline src/preload/index.ts
inline src/relay/dispatcher.ts
inline src/relay/external-automations-handler.ts
inline src/relay/git-handler.ts
inline src/relay/pty-handler.ts
inline src/relay/relay.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearIssueWorkspace.tsx
inline src/renderer/src/components/LinearItemDrawer.tsx
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/UpdateCard.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
@@ -101,26 +75,16 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/EditorContent.tsx
inline src/renderer/src/components/editor/IpynbViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/github-project/ProjectCell.tsx
inline src/renderer/src/components/github-project/ProjectPicker.tsx
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
inline src/renderer/src/components/linear-project-view-surfaces.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
@@ -132,9 +96,7 @@ inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
@@ -148,18 +110,12 @@ 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/diffComments.ts
inline src/renderer/src/store/slices/github.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/jira.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/repos.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/terminals.ts
inline src/renderer/src/store/slices/ui.ts
inline src/renderer/src/store/slices/workspace-cleanup.ts
inline src/renderer/src/web/web-preload-api.ts
inline src/renderer/src/web/web-runtime-client.ts
inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
+29
View File
@@ -348,6 +348,35 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch)
return
}
// Why delete only conpty.node: node-pty's loader tries build/Release, then
// build/Debug, then prebuilds/<platform>-<arch>, swallowing every failure in
// between. Only the source build carries Orca's job-object exports, so an ABI
// mismatch or an AV quarantine of build/Release/conpty.node would silently
// fall through to the UNPATCHED prebuild -- teardown back to guessing by PID
// ancestry, with no error anywhere.
//
// Why NOT the whole prebuilds/ tree: Orca's own patch deletes the
// `conpty_console_list` and winpty `pty` gyp targets, so a Windows source
// build emits conpty.node and nothing else. conpty_console_list.node,
// pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them
// silently kills console-membership probing (the forked agent throws at
// require, and its caller resolves null with silent: true), and removes the
// winpty backend that node-pty still selects below Windows build 18309.
//
// Why the arch check: a cross-arch package copies the host's build/Release,
// so its mere presence does not mean it matches electronArch -- deleting the
// target-arch prebuild would then remove the only loadable binary.
if (
electronPlatformName === 'win32' &&
electronArch === process.arch &&
existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))
) {
const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`)
for (const staleFallback of ['conpty.node', 'conpty.pdb']) {
rmSync(join(prebuildDir, staleFallback), { force: true })
}
}
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
if (allowedPrebuildPrefix) {
pruneNodePtyNativeDirectories(
+39 -1
View File
@@ -2,7 +2,12 @@ diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -5,9 +5,6 @@
@@ -1,13 +1,10 @@
{
'target_defaults': {
'dependencies': [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
],
'conditions': [
['OS=="win"', {
@@ -12,6 +17,39 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc83
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
@@ -42,32 +39,6 @@
'-lshlwapi'
],
},
- {
- 'target_name': 'conpty_console_list',
- 'sources' : [
- 'src/win/conpty_console_list.cc'
- ],
- },
- {
- 'target_name': 'pty',
- 'include_dirs' : [
- '<!(node -p "require(\'node-addon-api\').include_dir")',
- 'deps/winpty/src/include',
- ],
- # Disabled due to winpty
- 'msvs_disabled_warnings': [ 4506, 4530 ],
- 'dependencies' : [
- 'deps/winpty/src/winpty.gyp:winpty-agent',
- 'deps/winpty/src/winpty.gyp:winpty',
- ],
- 'sources' : [
- 'src/win/winpty.cc',
- 'src/win/path_util.cc'
- ],
- 'libraries': [
- '-lshlwapi'
- ],
- }
]
}, { # OS!="win"
'targets': [
@@ -88,6 +85,16 @@
'libraries!': [
'-lutil'
+134 -6
View File
@@ -7,14 +7,25 @@
* the only ones that statically import `node:sqlite`, so dropping them is what keeps
* the host Node floor at 18 instead of 22.5+.
*/
import { fork, spawnSync } from 'node:child_process'
import { build } from 'esbuild'
import { mkdirSync, rmSync } from 'node:fs'
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
// Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher
// fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath`
// looks for it in the app root. A deployment has no desktop out/main to fall back to.
const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts')
const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js')
const AGENT_BROWSER_NAME = `agent-browser-${platform()}-${arch()}${process.platform === 'win32' ? '.exe' : ''}`
const OUT_FILE = join(OUT_DIR, 'orcad.js')
const AGENT_BROWSER_SOURCE = join(ROOT, 'node_modules', 'agent-browser', 'bin', AGENT_BROWSER_NAME)
const AGENT_BROWSER_OUTPUT = join(OUT_DIR, AGENT_BROWSER_NAME)
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
@@ -48,6 +59,25 @@ const externalNativeAddons = {
rmSync(OUT_DIR, { recursive: true, force: true })
mkdirSync(OUT_DIR, { recursive: true })
copyFileSync(AGENT_BROWSER_SOURCE, AGENT_BROWSER_OUTPUT)
if (process.platform !== 'win32') {
chmodSync(AGENT_BROWSER_OUTPUT, 0o755)
}
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: WATCHER_OUT_FILE,
external: EXTERNAL,
plugins: [externalNativeAddons],
minify: true,
sourcemap: false,
define: { 'process.env.NODE_ENV': '"production"' },
logLevel: 'error'
})
const result = await build({
entryPoints: [ENTRY],
@@ -55,7 +85,7 @@ const result = await build({
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(OUT_DIR, 'orcad.js'),
outfile: OUT_FILE,
external: EXTERNAL,
plugins: [jsoncParserEsm, externalNativeAddons],
metafile: true,
@@ -78,19 +108,117 @@ for (const [file, info] of Object.entries(result.metafile.inputs)) {
}
}
}
const sqliteImporters = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
const specifier = imported.original ?? imported.path
if (specifier === 'node:sqlite') {
sqliteImporters.add(file)
}
}
}
const graphErrors = []
if (electronImporters.size > 0) {
console.error(
`[build-orcad] ${electronImporters.size} module(s) in the bundle import electron:
${[...electronImporters].map((f) => ` - ${f}`).join('\n')}`
graphErrors.push(
`${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters]
.map((file) => ` - ${file}`)
.join('\n')}`
)
}
if (sqliteImporters.size > 0) {
graphErrors.push(
`${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters]
.map((file) => ` - ${file}`)
.join('\n')}`
)
}
if (graphErrors.length > 0) {
console.error(`[build-orcad] ${graphErrors.join('\n')}`)
// Why this can exceed the ratchet baseline: the ratchet measures the graph reachable
// from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to
// install the PTY controller. Once orcad ships, it should become a ratchet entry
// point so the two numbers cannot drift.
process.exitCode = 1
} else {
// Why smoke-load and not just read the metafile: the import scan proves no module
// *names* electron, but a graph can still fail to resolve under plain Node — a
// dynamic require, a missing native, a top-level throw. The plain-node-entry-guard
// smoke-loads its entries for exactly this reason, and orcad cannot join that guard
// because it is an esbuild artifact rather than a rollup input.
const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], {
encoding: 'utf8',
timeout: 60_000
})
const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}`
if (
smoke.error ||
smoke.signal ||
!/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput)
) {
console.error(
`[build-orcad] the bundle did not load under plain Node.\n` +
`Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` +
`error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}`
)
process.exitCode = 1
}
const watcherFailure = await smokeLoadWatcherChild()
if (watcherFailure) {
console.error(
`[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}`
)
process.exitCode = 1
}
}
if (process.exitCode !== 1) {
console.log(
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.`
`[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.`
)
}
/**
* Fork the shipped watcher child and drive one message through it.
*
* Why a real fork and not existsSync: the file being present says nothing about whether
* its graph resolves under plain Node, and this child is only ever reached through
* `fork()` at runtime — a broken one degrades silently to in-process watching.
* `subscribe-started` is acked before the native module is touched, so this passes on a
* build machine with no compiled @parcel/watcher.
*/
async function smokeLoadWatcherChild() {
const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-'))
const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] })
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += String(chunk)
})
try {
return await new Promise((resolve) => {
const timer = setTimeout(() => {
child.kill('SIGKILL')
resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`)
}, 30_000)
const settle = (failure) => {
clearTimeout(timer)
resolve(failure)
}
child.on('message', (message) => {
if (message?.op === 'subscribe-started') {
child.disconnect()
}
})
child.on('error', (error) => settle(`fork failed: ${error.message}`))
// Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code
// or a signal here is a load failure rather than a clean teardown.
child.on('exit', (code, signal) =>
settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`)
)
child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} })
})
} finally {
rmSync(probeDir, { recursive: true, force: true })
}
}
+103 -3
View File
@@ -139,7 +139,99 @@ function diagnosticLineRange(root, filename, span) {
return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) }
}
export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) {
// Why: a file-splitting refactor makes every line of the new module an "added"
// line, so pre-existing lint debt in code that merely MOVED starts failing the
// changed-lines gate. The only way to satisfy it is to edit the moved code,
// which is exactly what a behavior-preserving refactor must not do. So a
// diagnostic is exempt when its highlighted lines already existed, verbatim and
// contiguous, somewhere in the base revision of the files this change touches.
function normalizeSourceLine(line) {
return line.replace(/\s+/g, ' ').trim()
}
export function collectBaseLineBlocks(root, comparisonBase, files = null) {
// Why: in a split, the moved code's base text lives in the ORIGINAL file, which is
// often deleted or renamed away. Deleted paths never reach the changed-file list
// (it filters to ACMRTUB), so read every path the diff touches, deletions included.
const paths =
files ??
splitNullDelimited(runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])).filter(
(file) => SOURCE_FILE_PATTERN.test(file)
)
const blocks = []
for (const file of paths) {
const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
})
if (result.status !== 0 || typeof result.stdout !== 'string') {
continue
}
blocks.push(
result.stdout
.split(/\r?\n/)
.map(normalizeSourceLine)
.filter((line) => line !== '')
)
}
return blocks
}
export function isMovedCode(highlightedLines, baseBlocks) {
const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '')
if (needle.length === 0) {
return false
}
// Why a near-match rather than an exact contiguous one: a split moves a block
// verbatim but a diagnostic's span often reaches past it — most commonly to a
// hook dependency array, which legitimately grows when closure variables become
// props. Requiring every line to match would report the moved body as new. So:
// the block must still start at the same line in the base and appear IN ORDER,
// and nearly all of it must be present. Genuinely new code shares neither the
// anchor nor the ordering, so it stays reported.
const MIN_COVERAGE = 0.9
return baseBlocks.some((rawHaystack) => {
const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '')
for (let start = 0; start < haystack.length; start += 1) {
if (haystack[start] !== needle[0]) {
continue
}
let matched = 1
let cursor = start + 1
for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) {
while (cursor < haystack.length && haystack[cursor] !== needle[index]) {
cursor += 1
}
if (cursor < haystack.length) {
matched += 1
cursor += 1
}
}
if (matched / needle.length >= MIN_COVERAGE) {
return true
}
}
return false
})
}
function diagnosticHighlightedLines(root, filename, span) {
const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename)
const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/)
const range = diagnosticLineRange(root, filename, span)
if (range === null) {
return []
}
return source.slice(range.start - 1, range.end)
}
export function diagnosticTouchesAddedLines(
diagnostic,
rangesByFile,
root = process.cwd(),
baseBlocks = []
) {
const file = normalizedDiagnosticPath(root, diagnostic.filename)
const ranges = rangesByFile.get(file)
if (!ranges) {
@@ -147,7 +239,13 @@ export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = pro
}
return (diagnostic.labels ?? []).some((label) => {
const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span)
return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges)
if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) {
return false
}
return !isMovedCode(
diagnosticHighlightedLines(root, diagnostic.filename, label.span),
baseBlocks
)
})
}
@@ -193,10 +291,12 @@ export function main(
return 0
}
const baseBlocks = collectBaseLineBlocks(root, comparisonBase)
let failures = 0
for (const scan of OXLINT_SCANS) {
const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) =>
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root)
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks)
)
for (const diagnostic of diagnostics) {
printDiagnostic(diagnostic, root)
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
OXLINT_SCANS,
diagnosticTouchesAddedLines,
isMovedCode,
overlapsAddedLines,
parseAddedLineRanges
} from './check-changed-code-quality.mjs'
@@ -52,3 +53,54 @@ describe('changed-code quality line matching', () => {
expect(scan.args).not.toContain('--disable-nested-config')
})
})
describe('moved-code exemption', () => {
it('treats a verbatim contiguous block from the base as moved', () => {
const base = [['const a = 1', 'items.map((item, index) => (', 'key={index}', '))']]
expect(isMovedCode(['items.map((item, index) => (', 'key={index}', '))'], base)).toBe(true)
})
it('ignores indentation and whitespace changes from the move', () => {
const base = [[' items.map((item, index) => (', ' key={index}']]
expect(isMovedCode(['items.map((item, index) => (', 'key={index}'], base)).toBe(true)
})
it('does not exempt a genuinely new violation', () => {
const base = [['const a = 1', 'const b = 2']]
expect(isMovedCode(['rows.map((row, i) => <td key={i} />)'], base)).toBe(false)
})
it('does not exempt a block that is only partly present in the base', () => {
const base = [['doThing()', 'unrelated()']]
expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false)
})
it('tolerates a few lines appended inside the moved block', () => {
// A split commonly grows a hook dependency array when closure variables
// become props; the moved body around it is still moved.
const body = Array.from({ length: 20 }, (_, i) => `line${i}()`)
const base = [body]
const moved = [...body.slice(0, 19), 'newDep,', body[19]]
expect(isMovedCode(moved, base)).toBe(true)
})
it('does not exempt when the anchor line is absent from the base', () => {
const base = [['doThing()', 'filler()', 'other()']]
expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false)
})
it('does not exempt when most of the block is absent from the base', () => {
const base = [['keep0()', 'keep1()', 'unrelated()']]
const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)]
expect(isMovedCode(mostlyNew, base)).toBe(false)
})
it('ignores blank lines when matching', () => {
const base = [['a()', 'b()']]
expect(isMovedCode(['a()', '', 'b()'], base)).toBe(true)
})
it('never exempts an empty highlight', () => {
expect(isMovedCode(['', ' '], [['a()']])).toBe(false)
})
})
@@ -34,7 +34,11 @@ const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
// itself and the RPC server that fronts it.
const ENTRY_POINTS = [
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts')
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'),
// Why orcad too: it imports ipc/pty directly to install the PTY controller, so its
// graph is strictly larger than the two runtime entries. Measuring only those let the
// two numbers drift — the gate would read zero while the shipped artifact regressed.
path.join(ROOT, 'src', 'main', 'orcad', 'main.ts')
]
// Native addons and electron cannot be bundled; externalising them is what the
@@ -0,0 +1,67 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
const NODE_PTY_PATCH = readFileSync(
new URL('../patches/node-pty@1.1.0.patch', import.meta.url),
'utf8'
)
const PATCHED = {
dir: 'build/Release/',
module: {
listJobProcessIds: () => [],
terminateJob: () => true,
assignCurrentProcessToJob: () => true
}
}
const PREBUILD = {
dir: 'prebuilds/win32-x64/',
module: {
startProcess: () => {},
connect: () => {},
resize: () => {},
clear: () => {},
kill: () => {}
}
}
describe('assertNodePtyJobOwnership', () => {
it('keeps node-addon-api project paths absolute during Windows source builds', () => {
expect(NODE_PTY_PATCH).toContain(
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
)
})
it('leaves the unchanged Windows helper and fallback on their upstream prebuilds', () => {
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'")
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'")
})
it('rejects the prebuild that shipped without the job exports', () => {
expect(() =>
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
})
it('names where the bad native came from, so the fix is obvious', () => {
expect(() =>
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
).toThrow(/prebuilds\/win32-x64/)
})
it('accepts a source build carrying the patch', () => {
expect(() =>
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED })
).not.toThrow()
})
it.each([
['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }],
['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }]
])('stays out of the way on %s', (_case, spec) => {
expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow()
})
})
+2
View File
@@ -7,6 +7,7 @@ import { release } from 'node:os'
import { basename, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
const scriptPath = import.meta.filename
const projectDir = resolve(import.meta.dirname, '../..')
const runtime = readRuntimeArg()
@@ -277,6 +278,7 @@ function loadNodePtyNativeModule() {
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
const native = loadNativeModule(nativeName)
assertNodePtyWindowsConptyRuntime(native?.dir)
assertNodePtyJobOwnership({ nativeName, native })
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
throw new Error(
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
@@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url))
const sourceNodePtyJobOwnershipPath = fileURLToPath(
new URL('./node-pty-job-ownership.cjs', import.meta.url)
)
describe('ensure-native-runtime', () => {
it('rechecks Node native modules in fresh child processes after rebuilding', () => {
@@ -156,6 +159,10 @@ describe('ensure-native-runtime', () => {
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(
sourceNodePtyJobOwnershipPath,
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
)
return projectDir
}
+29
View File
@@ -0,0 +1,29 @@
'use strict'
const NODE_PTY_JOB_EXPORTS = [
'listJobProcessIds',
'terminateJob',
'assignCurrentProcessToJob'
]
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
if (platform !== 'win32' || nativeName !== 'conpty') {
return
}
const exported = native?.module ?? native
const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function')
if (missing.length === 0) {
return
}
throw new Error(
[
`node-pty's conpty native is missing ${missing.join(', ')}.`,
`Resolved from: ${native?.dir ?? 'unknown'}`,
'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"',
'and pane teardown falls back to guessing by PID ancestry.',
'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.'
].join(' ')
)
}
module.exports = { assertNodePtyJobOwnership }
@@ -0,0 +1,105 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { prunePackagedNodePty } = require('../packaged-runtime-node-modules.cjs')
/**
* node-pty's loader tries build/Release, then build/Debug, then
* prebuilds/<platform>-<arch>, swallowing failures in between. Only the source
* build carries Orca's job-object exports, so leaving the prebuilt conpty.node
* beside it means an ABI mismatch or an AV quarantine silently downgrades the
* app to a binary that cannot own a PTY tree.
*
* The siblings must survive: Orca's patch deletes the `conpty_console_list` and
* winpty `pty` gyp targets, so those binaries exist nowhere but here.
*/
describe('prunePackagedNodePty: the Windows conpty fallback', () => {
let resources
const HOST_ARCH = process.arch
const nodePty = () => join(resources, 'node_modules', 'node-pty')
const write = (relative) => {
const target = join(nodePty(), relative)
mkdirSync(join(target, '..'), { recursive: true })
writeFileSync(target, 'x')
}
const prebuilt = (name, arch = HOST_ARCH) => join(nodePty(), 'prebuilds', `win32-${arch}`, name)
/** What a real packaged win32 prebuilds/<arch> directory holds. */
const SIBLINGS = ['conpty_console_list.node', 'pty.node', 'winpty.dll', 'winpty-agent.exe']
const seedWindowsTree = (arch = HOST_ARCH) => {
write(join('build', 'Release', 'conpty.node'))
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'conpty.dll'))
write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'OpenConsole.exe'))
write(join('prebuilds', `win32-${arch}`, 'conpty.node'))
for (const sibling of SIBLINGS) {
write(join('prebuilds', `win32-${arch}`, sibling))
}
}
beforeEach(() => {
resources = mkdtempSync(join(tmpdir(), 'orca-prune-'))
})
afterEach(() => {
rmSync(resources, { recursive: true, force: true })
})
it('removes the stale conpty fallback when the source build is present', () => {
seedWindowsTree()
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
expect(existsSync(join(nodePty(), 'build', 'Release', 'conpty.node'))).toBe(true)
expect(existsSync(prebuilt('conpty.node'))).toBe(false)
})
it.each(SIBLINGS)('keeps %s, which exists nowhere else in a packaged build', (sibling) => {
// Orca's patch removes the conpty_console_list and winpty gyp targets, so a
// Windows source build never produces these. Deleting them kills console
// membership silently and breaks PTY spawn below Windows build 18309.
seedWindowsTree()
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
expect(existsSync(prebuilt(sibling))).toBe(true)
})
it('keeps the fallback when there is no source build to prefer', () => {
write(join('prebuilds', `win32-${HOST_ARCH}`, 'conpty.node'))
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'conpty.dll'))
write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'OpenConsole.exe'))
prunePackagedNodePty(resources, 'win32', HOST_ARCH)
expect(existsSync(prebuilt('conpty.node'))).toBe(true)
})
it('keeps the fallback on a cross-arch package, where build/Release is the host arch', () => {
// electron-builder --win --arm64 on an x64 host copies an x64
// build/Release; deleting the arm64 prebuild would remove the only binary
// the shipped app could load.
const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64'
seedWindowsTree(target)
prunePackagedNodePty(resources, 'win32', target)
expect(existsSync(prebuilt('conpty.node', target))).toBe(true)
})
it.each([
['darwin', 'arm64'],
['linux', 'x64']
])('leaves %s prebuilds alone', (platform, arch) => {
write(join('build', 'Release', 'conpty.node'))
write(join('prebuilds', `${platform}-${arch}`, 'pty.node'))
prunePackagedNodePty(resources, platform, arch)
expect(existsSync(join(nodePty(), 'prebuilds', `${platform}-${arch}`, 'pty.node'))).toBe(true)
})
})
@@ -345,6 +345,7 @@ describe('PR workflow parallelism', () => {
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'managed_hook_node18',
'package',
'package_windows'
@@ -354,5 +355,9 @@ describe('PR workflow parallelism', () => {
)
expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}')
expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"')
// Why assert this one too: the browser provider test skips itself without
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
})
})
+6 -1
View File
@@ -475,9 +475,14 @@ function loadNativeModule(moduleName) {
}
if (moduleName === 'node-pty') {
projectRequire('node-pty')
const { assertNodePtyJobOwnership } = projectRequire(
'./config/scripts/node-pty-job-ownership.cjs'
)
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
const native = loadNativeModule(getNodePtyNativeModuleName())
const nativeName = getNodePtyNativeModuleName()
const native = loadNativeModule(nativeName)
assertNodePtyWindowsConptyRuntime(native.dir)
assertNodePtyJobOwnership({ nativeName, native })
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
throw new Error(
'node-pty resolved to ' +
+62 -2
View File
@@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo
const sourceInstallScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
const sourceNodePtyJobOwnershipPath = fileURLToPath(
new URL('./node-pty-job-ownership.cjs', import.meta.url)
)
describe('rebuild-native-deps Electron install fallback', () => {
it('continues non-strict postinstall when Electron retry download fails', () => {
@@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
writeFakeWindowsRegistry(projectDir)
writeFakeWindowsProcessTree(projectDir)
writeFakeNodePtyConptyPayload(projectDir, process.arch)
const result = runRebuildScript(projectDir, {
@@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir)
writeFakeWindowsProcessTree(projectDir)
writeFakeNodePtyConptyPayload(projectDir, process.arch)
const result = runRebuildScript(projectDir, {
@@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
}
)
it.skipIf(process.platform !== 'win32')(
'rebuilds a loadable ConPTY native that lacks Orca job ownership',
() => {
const projectDir = mkTempProject()
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false })
writeFakeWindowsRegistry(projectDir)
writeFakeWindowsProcessTree(projectDir)
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath,
npm_config_platform: 'win32',
npm_config_arch: process.arch
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Rebuilding failed native modules: node-pty')
expect(result.stdout).toContain('missing listJobProcessIds')
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
() => {
@@ -305,6 +340,10 @@ function mkTempProject() {
sourceInstallScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
copyFileSync(
sourceNodePtyJobOwnershipPath,
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
)
return projectDir
}
@@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) {
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
}
function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) {
function writeFakeLoadableNodePty(
projectDir,
{ nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {}
) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
@@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } =
join(nodePtyDir, 'lib', 'utils.js'),
`
exports.loadNativeModule = function loadNativeModule(nativeName) {
return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } }
return {
dir: ${JSON.stringify(nativeDir)},
module: {
nativeName,
...(${JSON.stringify(ownsPtyJob)}
? {
listJobProcessIds() {},
terminateJob() {},
assignCurrentProcessToJob() {}
}
: {})
}
}
}
`
)
@@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) {
)
}
function writeFakeWindowsProcessTree(projectDir) {
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
mkdirSync(processTreeDir, { recursive: true })
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
@@ -18,11 +18,14 @@
* - a paired client can list worktrees and create a terminal,
* - a command run in that terminal produces its output,
* - the server exits when asked.
* Optional `--browser` assertions:
* - create, navigate, evaluate, and screenshot through the selected host provider.
*/
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
@@ -274,6 +277,50 @@ async function main() {
throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
}
log(`server resolves ${shown.id}`)
if (process.argv.includes('--browser')) {
const status = orca(pairingCode, ['status'])
if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) {
throw new Error(
`status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}`
)
}
const fixturePath = join(userDataDir, 'browser-smoke.html')
writeFileSync(
fixturePath,
'<!doctype html><title>Orcad Browser Smoke</title><main>browser-ready</main>'
)
const browserPageId = orca(pairingCode, [
'tab',
'create',
'--worktree',
created.id,
'--url',
'about:blank'
])?.browserPageId
if (!browserPageId) {
throw new Error('browser.tabCreate returned no browser page id')
}
const targetFlags = ['--worktree', created.id, '--page', browserPageId]
const targetUrl = pathToFileURL(fixturePath).href
const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl])
if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') {
throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`)
}
const evaluated = orca(pairingCode, [
'eval',
...targetFlags,
'--expression',
'document.querySelector("main")?.textContent'
])
if (evaluated?.result !== 'browser-ready') {
throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`)
}
const screenshot = orca(pairingCode, ['screenshot', ...targetFlags])
if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) {
throw new Error('browser.screenshot returned no PNG data')
}
log('browser navigate/evaluate/screenshot round trip OK')
}
const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
if (!terminal?.handle) {
@@ -0,0 +1,25 @@
const { createRequire } = require('node:module')
const { join } = require('node:path')
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
function loadPackagedConpty(resourcesDir) {
const packagedRequire = createRequire(join(resourcesDir, 'package.json'))
const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils')
return loadNativeModule('conpty')
}
function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) {
const platform = options.platform ?? process.platform
if (platform !== 'win32') {
return
}
const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir)
assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native })
if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) {
throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`)
}
console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees')
}
module.exports = { verifyPackagedNodePtyJobOwnership }
@@ -0,0 +1,51 @@
import { createRequire } from 'node:module'
import { describe, expect, it, vi } from 'vitest'
const require = createRequire(import.meta.url)
const {
verifyPackagedNodePtyJobOwnership
} = require('./verify-packaged-node-pty-job-ownership.cjs')
const PATCHED = {
dir: '../build/Release/',
module: {
listJobProcessIds: () => [],
terminateJob: () => true,
assignCurrentProcessToJob: () => true
}
}
describe('verifyPackagedNodePtyJobOwnership', () => {
it('accepts the packaged patched ConPTY binding', () => {
expect(() =>
verifyPackagedNodePtyJobOwnership('resources', {
platform: 'win32',
loadNative: () => PATCHED
})
).not.toThrow()
})
it('rejects a packaged upstream prebuild', () => {
expect(() =>
verifyPackagedNodePtyJobOwnership('resources', {
platform: 'win32',
loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} })
})
).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
})
it('requires the patched source-build directory', () => {
expect(() =>
verifyPackagedNodePtyJobOwnership('resources', {
platform: 'win32',
loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' })
})
).toThrow(/expected patched build\/Release/)
})
it('does not load Windows natives for other targets', () => {
const loadNative = vi.fn()
verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative })
expect(loadNative).not.toHaveBeenCalled()
})
})
+7
View File
@@ -46,6 +46,7 @@
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-state-db.ts",
"../src/main/codex/codex-trust-identity.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
@@ -58,10 +59,16 @@
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-settings-promotion-write-target.ts",
"../src/main/codex/config-sync-stall.ts",
"../src/main/codex/config-toml-atomic-write.ts",
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
"../src/main/codex/config-toml-hook-trust-blocks.ts",
"../src/main/codex/config-toml-hook-trust-edit.ts",
"../src/main/codex/config-toml-hook-trust-read.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-project-trust.ts",
"../src/main/codex/config-toml-runtime-owned-sections.ts",
"../src/main/codex/config-toml-syntax.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
+2 -1361
View File
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,11 @@ import {
type HostStackNavigationState
} from '../navigation/host-stack-navigation'
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
const accountCardsSource = readFileSync(
new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url),
'utf8'
)
function navigationHarness(initialState: HostStackNavigationState) {
const stateListeners = new Set<() => void>()
@@ -83,14 +87,8 @@ describe('mobile accounts route', () => {
})
it('opens the home account-usage card through the cold-navigator-safe transition', () => {
const start = homeSource.indexOf('{/* ─── Account usage ─── */}')
// Assert the marker first: a renamed banner would otherwise slice garbage and report a
// missing call instead of the real cause.
expect(start).toBeGreaterThanOrEqual(0)
const accountsSection = homeSource.slice(start)
expect(accountsSection).toContain('openMobileAccounts(host.id)')
expect(accountsSection).not.toContain('/accounts`')
expect(homeSource).toContain('onOpenAccounts={openMobileAccounts}')
expect(accountCardsSource).toContain('props.onOpen(host.id)')
expect(accountCardsSource).not.toContain('/accounts`')
})
})
+20 -3
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -17,11 +19,18 @@ const STATUS_COLORS: Record<WorktreeStatus, string> = {
inactive: 'rgba(115,115,115,0.4)'
}
export function AgentSpinner({ status }: { status: WorktreeStatus }) {
export function AgentSpinner({
status,
workingMode
}: {
status: WorktreeStatus
workingMode?: AgentWorkingMode
}) {
const spinValue = useRef(new Animated.Value(0)).current
const monitoring = status === 'working' && workingMode === 'monitoring'
useEffect(() => {
if (status === 'working') {
if (status === 'working' && !monitoring) {
const animation = Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
@@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) {
return () => animation.stop()
}
spinValue.setValue(0)
}, [status, spinValue])
}, [monitoring, status, spinValue])
const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive
if (monitoring) {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={12} color={STATUS_COLORS.working} />
</View>
)
}
if (status === 'working') {
const rotate = spinValue.interpolate({
inputRange: [0, 1],
+12 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentDotState } from '../worktree/agent-row-display'
@@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display'
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
// agent vocabulary into the 5-state rollup the sidebar dot uses.
const DOT_COLORS: Record<Exclude<AgentDotState, 'working'>, string> = {
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
done: '#10b981',
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
idle: 'rgba(115,115,115,0.4)'
}
const WORKING_COLOR = '#eab308'
export function AgentStateDot({ state }: { state: AgentDotState }) {
const spinValue = useRef(new Animated.Value(0)).current
@@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
)
}
if (state === 'monitoring') {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={10} color={WORKING_COLOR} />
</View>
)
}
return (
<View style={styles.wrapper}>
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
@@ -59,7 +69,7 @@ const styles = StyleSheet.create({
height: 6,
borderRadius: 3,
borderWidth: 1.5,
borderColor: '#eab308',
borderColor: WORKING_COLOR,
borderTopColor: 'transparent'
}
})
@@ -0,0 +1,76 @@
import { Pressable, Switch, Text, View } from 'react-native'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import { colors } from '../theme/mobile-theme'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
import type { SetupRunPolicy } from './new-worktree-modal-types'
export function NewWorkspaceSetupScriptField({
command,
source,
runPolicy,
decision,
runSetup,
onDecisionChange,
onRunSetupChange
}: {
command: string
source: string | null
runPolicy: SetupRunPolicy
decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
runSetup: boolean
onDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
onRunSetupChange: (run: boolean) => void
}) {
return (
<View style={styles.field}>
<View style={styles.setupHeader}>
<Text style={styles.label}>Setup script</Text>
{source ? (
<View style={styles.sourceBadge}>
<Text style={styles.sourceBadgeText}>
{source === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
</Text>
</View>
) : null}
</View>
<View style={styles.setupBox}>
{runPolicy === 'ask' ? (
<View style={styles.setupChoiceRow}>
<Pressable
style={[
styles.setupChoiceButton,
decision === 'run' && styles.setupChoiceButtonSelected
]}
onPress={() => onDecisionChange('run')}
>
<Text style={styles.setupChoiceText}>Run</Text>
</Pressable>
<Pressable
style={[
styles.setupChoiceButton,
decision === 'skip' && styles.setupChoiceButtonSelected
]}
onPress={() => onDecisionChange('skip')}
>
<Text style={styles.setupChoiceText}>Skip</Text>
</Pressable>
</View>
) : (
<View style={styles.setupToggleRow}>
<Text style={styles.setupToggleLabel}>Run setup command</Text>
<Switch
value={runSetup}
onValueChange={onRunSetupChange}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
style={styles.setupSwitch}
/>
</View>
)}
<View style={styles.setupCommandBlock}>
<Text style={styles.setupCommand}>{command}</Text>
</View>
</View>
</View>
)
}
@@ -0,0 +1,52 @@
import { Pressable, Text, View } from 'react-native'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import { workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
export function NewWorkspaceSshConnectionField({
repoName,
sshGate,
onConnect
}: {
repoName: string
sshGate: WorkspaceSshGate
onConnect: () => void
}) {
return (
<View style={styles.field}>
<Text style={styles.label}>SSH Connection</Text>
<View style={styles.sshBox}>
<View style={styles.sshRow}>
<View
style={[
styles.sshDot,
sshGate.status === 'connected'
? styles.sshDotConnected
: sshGate.connectInProgress
? styles.sshDotProgress
: styles.sshDotDisconnected
]}
/>
<View style={styles.sshCopy}>
<Text style={styles.sshTitle} numberOfLines={1}>
{repoName}
</Text>
<Text style={styles.sshSubtitle}>{workspaceSshStatusLabel(sshGate.status)}</Text>
</View>
{sshGate.status === 'connected' ? null : (
<Pressable
style={[styles.sshConnectButton, sshGate.connectInProgress && styles.disabled]}
disabled={sshGate.connectInProgress}
onPress={onConnect}
>
<Text style={styles.sshConnectText}>
{sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
</Text>
</Pressable>
)}
</View>
{sshGate.error ? <Text style={styles.errorInline}>{sshGate.error}</Text> : null}
</View>
</View>
)
}
@@ -0,0 +1,184 @@
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ChevronDown, ChevronUp } from 'lucide-react-native'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
import type { SetupRunPolicy } from './new-worktree-modal-types'
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
import { NewWorkspaceSetupScriptField } from './NewWorkspaceSetupScriptField'
import { NewWorkspaceSshConnectionField } from './NewWorkspaceSshConnectionField'
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
type Composer = ReturnType<typeof useMobileComposerSource>
type Selection = { label: string; detail?: string }
export function NewWorktreeFormSheet(props: {
visible: boolean
interactive: boolean
loading: boolean
hasRepos: boolean
project: Selection | null
runTarget: Selection | null
projectBadgeColor: string | null
selectedRepoIsGit: boolean
selectedRepoConnectionId: string | null
selectedRepoName: string
sshGate: WorkspaceSshGate
composer: Composer
selectedAgent: NewWorktreeAgentOption
showAdvanced: boolean
note: string
setupCommand: string | null
setupSource: string | null
setupRunPolicy: SetupRunPolicy
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
runSetup: boolean
error: string
creating: boolean
canCreate: boolean
onClose: () => void
onOpenProject: () => void
onOpenRunTarget: () => void
onOpenSource: () => void
onClearError: () => void
onConnect: () => void
onOpenAgent: () => void
onShowAdvancedChange: (show: boolean) => void
onNoteChange: (note: string) => void
onSetupDecisionChange: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
onRunSetupChange: (run: boolean) => void
onCreate: () => void
}) {
return (
<BottomDrawer visible={props.visible} interactive={props.interactive} onClose={props.onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create worktree</Text>
</View>
{props.loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : !props.hasRepos ? (
<View style={styles.loadingContainer}>
<Text style={styles.emptyText}>No projects found</Text>
</View>
) : (
<>
<NewWorktreeProjectTargetFields
project={props.project}
runTarget={props.runTarget}
projectBadgeColor={props.projectBadgeColor}
onOpenProject={props.onOpenProject}
onOpenRunTarget={props.onOpenRunTarget}
/>
<SmartWorkspaceSourceField
composer={props.composer}
label={props.selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
disabled={props.sshGate.requiresConnection}
interactive={props.interactive}
onBeforeOpen={props.onClearError}
onOpenDrawer={props.onOpenSource}
/>
{props.composer.forkPushWarning ? (
<Text style={styles.sourceWarning}>{props.composer.forkPushWarning}</Text>
) : null}
{props.selectedRepoConnectionId ? (
<NewWorkspaceSshConnectionField
repoName={props.selectedRepoName}
sshGate={props.sshGate}
onConnect={props.onConnect}
/>
) : null}
<View style={styles.field}>
<Text style={styles.label}>Agent</Text>
<Pressable
style={[styles.fieldButton, props.sshGate.requiresConnection && styles.disabled]}
disabled={props.sshGate.requiresConnection}
onPress={props.onOpenAgent}
>
<MobileAgentIcon agentId={props.selectedAgent.id} size={16} />
<Text style={styles.fieldButtonText} numberOfLines={1}>
{props.sshGate.requiresConnection
? 'Connect target first'
: props.selectedAgent.label}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<Pressable
style={styles.advancedToggle}
onPress={() => props.onShowAdvancedChange(!props.showAdvanced)}
>
<Text style={styles.advancedText}>Advanced</Text>
{props.showAdvanced ? (
<ChevronUp size={14} color={colors.textSecondary} />
) : (
<ChevronDown size={14} color={colors.textSecondary} />
)}
</Pressable>
{props.showAdvanced ? (
<>
<SmartWorkspaceAdvancedFields
composer={props.composer}
selectedRepoIsGit={props.selectedRepoIsGit}
/>
<View style={styles.field}>
<Text style={styles.label}>Note</Text>
<TextInput
style={styles.input}
value={props.note}
onChangeText={props.onNoteChange}
placeholder="Write a note"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{props.setupCommand ? (
<NewWorkspaceSetupScriptField
command={props.setupCommand}
source={props.setupSource}
runPolicy={props.setupRunPolicy}
decision={props.setupDecisionChoice}
runSetup={props.runSetup}
onDecisionChange={props.onSetupDecisionChange}
onRunSetupChange={props.onRunSetupChange}
/>
) : null}
</>
) : null}
{props.error ? <Text style={styles.error}>{props.error}</Text> : null}
<View style={styles.actions}>
<Pressable
style={[styles.createButton, !props.canCreate && styles.createButtonDisabled]}
disabled={!props.canCreate}
onPress={props.onCreate}
>
{props.creating ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.createText}>
{props.sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
</Text>
)}
</Pressable>
</View>
</>
)}
</BottomDrawer>
)
}
@@ -68,6 +68,21 @@ function pickerItems(
return picker?.props.items ?? []
}
function sourceInputs(renderer: ReactTestRenderer) {
return renderer.root.findAll(
(node) =>
node.type === 'TextInput' && node.props.placeholder === 'Type a name or search a source'
)
}
async function flushUpdates(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
describe('NewWorktreeModal project targets', () => {
let renderer: ReactTestRenderer
@@ -164,4 +179,224 @@ describe('NewWorktreeModal project targets', () => {
})
])
})
it('loads SSH state before detecting agents on the selected remote target', async () => {
const remoteRepo = {
id: 'repo-remote',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
setCachedRepos('host-ssh', [remoteRepo])
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [remoteRepo] } })
}
if (method === 'ssh.getState') {
return Promise.resolve({
ok: true,
result: {
state: {
targetId: 'build-server',
status: 'connected',
error: null,
reconnectAttempt: 0
}
}
})
}
if (method === 'preflight.detectRemoteAgents') {
return Promise.resolve({ ok: true, result: ['codex'] })
}
if (method === 'settings.get') {
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
}
if (method === 'ui.get') {
return Promise.resolve({ ok: true, result: { ui: {} } })
}
if (method === 'preflight.check') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'linear.status') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } })
}
if (method === 'repo.hooks') {
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-ssh',
onCreated: () => {},
onClose: () => {}
})
)
})
await flushUpdates()
expect(sendRequest).toHaveBeenCalledWith('ssh.getState', { targetId: 'build-server' })
expect(sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', {
connectionId: 'build-server'
})
})
it('keeps folder workspaces on the local execution and text-only source path', async () => {
const folderRepo = {
id: 'folder-1',
displayName: 'notes',
path: '/src/notes',
kind: 'folder'
}
setCachedRepos('host-folder', [folderRepo])
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [folderRepo] } })
}
if (method === 'preflight.detectAgents') {
return Promise.resolve({ ok: true, result: ['codex'] })
}
if (method === 'settings.get') {
return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } })
}
if (method === 'ui.get') {
return Promise.resolve({ ok: true, result: { ui: {} } })
}
if (method === 'preflight.check') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'linear.status') {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
if (method === 'repo.hooks') {
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-folder',
onCreated: () => {},
onClose: () => {}
})
)
})
await flushUpdates()
expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1)
expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents')
expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything())
})
it('ignores a stale repo list after the client changes', async () => {
let resolveOldList: ((value: unknown) => void) | undefined
const oldList = new Promise((resolve) => {
resolveOldList = resolve
})
const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
const oldClient = {
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return oldList
}
return new Promise(() => {})
})
} as unknown as RpcClient
const freshClient = {
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
} as unknown as RpcClient
const modalProps = {
visible: true,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: oldClient }))
})
const sourceInput = sourceInputs(renderer)[0]!
act(() => sourceInput.props.onChangeText('stale-client-name'))
expect(sourceInput.props.value).toBe('stale-client-name')
await act(async () => {
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient }))
await Promise.resolve()
})
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
resolveOldList?.({ ok: true, result: { repos } })
await flushUpdates()
expect(pickerItems(renderer, 'Project')).toEqual([expect.objectContaining({ label: 'fresh' })])
})
it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
const client = { sendRequest } as unknown as RpcClient
const modalProps = {
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
})
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
// One repo.list per opening. A third means the previous session's instance
// re-ran its visible-gated effects before the remount key caught up.
const repoListCalls = sendRequest.mock.calls.filter(([method]) => method === 'repo.list')
expect(repoListCalls).toHaveLength(2)
})
it('starts with fresh form state after closing and reopening', async () => {
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = {
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true }))
})
const sourceInput = sourceInputs(renderer)[0]!
act(() => sourceInput.props.onChangeText('previous-workspace'))
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false })))
act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true })))
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
import { View } from 'react-native'
import { Monitor } from 'lucide-react-native'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors } from '../theme/mobile-theme'
import { MobileAgentIcon } from './MobileAgentIcon'
import type { NewWorktreeAgentOption } from './new-worktree-agent-selection'
import { newWorktreeFormStyles as styles } from './new-worktree-form-styles'
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
import type {
NewWorkspaceProjectOption,
NewWorkspaceRunTargetOption
} from './new-workspace-project-targets'
import { getMobileWorkspaceRepoBadgeColor } from './new-worktree-modal-types'
import { PickerListDrawer } from './PickerListDrawer'
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
type Composer = ReturnType<typeof useMobileComposerSource>
export function NewWorktreeModalDrawers(props: {
visible: boolean
drawerView: NewWorktreeDrawerView
client: Parameters<typeof SmartWorkspaceSourceDrawer>[0]['client']
composer: Composer
sourceAvailability: SmartModeAvailabilityInput
selectedRepo: MobileWorkspaceRepo | null
repos: MobileWorkspaceRepo[]
pasteRepos: PasteRepoCandidate[]
sshReady: boolean
projectPickerItems: NewWorkspaceProjectOption<MobileWorkspaceRepo>[]
selectedProjectId: string | null
runTargetPickerItems: NewWorkspaceRunTargetOption<MobileWorkspaceRepo>[]
pickerAgentOptions: NewWorktreeAgentOption[]
selectedAgent: NewWorktreeAgentOption
setupTrustPrompt: SetupTrustPrompt | null
creating: boolean
onSourceRepoChange: (repo: MobileWorkspaceRepo) => void
onRepoChange: (repo: MobileWorkspaceRepo) => void
onAgentChange: (agent: NewWorktreeAgentOption) => void
onTransitionToForm: () => void
onApproveSetupTrust: (alwaysTrust: boolean) => void
onSkipSetupTrust: () => void
onCloseSetupTrust: () => void
}) {
return (
<>
<SmartWorkspaceSourceDrawer
visible={props.visible && props.drawerView === 'source'}
client={props.client}
composer={props.composer}
availability={props.sourceAvailability}
repoId={props.selectedRepo?.id ?? null}
repos={props.pasteRepos}
sshReady={props.sshReady}
onRepoChange={(repoId) => {
const nextRepo = props.repos.find((repo) => repo.id === repoId)
if (nextRepo) {
props.onSourceRepoChange(nextRepo)
}
}}
onClose={props.onTransitionToForm}
/>
<PickerListDrawer
visible={props.visible && props.drawerView === 'project'}
title="Project"
items={props.projectPickerItems}
selectedId={props.selectedProjectId ?? ''}
onSelect={(item) => props.onRepoChange(item.repo)}
onClose={props.onTransitionToForm}
renderIcon={(item) => (
<View
style={[
styles.repoDot,
{ backgroundColor: getMobileWorkspaceRepoBadgeColor(item.repo) }
]}
/>
)}
/>
<PickerListDrawer
visible={props.visible && props.drawerView === 'runTarget'}
title="Run on"
items={props.runTargetPickerItems}
selectedId={props.selectedRepo?.id ?? ''}
onSelect={(item) => props.onRepoChange(item.repo)}
onClose={props.onTransitionToForm}
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
/>
<PickerListDrawer
visible={props.visible && props.drawerView === 'agent'}
title="Agent"
items={props.pickerAgentOptions}
selectedId={props.selectedAgent.id}
onSelect={props.onAgentChange}
onClose={props.onTransitionToForm}
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
/>
<SetupHookTrustDrawer
visible={props.visible && props.drawerView === 'trust' && props.setupTrustPrompt != null}
prompt={props.setupTrustPrompt}
busy={props.creating}
onRunOnce={() => props.onApproveSetupTrust(false)}
onAlwaysTrust={() => props.onApproveSetupTrust(true)}
onDontRun={props.onSkipSetupTrust}
onClose={props.onCloseSetupTrust}
/>
</>
)
}
+18 -1
View File
@@ -199,7 +199,7 @@ describe('memoized worktree rows', () => {
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: agent({ state: 'done', updatedAt: 2_000 }),
agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }),
depth: 0,
now: 2_000,
unvisited: false
@@ -207,5 +207,22 @@ describe('memoized worktree rows', () => {
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' })
})
it('passes workspace monitoring mode to the status indicator', async () => {
await act(async () => {
renderer = create(
createElement(ListRowHarness, {
item: { ...baseItem, status: 'working', workingMode: 'monitoring' },
now: 2_000
})
)
})
expect(agentSpinnerRender).toHaveBeenLastCalledWith({
status: 'working',
workingMode: 'monitoring'
})
})
})
+3 -1
View File
@@ -2,6 +2,7 @@ import { memo } from 'react'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { triggerMediumImpact } from '../platform/haptics'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
@@ -39,6 +40,7 @@ export type WorktreeListRowItem = {
lineageChildCount?: number
lineageCollapsed?: boolean
agents?: RuntimeWorktreeAgentRow[]
workingMode?: AgentWorkingMode
}
type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -97,7 +99,7 @@ function WorktreeListRowComponent<T extends WorktreeListRowItem>({
delayLongPress={400}
>
<View style={styles.indicatorCol}>
<AgentSpinner status={status} />
<AgentSpinner status={status} workingMode={item.workingMode} />
{item.unread && (
<Bell
size={10}
@@ -0,0 +1,87 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentSpinner } from './AgentSpinner'
import { AgentStateDot } from './AgentStateDot'
const DESKTOP_WORKING_COLOR = '#eab308'
type MonitoringTestRenderer = {
readonly root: {
findByType(type: string): { props: Record<string, unknown> }
}
unmount(): void
}
const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
animationTiming: vi.fn(() => ({})),
setValue: vi.fn()
}))
vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
vi.mock('react-native', () => ({
Animated: {
Value: function Value() {
return { interpolate: vi.fn(() => 'rotation'), setValue }
},
View: 'AnimatedView',
loop: animationLoop,
timing: animationTiming
},
Easing: { linear: 'linear' },
StyleSheet: { create: <T>(styles: T) => styles },
View: 'View'
}))
describe('mobile monitoring indicators', () => {
let renderer: MonitoringTestRenderer | null = null
beforeEach(() => {
animationLoop.mockClear()
animationTiming.mockClear()
setValue.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('renders a static Radio for a monitoring agent', async () => {
await act(async () => {
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
})
expect(renderer?.root.findByType('Radio').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 10
})
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
it('renders a static Radio for an all-monitoring workspace', async () => {
await act(async () => {
renderer = create(
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
)
})
expect(renderer?.root.findByType('Radio').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 12
})
expect(animationTiming).not.toHaveBeenCalled()
expect(animationLoop).not.toHaveBeenCalled()
})
it('keeps the spinner fallback when workingMode is absent', async () => {
await act(async () => {
renderer = create(createElement(AgentSpinner, { status: 'working' }))
})
expect(animationTiming).toHaveBeenCalledOnce()
expect(animationLoop).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,244 @@
import { Platform, StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const newWorktreeFormStyles = StyleSheet.create({
header: {
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
loadingContainer: {
paddingVertical: spacing.xl,
alignItems: 'center'
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
},
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
labelHint: {
fontWeight: '400',
color: colors.textMuted
},
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle
},
fieldButtonText: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
fieldButtonPlaceholder: {
color: colors.textMuted
},
repoDot: {
width: 8,
height: 8,
borderRadius: 999
},
disabled: {
opacity: 0.55
},
sshBox: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.xs
},
sshRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
sshDot: {
width: 8,
height: 8,
borderRadius: 999
},
sshDotConnected: {
backgroundColor: colors.statusGreen
},
sshDotProgress: {
backgroundColor: colors.statusAmber
},
sshDotDisconnected: {
backgroundColor: colors.statusRed
},
sshCopy: {
flex: 1,
minWidth: 0
},
sshTitle: {
fontSize: typography.bodySize,
color: colors.textPrimary,
fontWeight: '600'
},
sshSubtitle: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 1
},
sshConnectButton: {
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sshConnectText: {
color: colors.textPrimary,
fontSize: 12,
fontWeight: '600'
},
errorInline: {
color: colors.statusRed,
fontSize: 12
},
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle
},
error: {
color: colors.statusRed,
fontSize: 13,
marginBottom: spacing.md
},
sourceWarning: {
marginTop: -spacing.sm,
marginBottom: spacing.md,
fontSize: 12,
color: colors.statusAmber
},
advancedToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.sm,
marginBottom: spacing.xs
},
advancedText: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textSecondary
},
setupHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.xs
},
sourceBadge: {
backgroundColor: colors.bgRaised,
borderRadius: 4,
paddingHorizontal: spacing.xs + 2,
paddingVertical: 2
},
sourceBadgeText: {
fontSize: 10,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5
},
setupBox: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md
},
setupToggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm
},
setupToggleLabel: {
fontSize: 13,
color: colors.textSecondary
},
setupChoiceRow: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.sm
},
setupChoiceButton: {
flex: 1,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingVertical: spacing.sm
},
setupChoiceButtonSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
setupChoiceText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
setupSwitch: {
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
},
setupCommandBlock: {
backgroundColor: colors.bgBase,
borderRadius: 6,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.sm
},
setupCommand: {
fontSize: 13,
fontFamily: typography.monoFamily,
color: colors.textPrimary
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: spacing.sm
},
createButton: {
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
minWidth: 160,
alignItems: 'center'
},
createButtonDisabled: {
opacity: 0.4
},
createText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
})
@@ -0,0 +1,49 @@
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
import type { RpcClient } from '../transport/rpc-client'
import type { SetupHookTrust } from '../tasks/setup-hook-trust'
import { repoColor } from '../worktree/repo-color'
export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<
SharedRepo,
| 'badgeColor'
| 'connectionId'
| 'executionHostId'
| 'kind'
| 'upstream'
| 'repoIcon'
| 'gitRemoteIdentity'
>
>
export type NewWorktreeModalProps = {
visible: boolean
client: RpcClient | null
hostId?: string
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
onCreated: (worktreeId: string, name: string) => void
onClose: () => void
}
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
export type RepoHooksResponse = {
hooks: { scripts?: { setup?: string } } | null
source: string | null
setupRunPolicy?: SetupRunPolicy
setupTrust?: SetupHookTrust
}
export type SetupHookDetails = {
repoId: string
command: string | null
source: string | null
trust: SetupHookTrust | null
runPolicy: SetupRunPolicy
}
export function getMobileWorkspaceRepoBadgeColor(repo: MobileWorkspaceRepo | null): string {
return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
}
@@ -0,0 +1,51 @@
import { useState } from 'react'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import {
NEW_WORKTREE_AGENT_OPTIONS,
NEW_WORKTREE_BLANK_AGENT,
resolveNewWorktreeAgentSelection,
type NewWorktreeAgentOption,
type NewWorktreeRuntimeSettings
} from './new-worktree-agent-selection'
export function useNewWorkspaceAgentSelection(args: {
visible: boolean
runtimeSettings: NewWorktreeRuntimeSettings | null
detectedAgentIds: Set<string> | null
}): {
selectedAgent: NewWorktreeAgentOption
setSelectedAgent: (agent: NewWorktreeAgentOption) => void
setAgentOverridden: (overridden: boolean) => void
pickerAgentOptions: NewWorktreeAgentOption[]
} {
const [selectedAgentState, setSelectedAgent] = useState<NewWorktreeAgentOption>(
NEW_WORKTREE_AGENT_OPTIONS[0]!
)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const resolution = resolveNewWorktreeAgentSelection({
visible: args.visible,
selectedAgent: selectedAgentState,
agentOverridden: agentOverriddenState,
runtimeSettings: args.runtimeSettings,
detectedAgentIds: args.detectedAgentIds
})
if (
selectedAgentState.id !== resolution.selectedAgent.id ||
agentOverriddenState !== resolution.agentOverridden
) {
setSelectedAgent(resolution.selectedAgent)
setAgentOverridden(resolution.agentOverridden)
}
const visibleAgentOptions = NEW_WORKTREE_AGENT_OPTIONS.filter(
(agent) =>
agent.id !== '__blank__' &&
(args.detectedAgentIds === null || args.detectedAgentIds.has(agent.id)) &&
isMobileTuiAgentEnabled(agent.id, args.runtimeSettings?.disabledTuiAgents)
)
return {
selectedAgent: resolution.selectedAgent,
setSelectedAgent,
setAgentOverridden,
pickerAgentOptions: [...visibleAgentOptions, NEW_WORKTREE_BLANK_AGENT]
}
}
@@ -0,0 +1,243 @@
import { useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import {
isSetupHookTrusted,
persistSetupHookTrustApproval,
wasSetupHookPreviouslyApproved,
type SetupHookTrust
} from '../tasks/setup-hook-trust'
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import {
pickPreferredNewWorktreeAgent,
type NewWorktreeAgentOption,
type NewWorktreeRuntimeSettings
} from './new-worktree-agent-selection'
import type { MobileWorkspaceRepo, SetupRunPolicy } from './new-worktree-modal-types'
import type { SetupTrustPrompt } from './SetupHookTrustDrawer'
import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
type CreateOptions = {
setupOverride?: Exclude<WorkspaceCreateSetupDecision, 'inherit'>
approvedSetupContentHash?: string
}
type Composer = ReturnType<typeof useMobileComposerSource>
export function useNewWorkspaceCreateSubmit(args: {
client: RpcClient | null
selectedRepo: MobileWorkspaceRepo | null
selectedAgent: NewWorktreeAgentOption
setSelectedAgent: (agent: NewWorktreeAgentOption) => void
setAgentOverridden: (overridden: boolean) => void
runtimeSettings: NewWorktreeRuntimeSettings | null
setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void
detectedAgentIds: Set<string> | null
sshGate: WorkspaceSshGate
composer: Composer
note: string
existingWorktreePaths?: readonly string[]
retiredWorktreeNames: RetiredNameRegistry
setupCommand: string | null
setupTrust: SetupHookTrust | null
setupRunPolicy: SetupRunPolicy
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
runSetup: boolean
trustedOrcaHooks: PersistedTrustedOrcaHooks
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
getWorktreeCreateCutoverSupport: () => Promise<boolean>
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
setError: Dispatch<SetStateAction<string>>
onCreated: (worktreeId: string, name: string) => void
onClose: () => void
}): {
creating: boolean
setupTrustPrompt: SetupTrustPrompt | null
create: () => Promise<void>
approveSetupTrust: (alwaysTrust: boolean) => Promise<void>
closeSetupTrust: () => void
skipSetupTrust: () => void
} {
const createInFlightRef = useRef(false)
const setupTrustActionInFlightRef = useRef(false)
const [creating, setCreating] = useState(false)
const [setupTrustPrompt, setSetupTrustPrompt] = useState<SetupTrustPrompt | null>(null)
async function create(options: CreateOptions = {}): Promise<void> {
const { client, selectedRepo } = args
if (!client || !selectedRepo || createInFlightRef.current) {
return
}
createInFlightRef.current = true
setCreating(true)
args.setError('')
try {
if (args.sshGate.requiresConnection) {
args.setError(`Connect ${selectedRepo.displayName} before creating a workspace.`)
return
}
let latestRuntimeSettings = args.runtimeSettings
try {
const settingsResponse = await client.sendRequest('settings.get')
if (settingsResponse.ok) {
const result = (settingsResponse as RpcSuccess).result as {
settings: NewWorktreeRuntimeSettings
}
latestRuntimeSettings = result.settings
args.setRuntimeSettings(result.settings)
}
} catch {
// The runtime validates the same setting before spawning.
}
if (
args.selectedAgent.id !== '__blank__' &&
!isMobileTuiAgentEnabled(args.selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents)
) {
args.setSelectedAgent(
pickPreferredNewWorktreeAgent(latestRuntimeSettings, args.detectedAgentIds)
)
args.setAgentOverridden(false)
args.setError('Selected agent is disabled. Choose an enabled agent before creating.')
return
}
const trimmedName = args.composer.name.trim()
const baseName =
trimmedName ||
getSuggestedCreatureName(
args.existingWorktreePaths ?? [],
undefined,
args.retiredWorktreeNames
)
let setupDecision: WorkspaceCreateSetupDecision = 'inherit'
if (args.setupCommand) {
if (options.setupOverride) {
setupDecision = options.setupOverride
} else if (args.setupRunPolicy === 'ask') {
if (!args.setupDecisionChoice) {
args.setError('Choose whether to run the setup script.')
return
}
setupDecision = args.setupDecisionChoice
} else {
setupDecision = args.runSetup ? 'run' : 'skip'
}
}
if (
setupDecision === 'run' &&
args.setupTrust &&
args.setupTrust.contentHash !== options.approvedSetupContentHash &&
!isSetupHookTrusted(args.trustedOrcaHooks, selectedRepo.id, args.setupTrust.contentHash)
) {
setSetupTrustPrompt({
repoId: selectedRepo.id,
repoName: selectedRepo.displayName,
scriptContent: args.setupTrust.scriptContent,
contentHash: args.setupTrust.contentHash,
previouslyApproved: wasSetupHookPreviouslyApproved(args.trustedOrcaHooks, selectedRepo.id)
})
args.transitionDrawer('trust')
return
}
const createdWithAgentId =
args.selectedAgent.id !== '__blank__' ? args.selectedAgent.id : undefined
const trimmedNote = args.note.trim() || undefined
const selection = args.composer.createSelection
const result = selection
? await createWorkspaceFromComposerSource({
client,
selection,
targetRepoId: selectedRepo.id,
setupDecision,
agent: { choice: normalizeWorkspaceAgent(args.selectedAgent.id) ?? 'blank' },
workspaceName: trimmedName || undefined,
note: trimmedNote,
nameIsAutoManaged: args.composer.isNameAutoManaged,
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
})
: await createBlankWorkspace({
client,
repoId: selectedRepo.id,
baseName,
nameWasGenerated: !trimmedName,
createdWithAgentId,
comment: trimmedNote,
setupDecision,
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
})
if ('error' in result) {
args.setError(result.error)
return
}
args.onClose()
args.onCreated(result.worktreeId, result.name)
} catch (error) {
args.setError(error instanceof Error ? error.message : 'Failed to create workspace')
} finally {
createInFlightRef.current = false
setCreating(false)
}
}
async function approveSetupTrust(alwaysTrust: boolean): Promise<void> {
if (
!args.client ||
!setupTrustPrompt ||
setupTrustActionInFlightRef.current ||
createInFlightRef.current
) {
return
}
setupTrustActionInFlightRef.current = true
setCreating(true)
try {
const nextTrust = await persistSetupHookTrustApproval({
client: args.client,
trust: args.trustedOrcaHooks,
repoId: setupTrustPrompt.repoId,
contentHash: setupTrustPrompt.contentHash,
alwaysTrust
})
args.setTrustedOrcaHooks(nextTrust)
const approvedHash = setupTrustPrompt.contentHash
setSetupTrustPrompt(null)
args.transitionDrawer('form')
await create({ setupOverride: 'run', approvedSetupContentHash: approvedHash })
} catch (error) {
args.setError(error instanceof Error ? error.message : 'Failed to trust setup script.')
} finally {
setupTrustActionInFlightRef.current = false
if (!createInFlightRef.current) {
setCreating(false)
}
}
}
function closeSetupTrust(): void {
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
return
}
setSetupTrustPrompt(null)
args.transitionDrawer('form')
}
function skipSetupTrust(): void {
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
return
}
closeSetupTrust()
void create({ setupOverride: 'skip' })
}
return { creating, setupTrustPrompt, create, approveSetupTrust, closeSetupTrust, skipSetupTrust }
}
@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { deriveWorkspaceSshGate, type WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
type DetectedAgentIdsState = {
connectionId: string | null
ids: Set<string>
}
function fallbackSshState(
targetId: string,
status: SshConnectionState['status'],
error: string | null
): SshConnectionState {
return { targetId, status, error, reconnectAttempt: 0 }
}
export function useNewWorkspaceExecutionTarget(args: {
client: RpcClient | null
connectionId: string | null
visible: boolean
}): {
sshGate: WorkspaceSshGate
detectedAgentIds: Set<string> | null
connect: () => Promise<void>
} {
const { client, connectionId, visible } = args
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [connectingTargetId, setConnectingTargetId] = useState<string | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
null
)
const sshGate = deriveWorkspaceSshGate({
connectionId,
state: sshState,
connecting: connectingTargetId === connectionId
})
const detectedAgentIds =
detectedAgentIdsState?.connectionId === connectionId &&
(connectionId === null || sshGate.status === 'connected')
? detectedAgentIdsState.ids
: null
useEffect(() => {
if (!visible || !client || !connectionId) {
return
}
let stale = false
void client
.sendRequest('ssh.getState', { targetId: connectionId })
.then((response) => {
if (stale) {
return
}
if (!response.ok) {
throw new Error(response.error.message)
}
const state = (response as RpcSuccess).result as { state?: SshConnectionState | null }
setSshState(state.state ?? fallbackSshState(connectionId, 'disconnected', null))
})
.catch((error) => {
if (!stale) {
setSshState(
fallbackSshState(
connectionId,
'error',
error instanceof Error ? error.message : 'Failed to read SSH connection state.'
)
)
}
})
return () => {
stale = true
}
}, [client, connectionId, visible])
useEffect(() => {
if (!visible || !client || (connectionId && sshGate.status !== 'connected')) {
return
}
let stale = false
void (async () => {
try {
const response = connectionId
? await client.sendRequest('preflight.detectRemoteAgents', { connectionId })
: await client.sendRequest('preflight.detectAgents')
if (!stale) {
setDetectedAgentIdsState({
connectionId,
ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
})
}
} catch {
if (!stale) {
setDetectedAgentIdsState({ connectionId, ids: new Set() })
}
}
})()
return () => {
stale = true
}
}, [client, connectionId, sshGate.status, visible])
async function connect(): Promise<void> {
if (!client || !connectionId) {
return
}
setConnectingTargetId(connectionId)
setSshState(fallbackSshState(connectionId, 'connecting', null))
try {
const response = await client.sendRequest(
'ssh.connect',
{ targetId: connectionId },
{ timeoutMs: 120_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
setSshState(result.state ?? fallbackSshState(connectionId, 'connected', null))
} catch (error) {
setSshState(
fallbackSshState(
connectionId,
'error',
error instanceof Error ? error.message : 'Failed to connect to SSH repository.'
)
)
} finally {
setConnectingTargetId((current) => (current === connectionId ? null : current))
}
}
return { sshGate, detectedAgentIds, connect }
}
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { getCachedRepos, setCachedRepos } from '../cache/repo-cache'
import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo'
import {
getMobileNewWorkspaceDialogEligibleRepos,
refreshMobileNewWorkspaceDialogSelectedRepo,
resolveMobileNewWorkspaceDialogRepoId
} from '../worktree/new-workspace-dialog-repo-selection'
import type { MobileWorkspaceRepo } from './new-worktree-modal-types'
export function useNewWorkspaceRepositories(args: {
client: RpcClient | null
hostId?: string
visible: boolean
}): {
repos: MobileWorkspaceRepo[]
selectedRepo: MobileWorkspaceRepo | null
setSelectedRepo: (repo: MobileWorkspaceRepo | null) => void
loading: boolean
} {
const { client, hostId, visible } = args
const [initialRepos] = useState(() =>
hostId ? (getCachedRepos(hostId) as MobileWorkspaceRepo[] | null) : null
)
const [repos, setRepos] = useState<MobileWorkspaceRepo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<MobileWorkspaceRepo | null>(null)
const [loading, setLoading] = useState(initialRepos == null)
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
useEffect(() => {
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
return
}
const eligibleRepos = getMobileNewWorkspaceDialogEligibleRepos(repos)
const preferredRepoId = resolveMobileNewWorkspaceDialogRepoId({
eligibleRepos,
activeRepoId: lastVisitedRepo.repoId
})
const preferredRepo = repos.find((repo) => repo.id === preferredRepoId) ?? null
if (preferredRepo) {
setSelectedRepo(preferredRepo)
}
}, [lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible])
useEffect(() => {
if (!visible || !client) {
return
}
let stale = false
setLoading(true)
void client
.sendRequest('repo.list')
.then((response) => {
if (stale || !response.ok) {
return
}
const result = (response as RpcSuccess).result as { repos: MobileWorkspaceRepo[] }
setRepos(result.repos)
if (hostId) {
setCachedRepos(hostId, result.repos)
}
setSelectedRepo((current) =>
refreshMobileNewWorkspaceDialogSelectedRepo(result.repos, current)
)
})
.catch(() => undefined)
.finally(() => {
if (!stale) {
setLoading(false)
}
})
return () => {
stale = true
}
}, [visible, client, hostId])
return { repos, selectedRepo, setSelectedRepo, loading: loading && repos.length === 0 }
}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import {
filterAvailableTaskProviders,
normalizeVisibleTaskProviders,
type TaskProvider
} from '../tasks/mobile-task-providers'
import type { NewWorktreeRuntimeSettings } from './new-worktree-agent-selection'
function settledSuccess(entry: PromiseSettledResult<RpcResponse>): RpcSuccess | null {
return entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null
}
export function useNewWorkspaceRuntimeContext(
client: RpcClient | null,
visible: boolean,
hostId?: string
): {
runtimeSettings: NewWorktreeRuntimeSettings | null
setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void
trustedOrcaHooks: PersistedTrustedOrcaHooks
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
availableProviders: TaskProvider[]
} {
const [runtimeSettings, setRuntimeSettings] = useState<NewWorktreeRuntimeSettings | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
useEffect(() => {
if (!visible || !client) {
return
}
let stale = false
void (async () => {
const probes = Promise.allSettled([
client.sendRequest('preflight.check'),
client.sendRequest('linear.status')
])
const [settingsRes, uiRes] = await Promise.allSettled([
client.sendRequest('settings.get'),
client.sendRequest('ui.get')
])
if (stale) {
return
}
const settingsResult = settledSuccess(settingsRes)
const settingsValue = settingsResult
? (
settingsResult.result as {
settings: NewWorktreeRuntimeSettings & { visibleTaskProviders?: unknown }
}
).settings
: null
if (settingsValue) {
setRuntimeSettings(settingsValue)
}
const uiResult = settledSuccess(uiRes)
if (uiResult) {
const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui
setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {})
}
const [preflightRes, linearRes] = await probes
if (stale) {
return
}
const glabInstalled =
(settledSuccess(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)
?.glab?.installed === true
const linearConnected =
(settledSuccess(linearRes)?.result as { connected?: boolean } | undefined)?.connected ===
true
const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders)
setAvailableProviders(
filterAvailableTaskProviders(visibleProviders, {
gitlabInstalled: glabInstalled,
linearConnected
}).filter((provider) => visibleProviders.includes(provider))
)
})()
return () => {
stale = true
}
}, [visible, client, hostId])
return {
runtimeSettings,
setRuntimeSettings,
trustedOrcaHooks,
setTrustedOrcaHooks,
availableProviders
}
}
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { normalizeSetupHookTrust } from '../tasks/setup-hook-trust'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type {
MobileWorkspaceRepo,
RepoHooksResponse,
SetupHookDetails
} from './new-worktree-modal-types'
export function useNewWorkspaceSetupScript(args: {
client: RpcClient | null
selectedRepo: MobileWorkspaceRepo | null
}): {
setupCommand: string | null
setupSource: string | null
setupTrust: SetupHookDetails['trust']
setupRunPolicy: SetupHookDetails['runPolicy']
setupDecisionChoice: Exclude<WorkspaceCreateSetupDecision, 'inherit'> | null
setSetupDecisionChoice: (decision: Exclude<WorkspaceCreateSetupDecision, 'inherit'>) => void
runSetup: boolean
setRunSetup: (run: boolean) => void
showAdvanced: boolean
setShowAdvanced: (show: boolean) => void
} {
const { client, selectedRepo } = args
const [details, setDetails] = useState<SetupHookDetails | null>(null)
const [setupDecisionChoice, setSetupDecisionChoice] = useState<Exclude<
WorkspaceCreateSetupDecision,
'inherit'
> | null>(null)
const [runSetup, setRunSetup] = useState(true)
const [showAdvanced, setShowAdvanced] = useState(false)
const activeDetails = selectedRepo && details?.repoId === selectedRepo.id ? details : null
useEffect(() => {
if (!client || !selectedRepo) {
return
}
let stale = false
void client
.sendRequest('repo.hooks', { repo: `id:${selectedRepo.id}` })
.then((response) => {
if (stale || !response.ok) {
return
}
const result = (response as RpcSuccess).result as RepoHooksResponse
const command = result.hooks?.scripts?.setup?.trim() || null
const runPolicy = result.setupRunPolicy ?? 'run-by-default'
setDetails({
repoId: selectedRepo.id,
command,
source: result.source,
trust: normalizeSetupHookTrust(result.setupTrust),
runPolicy
})
setSetupDecisionChoice(null)
setRunSetup(runPolicy !== 'skip-by-default')
if (command && runPolicy === 'ask') {
setShowAdvanced(true)
}
})
.catch(() => {
if (!stale) {
setDetails({
repoId: selectedRepo.id,
command: null,
source: null,
trust: null,
runPolicy: 'run-by-default'
})
setSetupDecisionChoice(null)
}
})
return () => {
stale = true
}
}, [client, selectedRepo])
return {
setupCommand: activeDetails?.command ?? null,
setupSource: activeDetails?.source ?? null,
setupTrust: activeDetails?.trust ?? null,
setupRunPolicy: activeDetails?.runPolicy ?? 'run-by-default',
setupDecisionChoice,
setSetupDecisionChoice,
runSetup,
setRunSetup,
showAdvanced,
setShowAdvanced
}
}
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'utf8')
const homeSource = readFileSync(new URL('./home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
describe('Home host edit navigation wiring', () => {
it('uses the cold-navigator-safe edit transition', () => {
@@ -0,0 +1,133 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { ClaudeIcon, OpenAIIcon } from '../components/AgentIcons'
import {
getActiveProviderRateLimits,
getUsageBarState,
hasActiveProviderUsage,
UsageBar,
type AccountsSnapshot,
type ProviderKey
} from '../components/AccountUsage'
import { colors, radii, spacing } from '../theme/mobile-theme'
import type { HostProfile } from '../transport/types'
export function MobileHomeAccountUsageCards(props: {
items: { host: HostProfile; snapshot: AccountsSnapshot }[]
onOpen: (hostId: string) => void
}) {
if (props.items.length === 0) {
return null
}
return (
<>
<Text style={styles.sectionHeading}>Account usage</Text>
{props.items.map(({ host, snapshot }) => {
const claudeActive =
snapshot.claude.accounts.find(
(account) => account.id === snapshot.claude.activeAccountId
) ?? null
const codexActive =
snapshot.codex.accounts.find(
(account) => account.id === snapshot.codex.activeAccountId
) ?? null
return (
<Pressable
key={host.id}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={() => props.onOpen(host.id)}
>
{props.items.length > 1 ? (
<Text style={styles.hostLabel} numberOfLines={1}>
{host.name}
</Text>
) : null}
{(['claude', 'codex'] as ProviderKey[]).map((provider) => {
const active = provider === 'claude' ? claudeActive : codexActive
const accounts =
provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts
const limits = getActiveProviderRateLimits(snapshot, provider)
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
return null
}
const sessionBar = getUsageBarState(limits, 'session')
const weeklyBar = getUsageBarState(limits, 'weekly')
return (
<View key={provider} style={styles.row}>
<View style={styles.icon}>
{provider === 'claude' ? (
<ClaudeIcon size={18} />
) : (
<OpenAIIcon size={18} color={colors.textPrimary} />
)}
</View>
<View style={styles.info}>
<Text style={styles.email} numberOfLines={1}>
{active?.email ?? 'System default'}
</Text>
<View style={styles.bars}>
<UsageBar
label="5h"
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
/>
</View>
</View>
</View>
)
})}
</Pressable>
)
})}
</>
)
}
const styles = StyleSheet.create({
sectionHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.6,
marginTop: spacing.xl,
marginBottom: spacing.sm,
paddingHorizontal: spacing.xs
},
card: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
gap: spacing.sm,
marginBottom: spacing.sm
},
cardPressed: { backgroundColor: colors.bgRaised },
hostLabel: {
fontSize: 11,
color: colors.textMuted,
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: 0.4
},
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2 },
icon: {
width: 32,
height: 32,
borderRadius: 9,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
info: { flex: 1, minWidth: 0, gap: 2 },
email: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
bars: { flexDirection: 'row', gap: spacing.md, marginTop: 4 }
})
+137
View File
@@ -0,0 +1,137 @@
import { QrCode } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing } from '../theme/mobile-theme'
const ONBOARDING_STEPS = [
{
title: 'Open Orca desktop',
desc: 'Go to Settings → Mobile and generate a pairing QR code.'
},
{
title: 'Scan the code',
desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.'
},
{
title: "You're connected",
desc: 'Your desktop will appear here. Everything is encrypted end-to-end.'
}
]
export function MobileHomeEmptyState(props: {
bottomInset: number
contentMaxWidth: number
isWideLayout: boolean
onPairDesktop: () => void
}) {
return (
<View
style={[
styles.emptyContainer,
{ paddingBottom: props.bottomInset },
props.isWideLayout && {
maxWidth: props.contentMaxWidth,
width: '100%',
alignSelf: 'center'
}
]}
>
<View style={styles.emptyHero}>
<Text style={styles.emptyTitle}>Connect your desktop</Text>
<Text style={styles.emptyBody}>
Pair with Orca on your computer to check on your agents, jump into any terminal, and drive
work from your phone.
</Text>
<Pressable style={styles.primaryButton} onPress={props.onPairDesktop}>
<QrCode size={17} color={colors.bgBase} />
<Text style={styles.primaryButtonText}>Pair Desktop</Text>
</Pressable>
</View>
<View style={styles.stepsSection}>
<Text style={styles.sectionHeading}>How it works</Text>
{ONBOARDING_STEPS.map((step, index) => (
<View key={step.title} style={[styles.stepRow, index > 0 && styles.stepRowBorder]}>
<View style={styles.stepNum}>
<Text style={styles.stepNumText}>{index + 1}</Text>
</View>
<View style={styles.stepText}>
<Text style={styles.stepTitle}>{step.title}</Text>
<Text style={styles.stepDesc}>{step.desc}</Text>
</View>
</View>
))}
</View>
</View>
)
}
const styles = StyleSheet.create({
emptyContainer: { flex: 1 },
emptyHero: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 40
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
color: colors.textPrimary,
textAlign: 'center',
marginBottom: 10
},
emptyBody: {
fontSize: 15,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: colors.textPrimary,
paddingHorizontal: 28,
paddingVertical: 14,
borderRadius: radii.card
},
primaryButtonText: { color: colors.bgBase, fontSize: 15, fontWeight: '700' },
stepsSection: { paddingHorizontal: spacing.xl },
sectionHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.6,
marginBottom: spacing.sm,
paddingHorizontal: spacing.xs
},
stepRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
paddingVertical: spacing.lg
},
stepRowBorder: { borderTopWidth: 1, borderTopColor: colors.borderSubtle },
stepNum: {
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: 'rgba(255,255,255,0.04)',
borderWidth: 1,
borderColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1
},
stepNumText: { fontSize: 12, fontWeight: '700', color: colors.textSecondary },
stepText: { flex: 1 },
stepTitle: {
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: 3
},
stepDesc: { fontSize: 12, color: colors.textMuted, lineHeight: 17 }
})
+144
View File
@@ -0,0 +1,144 @@
import { memo, useCallback, type ReactElement } from 'react'
import { FlatList, StyleSheet, View } from 'react-native'
import type { ListRenderItemInfo } from 'react-native'
import { MobileHostCard } from '../components/MobileHostCard'
import type { HomeStatsSummary } from '../stats/home-stats-total'
import { spacing } from '../theme/mobile-theme'
import { classifyConnection } from '../transport/connection-health'
import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostCatalogEntry } from '../transport/types'
import type { HostWorktreeInfo } from '../worktree/home-worktree-info'
import { MobileHomeListHeader } from './MobileHomeListHeader'
type MobileHomeHostListProps = {
autoConnectHostIds: string[]
bottomInset: number
contentMaxWidth: number
footer: ReactElement
hostAttempts: Record<string, number>
hostLastConnected: Record<string, number | null>
hostPairingRejected: Record<string, boolean>
hostPaths: Record<string, MobileConnectionPath>
hostPendingPaths: Record<string, MobileConnectionPath | null>
hosts: HostCatalogEntry[]
hostStates: Record<string, ConnectionState>
isWideLayout: boolean
stats: HomeStatsSummary | null
worktreeInfo: Record<string, HostWorktreeInfo>
onOpen: (host: HostCatalogEntry) => void
onLongPress: (host: HostCatalogEntry) => void
onOpenActions: (host: HostCatalogEntry) => void
}
export function MobileHomeHostList(props: MobileHomeHostListProps) {
const renderHost = useCallback(
({ item }: ListRenderItemInfo<HostCatalogEntry>) => (
<MobileHomeHostRow
item={item}
autoConnectHostIds={props.autoConnectHostIds}
hostAttempts={props.hostAttempts}
hostLastConnected={props.hostLastConnected}
hostPairingRejected={props.hostPairingRejected}
hostPaths={props.hostPaths}
hostPendingPaths={props.hostPendingPaths}
hostStates={props.hostStates}
worktreeInfo={props.worktreeInfo}
onOpen={props.onOpen}
onLongPress={props.onLongPress}
onOpenActions={props.onOpenActions}
/>
),
[
props.autoConnectHostIds,
props.hostAttempts,
props.hostLastConnected,
props.hostPairingRejected,
props.hostPaths,
props.hostPendingPaths,
props.hostStates,
props.onLongPress,
props.onOpen,
props.onOpenActions,
props.worktreeInfo
]
)
return (
<FlatList
data={props.hosts}
keyExtractor={(host) => host.id}
contentContainerStyle={[
styles.list,
{ paddingBottom: spacing.xl + props.bottomInset },
props.isWideLayout && {
maxWidth: props.contentMaxWidth,
width: '100%',
alignSelf: 'center'
}
]}
ListHeaderComponent={<MobileHomeListHeader stats={props.stats} />}
ItemSeparatorComponent={CardGap}
renderItem={renderHost}
ListFooterComponent={props.footer}
/>
)
}
type MobileHomeHostRowProps = Pick<
MobileHomeHostListProps,
| 'autoConnectHostIds'
| 'hostAttempts'
| 'hostLastConnected'
| 'hostPairingRejected'
| 'hostPaths'
| 'hostPendingPaths'
| 'hostStates'
| 'worktreeInfo'
| 'onOpen'
| 'onLongPress'
| 'onOpenActions'
> & { item: HostCatalogEntry }
const MobileHomeHostRow = memo(function MobileHomeHostRow(props: MobileHomeHostRowProps) {
const { item, onLongPress, onOpen, onOpenActions } = props
const state = resolveHomeHostConnectionState(
item.id,
props.hostStates[item.id],
props.autoConnectHostIds
)
const verdict = classifyConnection({
state,
reconnectAttempts: props.hostAttempts[item.id] ?? 0,
lastConnectedAt: props.hostLastConnected[item.id] ?? null,
endpoint: item.endpoint,
pendingPath: props.hostPendingPaths[item.id] ?? null,
pairingRejected: props.hostPairingRejected[item.id] ?? false
})
const open = useCallback(() => onOpen(item), [item, onOpen])
const longPress = useCallback(() => onLongPress(item), [item, onLongPress])
const openActions = useCallback(() => onOpenActions(item), [item, onOpenActions])
return (
<MobileHostCard
host={item}
credentialStatus={item.credentialStatus}
state={state}
verdict={verdict}
path={props.hostPaths[item.id] ?? 'lan'}
worktreeInfo={props.worktreeInfo[item.id]}
onPress={open}
onLongPress={longPress}
onOpenActions={openActions}
/>
)
})
function CardGap() {
return <View style={styles.cardGap} />
}
const styles = StyleSheet.create({
list: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl },
cardGap: { height: spacing.sm }
})
+59
View File
@@ -0,0 +1,59 @@
import { StyleSheet, Text, View } from 'react-native'
import type { AccountsSnapshot } from '../components/AccountUsage'
import { MobileHomeQuickActions } from '../components/MobileHomeQuickActions'
import type { TaskProvider } from '../tasks/mobile-task-providers'
import { colors, spacing } from '../theme/mobile-theme'
import type { HostProfile } from '../transport/types'
import type { HomeResumeCard } from '../worktree/home-resume-card'
import { MobileHomeAccountUsageCards } from './MobileHomeAccountUsageCards'
import { MobileHomeResumeCard } from './MobileHomeResumeCard'
import { MobileHomeTasksCard } from './MobileHomeTasksCard'
export function MobileHomeListFooter(props: {
accountsHosts: { host: HostProfile; snapshot: AccountsSnapshot }[]
connectedHosts: HostProfile[]
primaryHost: HostProfile | null
primaryTaskProviders: TaskProvider[]
resumeCard: HomeResumeCard | null
onCreateWorkspace: (hostId: string) => void
onOpenAccounts: (hostId: string) => void
onOpenResume: (card: HomeResumeCard) => void
onOpenTasks: (provider?: TaskProvider) => void
onPairDesktop: () => void
}) {
return (
<View>
{props.resumeCard ? (
<>
<Text style={styles.sectionHeading}>Resume</Text>
<MobileHomeResumeCard card={props.resumeCard} onOpen={props.onOpenResume} />
</>
) : null}
<Text style={styles.sectionHeading}>Tasks</Text>
<MobileHomeTasksCard
enabled={props.primaryHost != null}
providers={props.primaryTaskProviders}
onOpen={props.onOpenTasks}
/>
<MobileHomeQuickActions
connectedHosts={props.connectedHosts}
onPairDesktop={props.onPairDesktop}
onCreateWorkspace={props.onCreateWorkspace}
/>
<MobileHomeAccountUsageCards items={props.accountsHosts} onOpen={props.onOpenAccounts} />
</View>
)
}
const styles = StyleSheet.create({
sectionHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.6,
marginTop: spacing.lg,
marginBottom: spacing.sm,
paddingHorizontal: spacing.xs
}
})
+78
View File
@@ -0,0 +1,78 @@
import { StyleSheet, Text, View } from 'react-native'
import type { HomeStatsSummary } from '../stats/home-stats-total'
import { colors, spacing } from '../theme/mobile-theme'
function formatDuration(ms: number): string {
const totalMinutes = Math.floor(ms / 60_000)
const totalHours = Math.floor(totalMinutes / 60)
const days = Math.floor(totalHours / 24)
const hours = totalHours % 24
if (days > 0) {
return `${days}d ${hours}h`
}
const minutes = totalMinutes % 60
return totalHours > 0 ? `${totalHours}h ${minutes}m` : `${totalMinutes}m`
}
export function MobileHomeListHeader({ stats }: { stats: HomeStatsSummary | null }) {
return (
<View>
<View style={styles.hero}>
<Text style={styles.heroTitle}>Welcome back</Text>
</View>
{stats ? (
<View style={styles.statsRow}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{stats.totalAgentsSpawned.toLocaleString()}</Text>
<Text style={styles.statLabel}>Agents spawned</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{formatDuration(stats.totalAgentTimeMs)}</Text>
<Text style={styles.statLabel}>Agent time</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{stats.totalPRsCreated.toLocaleString()}</Text>
<Text style={styles.statLabel}>PRs created</Text>
</View>
</View>
) : null}
<Text style={styles.sectionHeading}>Desktops</Text>
</View>
)
}
const styles = StyleSheet.create({
hero: { paddingTop: spacing.xs, paddingBottom: spacing.md },
heroTitle: {
color: colors.textPrimary,
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.3
},
statsRow: { flexDirection: 'row', gap: 10, marginBottom: spacing.lg },
statCard: {
flex: 1,
backgroundColor: 'rgba(26,26,26,0.6)',
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: spacing.md
},
statValue: {
color: colors.textPrimary,
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3
},
statLabel: { color: colors.textMuted, fontSize: 11, fontWeight: '500', marginTop: 2 },
sectionHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.6,
marginBottom: spacing.sm,
paddingHorizontal: spacing.xs
}
})
+84
View File
@@ -0,0 +1,84 @@
import { ChevronRight, Terminal } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing } from '../theme/mobile-theme'
import type { HomeResumeCard } from '../worktree/home-resume-card'
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
function homeResumeRepoColor(name: string): string {
let hash = 0
for (let index = 0; index < name.length; index += 1) {
hash = (hash * 31 + name.charCodeAt(index)) | 0
}
return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length]
}
export function MobileHomeResumeCard(props: {
card: HomeResumeCard
onOpen: (card: HomeResumeCard) => void
}) {
return (
<Pressable
disabled={!props.card.actionable}
style={({ pressed }) => [
styles.resumeCard,
!props.card.actionable && styles.cardDisabled,
pressed && styles.cardPressed
]}
onPress={() => props.onOpen(props.card)}
>
<View style={styles.resumeIcon}>
<Terminal size={18} color={colors.textSecondary} />
</View>
<View style={styles.resumeMain}>
<Text style={styles.resumeTitle} numberOfLines={1}>
{props.card.worktree.displayName}
</Text>
<View style={styles.resumeSub}>
<View
style={[
styles.repoDot,
{ backgroundColor: homeResumeRepoColor(props.card.worktree.repo) }
]}
/>
<Text style={styles.resumeSubText} numberOfLines={1}>
{props.card.worktree.repo}
{' · '}
{props.card.worktree.branch}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
const styles = StyleSheet.create({
resumeCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 12
},
cardDisabled: { opacity: 0.45 },
cardPressed: { backgroundColor: colors.bgRaised },
resumeIcon: {
width: 46,
height: 46,
borderRadius: 13,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14
},
resumeMain: { flex: 1, minWidth: 0 },
resumeTitle: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
resumeSub: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 },
repoDot: { width: 7, height: 7, borderRadius: 3.5 },
resumeSubText: { fontSize: 12, color: colors.textSecondary, flex: 1 }
})
+200
View File
@@ -0,0 +1,200 @@
import { useCallback, useState } from 'react'
import { Alert, StyleSheet } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useOpenMobileAccounts } from '../accounts/use-open-mobile-accounts'
import { getProvenCachedWorktrees } from '../cache/worktree-cache'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { ConfirmModal } from '../components/ConfirmModal'
import { getHostListActionSheetActions } from '../host-list-action-sheet-actions'
import { hostNewWorktreeRoute } from '../host-route-action-state'
import { hostRouteWithNotice } from '../host-route-notice'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { triggerMediumImpact } from '../platform/haptics'
import { useOpenMobileSession } from '../session/use-open-mobile-session'
import type { TaskProvider } from '../tasks/mobile-task-providers'
import { useOpenMobileTasks } from '../tasks/use-open-mobile-tasks'
import { colors } from '../theme/mobile-theme'
import {
useDisconnectHostClient,
useForceReconnect,
useForgetHostClient
} from '../transport/client-context'
import { hostEndpointLabel } from '../transport/host-endpoint-label'
import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect'
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry, HostProfile } from '../transport/types'
import { useOpenMobileHostEdit } from '../transport/use-open-mobile-host-edit'
import type { HomeWorktreeSummary } from '../worktree/home-worktree-info'
import { isResumeTargetConfirmedMissing, type HomeResumeCard } from '../worktree/home-resume-card'
import { MobileHomeEmptyState } from './MobileHomeEmptyState'
import { MobileHomeHostList } from './MobileHomeHostList'
import { MobileHomeListFooter } from './MobileHomeListFooter'
import { MobileHomeTopBar } from './MobileHomeTopBar'
import { useMobileHomeData } from './use-mobile-home-data'
export function MobileHomeScreen() {
const data = useMobileHomeData()
const insets = useSafeAreaInsets()
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
const openMobileSession = useOpenMobileSession()
const openMobileAccounts = useOpenMobileAccounts()
const disconnectHostClient = useDisconnectHostClient()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
const openResume = useCallback(
(card: HomeResumeCard) => {
if (
isResumeTargetConfirmedMissing(
card,
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
)
) {
data.router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
return
}
openMobileSession({
hostId: card.hostId,
worktreeId: card.worktree.worktreeId,
name: card.worktree.displayName || card.worktree.repo
})
},
[data.router, openMobileSession]
)
const openTasks = useCallback(
(provider?: TaskProvider) => {
if (data.primaryHost) {
openMobileTasks(data.primaryHost.id, provider)
}
},
[data.primaryHost, openMobileTasks]
)
function openHost(host: HostCatalogEntry): void {
if (host.credentialStatus === 'missing') {
data.router.push('/pair-scan')
} else if (host.credentialStatus === 'temporarily-unavailable') {
void loadHostCatalog()
.then(data.setHostCatalog)
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
} else {
data.router.push(`/h/${host.id}`)
}
}
function openHostActions(host: HostCatalogEntry): void {
if (host.profile) {
setActionTarget(host.profile)
} else {
setConfirmRemove(host)
}
}
async function handleRemove(): Promise<void> {
if (!confirmRemove) {
return
}
const host = confirmRemove
try {
await removeHostAndCloseClient(host.id, forgetHostClient)
setConfirmRemove(null)
data.setHostCatalog(await loadHostCatalog())
} catch {
setConfirmRemove(host)
Alert.alert('Could not remove host', 'Please try again.')
}
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<MobileHomeTopBar onOpenSettings={() => data.router.push('/settings')} />
{data.hostCatalog.length === 0 ? (
<MobileHomeEmptyState
bottomInset={insets.bottom}
contentMaxWidth={contentMaxWidth}
isWideLayout={isWideLayout}
onPairDesktop={() => data.router.push('/pair-scan')}
/>
) : (
<MobileHomeHostList
autoConnectHostIds={data.autoConnectHostIds}
bottomInset={insets.bottom}
contentMaxWidth={contentMaxWidth}
footer={
<MobileHomeListFooter
accountsHosts={data.accountsHosts}
connectedHosts={data.connectedHosts}
primaryHost={data.primaryHost}
primaryTaskProviders={data.primaryTaskProviders}
resumeCard={data.resumeCard}
onCreateWorkspace={(hostId) => data.router.push(hostNewWorktreeRoute(hostId))}
onOpenAccounts={openMobileAccounts}
onOpenResume={openResume}
onOpenTasks={openTasks}
onPairDesktop={() => data.router.push('/pair-scan')}
/>
}
hostAttempts={data.hostAttempts}
hostLastConnected={data.hostLastConnected}
hostPairingRejected={data.hostPairingRejected}
hostPaths={data.hostPaths}
hostPendingPaths={data.hostPendingPaths}
hosts={data.sortedHostCatalog}
hostStates={data.hostStates}
isWideLayout={isWideLayout}
stats={data.stats}
worktreeInfo={data.worktreeInfo}
onOpen={openHost}
onLongPress={(host) => {
triggerMediumImpact()
openHostActions(host)
}}
onOpenActions={openHostActions}
/>
)}
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? hostEndpointLabel(actionTarget.endpoint) : undefined}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget
? resolveHomeHostConnectionState(
actionTarget.id,
data.hostStates[actionTarget.id],
data.autoConnectHostIds
)
: 'disconnected',
hasEverConnected: actionTarget
? (data.hostLastConnected[actionTarget.id] ?? null) != null
: false,
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: disconnectHostClient,
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
onClose={() => setActionTarget(null)}
/>
<ConfirmModal
visible={confirmRemove != null}
title="Remove Host"
message={`Remove "${confirmRemove?.name}"? You can re-pair later.`}
confirmLabel="Remove"
destructive
onConfirm={() => void handleRemove()}
onCancel={() => setConfirmRemove(null)}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgBase }
})
+118
View File
@@ -0,0 +1,118 @@
import { ChevronRight, ListTodo } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { TaskProviderLogo } from '../components/TaskProviderLogo'
import type { TaskProvider } from '../tasks/mobile-task-providers'
import { colors, radii, spacing } from '../theme/mobile-theme'
const TASK_PROVIDER_LABELS: Record<TaskProvider, string> = {
github: 'GitHub',
gitlab: 'GitLab',
linear: 'Linear'
}
export function MobileHomeTasksCard(props: {
enabled: boolean
providers: TaskProvider[]
onOpen: (provider?: TaskProvider) => void
}) {
return (
<Pressable
disabled={!props.enabled}
style={({ pressed }) => [
styles.card,
!props.enabled && styles.cardDisabled,
pressed && styles.cardPressed
]}
onPress={() => props.onOpen()}
>
<View style={styles.icon}>
<ListTodo size={18} color={colors.textSecondary} />
</View>
<View style={styles.main}>
<Text style={styles.title}>Tasks</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{props.providers.length > 0
? props.providers.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ')
: 'No task sources connected'}
</Text>
</View>
<View style={styles.trailing}>
<View
style={styles.providerRow}
accessibilityLabel={props.providers
.map((provider) => TASK_PROVIDER_LABELS[provider])
.join(', ')}
>
{props.providers.map((provider) => (
<Pressable
key={provider}
accessibilityRole="button"
accessibilityLabel={`Open ${TASK_PROVIDER_LABELS[provider]} tasks`}
hitSlop={8}
style={({ pressed }) => [
styles.providerButton,
pressed && styles.providerButtonPressed
]}
onPress={(event) => {
event.stopPropagation()
props.onOpen(provider)
}}
>
<TaskProviderLogo provider={provider} size={22} color={colors.textSecondary} />
</Pressable>
))}
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
minHeight: 72,
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 12
},
cardDisabled: { opacity: 0.45 },
cardPressed: { backgroundColor: colors.bgRaised },
icon: {
width: 46,
height: 46,
borderRadius: 13,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14
},
main: { flex: 1, minWidth: 0 },
title: { fontSize: 13, fontWeight: '600', color: colors.textPrimary },
subtitle: { fontSize: 12, color: colors.textSecondary, marginTop: 3 },
trailing: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
marginLeft: spacing.sm
},
providerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 2
},
providerButton: {
width: 34,
height: 34,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
providerButtonPressed: { backgroundColor: colors.bgRaised }
})
+45
View File
@@ -0,0 +1,45 @@
import { Settings } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { OrcaLogo } from '../components/OrcaLogo'
import { colors, spacing } from '../theme/mobile-theme'
export function MobileHomeTopBar({ onOpenSettings }: { onOpenSettings: () => void }) {
return (
<View style={styles.topBar}>
<View style={styles.brandLockup}>
<View style={styles.logoMark}>
<OrcaLogo size={18} />
</View>
<Text style={styles.brandName}>Orca</Text>
</View>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={onOpenSettings}
>
<Settings size={18} color={colors.textSecondary} />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: spacing.md
},
brandLockup: { flexDirection: 'row', alignItems: 'center', minWidth: 0 },
logoMark: { marginRight: spacing.sm },
brandName: { color: colors.textPrimary, fontSize: 17, fontWeight: '700' },
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
iconButtonPressed: { backgroundColor: colors.bgRaised }
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { HostCatalogEntry } from '../transport/types'
import {
reconcileMobileHomeHostStates,
type MobileHomeClientEntry
} from './mobile-home-connection-state'
function catalogHost(
id: string,
credentialStatus: HostCatalogEntry['credentialStatus']
): HostCatalogEntry {
return {
id,
name: id,
endpoint: `ws://${id}`,
publicKeyB64: `key-${id}`,
lastConnected: 0,
credentialStatus,
profile: null
}
}
function liveClient(hostId: string, state: MobileHomeClientEntry['state']): MobileHomeClientEntry {
return { hostId, state, client: {} as RpcClient }
}
describe('mobile home connection state', () => {
it('keeps an unacquired ready host absent on the initial frame', () => {
const previous = {}
const next = reconcileMobileHomeHostStates(previous, [], [catalogHost('host-1', 'ready')])
expect(next).toBe(previous)
})
it('uses credential status when a host has no live client', () => {
const next = reconcileMobileHomeHostStates(
{ ready: 'connected' },
[],
[
catalogHost('missing', 'missing'),
catalogHost('locked', 'temporarily-unavailable'),
catalogHost('ready', 'ready')
]
)
expect(next).toEqual({ missing: 'auth-failed', locked: 'disconnected', ready: 'disconnected' })
})
it('prefers the live client and drops hosts removed from the catalog', () => {
const next = reconcileMobileHomeHostStates(
{ removed: 'connected', current: 'reconnecting' },
[liveClient('current', 'connected')],
[catalogHost('current', 'missing')]
)
expect(next).toEqual({ current: 'connected' })
})
})
@@ -0,0 +1,77 @@
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, HostCatalogEntry } from '../transport/types'
export type MobileHomeClientEntry = {
hostId: string
client: RpcClient
state: ConnectionState
}
export function readMobileHomeReconnectAttempts(
previous: Record<string, number>,
clients: MobileHomeClientEntry[]
): Record<string, number> {
const next = { ...previous }
let changed = false
for (const entry of clients) {
const attempts = entry.client.getReconnectAttempt()
if (next[entry.hostId] !== attempts) {
next[entry.hostId] = attempts
changed = true
}
}
return changed ? next : previous
}
export function readMobileHomeLastConnected(
previous: Record<string, number | null>,
clients: MobileHomeClientEntry[]
): Record<string, number | null> {
const next = { ...previous }
let changed = false
for (const entry of clients) {
const connectedAt = entry.client.getLastConnectedAt()
if (next[entry.hostId] !== connectedAt) {
next[entry.hostId] = connectedAt
changed = true
}
}
return changed ? next : previous
}
export function reconcileMobileHomeHostStates(
previous: Record<string, ConnectionState>,
clients: MobileHomeClientEntry[],
hostCatalog: HostCatalogEntry[]
): Record<string, ConnectionState> {
const next = { ...previous }
let changed = false
const liveIds = new Set(clients.map((entry) => entry.hostId))
for (const entry of clients) {
if (next[entry.hostId] !== entry.state) {
next[entry.hostId] = entry.state
changed = true
}
}
for (const host of hostCatalog) {
if (liveIds.has(host.id)) {
continue
}
const fallback = host.credentialStatus === 'missing' ? 'auth-failed' : 'disconnected'
const previousState = next[host.id]
const shouldTrack =
host.credentialStatus !== 'ready' ||
(previousState != null && previousState !== 'disconnected' && previousState !== 'auth-failed')
if (shouldTrack && previousState !== fallback) {
next[host.id] = fallback
changed = true
}
}
for (const id of Object.keys(next)) {
if (!liveIds.has(id) && !hostCatalog.some((host) => host.id === id)) {
delete next[id]
changed = true
}
}
return changed ? next : previous
}
@@ -0,0 +1,108 @@
import { decodeAccountsSnapshot, type AccountsSnapshot } from '../components/AccountUsage'
import type { HomeStatsSummary } from '../stats/home-stats-total'
import {
filterAvailableTaskProviders,
normalizeVisibleTaskProviders,
type TaskProvider
} from '../tasks/mobile-task-providers'
import type { RpcClient } from '../transport/rpc-client'
import { sendSingleFlightRequest } from '../transport/request-single-flight'
type HomeTaskSettings = {
visibleTaskProviders?: unknown
}
type HomePreflightStatus = {
glab?: { installed?: boolean }
}
type HomeLinearStatus = {
connected?: boolean
}
export type HomeStatsSetter = (
updater: (previous: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
) => void
export type HomeAccountsSetter = (
updater: (previous: Record<string, AccountsSnapshot>) => Record<string, AccountsSnapshot>
) => void
export type HomeTaskProvidersSetter = (
updater: (previous: Record<string, TaskProvider[]>) => Record<string, TaskProvider[]>
) => void
export function fetchMobileHomeStats(
client: RpcClient,
hostId: string,
setStats: HomeStatsSetter,
disposed: () => boolean
): void {
sendSingleFlightRequest(client, hostId, 'stats.summary')
.then((response) => {
if (!disposed() && response.ok) {
setStats((previous) => ({
...previous,
[hostId]: response.result as HomeStatsSummary
}))
}
})
.catch(() => {})
}
export function fetchMobileHomeAccounts(
client: RpcClient,
hostId: string,
setSnapshots: HomeAccountsSetter,
disposed: () => boolean
): void {
sendSingleFlightRequest(client, hostId, 'accounts.list')
.then((response) => {
if (!disposed() && response.ok) {
const snapshot = decodeAccountsSnapshot(response.result)
setSnapshots((previous) => ({ ...previous, [hostId]: snapshot }))
}
})
.catch(() => {})
}
export function fetchMobileHomeTaskProviders(
client: RpcClient,
hostId: string,
setProviders: HomeTaskProvidersSetter,
disposed: () => boolean
): void {
Promise.all([
sendSingleFlightRequest(client, hostId, 'settings.get'),
sendSingleFlightRequest(client, hostId, 'preflight.check'),
sendSingleFlightRequest(client, hostId, 'linear.status')
])
.then(([settingsResponse, preflightResponse, linearResponse]) => {
if (disposed()) {
return
}
const settings = settingsResponse.ok
? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ??
{}) as HomeTaskSettings)
: {}
const preflight = preflightResponse.ok
? (preflightResponse.result as HomePreflightStatus)
: null
const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null
const providers = filterAvailableTaskProviders(
normalizeVisibleTaskProviders(settings.visibleTaskProviders),
{
gitlabInstalled: preflight?.glab?.installed === true,
linearConnected: linear?.connected === true
}
)
setProviders((previous) => ({ ...previous, [hostId]: providers }))
})
.catch(() => {
if (!disposed()) {
setProviders((previous) =>
previous[hostId] ? previous : { ...previous, [hostId]: ['github'] }
)
}
})
}
+200
View File
@@ -0,0 +1,200 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useRouter, useFocusEffect } from 'expo-router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AccountsSnapshot } from '../components/AccountUsage'
import { hasRenderableUsage } from '../components/AccountUsage'
import { loadHomeSnapshot, saveHomeSnapshot } from '../cache/home-snapshot-cache'
import { getCachedWorktrees, setCachedWorktrees } from '../cache/worktree-cache'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../onboarding/mobile-onboarding-plan'
import { totalHomeStats, type HomeStatsSummary } from '../stats/home-stats-total'
import type { TaskProvider } from '../tasks/mobile-task-providers'
import { selectConnectableHostProfiles } from '../transport/host-catalog-selection'
import { loadHostCatalog } from '../transport/host-store'
import type { HostCatalogEntry, HostProfile } from '../transport/types'
import { fetchHomeHostWorktreeInfo } from '../worktree/home-host-worktree-fetch'
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../worktree/home-worktree-info'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord
} from '../worktree/last-visited-worktree-repo'
import { selectHomeResumeCard } from '../worktree/home-resume-card'
import {
fetchMobileHomeAccounts,
fetchMobileHomeStats,
fetchMobileHomeTaskProviders
} from './mobile-home-host-requests'
import { useMobileHomeHostConnections } from './use-mobile-home-host-connections'
export function useMobileHomeData() {
const router = useRouter()
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
const onboardingCheckedRef = useRef(false)
const hydratedRef = useRef(false)
const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
const connections = useMobileHomeHostConnections(hosts, hostCatalog, {
setStats: setStatsByHost,
setWorktreeInfo,
setAccounts: setAccountsByHost,
setTaskProviders: setTaskProvidersByHost
})
const allClientsRef = useRef(connections.allClients)
useEffect(() => {
if (hydratedRef.current) {
return
}
hydratedRef.current = true
let cancelled = false
void loadHomeSnapshot().then((snapshot) => {
if (cancelled || !snapshot) {
return
}
setWorktreeInfo((previous) =>
Object.keys(previous).length > 0 ? previous : snapshot.worktreeInfo
)
setAccountsByHost((previous) =>
Object.keys(previous).length > 0 ? previous : snapshot.accountsByHost
)
for (const [hostId, info] of Object.entries(snapshot.worktreeInfo)) {
if (info.lastActiveWorktree) {
setCachedWorktrees(hostId, [info.lastActiveWorktree])
}
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (Object.keys(worktreeInfo).length > 0 || Object.keys(accountsByHost).length > 0) {
saveHomeSnapshot({ worktreeInfo, accountsByHost, savedAt: Date.now() })
}
}, [worktreeInfo, accountsByHost])
useEffect(() => {
allClientsRef.current = connections.allClients
}, [connections.allClients])
useFocusEffect(
useCallback(() => {
let stale = false
void loadHostCatalog().then(async (catalog) => {
if (stale) {
return
}
setHostCatalog(catalog)
if (catalog.length === 0 || onboardingCheckedRef.current) {
return
}
onboardingCheckedRef.current = true
const steps = await loadMobileOnboardingSteps()
if (!stale && steps.length > 0) {
router.replace(mobileOnboardingDestination(steps))
}
})
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
if (!stale) {
setLastVisited(readLastVisitedWorktreeRecord(raw))
}
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchMobileHomeStats(entry.client, entry.hostId, setStatsByHost, () => stale)
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchMobileHomeAccounts(entry.client, entry.hostId, setAccountsByHost, () => stale)
fetchMobileHomeTaskProviders(
entry.client,
entry.hostId,
setTaskProvidersByHost,
() => stale
)
}
}
return () => {
stale = true
}
}, [router])
)
const sortedHosts = useMemo(
() => hosts.toSorted((left, right) => right.lastConnected - left.lastConnected),
[hosts]
)
const sortedHostCatalog = useMemo(
() => hostCatalog.toSorted((left, right) => right.lastConnected - left.lastConnected),
[hostCatalog]
)
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const resumeCard = useMemo(
() =>
selectHomeResumeCard({
hosts: sortedHosts,
hostStates: connections.hostStates,
worktreeInfo,
lastVisited,
cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
}),
[sortedHosts, connections.hostStates, worktreeInfo, lastVisited]
)
const accountsHosts = useMemo(() => {
const items: { host: HostProfile; snapshot: AccountsSnapshot }[] = []
for (const host of sortedHosts) {
const snapshot = accountsByHost[host.id]
if (
connections.hostStates[host.id] === 'connected' &&
snapshot &&
(hasRenderableUsage(snapshot, 'claude') || hasRenderableUsage(snapshot, 'codex'))
) {
items.push({ host, snapshot })
}
}
return items
}, [sortedHosts, connections.hostStates, accountsByHost])
const connectedHosts = useMemo(
() => sortedHosts.filter((host) => connections.hostStates[host.id] === 'connected'),
[sortedHosts, connections.hostStates]
)
const primaryHost = connectedHosts[0] ?? null
const primaryTaskProviders = primaryHost
? (taskProvidersByHost[primaryHost.id] ?? ['github'])
: []
const hostPaths = Object.fromEntries(
connections.allClients.map(({ hostId, path }) => [hostId, path])
)
const hostPendingPaths = Object.fromEntries(
connections.allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])
)
const hostPairingRejected = Object.fromEntries(
connections.allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected])
)
return {
...connections,
accountsHosts,
connectedHosts,
hostCatalog,
hostPairingRejected,
hostPaths,
hostPendingPaths,
primaryHost,
primaryTaskProviders,
resumeCard,
router,
setHostCatalog,
sortedHostCatalog,
stats,
worktreeInfo
}
}
@@ -0,0 +1,157 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { decodeAccountsSnapshot } from '../components/AccountUsage'
import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications'
import { usePrimeHosts } from '../transport/client-context'
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types'
import { useAllHostClients } from '../transport/use-all-host-clients'
import {
fetchHomeHostWorktreeInfo,
type HostWorktreeInfoSetter
} from '../worktree/home-host-worktree-fetch'
import {
readMobileHomeLastConnected,
readMobileHomeReconnectAttempts,
reconcileMobileHomeHostStates
} from './mobile-home-connection-state'
import {
fetchMobileHomeStats,
fetchMobileHomeTaskProviders,
type HomeAccountsSetter,
type HomeStatsSetter,
type HomeTaskProvidersSetter
} from './mobile-home-host-requests'
type Setters = {
setStats: HomeStatsSetter
setWorktreeInfo: HostWorktreeInfoSetter
setAccounts: HomeAccountsSetter
setTaskProviders: HomeTaskProvidersSetter
}
function wireMobileHomeHostSubscriptions(
entry: { hostId: string; client: RpcClient; state: ConnectionState },
setters: Setters
): () => void {
let unsubscribeNotifications: (() => void) | null = null
let unsubscribeAccounts: (() => void) | null = null
const refetchGate = createHostConnectRefetchGate()
const wireState = (state: ConnectionState): void => {
const reconnected = refetchGate.observe(state)
if (state === 'connected') {
unsubscribeNotifications ??= subscribeToDesktopNotifications(entry.client, entry.hostId)
unsubscribeAccounts ??= entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
}
const event = payload as { type?: string; snapshot?: unknown }
if (event.type !== 'ready' && event.type !== 'snapshot') {
return
}
try {
const snapshot = decodeAccountsSnapshot(event.snapshot)
setters.setAccounts((previous) => ({ ...previous, [entry.hostId]: snapshot }))
} catch {
// Keep the last proven snapshot when a mixed-version host publishes malformed data.
}
})
if (reconnected) {
fetchMobileHomeStats(entry.client, entry.hostId, setters.setStats, () => false)
void fetchHomeHostWorktreeInfo(
entry.client,
entry.hostId,
setters.setWorktreeInfo,
() => false
)
fetchMobileHomeTaskProviders(
entry.client,
entry.hostId,
setters.setTaskProviders,
() => false
)
}
return
}
unsubscribeNotifications?.()
unsubscribeNotifications = null
unsubscribeAccounts?.()
unsubscribeAccounts = null
}
wireState(entry.state)
const unsubscribeState = entry.client.onStateChange(wireState)
return () => {
unsubscribeState()
unsubscribeNotifications?.()
unsubscribeAccounts?.()
}
}
export function useMobileHomeHostConnections(
hosts: HostProfile[],
hostCatalog: HostCatalogEntry[],
setters: Setters
) {
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
const allClients = useAllHostClients(hostIds, {
autoConnectHostIds,
closeUnusedOnRelease: true
})
const primeHosts = usePrimeHosts()
const subscriptionsRef = useRef(new Map<string, { client: RpcClient; cleanup: () => void }>())
const { setAccounts, setStats, setTaskProviders, setWorktreeInfo } = setters
useEffect(() => {
if (hosts.length > 0) {
primeHosts(hosts)
}
}, [hosts, primeHosts])
useEffect(() => {
setHostAttempts((previous) => readMobileHomeReconnectAttempts(previous, allClients))
setHostLastConnected((previous) => readMobileHomeLastConnected(previous, allClients))
setHostStates((previous) => reconcileMobileHomeHostStates(previous, allClients, hostCatalog))
}, [allClients, hostCatalog])
useEffect(() => {
const clientsByHostId = new Map(allClients.map((entry) => [entry.hostId, entry.client]))
for (const [hostId, subscription] of subscriptionsRef.current) {
if (clientsByHostId.get(hostId) !== subscription.client) {
subscription.cleanup()
subscriptionsRef.current.delete(hostId)
}
}
const activeSetters = { setAccounts, setStats, setTaskProviders, setWorktreeInfo }
for (const entry of allClients) {
if (!subscriptionsRef.current.has(entry.hostId)) {
subscriptionsRef.current.set(entry.hostId, {
client: entry.client,
cleanup: wireMobileHomeHostSubscriptions(entry, activeSetters)
})
}
}
}, [allClients, setAccounts, setStats, setTaskProviders, setWorktreeInfo])
useEffect(() => {
const subscriptions = subscriptionsRef.current
return () => {
for (const subscription of subscriptions.values()) {
subscription.cleanup()
}
subscriptions.clear()
}
}, [])
return {
allClients,
autoConnectHostIds,
hostAttempts,
hostLastConnected,
hostStates
}
}
@@ -7,7 +7,7 @@ import {
type HostStackNavigationState
} from '../navigation/host-stack-navigation'
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8')
function navigationHarness(initialState: HostStackNavigationState) {
const stateListeners = new Set<() => void>()
@@ -97,28 +97,18 @@ describe('mobile session route', () => {
})
it('routes the home Resume card through the cold-navigator-safe transition', () => {
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
// Assert the markers first: a renamed banner would otherwise slice garbage and
// report a missing call instead of the real cause.
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const resumeCard = homeSource.slice(start, end)
expect(resumeCard).toContain('openResume(')
expect(resumeCard).not.toContain('router.push(')
expect(homeSource).toContain('onOpenResume={openResume}')
// The tap handler itself must go through the coordinated transition; its only
// direct push is the shallow noticed host-index route for a proven-missing target.
const handlerStart = homeSource.indexOf('const openResume = useCallback(')
const handlerEnd = homeSource.indexOf('[openMobileSession, router]', handlerStart)
const handlerEnd = homeSource.indexOf('[data.router, openMobileSession]', handlerStart)
expect(handlerStart).toBeGreaterThanOrEqual(0)
expect(handlerEnd).toBeGreaterThan(handlerStart)
const openResume = homeSource.slice(handlerStart, handlerEnd)
expect(openResume).toContain('openMobileSession({')
expect(openResume.match(/router\.push\(/g)).toHaveLength(1)
expect(openResume).toContain('router.push(hostRouteWithNotice(')
expect(openResume).toContain('data.router.push(hostRouteWithNotice(')
})
})
@@ -697,7 +697,9 @@ describe('useMobileNativeChatController streaming scope', () => {
launchAgent: 'claude',
agentStatus: {
state: 'working',
workingMode: undefined as 'monitoring' | undefined,
agentType: 'claude',
lastAssistantMessage: 'Partial reply',
providerSession: { id: 'session-1' }
},
isActive: true
@@ -723,6 +725,7 @@ describe('useMobileNativeChatController streaming scope', () => {
beforeEach(() => {
viewMode.isTabChatView = () => true
workingTab.agentStatus.workingMode = undefined
act(() => {
renderer = create(createElement(Harness))
})
@@ -737,6 +740,7 @@ describe('useMobileNativeChatController streaming scope', () => {
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
expect(controller?.showNativeChat).toBe(true)
expect(controller?.nativeChatAgentWorking).toBe(true)
const scopeKey = controller?.nativeChatStreamScopeKey
expect(scopeKey).toContain('session-1')
expect(controller?.nativeChatStreamLive).toBe(true)
@@ -750,6 +754,16 @@ describe('useMobileNativeChatController streaming scope', () => {
expect(controller?.nativeChatStreamLive).toBe(true)
})
it('treats passive monitoring as outside the foreground turn', () => {
workingTab.agentStatus.workingMode = 'monitoring'
act(() => renderer?.update(createElement(Harness)))
expect(controller?.nativeChatAgentWorking).toBe(false)
expect(controller?.nativeChatStreamLive).toBe(false)
expect(controller?.nativeChatStreamingText).toBeUndefined()
expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false)
})
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
const before = answerSendArgs.at(-1)?.streamIdentity
expect(before).toBe(controller?.nativeChatStreamScopeKey)
@@ -115,11 +115,14 @@ export function useMobileNativeChatController(args: {
transcriptSettled: nativeChatSession.status === 'ready'
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
const activeTabStatus = activeSessionTab?.agentStatus
const activeTabAgentWorking =
activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
const nativeChatStatus = activeChatResolution ? activeTabStatus : null
const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking
// Deliberately not gated on the chat view being visible: the streaming gate
// has to tell "hidden mid-turn" from "the turn ended".
const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
const nativeChatStreamLive = activeTabAgentWorking
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
// part, and each one re-renders and re-parses the whole accumulated markdown.
const nativeChatStreamingText = useThrottledLatestValue(
+324
View File
@@ -0,0 +1,324 @@
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
import { RpcClientConnectionState } from './rpc-client-connection-state'
import {
RpcClientReconnectSchedule,
RPC_RECONNECT_ATTEMPT_LIMIT
} from './rpc-client-reconnect-schedule'
import { RpcClientRequestTracker } from './rpc-client-request-tracker'
import { RpcClientSocketCloseController } from './rpc-client-socket-close-controller'
import { RpcClientSocketFactory } from './rpc-client-socket-factory'
import type { RpcClientSocketSession } from './rpc-client-socket-session'
import {
RpcClientStreamRegistry,
type RpcStreamingListener,
type RpcStreamSubscribeOptions
} from './rpc-client-stream-registry'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { isStaleForegroundDial } from './rpc-stale-dial'
import type {
ConnectionLogLevel,
ConnectionState,
ForegroundNudgeReason,
RpcResponse
} from './types'
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
export class DirectRpcClient implements RpcClient {
private socketSession: RpcClientSocketSession | null = null
private readonly connectionState: RpcClientConnectionState
private readonly reconnect: RpcClientReconnectSchedule
private readonly requests: RpcClientRequestTracker
private readonly streams: RpcClientStreamRegistry
private readonly liveness: RpcSessionLivenessWatchdog
private readonly socketFactory: RpcClientSocketFactory
private readonly authenticationRetry: RpcClientAuthenticationRetry
private readonly socketClose: RpcClientSocketCloseController
private requestCounter = 0
private logCounter = 0
private intentionallyClosed = false
private authenticationGeneration = 0
private livenessSession: RpcClientSocketSession | null = null
constructor(
private readonly endpoint: string,
private readonly deviceToken: string,
serverPublicKeyB64: string,
private readonly options: ConnectOptions
) {
this.reconnect = new RpcClientReconnectSchedule({
openConnection: () => this.openConnection(),
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
emitLog: (message, detail) => this.emitLog('info', message, detail)
})
this.connectionState = new RpcClientConnectionState({
endpoint,
initialListener: options.onStateChange,
getReconnectAttempt: () => this.reconnect.getAttempt(),
isClosed: () => this.intentionallyClosed
})
this.streams = new RpcClientStreamRegistry({
nextId: () => this.nextId(),
deviceToken,
getState: () => this.connectionState.get(),
sendEncrypted: (request) => this.sendEncrypted(request)
})
this.requests = new RpcClientRequestTracker({
nextId: () => this.nextId(),
deviceToken,
getState: () => this.connectionState.get(),
waitForConnected: (timeoutMs) => this.waitForConnected(timeoutMs),
sendEncrypted: (request) => this.sendEncrypted(request)
})
this.liveness = new RpcSessionLivenessWatchdog({
transport: 'direct',
sendProbe: (identity) => this.sendLivenessProbe(identity),
terminate: (identity) => {
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
this.socketClose.forceClose(this.livenessSession)
}
}
})
this.socketFactory = new RpcClientSocketFactory({
endpoint,
deviceToken,
serverPublicKeyB64,
getCurrentSocket: () => this.socketSession?.socket ?? null,
getState: () => this.getState(),
getReconnectAttempt: () => this.getReconnectAttempt(),
getLastConnectedAt: () => this.getLastConnectedAt(),
isIntentionallyClosed: () => this.intentionallyClosed,
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
onAuthenticated: (session) => this.handleAuthenticated(session),
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
onRpcResponse: (response) => this.handleRpcResponse(response),
onBinary: (bytes) => this.streams.handleBinary(bytes),
onAuthenticatedInbound: (session) => this.liveness.noteAuthenticatedInbound(session),
onClosed: (session, closeCode) => this.socketClose.handle(session, closeCode),
onForcedClose: (session) => this.socketClose.forceClose(session)
})
this.authenticationRetry = new RpcClientAuthenticationRetry({
endpoint,
stopLiveness: () => this.stopLiveness(),
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
retry: (reason) => this.retryAuthentication(reason),
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
})
this.socketClose = new RpcClientSocketCloseController({
connectionState: this.connectionState,
reconnect: this.reconnect,
requests: this.requests,
streams: this.streams,
socketFactory: this.socketFactory,
authenticationRetry: this.authenticationRetry,
getCurrentSession: () => this.socketSession,
clearCurrentSession: () => (this.socketSession = null),
getAuthenticationGeneration: () => this.authenticationGeneration,
isIntentionallyClosed: () => this.intentionallyClosed,
stopLiveness: (session) => {
if (this.livenessSession === session) {
this.stopLiveness()
}
},
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
})
this.openConnection()
}
sendRequest(
method: string,
params?: unknown,
options?: SendRequestOptions
): Promise<RpcResponse> {
return this.requests.sendRequest(method, params, options)
}
subscribe(
method: string,
params: unknown,
onData: RpcStreamingListener,
options?: RpcStreamSubscribeOptions
): () => void {
return this.streams.subscribe(method, params, onData, options)
}
updateTerminalSubscriptionViewport(
terminal: string,
viewport: { cols: number; rows: number }
): void {
this.streams.updateTerminalViewport(terminal, viewport)
}
getState(): ConnectionState {
return this.connectionState.get()
}
getReconnectAttempt(): number {
return this.reconnect.getAttempt()
}
getLastConnectedAt(): number | null {
return this.connectionState.getLastConnectedAt()
}
getLastInboundAt = (): number | null => this.liveness.getLastInboundAt() || null
onStateChange(listener: (state: ConnectionState) => void): () => void {
return this.connectionState.addListener(listener)
}
notifyForeground(_reason?: ForegroundNudgeReason): void {
if (this.intentionallyClosed) {
return
}
if (this.getState() === 'connected') {
console.log('[net] foreground — probing live connection')
if (this.livenessSession) {
this.liveness.probeNow(this.livenessSession)
}
return
}
const dialing = this.socketSession
const dialAgeMs = Date.now() - this.socketFactory.getDialStartedAt()
let abandoned = false
if (dialing && isStaleForegroundDial(this.getState(), dialAgeMs)) {
console.log('[net] foreground — abandoning stale dial', {
state: this.getState(),
dialAgeMs
})
this.socketClose.forceClose(dialing)
abandoned = true
}
if (this.getState() === 'reconnecting') {
console.log('[net] foreground — restarting reconnect loop', {
attempt: this.getReconnectAttempt(),
hadTimer: this.reconnect.hasTimer()
})
this.reconnect.redialNow(!abandoned)
}
}
close(): void {
this.intentionallyClosed = true
this.reconnect.cancel()
const session = this.socketSession
session?.clearTimers()
if (this.livenessSession) {
this.liveness.stop(this.livenessSession)
this.livenessSession = null
}
session?.close()
this.socketSession = null
session?.clearKey()
this.connectionState.publish('disconnected')
this.requests.rejectAll('Client closed', { deliveryUnknown: true })
}
private openConnection(): void {
if (this.intentionallyClosed) {
return
}
this.connectionState.publish('connecting')
this.socketSession = this.socketFactory.open()
}
private handleAuthenticated(session: RpcClientSocketSession): void {
console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() })
this.livenessSession = session
this.liveness.start(session)
this.authenticationGeneration++
this.reconnect.authenticated()
this.authenticationRetry.accepted()
this.connectionState.publish('connected')
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
this.streams.replayAfterAuthentication()
}
private handleRpcResponse(response: RpcResponse): void {
if (response.id.startsWith(LIVENESS_REQUEST_ID_PREFIX)) {
return
}
if (!response.ok && response.error.code === 'unauthorized') {
this.authenticationRetry.reject('Unauthorized — pairing may be revoked')
return
}
if (!this.streams.handleResponse(response)) {
this.requests.resolve(response)
}
}
private stopLiveness(): void {
if (this.livenessSession) {
this.liveness.stop(this.livenessSession)
this.livenessSession = null
}
}
private retryAuthentication(reason: string): void {
const closing = this.socketSession
this.socketSession = null
closing?.clearKey()
this.streams.markForReplay()
this.requests.rejectAll(reason)
closing?.close()
this.connectionState.publish('reconnecting')
this.reconnect.schedule()
}
private latchAuthenticationFailure(reason: string): void {
this.intentionallyClosed = true
this.socketSession?.close()
this.socketSession = null
this.connectionState.publish('auth-failed')
this.requests.rejectAll(reason)
}
private sendEncrypted(request: unknown): boolean {
if (this.socketSession) {
return this.socketSession.sendEncrypted(request)
}
console.log('[net] sendEncrypted FAILED — channel not ready', {
hasWs: false,
hasKey: false,
state: this.getState()
})
return false
}
private sendLivenessProbe(identity: object): boolean {
if (identity !== this.livenessSession || this.getState() !== 'connected') {
return false
}
return this.sendEncrypted({
id: `${LIVENESS_REQUEST_ID_PREFIX}${this.nextId()}`,
deviceToken: this.deviceToken,
method: 'status.get'
})
}
private waitForConnected(timeoutMs?: number): Promise<void> {
if (
this.getState() === 'reconnecting' &&
this.getReconnectAttempt() >= RPC_RECONNECT_ATTEMPT_LIMIT
) {
return Promise.reject(new Error('Connection retry limit reached'))
}
return this.connectionState.waitForConnected(timeoutMs)
}
private nextId(): string {
return `rpc-${++this.requestCounter}-${Date.now()}`
}
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
this.options.onLog?.({
id: `log-${++this.logCounter}-${Date.now()}`,
ts: Date.now(),
level,
message,
detail
})
}
}
@@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => {
)
expect(socket.close).toHaveBeenCalledOnce()
})
it('keeps a typed close code when transport error precedes close', () => {
const socket = new ThrowingSocket()
const onError = vi.fn()
new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
socket.onclose?.({ code: 4409 })
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' }))
})
it('classifies an opaque close after transport error as 1006', () => {
const socket = new ThrowingSocket()
const onError = vi.fn()
new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
socket.onclose?.({ code: 0 })
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
})
it('bounds an error when the platform never emits close', async () => {
vi.useFakeTimers()
try {
const socket = new ThrowingSocket()
const onError = vi.fn()
const link = new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
await vi.advanceTimersByTimeAsync(250)
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' }))
link.close()
} finally {
vi.useRealTimers()
}
})
it('cancels the missing-close timer when explicitly closed', async () => {
vi.useFakeTimers()
try {
const socket = new ThrowingSocket()
const onError = vi.fn()
const link = new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError,
createSocket: () => socket as unknown as WebSocket
})
socket.onerror?.()
link.close()
await vi.advanceTimersByTimeAsync(250)
expect(onError).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
+28 -2
View File
@@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
// Native WebSockets normally emit close immediately after error; bound the
// missing-close case so a dead socket cannot leave recovery pending forever.
const RELAY_ERROR_CLOSE_GRACE_MS = 250
export class RelayOuterError extends Error {
constructor(readonly code: number) {
super(`relay_outer_${code}`)
@@ -32,6 +36,7 @@ export class MobileRelayE2eeLink {
private readonly channel: MobileE2EEV2PhysicalChannel
private outerReady = false
private closed = false
private transportErrorTimer: ReturnType<typeof setTimeout> | null = null
private inboundChain: Promise<void> = Promise.resolve()
constructor(options: MobileRelayE2eeLinkOptions) {
@@ -70,6 +75,10 @@ export class MobileRelayE2eeLink {
return
}
this.closed = true
if (this.transportErrorTimer) {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
this.channel.dispose()
this.socket.close()
}
@@ -103,8 +112,21 @@ export class MobileRelayE2eeLink {
})
.catch((error: unknown) => this.fail(asError(error)))
}
this.socket.onerror = () => this.fail(new Error('relay transport error'))
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
// `error` is often delivered just before `close`; wait for close so a
// typed relay code is not replaced by a generic transport error.
this.socket.onerror = () => {
this.transportErrorTimer ??= setTimeout(() => {
this.transportErrorTimer = null
this.fail(new RelayOuterError(1006))
}, RELAY_ERROR_CLOSE_GRACE_MS)
}
this.socket.onclose = (event) => {
if (this.transportErrorTimer) {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
this.fail(new RelayOuterError(event.code || 1006))
}
}
private acceptHello(raw: unknown): void {
@@ -137,6 +159,10 @@ export class MobileRelayE2eeLink {
return
}
this.closed = true
if (this.transportErrorTimer) {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
this.channel.dispose()
this.options.onError(error)
this.socket.close()
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import {
RelayDirectorMoveNotNewerError,
resolvePairingInviteThroughDirector
} from './mobile-relay-invite-director'
class FakeSocket {
sent: string[] = []
@@ -59,7 +62,7 @@ describe('pairing invite director resolution', () => {
})
})
it('rejects same/older epochs and untrusted extra fields', async () => {
it('rejects malformed moves with untrusted extra fields', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
@@ -75,6 +78,31 @@ describe('pairing invite director resolution', () => {
})
})
await expect(resolving).rejects.toThrow(/not strictly newer/)
await expect(resolving).rejects.toThrow(/invalid relay director move/)
})
it('describes a same-epoch move without accepting it', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: () => socket as unknown as WebSocket
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch
})
})
const error = await resolving.catch((value: unknown) => value)
expect(error).toBeInstanceOf(RelayDirectorMoveNotNewerError)
expect(error).toMatchObject({
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
})
})
@@ -1,6 +1,27 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
export class RelayDirectorMoveNotNewerError extends Error {
readonly cellUrl: string
readonly assignmentEpoch: number
readonly currentCellUrl: string
readonly currentAssignmentEpoch: number
constructor(args: {
cellUrl: string
assignmentEpoch: number
currentCellUrl: string
currentAssignmentEpoch: number
}) {
super('relay director move was not strictly newer')
this.name = 'RelayDirectorMoveNotNewerError'
this.cellUrl = args.cellUrl
this.assignmentEpoch = args.assignmentEpoch
this.currentCellUrl = args.currentCellUrl
this.currentAssignmentEpoch = args.currentAssignmentEpoch
}
}
export function resolvePairingInviteThroughDirector(args: {
relay: PairingRelay
timeoutMs?: number
@@ -38,8 +59,19 @@ export function resolvePairingInviteThroughDirector(args: {
return
}
const moved = RelayMovedSchema.safeParse(value)
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(new Error('relay director move was not strictly newer'))
if (!moved.success) {
finish(new Error('invalid relay director move'))
return
}
if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(
new RelayDirectorMoveNotNewerError({
cellUrl: moved.data.cellUrl,
assignmentEpoch: moved.data.assignmentEpoch,
currentCellUrl: args.relay.cellUrl,
currentAssignmentEpoch: args.relay.assignmentEpoch
})
)
return
}
settled = true
@@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => {
expect(fakes.start).not.toHaveBeenCalled()
})
it('keeps the typed close code when transport error precedes close', async () => {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
socket.onerror?.()
socket.onclose?.({ code: 4409 })
await expect(status).rejects.toEqual(new RelayOuterError(4409))
})
it('classifies an opaque close after transport error as 1006', async () => {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
socket.onerror?.()
socket.onclose?.({ code: 0 })
await expect(status).rejects.toEqual(new RelayOuterError(1006))
})
it('settles after an error when the platform never emits close', async () => {
vi.useFakeTimers()
try {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006))
socket.onerror?.()
await vi.advanceTimersByTimeAsync(250)
await rejected
} finally {
vi.useRealTimers()
}
})
it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => {
const socket = new FakeSocket()
const entries: ConnectionLogEntry[] = []
@@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export { RelayOuterError } from './mobile-relay-e2ee-link'
import { RelayOuterError } from './mobile-relay-e2ee-link'
const RELAY_ERROR_CLOSE_GRACE_MS = 250
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
@@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: {
let requestCounter = 0
let closed = false
let intentionallyClosed = false
let transportErrorTimer: ReturnType<typeof setTimeout> | null = null
let outerReady = false
let authenticated = false
let resolveAuthenticated!: () => void
@@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: {
})
.catch((error: unknown) => fail(asError(error)))
}
socket.onerror = () => fail(new Error('relay transport error'))
socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006))
// WebSocket implementations commonly emit `error` immediately before
// `close`; the bounded fallback represents an opaque 1006 close.
socket.onerror = () => {
transportErrorTimer ??= setTimeout(() => {
transportErrorTimer = null
fail(new RelayOuterError(1006))
}, RELAY_ERROR_CLOSE_GRACE_MS)
}
socket.onclose = (event) => {
if (transportErrorTimer) {
clearTimeout(transportErrorTimer)
transportErrorTimer = null
}
fail(new RelayOuterError(event.code || 1006))
}
function acceptRelayHello(raw: unknown): void {
if (typeof raw !== 'string') {
@@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: {
return
}
closed = true
if (transportErrorTimer) {
clearTimeout(transportErrorTimer)
transportErrorTimer = null
}
if (intentionallyClosed) {
log('info', 'Relay: pairing socket closed', cellHost)
} else {
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { PairingCandidateClient } from './mobile-relay-physical-client'
import { RelayOuterError } from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import type { ConnectionLogEntry } from './types'
@@ -89,6 +90,109 @@ describe('recovering pairing relay candidate', () => {
expect(stale.close).toHaveBeenCalledOnce()
})
it('retries the authoritative cell when the director confirms the same assignment', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const target = client(Promise.resolve(success()))
const persistMove = vi.fn()
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => {
expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl)
expect(relay.assignmentEpoch).toBe(journal.metadata.relay.assignmentEpoch)
return connects++ === 0 ? stale : target
},
resolveDirector: async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
},
persistMove,
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(connects).toBe(2)
expect(persistMove).not.toHaveBeenCalled()
})
it('keeps recovery bounded when the authoritative-cell constructor throws', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const target = client(Promise.resolve(success()))
const entries: ConnectionLogEntry[] = []
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => {
connects++
if (connects === 1) {
return stale
}
if (connects === 2) {
throw new Error('relay constructor failed')
}
return target
},
resolveDirector: async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
},
persistMove: vi.fn(),
now: () => 1,
random: () => 0,
sleep: async () => {},
onLog: (entry) => entries.push(entry)
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(connects).toBe(3)
expect(entries).toContainEqual(
expect.objectContaining({
level: 'warn',
message: 'Relay: recovery attempt 1 failed',
detail: 'Error: relay constructor failed'
})
)
})
it('does not retry a same-epoch move to a different cell', async () => {
const stale = client(Promise.reject(new RelayOuterError(1006)))
const resolveDirector = vi.fn(async (relay) => {
throw new RelayDirectorMoveNotNewerError({
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: relay.assignmentEpoch,
currentCellUrl: relay.cellUrl,
currentAssignmentEpoch: relay.assignmentEpoch
})
})
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => {
connects++
return stale
},
resolveDirector,
persistMove: vi.fn(),
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/)
expect(resolveDirector).toHaveBeenCalledTimes(3)
expect(connects).toBe(1)
})
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
const offline = client(Promise.reject(new RelayOuterError(4404)))
const resolveDirector = vi.fn()
@@ -1,6 +1,7 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director'
import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogSink } from './types'
@@ -88,6 +89,35 @@ export function createRecoveringPairingRelayCandidate(args: {
return await client.sendRequest(method, params)
} catch (error) {
lastError = error
if (isCurrentAssignmentMove(error, relay)) {
log(
'info',
'Relay: director kept current assignment',
redactSocketEndpoint(relay.cellUrl)
)
client.close()
await backOff(attempt)
if (closed) {
throw new Error('relay pairing client closed')
}
try {
client = args.connect(relay, args.onLog)
return await client.sendRequest(method, params)
} catch (retryError) {
lastError = retryError
log(
'warn',
`Relay: recovery attempt ${attempt + 1} failed`,
pairingRelayErrorDetail(retryError)
)
if (!isDirectorRecoverable(retryError) || attempt + 1 >= maxAttempts) {
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
throw retryError
}
await backOff(attempt)
continue
}
}
log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error))
if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {
log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`)
@@ -100,6 +130,16 @@ export function createRecoveringPairingRelayCandidate(args: {
}
}
function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean {
return (
error instanceof RelayDirectorMoveNotNewerError &&
error.assignmentEpoch === relay.assignmentEpoch &&
error.cellUrl === relay.cellUrl &&
error.currentAssignmentEpoch === relay.assignmentEpoch &&
error.currentCellUrl === relay.cellUrl
)
}
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
return {
...journal.metadata.relay,
@@ -0,0 +1,46 @@
import { redactSocketEndpoint } from './socket-event-debug'
const AUTH_RETRY_BUDGET = 3
type AuthenticationRetryOptions = {
endpoint: string
stopLiveness: () => void
emitWarning: (message: string, detail: string) => void
retry: (reason: string) => void
latchFailure: (reason: string) => void
}
export class RpcClientAuthenticationRetry {
private rejectionCount = 0
constructor(private readonly options: AuthenticationRetryOptions) {}
accepted(): void {
this.rejectionCount = 0
}
reject(reason: string, preserveRecovery = false): void {
this.options.stopLiveness()
this.rejectionCount++
if (this.rejectionCount < AUTH_RETRY_BUDGET) {
console.log('[net] auth rejected — retrying handshake', {
attempt: this.rejectionCount,
budget: AUTH_RETRY_BUDGET,
endpoint: redactSocketEndpoint(this.options.endpoint)
})
this.options.emitWarning(
'Authentication rejected',
`Retrying (${this.rejectionCount}/${AUTH_RETRY_BUDGET})`
)
if (!preserveRecovery) {
this.options.retry(reason)
}
return
}
console.log('[net] auth rejected — budget exhausted, latching auth-failed', {
attempt: this.rejectionCount,
endpoint: redactSocketEndpoint(this.options.endpoint)
})
this.options.latchFailure(reason)
}
}
@@ -0,0 +1,114 @@
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionState } from './types'
type ConnectWaiter = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout> | null
}
type ConnectionStateOptions = {
endpoint: string
initialListener?: (state: ConnectionState) => void
getReconnectAttempt: () => number
isClosed: () => boolean
}
export class RpcClientConnectionState {
private state: ConnectionState = 'disconnected'
private lastConnectedAt: number | null = null
private stateEnteredAt = Date.now()
private readonly listeners = new Set<(state: ConnectionState) => void>()
private readonly waiters: ConnectWaiter[] = []
constructor(private readonly options: ConnectionStateOptions) {
if (options.initialListener) {
this.listeners.add(options.initialListener)
}
}
get(): ConnectionState {
return this.state
}
getLastConnectedAt(): number | null {
return this.lastConnectedAt
}
publish(next: ConnectionState): void {
if (this.state === next) {
return
}
const previous = this.state
const dweltMs = Date.now() - this.stateEnteredAt
this.state = next
this.stateEnteredAt = Date.now()
console.log('[net] state', {
from: previous,
to: next,
dweltMs,
attempt: this.options.getReconnectAttempt(),
endpoint: redactSocketEndpoint(this.options.endpoint)
})
if (next === 'connected') {
this.lastConnectedAt = Date.now()
this.resolveWaiters()
} else if (next === 'disconnected' || next === 'auth-failed') {
this.rejectWaiters(
next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed'
)
}
for (const listener of this.listeners) {
listener(next)
}
}
waitForConnected(timeoutMs?: number): Promise<void> {
if (this.state === 'connected') {
return Promise.resolve()
}
if (this.options.isClosed()) {
return Promise.reject(new Error('Client closed'))
}
return new Promise((resolve, reject) => {
const waiter: ConnectWaiter = { resolve, reject, timeout: null }
if (timeoutMs !== undefined) {
waiter.timeout = setTimeout(
() => {
const index = this.waiters.indexOf(waiter)
if (index !== -1) {
this.waiters.splice(index, 1)
}
reject(new Error('Timed out while connecting to the remote Orca runtime.'))
},
Math.max(0, timeoutMs)
)
}
this.waiters.push(waiter)
})
}
rejectWaiters(reason: string): void {
const error = new Error(reason)
for (const waiter of this.waiters.splice(0)) {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
}
waiter.reject(error)
}
}
addListener(listener: (state: ConnectionState) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private resolveWaiters(): void {
for (const waiter of this.waiters.splice(0)) {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
}
waiter.resolve()
}
}
}
@@ -0,0 +1,68 @@
const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000]
export const RPC_RECONNECT_ATTEMPT_LIMIT = 12
const TRICKLE_RECONNECT_DELAY_MS = 90_000
type ReconnectScheduleOptions = {
openConnection: () => void
rejectConnectWaiters: (reason: string) => void
emitLog: (message: string, detail: string) => void
}
export class RpcClientReconnectSchedule {
private attempt = 0
private timer: ReturnType<typeof setTimeout> | null = null
constructor(private readonly options: ReconnectScheduleOptions) {}
getAttempt(): number {
return this.attempt
}
authenticated(): void {
this.attempt = 0
}
schedule(): void {
const trickle = this.attempt >= RPC_RECONNECT_ATTEMPT_LIMIT
let delayMs: number
if (trickle) {
delayMs = TRICKLE_RECONNECT_DELAY_MS
this.options.rejectConnectWaiters('Connection retry limit reached')
} else {
delayMs = RECONNECT_DELAYS[Math.min(this.attempt, RECONNECT_DELAYS.length - 1)]!
this.attempt++
}
console.log('[net] scheduleReconnect', {
delayMs,
attempt: this.attempt,
trickle
})
this.options.emitLog(
`Reconnect scheduled in ${delayMs}ms`,
trickle ? `Attempt ${this.attempt} (slow retry)` : `Attempt ${this.attempt}`
)
this.timer = setTimeout(() => {
this.timer = null
this.options.openConnection()
}, delayMs)
}
redialNow(resetAttempts: boolean): void {
this.cancel()
if (resetAttempts) {
this.attempt = 0
}
this.options.openConnection()
}
hasTimer(): boolean {
return this.timer !== null
}
cancel(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
}
@@ -0,0 +1,105 @@
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import type { SendRequestOptions } from './rpc-client'
import type { ConnectionState, RpcResponse } from './types'
const REQUEST_TIMEOUT_MS = 30_000
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
}
type RequestTrackerOptions = {
nextId: () => string
getState: () => ConnectionState
waitForConnected: (timeoutMs?: number) => Promise<void>
sendEncrypted: (request: unknown) => boolean
deviceToken: string
}
export class RpcClientRequestTracker {
private readonly pending = new Map<string, PendingRequest>()
constructor(private readonly options: RequestTrackerOptions) {}
async sendRequest(
method: string,
params?: unknown,
requestOptions?: SendRequestOptions
): Promise<RpcResponse> {
const budget = openRpcRequestBudget(requestOptions)
const waitStart = budget.startedAt
const wasConnected = this.options.getState() === 'connected'
if (requestOptions?.failWhenDisconnected && !wasConnected) {
throw new Error(`Not connected: ${method}`)
}
await this.options.waitForConnected(requestOptions?.timeoutMs)
if (!wasConnected) {
console.log('[net] sendRequest waited for connect', {
method,
waitedMs: Date.now() - waitStart
})
}
return new Promise((resolve, reject) => {
const id = this.options.nextId()
const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS)
const timeout = setTimeout(() => {
this.pending.delete(id)
console.log('[net] sendRequest TIMEOUT', {
method,
timeoutMs,
state: this.options.getState()
})
reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`)))
}, timeoutMs)
this.pending.set(id, {
resolve: (response) => {
clearTimeout(timeout)
resolve(response)
},
reject: (error) => {
clearTimeout(timeout)
reject(error)
}
})
if (
!this.options.sendEncrypted({
id,
deviceToken: this.options.deviceToken,
method,
params
})
) {
this.pending.delete(id)
clearTimeout(timeout)
reject(new Error('Connection interrupted'))
}
})
}
resolve(response: RpcResponse): boolean {
const request = this.pending.get(response.id)
if (!request) {
return false
}
this.pending.delete(response.id)
request.resolve(response)
return true
}
rejectAll(reason: string, options?: { deliveryUnknown?: boolean }): void {
const error = options?.deliveryUnknown
? markRpcDeliveryUnknown(new Error(reason))
: new Error(reason)
for (const [id, request] of this.pending) {
this.pending.delete(id)
queueMicrotask(() => request.reject(error))
}
}
size(): number {
return this.pending.size
}
}
@@ -0,0 +1,12 @@
export function buildReadyStreamUnsubscribe(
method: string,
subscriptionId: string
): { method: string; params: { subscriptionId: string } } | null {
if (method === 'browser.screencast') {
return { method: 'browser.screencast.unsubscribe', params: { subscriptionId } }
}
if (method === 'runtime.clientEvents.subscribe') {
return { method: 'runtime.clientEvents.unsubscribe', params: { subscriptionId } }
}
return null
}
@@ -0,0 +1,88 @@
import type { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
import type { RpcClientConnectionState } from './rpc-client-connection-state'
import type { RpcClientReconnectSchedule } from './rpc-client-reconnect-schedule'
import type { RpcClientRequestTracker } from './rpc-client-request-tracker'
import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
import type { RpcClientSocketSession } from './rpc-client-socket-session'
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
const UNAUTHORIZED_CLOSE_CODE = 4001
type SocketCloseControllerOptions = {
connectionState: RpcClientConnectionState
reconnect: RpcClientReconnectSchedule
requests: RpcClientRequestTracker
streams: RpcClientStreamRegistry
socketFactory: RpcClientSocketFactory
authenticationRetry: RpcClientAuthenticationRetry
getCurrentSession: () => RpcClientSocketSession | null
clearCurrentSession: () => void
getAuthenticationGeneration: () => number
isIntentionallyClosed: () => boolean
stopLiveness: (session: RpcClientSocketSession) => void
emitWarning: (message: string, detail: string) => void
}
export class RpcClientSocketCloseController {
private readonly synthesizedCloses = new RpcSynthesizedCloseIndex()
constructor(private readonly options: SocketCloseControllerOptions) {}
forceClose(session: RpcClientSocketSession): void {
session.close()
if (this.options.getCurrentSession() === session) {
this.synthesizedCloses.remember(session.socket, this.options.getAuthenticationGeneration())
this.handle(session)
}
}
handle(session: RpcClientSocketSession, closeCode?: number): void {
if (this.options.getCurrentSession() !== session) {
if (
this.synthesizedCloses.takeUnauthorized(
session.socket,
closeCode,
this.options.getAuthenticationGeneration(),
UNAUTHORIZED_CLOSE_CODE
)
) {
this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked', true)
return
}
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
state: this.options.connectionState.get(),
attempt: this.options.reconnect.getAttempt()
})
return
}
this.options.socketFactory.noteClosed()
session.clearTimers()
session.clearKey()
this.options.clearCurrentSession()
this.options.streams.markForReplay()
this.options.stopLiveness(session)
if (this.options.isIntentionallyClosed()) {
console.log('[net] handleSocketClosed — intentional close')
this.options.connectionState.publish('disconnected')
this.options.requests.rejectAll('Connection closed', { deliveryUnknown: true })
return
}
if (closeCode === UNAUTHORIZED_CLOSE_CODE) {
console.log('[net] handleSocketClosed — unauthorized close code', {
attempt: this.options.reconnect.getAttempt()
})
this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked')
return
}
console.log('[net] handleSocketClosed → reconnect', {
pendingCount: this.options.requests.size(),
streamCount: this.options.streams.size(),
attempt: this.options.reconnect.getAttempt()
})
this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
this.options.connectionState.publish('reconnecting')
this.options.reconnect.schedule()
}
}
@@ -0,0 +1,85 @@
import { publicKeyFromBase64 } from './e2ee'
import { RpcClientSocketSession } from './rpc-client-socket-session'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
type SocketFactoryOptions = {
endpoint: string
deviceToken: string
serverPublicKeyB64: string
getCurrentSocket: () => WebSocket | null
getState: () => ConnectionState
getReconnectAttempt: () => number
getLastConnectedAt: () => number | null
isIntentionallyClosed: () => boolean
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
onRpcResponse: (response: RpcResponse) => void
onBinary: (bytes: Uint8Array) => void
onAuthenticatedInbound: (session: RpcClientSocketSession) => void
onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
onForcedClose: (session: RpcClientSocketSession) => void
}
export class RpcClientSocketFactory {
private readonly serverPublicKey: Uint8Array
private lastInboundAt: number | null = null
private lastSocketClosedAt: number | null = null
private constructionCount = 0
private dialStartedAt = 0
constructor(private readonly options: SocketFactoryOptions) {
this.serverPublicKey = publicKeyFromBase64(options.serverPublicKeyB64)
}
open(): RpcClientSocketSession {
const now = Date.now()
const lastConnectedAt = this.options.getLastConnectedAt()
this.constructionCount++
console.log('[net] openConnection', {
attempt: this.options.getReconnectAttempt(),
endpoint: redactSocketEndpoint(this.options.endpoint),
wsCount: this.constructionCount,
msSinceLastConnected: lastConnectedAt !== null ? now - lastConnectedAt : null,
msSinceLastClose: this.lastSocketClosedAt !== null ? now - this.lastSocketClosedAt : null,
msSinceLastInbound: this.lastInboundAt !== null ? now - this.lastInboundAt : null
})
this.dialStartedAt = now
this.options.emitLog(
'info',
this.options.getReconnectAttempt() > 0
? `Reconnecting (attempt ${this.options.getReconnectAttempt() + 1})`
: 'Opening WebSocket',
redactSocketEndpoint(this.options.endpoint)
)
return new RpcClientSocketSession({
endpoint: this.options.endpoint,
deviceToken: this.options.deviceToken,
serverPublicKey: this.serverPublicKey,
getCurrentSocket: this.options.getCurrentSocket,
getState: this.options.getState,
getReconnectAttempt: this.options.getReconnectAttempt,
isIntentionallyClosed: this.options.isIntentionallyClosed,
emitLog: this.options.emitLog,
onHandshakeStarted: this.options.onHandshakeStarted,
onAuthenticated: this.options.onAuthenticated,
onAuthRejected: this.options.onAuthRejected,
onRpcResponse: this.options.onRpcResponse,
onBinary: this.options.onBinary,
onAnyInbound: (receivedAt) => (this.lastInboundAt = receivedAt),
onAuthenticatedInbound: this.options.onAuthenticatedInbound,
onClosed: this.options.onClosed,
onForcedClose: this.options.onForcedClose
})
}
getDialStartedAt(): number {
return this.dialStartedAt
}
noteClosed(): void {
this.lastSocketClosedAt = Date.now()
}
}
@@ -0,0 +1,312 @@
import {
decrypt,
decryptBytes,
deriveSharedKey,
encrypt,
generateKeyPair,
publicKeyToBase64
} from './e2ee'
import { isRpcResponse } from './rpc-response-shape'
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
const CONNECT_TIMEOUT_MS = 12_000
const HANDSHAKE_TIMEOUT_MS = 5_000
const WEBSOCKET_CONNECTING_STATE = 0
type SocketSessionOptions = {
endpoint: string
deviceToken: string
serverPublicKey: Uint8Array
getCurrentSocket: () => WebSocket | null
getState: () => ConnectionState
getReconnectAttempt: () => number
isIntentionallyClosed: () => boolean
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
onRpcResponse: (response: RpcResponse) => void
onBinary: (bytes: Uint8Array) => void
onAnyInbound: (receivedAt: number) => void
onAuthenticatedInbound: (session: RpcClientSocketSession) => void
onClosed: (session: RpcClientSocketSession, closeCode?: number) => void
onForcedClose: (session: RpcClientSocketSession) => void
}
export class RpcClientSocketSession {
readonly socket: WebSocket
readonly constructedAt = Date.now()
private sharedKey: Uint8Array | null = null
private authenticated = false
private lastInboundAt: number | null = null
private connectTimer: ReturnType<typeof setTimeout> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
constructor(private readonly options: SocketSessionOptions) {
this.socket = new WebSocket(options.endpoint)
this.attachHandlers()
this.armConnectTimeout()
}
sendEncrypted(request: unknown): boolean {
if (this.socket.readyState === WebSocket.OPEN && this.sharedKey) {
try {
this.socket.send(encrypt(JSON.stringify(request), this.sharedKey))
return true
} catch {
if (this.options.getCurrentSocket() === this.socket) {
this.options.onForcedClose(this)
}
return false
}
}
console.log('[net] sendEncrypted FAILED — channel not ready', {
hasWs: this.options.getCurrentSocket() !== null,
readyState: this.socket.readyState,
hasKey: this.sharedKey !== null,
state: this.options.getState()
})
if (
this.options.getState() === 'connected' &&
this.options.getCurrentSocket() === this.socket &&
this.socket.readyState !== WebSocket.OPEN
) {
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
readyState: this.socket.readyState
})
this.options.onForcedClose(this)
}
return false
}
close(): void {
this.socket.close()
}
clearTimers(): void {
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
clearKey(): void {
this.sharedKey = null
}
private attachHandlers(): void {
this.socket.onopen = () => {
if (this.isStale('open')) {
return
}
console.log('[net] ws.onopen', { attempt: this.options.getReconnectAttempt() })
this.clearConnectTimer()
this.options.onHandshakeStarted()
this.options.emitLog('success', 'WebSocket open', 'Starting E2EE handshake')
const ephemeral = generateKeyPair()
const hello = JSON.stringify({
type: 'e2ee_hello',
publicKeyB64: publicKeyToBase64(ephemeral.publicKey)
})
try {
this.socket.send(hello)
} catch {
this.options.onForcedClose(this)
return
}
this.options.emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready')
this.sharedKey = deriveSharedKey(ephemeral.secretKey, this.options.serverPublicKey)
this.armHandshakeTimeout()
}
this.socket.onmessage = (event) => {
if (!this.isStale('message')) {
void this.handleMessage(event.data)
}
}
this.socket.onclose = (event) => {
const closeCode = logRpcSocketClose({
event,
state: this.options.getState(),
attempt: this.options.getReconnectAttempt(),
intentionallyClosed: this.options.isIntentionallyClosed(),
endpoint: redactSocketEndpoint(this.options.endpoint),
constructedAt: this.constructedAt,
authenticated: this.authenticated,
lastInboundAt: this.lastInboundAt
})
this.options.onClosed(this, closeCode)
}
this.socket.onerror = (event) => {
if (this.isStale('error')) {
return
}
const error = event as { message?: string } | undefined
const description = describeSocketEvent(event)
console.log('[net] ws.onerror', {
message: error?.message,
state: this.options.getState(),
attempt: this.options.getReconnectAttempt(),
eventKeys: description.keys,
eventStr: description.json
})
}
}
private async handleMessage(rawData: unknown): Promise<void> {
const receivedAt = Date.now()
this.lastInboundAt = receivedAt
this.options.onAnyInbound(receivedAt)
const raw = typeof rawData === 'string' ? rawData : null
if (!this.authenticated) {
this.handleHandshakeMessage(raw)
return
}
if (!this.sharedKey || this.sharedKey.length !== 32) {
return
}
if (raw === null) {
const bytes = await websocketPayloadToUint8(rawData)
if (this.options.getCurrentSocket() !== this.socket || !bytes) {
return
}
const plaintext = decryptBytes(bytes, this.sharedKey)
if (!plaintext) {
return
}
this.options.onAuthenticatedInbound(this)
this.options.onBinary(plaintext)
return
}
const plaintext = decrypt(raw, this.sharedKey)
if (plaintext === null) {
return
}
this.options.onAuthenticatedInbound(this)
let response: unknown
try {
response = JSON.parse(plaintext)
} catch {
return
}
if (isRpcResponse(response)) {
this.options.onRpcResponse(response)
}
}
private handleHandshakeMessage(raw: string | null): void {
if (raw === null) {
return
}
try {
const message = JSON.parse(raw) as { type?: unknown }
if (message.type === 'e2ee_ready') {
this.options.emitLog('success', 'Received e2ee_ready', 'Sending device token')
this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.options.deviceToken })
return
}
} catch {
// The authenticated handshake messages are encrypted.
}
if (!this.sharedKey || this.sharedKey.length !== 32) {
return
}
const plaintext = decrypt(raw, this.sharedKey)
if (plaintext === null) {
return
}
try {
const message = JSON.parse(plaintext) as {
type?: unknown
ok?: unknown
error?: { code?: unknown }
}
if (message.type === 'e2ee_authenticated') {
this.clearHandshakeTimer()
this.authenticated = true
this.options.onAuthenticated(this)
} else if (
message.type === 'e2ee_error' ||
(message.ok === false && message.error?.code === 'unauthorized')
) {
console.log('[net] e2ee auth FAILED', {
msgType: message.type,
error: message.error
})
this.clearHandshakeTimer()
this.options.onAuthRejected('Unauthorized — pairing may be revoked')
}
} catch {
// Ignore malformed handshake payloads.
}
}
private armConnectTimeout(): void {
this.connectTimer = setTimeout(() => {
this.connectTimer = null
if (
this.options.getCurrentSocket() === this.socket &&
this.socket.readyState === WEBSOCKET_CONNECTING_STATE
) {
console.log('[net] connect-timeout fired (onopen never arrived)', {
attempt: this.options.getReconnectAttempt(),
timeoutMs: CONNECT_TIMEOUT_MS
})
this.options.emitLog(
'error',
'WebSocket connect timeout',
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
)
this.options.onForcedClose(this)
}
}, CONNECT_TIMEOUT_MS)
}
private armHandshakeTimeout(): void {
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (this.options.getCurrentSocket() !== this.socket || this.authenticated) {
return
}
console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', {
timeoutMs: HANDSHAKE_TIMEOUT_MS
})
this.options.emitLog(
'error',
'Handshake timeout',
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
)
this.options.onForcedClose(this)
}, HANDSHAKE_TIMEOUT_MS)
}
private clearConnectTimer(): void {
if (this.connectTimer) {
clearTimeout(this.connectTimer)
this.connectTimer = null
}
}
private clearHandshakeTimer(): void {
if (this.handshakeTimer) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
private isStale(eventName: string): boolean {
return isStaleRpcSocketEvent(
this.options.getCurrentSocket(),
this.socket,
eventName,
this.options.getState(),
this.options.getReconnectAttempt()
)
}
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { RpcClientStreamRegistry } from './rpc-client-stream-registry'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
import type { ConnectionState, RpcResponse } from './types'
type SentRequest = {
id: string
method: string
params?: unknown
}
function createRegistry(initialState: ConnectionState = 'connected') {
const sent: SentRequest[] = []
let state = initialState
let id = 0
const registry = new RpcClientStreamRegistry({
nextId: () => `rpc-${++id}`,
deviceToken: 'device-token',
getState: () => state,
sendEncrypted: (request) => {
sent.push(request as SentRequest)
return true
}
})
return {
registry,
sent,
setState(next: ConnectionState) {
state = next
}
}
}
function streamingResponse(id: string, result: unknown): RpcResponse {
return {
id,
ok: true,
streaming: true,
result,
_meta: { runtimeId: 'runtime-1' }
}
}
function terminalOutput(streamId: number, chunk: string): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId,
seq: 1,
payload: new TextEncoder().encode(chunk)
})
}
describe('RpcClientStreamRegistry', () => {
it('replays the latest terminal viewport without retaining stale stream routing', () => {
const { registry, sent } = createRegistry()
const events: unknown[] = []
registry.subscribe(
'terminal.subscribe',
{ terminal: 'term-1', viewport: { cols: 45, rows: 20 } },
(event) => events.push(event)
)
const first = sent[0]!
registry.handleResponse(streamingResponse(first.id, { type: 'subscribed', streamId: 7 }))
registry.handleBinary(terminalOutput(7, 'before'))
registry.updateTerminalViewport('term-1', { cols: 60, rows: 24 })
registry.markForReplay()
registry.replayAfterAuthentication()
registry.handleBinary(terminalOutput(7, 'stale'))
expect(sent[1]).toMatchObject({
id: first.id,
method: 'terminal.subscribe',
params: { terminal: 'term-1', viewport: { cols: 60, rows: 24 } }
})
expect(events).toEqual([
{ type: 'subscribed', streamId: 7 },
{ type: 'data', streamId: 7, chunk: 'before' }
])
})
it('keeps a disposed browser tombstone until ready can be unsubscribed', () => {
const { registry, sent } = createRegistry()
const dispose = registry.subscribe('browser.screencast', { page: 'page-1' }, () => {})
const request = sent[0]!
dispose()
expect(sent).toHaveLength(1)
registry.handleResponse(
streamingResponse(request.id, {
type: 'ready',
subscriptionId: 'browser-screencast:page-1:test'
})
)
expect(sent[1]).toMatchObject({
method: 'browser.screencast.unsubscribe',
params: { subscriptionId: 'browser-screencast:page-1:test' }
})
})
})
@@ -0,0 +1,315 @@
import {
decodeBrowserScreencastFrame,
type BrowserScreencastFrame
} from './browser-screencast-protocol'
import {
buildStreamUnsubscribe,
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription'
import {
isStreamingSubscriptionReadyResult,
isTerminalSubscribedResult
} from './rpc-subscription-result-shapes'
import { RpcClientTerminalStreamRouter } from './rpc-client-terminal-stream-router'
import type { ConnectionState, RpcResponse, RpcSuccess } from './types'
export type RpcStreamingListener = (result: unknown) => void
export type RpcStreamSubscribeOptions = {
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
}
type StreamRequest = {
method: string
params: unknown
listener: RpcStreamingListener
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
subscriptionId?: string
cancelled?: boolean
sent?: boolean
}
type StreamRegistryOptions = {
nextId: () => string
deviceToken: string
getState: () => ConnectionState
sendEncrypted: (request: unknown) => boolean
}
export class RpcClientStreamRegistry {
private readonly streams = new Map<string, StreamRequest>()
private readonly terminalRouter = new RpcClientTerminalStreamRouter()
private activeBrowserRequestId: string | null = null
private pendingBrowserRequestId: string | null = null
constructor(private readonly options: StreamRegistryOptions) {}
subscribe(
method: string,
params: unknown,
listener: RpcStreamingListener,
subscribeOptions?: RpcStreamSubscribeOptions
): () => void {
const id = this.options.nextId()
const stream: StreamRequest = {
method,
params,
listener,
onBinaryFrame: subscribeOptions?.onBinaryFrame
}
this.streams.set(id, stream)
if (method === 'browser.screencast') {
this.replaceBrowserStream(id)
}
if (this.options.getState() === 'connected') {
if (this.send(id, stream)) {
stream.sent = true
} else {
this.emitError(stream, 'Connection interrupted')
this.remove(id)
}
} else {
console.log('[net] subscribe queued — waiting for connected', {
method,
state: this.options.getState()
})
}
return () => this.dispose(id)
}
replayAfterAuthentication(): void {
for (const [id, stream] of this.streams) {
if (stream.cancelled) {
this.remove(id)
continue
}
if (stream.sent) {
continue
}
if (stream.method === 'browser.screencast') {
this.pendingBrowserRequestId = id
this.activeBrowserRequestId = null
}
this.resetTerminalRouting(id)
if (this.send(id, stream)) {
stream.sent = true
} else {
this.markForReplay()
break
}
}
}
markForReplay(): void {
this.activeBrowserRequestId = null
this.pendingBrowserRequestId = null
for (const [id, stream] of this.streams) {
stream.sent = false
this.resetTerminalRouting(id)
}
}
handleResponse(response: RpcResponse): boolean {
if (response.ok && response.streaming === true) {
this.handleStreamingResponse(response)
return true
}
const stream = this.streams.get(response.id)
if (response.ok) {
const result = (response as RpcSuccess).result as Record<string, unknown> | null
if (stream && result?.type === 'end') {
if (!stream.cancelled) {
stream.listener(result)
}
this.remove(response.id)
return true
}
if (stream && result?.type === 'scrollback') {
stream.listener(result)
return true
}
}
if (!stream) {
return false
}
this.emitError(
stream,
response.ok ? 'Streaming request ended before it was ready.' : response.error.message,
response.ok ? undefined : response.error
)
this.remove(response.id)
return true
}
handleBinary(bytes: Uint8Array): void {
const browserFrame = decodeBrowserScreencastFrame(bytes)
if (browserFrame) {
this.handleBrowserFrame(browserFrame)
return
}
this.terminalRouter.handle(bytes)
}
updateTerminalViewport(terminal: string, viewport: { cols: number; rows: number }): void {
updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport)
}
size(): number {
return this.streams.size
}
private handleStreamingResponse(response: RpcSuccess): void {
const stream = this.streams.get(response.id)
if (!stream) {
return
}
const result = response.result
if (isStreamingSubscriptionReadyResult(result)) {
stream.subscriptionId = result.subscriptionId
if (stream.cancelled) {
this.sendServerSubscriptionUnsubscribe(stream)
this.remove(response.id)
return
}
if (stream.method === 'browser.screencast') {
if (
this.pendingBrowserRequestId !== response.id &&
this.activeBrowserRequestId !== response.id
) {
this.sendBrowserUnsubscribe(result.subscriptionId)
this.remove(response.id)
return
}
this.pendingBrowserRequestId = null
this.activeBrowserRequestId = response.id
}
}
if (isTerminalSubscribedResult(result)) {
this.terminalRouter.register(response.id, result.streamId, stream.listener)
}
if (!stream.cancelled) {
stream.listener(result)
}
}
private dispose(id: string): void {
const stream = this.streams.get(id)
if (stream?.method === 'browser.screencast') {
stream.cancelled = true
this.clearBrowserRequest(id)
this.disposeServerSubscription(id, stream)
return
}
if (stream?.method === 'runtime.clientEvents.subscribe') {
this.disposeServerSubscription(id, stream)
return
}
if (stream?.method === 'terminal.subscribe') {
const params = buildTerminalUnsubscribeParams(stream.params)
if (params) {
this.sendRpc('terminal.unsubscribe', params)
}
} else {
const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params)
if (unsubscribe) {
this.sendRpc(unsubscribe.method, unsubscribe.params)
}
}
this.remove(id)
}
private replaceBrowserStream(id: string): void {
if (this.activeBrowserRequestId && this.activeBrowserRequestId !== id) {
this.dispose(this.activeBrowserRequestId)
}
if (this.pendingBrowserRequestId && this.pendingBrowserRequestId !== id) {
this.dispose(this.pendingBrowserRequestId)
}
this.pendingBrowserRequestId = id
this.activeBrowserRequestId = null
}
private disposeServerSubscription(id: string, stream: StreamRequest): void {
stream.cancelled = true
if (stream.subscriptionId) {
this.sendServerSubscriptionUnsubscribe(stream)
this.remove(id)
} else if (!stream.sent) {
this.remove(id)
}
}
private sendServerSubscriptionUnsubscribe(stream: StreamRequest): void {
if (!stream.subscriptionId) {
return
}
const unsubscribe = buildReadyStreamUnsubscribe(stream.method, stream.subscriptionId)
if (unsubscribe) {
this.sendRpc(unsubscribe.method, unsubscribe.params)
}
}
private sendBrowserUnsubscribe(subscriptionId: string): void {
this.sendRpc('browser.screencast.unsubscribe', { subscriptionId })
}
private sendRpc(method: string, params: unknown): boolean {
return this.options.sendEncrypted({
id: this.options.nextId(),
deviceToken: this.options.deviceToken,
method,
params
})
}
private send(id: string, stream: StreamRequest): boolean {
return this.options.sendEncrypted({
id,
deviceToken: this.options.deviceToken,
method: stream.method,
params: stream.params
})
}
private remove(id: string): void {
const stream = this.streams.get(id)
this.streams.delete(id)
this.clearBrowserRequest(id)
this.resetTerminalRouting(id)
if (stream?.method === 'browser.screencast') {
stream.cancelled = true
}
}
private clearBrowserRequest(id: string): void {
if (this.activeBrowserRequestId === id) {
this.activeBrowserRequestId = null
}
if (this.pendingBrowserRequestId === id) {
this.pendingBrowserRequestId = null
}
}
private resetTerminalRouting(id: string): void {
this.terminalRouter.reset(id)
}
private handleBrowserFrame(frame: BrowserScreencastFrame): void {
if (!this.activeBrowserRequestId) {
return
}
const stream = this.streams.get(this.activeBrowserRequestId)
if (!stream || stream.cancelled || stream.method !== 'browser.screencast') {
return
}
stream.onBinaryFrame?.(frame)
}
private emitError(stream: StreamRequest, message: string, error?: unknown): void {
if (!stream.cancelled) {
stream.listener({ type: 'error', message, error })
}
}
}
@@ -0,0 +1,38 @@
import {
handleTerminalBinaryFrame,
type TerminalSnapshotState
} from './rpc-client-terminal-binary-frame'
type TerminalListener = (result: unknown) => void
export class RpcClientTerminalStreamRouter {
private readonly listeners = new Map<number, TerminalListener>()
private readonly idsByRequest = new Map<string, Set<number>>()
private readonly snapshots = new Map<number, TerminalSnapshotState>()
register(requestId: string, streamId: number, listener: TerminalListener): void {
const ids = this.idsByRequest.get(requestId) ?? new Set<number>()
this.idsByRequest.set(requestId, ids)
ids.add(streamId)
this.listeners.set(streamId, listener)
}
reset(requestId: string): void {
const streamIds = this.idsByRequest.get(requestId)
if (!streamIds) {
return
}
for (const streamId of streamIds) {
this.listeners.delete(streamId)
this.snapshots.delete(streamId)
}
this.idsByRequest.delete(requestId)
}
handle(bytes: Uint8Array): void {
handleTerminalBinaryFrame(bytes, {
terminalSnapshots: this.snapshots,
getListener: (streamId) => this.listeners.get(streamId)
})
}
}
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,9 @@ function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeA
describe('agentDotState', () => {
it('maps known states through and unknown to idle', () => {
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working')
expect(
agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0)
).toBe('monitoring')
expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked')
expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting')
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done')
@@ -65,6 +68,12 @@ describe('agentDisplayLabel', () => {
expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe(
'Working'
)
expect(
agentDisplayLabel(
row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }),
0
)
).toBe('Monitoring background tasks')
})
it('falls back to the decayed state label when stale', () => {
+16 -3
View File
@@ -9,23 +9,34 @@ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000
// Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent
// status state; 'blocked'/'waiting' read as attention states, 'done' as
// complete, everything else idle.
export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted'
export type AgentDotState =
| 'working'
| 'monitoring'
| 'blocked'
| 'waiting'
| 'done'
| 'idle'
| 'interrupted'
export function agentDotState(
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'updatedAt'>,
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
now: number
): AgentDotState {
if (row.interrupted) {
return 'interrupted'
}
switch (row.state) {
case 'working':
case 'blocked':
case 'waiting':
// Why: an agent that exits without a final report would otherwise read as
// active forever. Decay a stale active state to idle, matching desktop's
// renderer-side staleness decay (worktree-agent-rows.ts).
return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state
case 'working':
if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
return 'idle'
}
return row.workingMode === 'monitoring' ? 'monitoring' : 'working'
case 'done':
return 'done'
}
@@ -37,6 +48,8 @@ export function agentStateLabel(state: AgentDotState): string {
switch (state) {
case 'working':
return 'Working'
case 'monitoring':
return 'Monitoring background tasks'
case 'blocked':
return 'Blocked'
case 'waiting':
+6 -12
View File
@@ -7,7 +7,10 @@ import {
} from './home-resume-card'
import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info'
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
const resumeCardSource = readFileSync(
new URL('../home/MobileHomeResumeCard.tsx', import.meta.url),
'utf8'
)
function worktree(worktreeId: string): HomeWorktreeSummary {
return {
@@ -120,17 +123,8 @@ describe('home resume card', () => {
})
it('renders the home Resume card inert until its host connects', () => {
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
// Assert the markers first: a renamed banner would otherwise slice garbage and report a
// missing prop instead of the real cause.
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const resumeCard = homeSource.slice(start, end)
expect(resumeCard).toContain('disabled={!resumeCard.actionable}')
expect(resumeCard).toContain('!resumeCard.actionable && styles.cardDisabled')
expect(resumeCardSource).toContain('disabled={!props.card.actionable}')
expect(resumeCardSource).toContain('!props.card.actionable && styles.cardDisabled')
})
})
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from '../../../src/shared/execution-host'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
export type Worktree = {
@@ -45,6 +46,7 @@ export type Worktree = {
linkedGitLabIssue?: number | null
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
workingMode?: AgentWorkingMode
agents?: RuntimeWorktreeAgentRow[]
}
@@ -160,6 +160,20 @@ describe('areWorktreeListsEqual', () => {
expect(areWorktreeListsEqual(first, second)).toBe(false)
})
it('detects monitoring mode changes within working', () => {
const first = [worktree({ agents: [agent({ state: 'working' })] })]
const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })]
expect(areWorktreeListsEqual(first, second)).toBe(false)
})
it('detects workspace monitoring mode changes within working', () => {
const first = [worktree({ status: 'working' })]
const second = [worktree({ status: 'working', workingMode: 'monitoring' })]
expect(areWorktreeListsEqual(first, second)).toBe(false)
})
it('treats missing and empty agent arrays as equivalent for rendering', () => {
const first = [worktree({ agents: undefined })]
const second = [worktree({ agents: [] })]
@@ -55,6 +55,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
(left.linkedGitLabIssue ?? null) === (right.linkedGitLabIssue ?? null) &&
(left.comment ?? '') === (right.comment ?? '') &&
(left.status ?? null) === (right.status ?? null) &&
(left.workingMode ?? null) === (right.workingMode ?? null) &&
arePullRequestsEqual(left.linkedPR, right.linkedPR) &&
areAgentRowsEqual(left.agents ?? [], right.agents ?? [])
)
@@ -102,6 +103,7 @@ function areAgentRowsEqual(
a.paneKey !== b.paneKey ||
a.parentPaneKey !== b.parentPaneKey ||
a.state !== b.state ||
a.workingMode !== b.workingMode ||
a.agentType !== b.agentType ||
a.prompt !== b.prompt ||
a.lastAssistantMessage !== b.lastAssistantMessage ||
+3 -3
View File
@@ -27,7 +27,7 @@ patchedDependencies:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
path: config/patches/lint-staged@16.4.0.patch
node-pty@1.1.0:
hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5
hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -69,7 +69,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5)
version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -12046,7 +12046,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5):
node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
dependencies:
node-addon-api: 7.1.1
+64 -1
View File
@@ -1,3 +1,4 @@
import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution'
import { EventEmitter } from 'node:events'
import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
@@ -12,6 +13,7 @@ const {
resolveCliCommandMock,
rmSyncMock,
spawnMock,
stdioForWindowsInteractiveChildMock,
writeKeychainMock
} = vi.hoisted(() => ({
deleteKeychainMock: vi.fn(),
@@ -20,6 +22,7 @@ const {
resolveCliCommandMock: vi.fn(),
rmSyncMock: vi.fn(),
spawnMock: vi.fn(),
stdioForWindowsInteractiveChildMock: vi.fn(),
writeKeychainMock: vi.fn()
}))
@@ -41,10 +44,16 @@ vi.mock('../../main/claude-accounts/keychain', () => ({
readActiveClaudeKeychainCredentialsStrict: readKeychainMock,
writeActiveClaudeKeychainCredentials: writeKeychainMock
}))
vi.mock('../../shared/node-cli-command-resolution', () => ({
// Why importOriginal: withCliRuntimeOnPath is a pure filesystem-probing helper,
// and the PATH assertions below are only meaningful against the real one.
vi.mock('../../shared/node-cli-command-resolution', async (importOriginal) => ({
...(await importOriginal<typeof NodeCliCommandResolutionModule>()),
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
resolveCliCommand: resolveCliCommandMock
}))
vi.mock('../../shared/windows-console-input', () => ({
stdioForWindowsInteractiveChild: stdioForWindowsInteractiveChildMock
}))
import { ACCOUNT_HANDLERS } from './account'
import type { HandlerContext } from '../dispatch'
@@ -105,6 +114,10 @@ describe('account CLI handlers', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', originalPlatform)
spawnMock.mockReset().mockImplementation(() => successfulChild())
stdioForWindowsInteractiveChildMock.mockReset().mockImplementation((json: boolean) => ({
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
dispose: vi.fn()
}))
resolveCliCommandMock.mockReset().mockImplementation((command: string) => command)
getVersionManagerBinPathsMock.mockReset().mockReturnValue([])
readKeychainMock.mockReset().mockResolvedValue(null)
@@ -159,6 +172,56 @@ describe('account CLI handlers', () => {
})
})
it('passes Windows console device handles to the login child instead of inheriting Electron stdio', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const dispose = vi.fn()
stdioForWindowsInteractiveChildMock.mockReturnValue({
stdio: [11, 'inherit', 'inherit'],
dispose
})
await ACCOUNT_HANDLERS['account add'](context('codex'))
expect(spawnMock).toHaveBeenCalledWith(
'codex',
['login', '--device-auth'],
expect.objectContaining({ stdio: [11, 'inherit', 'inherit'] })
)
expect(dispose).toHaveBeenCalledTimes(1)
})
it('disposes the Windows console input fd when spawn throws synchronously', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const dispose = vi.fn()
stdioForWindowsInteractiveChildMock.mockReturnValue({
stdio: [11, 'inherit', 'inherit'],
dispose
})
spawnMock.mockImplementationOnce(() => {
throw new Error('invalid stdio')
})
await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow('invalid stdio')
expect(dispose).toHaveBeenCalledOnce()
})
it('keeps JSON login prompts off the CLI stdout envelope when console fds are attached', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
stdioForWindowsInteractiveChildMock.mockReturnValue({
stdio: [11, process.stderr, 'inherit'],
dispose: vi.fn()
})
await ACCOUNT_HANDLERS['account add'](context('codex', true))
expect(spawnMock).toHaveBeenCalledWith(
'codex',
['login', '--device-auth'],
expect.objectContaining({ stdio: [11, process.stderr, 'inherit'] })
)
})
it('routes Windows package-manager shims through the safe cmd launcher', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd')
+22 -8
View File
@@ -14,13 +14,15 @@ import {
} from '../../main/claude-accounts/keychain'
import {
getVersionManagerBinPaths,
resolveCliCommand
resolveCliCommand,
withCliRuntimeOnPath
} from '../../shared/node-cli-command-resolution'
import {
getSpawnArgsForWindows,
UnsafeWindowsBatchArgumentsError,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type {
@@ -112,13 +114,25 @@ async function runAgentLoginInTerminal(
)
return
}
const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
const child = spawn(spawnCmd, spawnArgs, {
// Why: JSON mode reserves stdout for the response envelope while keeping
// the interactive login attached to the user's terminal via stderr.
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
env
})
// Why paired after the seed: addAgentNodePaths prepends the *newest* version
// manager bin, which is not necessarily where this CLI lives. Pairing last puts
// the CLI's own node in front of that seed (stablyai/orca#10932).
const env = withCliRuntimeOnPath(
resolvedCommand,
addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
)
const consoleStdio = stdioForWindowsInteractiveChild(json)
let child: ReturnType<typeof spawn>
try {
child = spawn(spawnCmd, spawnArgs, {
// Why: JSON mode reserves stdout for the response envelope while keeping
// the interactive login attached to the user's terminal via stderr.
stdio: consoleStdio.stdio,
env
})
} finally {
consoleStdio.dispose()
}
session.child = child
child.once('error', (error) =>
rejectPromise(
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../format', () => ({ printResult: vi.fn() }))
import { printResult } from '../format'
import { HANDLER_GROUPS } from '../handler-group-manifest'
import { ORCHESTRATION_HANDLERS } from './orchestration'
import {
ORCHESTRATION_DISPATCH_HANDLER,
ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS
} from './orchestration/dispatch-handlers'
import { getOptionalStructuredMessagePayload } from './orchestration/message-payload'
import { getOptionalPositiveIntegerValueFlag } from './orchestration/numeric-flags'
import { formatWorkerRead, formatWorkerRelease } from './orchestration/worker-output'
describe('extracted orchestration flag parsing', () => {
it('serializes phase and omits empty CSV entries', () => {
const payload = getOptionalStructuredMessagePayload(
new Map([
['phase', 'verification'],
['files-modified', ' src/a.ts, ,src/b.ts, ']
])
)
expect(JSON.parse(payload ?? '')).toEqual({
filesModified: ['src/a.ts', 'src/b.ts'],
phase: 'verification'
})
})
it('rejects unsafe positive integers before dispatch', () => {
expect(() =>
getOptionalPositiveIntegerValueFlag(
new Map([['timeout-ms', '9007199254740992']]),
'timeout-ms'
)
).toThrow('Invalid positive safe integer for --timeout-ms')
})
})
it('composes handlers in the canonical command order', () => {
const declared = HANDLER_GROUPS.find((group) => group.name === 'orchestration')?.keys
expect(Object.keys(ORCHESTRATION_HANDLERS)).toEqual(declared)
})
describe('extracted orchestration worker formatting', () => {
it('renders terminal tails without changing line boundaries', () => {
expect(
formatWorkerRead({
dispatchId: 'dispatch_1',
terminal: { tail: ['first', '', 'third'] }
} as never)
).toBe('first\n\nthird')
})
it('renders transcript blocks and survives unserializable tool input', () => {
const circular: { self?: unknown } = {}
circular.self = circular
expect(
formatWorkerRead({
source: 'transcript',
transcript: {
messages: [
{
id: 'message_1',
role: 'assistant',
blocks: [
{ type: 'text', text: 'working' },
{ type: 'tool-call', name: 'inspect', input: circular },
{ type: 'tool-result', output: 'failed', isError: true },
{ type: 'image-ref', url: 'https://example.test/proof.png' }
],
timestamp: null,
source: 'transcript'
}
]
}
} as never)
).toBe(
'[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png'
)
})
it('renders release evidence in receipt order', () => {
expect(
formatWorkerRelease({
dispatchId: 'dispatch_1',
state: 'release_unknown',
reason: 'host_unreachable',
processAction: 'unverifiable',
archive: { source: 'transcript', status: 'saved' },
lastError: 'transport closed',
recovery: 'Reconnect and retry.'
})
).toBe(
'Worker dispatch_1 terminal [release_unknown] reason=host_unreachable process=unverifiable\n' +
'archive transcript [saved]\ntransport closed\nReconnect and retry.'
)
})
})
describe('extracted orchestration dispatch handlers', () => {
const call = vi.fn()
beforeEach(() => {
call.mockReset()
vi.mocked(printResult).mockReset()
})
it('allows a dry run without a recipient and returns only its preamble', async () => {
call.mockResolvedValue({
result: { dispatch: null, dryRun: true, preamble: 'preview preamble' }
})
await ORCHESTRATION_DISPATCH_HANDLER['orchestration dispatch']({
flags: new Map<string, string | boolean>([
['task', 'task_1'],
['from', 'terminal_coordinator'],
['dry-run', true],
['return-preamble', true]
]),
client: { call },
cwd: '/repo',
json: false
} as never)
expect(call).toHaveBeenCalledWith('orchestration.dispatch', {
task: 'task_1',
run: undefined,
to: undefined,
from: 'terminal_coordinator',
inject: undefined,
dryRun: true,
returnPreamble: true,
devMode: false
})
expect(renderedValue({ dispatch: null, dryRun: true, preamble: 'preview preamble' })).toBe(
'preview preamble'
)
})
it('does not resolve coordinator identity for a non-preamble dispatch lookup', async () => {
call.mockResolvedValue({ result: { dispatch: null } })
await ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS['orchestration dispatch-show']({
flags: new Map([['task', 'task_1']]),
client: { call },
cwd: '/repo',
json: false
} as never)
expect(call).toHaveBeenCalledWith('orchestration.dispatchShow', {
task: 'task_1',
preamble: undefined,
from: undefined,
devMode: false
})
expect(renderedValue({ dispatch: null })).toBe('No dispatch context found.')
})
})
function renderedValue(value: unknown): string {
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as (result: unknown) => string
return formatter(value)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default.
function resolveKeepaliveIntervalMs(): number {
const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS
if (!raw) {
return DEFAULT_KEEPALIVE_INTERVAL_MS
}
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_KEEPALIVE_INTERVAL_MS
}
return parsed
}
export function startCheckKeepalive(deadlineMs: number | undefined): () => void {
const startedAt = Date.now()
const interval = setInterval(() => {
const payload = {
_keepalive: true,
// Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive.
_heartbeat: true,
elapsedMs: Date.now() - startedAt,
deadlineMs: deadlineMs ?? null
}
process.stderr.write(`${JSON.stringify(payload)}\n`)
}, resolveKeepaliveIntervalMs())
if (typeof interval.unref === 'function') {
interval.unref()
}
return () => clearInterval(interval)
}
@@ -0,0 +1,84 @@
import type { CommandHandler } from '../../dispatch'
import { printResult } from '../../format'
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
import { RuntimeClientError } from '../../runtime-client'
import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract'
import { callOrchestrationMutation } from './mutation-request'
import { isDevCliInvocation } from './runtime-compatibility'
import { resolveCoordinatorTerminalHandle } from './terminal-identity'
export const ORCHESTRATION_DISPATCH_HANDLER: Record<string, CommandHandler> = {
'orchestration dispatch': async ({ flags, client, cwd, json }) => {
const from = await resolveCoordinatorTerminalHandle(flags, cwd, client)
const dryRun = flags.has('dry-run') ? true : undefined
const returnPreamble = flags.has('return-preamble') ? true : undefined
// Why: --to is only required for non-dry-run; the RPC handler re-enforces.
const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to')
const result = await callOrchestrationMutation<{
dispatch: { id: string; task_id: string; status: string } | null
injected?: boolean
dryRun?: boolean
preamble?: string
}>(client, flags, 'orchestration.dispatch', {
task: getRequiredStringFlag(flags, 'task'),
run: getOptionalStringFlag(flags, 'run'),
to,
from,
inject: flags.has('inject') ? true : undefined,
dryRun,
returnPreamble,
devMode: isDevCliInvocation()
})
printResult(result, json, (value) => {
if (value.dryRun) {
return value.preamble ?? ''
}
const base = `Dispatched ${value.dispatch?.task_id} -> ${value.dispatch?.id} [${value.dispatch?.status}]`
return value.preamble ? `${base}\n\n--- Preamble ---\n${value.preamble}` : base
})
}
}
export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record<string, CommandHandler> = {
'orchestration dispatch-show': async ({ flags, client, cwd, json }) => {
const showPreamble = flags.has('preamble') ? true : undefined
// Why: a preview must embed the same real coordinator handle as an actual dispatch.
const from = showPreamble
? await resolveCoordinatorTerminalHandle(flags, cwd, client)
: undefined
const result = await client.call<{
dispatch: { id: string; task_id: string; status: string } | null
preamble?: string
}>('orchestration.dispatchShow', {
task: getRequiredStringFlag(flags, 'task'),
preamble: showPreamble,
from,
devMode: isDevCliInvocation()
})
printResult(result, json, (value) => {
if (value.preamble && showPreamble) {
return value.preamble
}
if (!value.dispatch) {
return 'No dispatch context found.'
}
return `${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]`
})
},
'orchestration coordinator-start': async () => {
throw new RuntimeClientError(
'orchestration_migration_required',
'The legacy automatic coordinator command is retired. No effects were applied.',
orchestrationMigrationData('command_retired')
)
},
'orchestration coordinator-stop': async () => {
throw new RuntimeClientError(
'orchestration_migration_required',
'The legacy automatic coordinator command is retired. No effects were applied.',
orchestrationMigrationData('command_retired')
)
}
}
@@ -0,0 +1,60 @@
import type { CommandHandler } from '../../dispatch'
import { printResult } from '../../format'
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
import { callOrchestrationMutation } from './mutation-request'
import { resolveCoordinatorTerminalHandle } from './terminal-identity'
export const ORCHESTRATION_GATE_HANDLERS: Record<string, CommandHandler> = {
'orchestration gate-create': async ({ flags, client, cwd, json }) => {
const result = await callOrchestrationMutation<{
gate: { id: string; task_id: string; status: string }
}>(client, flags, 'orchestration.gateCreate', {
task: getRequiredStringFlag(flags, 'task'),
question: getRequiredStringFlag(flags, 'question'),
options: getOptionalStringFlag(flags, 'options'),
// Why: gates are Run-scoped, so the coordinator handle is the authorized caller identity.
from: await resolveCoordinatorTerminalHandle(flags, cwd, client)
})
printResult(
result,
json,
(value) =>
`Gate ${value.gate.id} created for task ${value.gate.task_id} [${value.gate.status}]`
)
},
'orchestration gate-resolve': async ({ flags, client, cwd, json }) => {
const result = await callOrchestrationMutation<{
gate: { id: string; task_id: string; status: string; resolution: string }
}>(client, flags, 'orchestration.gateResolve', {
id: getRequiredStringFlag(flags, 'id'),
resolution: getRequiredStringFlag(flags, 'resolution'),
from: await resolveCoordinatorTerminalHandle(flags, cwd, client)
})
printResult(result, json, (value) => `Gate ${value.gate.id} resolved: ${value.gate.resolution}`)
},
'orchestration gate-list': async ({ flags, client, cwd, json }) => {
const run = getOptionalStringFlag(flags, 'run')
// Why: named runs remain inspectable without a pane; only implicit runs resolve identity.
const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client)
const result = await client.call<{
gates: { id: string; task_id: string; question: string; status: string }[]
count: number
runId?: string
}>('orchestration.gateList', {
task: getOptionalStringFlag(flags, 'task'),
status: getOptionalStringFlag(flags, 'status'),
run,
from
})
printResult(result, json, (value) => {
if (value.gates.length === 0) {
return 'No gates found.'
}
return value.gates
.map((gate) => `${gate.id} task=${gate.task_id} [${gate.status}] "${gate.question}"`)
.join('\n')
})
}
}
@@ -0,0 +1,120 @@
import type { CommandHandler } from '../../dispatch'
import { printResult } from '../../format'
import { getOptionalStringFlag } from '../../flags'
import { RuntimeClientError } from '../../runtime-client'
import type { RuntimeRpcSuccess } from '../../runtime-client'
import {
formatOrchestrationCheckText,
prepareOrchestrationCheckOutput,
type LegacyCompatibilityResult,
type OrchestrationMessageSummary as MessageSummary
} from '../../../shared/orchestration-check-output'
import { startCheckKeepalive } from './check-keepalive'
import { callOrchestrationMutation } from './mutation-request'
import { getOptionalPositiveIntegerValueFlag } from './numeric-flags'
import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility'
import { resolveOrchestrationTerminalHandle } from './terminal-identity'
type CheckResult = {
messages: MessageSummary[]
count: number
formatted?: string
deliveryId?: string | null
runId?: string
timedOut?: boolean
cancelled?: boolean
connectionLost?: boolean
legacyCompatibility?: LegacyCompatibilityResult
}
export const ORCHESTRATION_CHECK_HANDLER: Record<string, CommandHandler> = {
'orchestration check': async ({ flags, client, cwd, json }) => {
const wait = flags.has('wait')
const peek = flags.has('peek')
// Why: older runtimes strip unknown peek and run --unread --peek as destructive mark-read.
if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) {
throw new RuntimeClientError(
'invalid_argument',
'Choose at most one message read mode: --unread, --peek, or --all.'
)
}
const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
const explicitTerminal = getOptionalStringFlag(flags, 'terminal')
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null
let result: Awaited<ReturnType<typeof client.call<CheckResult>>>
try {
result = await callOrchestrationMutation<CheckResult>(client, flags, 'orchestration.check', {
terminal,
terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined,
// Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read.
unread: flags.has('unread') ? true : peek ? false : undefined,
peek: peek ? true : undefined,
all: flags.has('all') ? true : undefined,
types: getOptionalStringFlag(flags, 'types'),
format: flags.has('format') ? true : undefined,
inject: flags.has('inject') ? true : undefined,
compatibilityCliCommand: resolveCompatibilityCliCommand(),
run: getOptionalStringFlag(flags, 'run'),
ack: getOptionalStringFlag(flags, 'ack'),
wait: wait ? true : undefined,
timeoutMs
})
} finally {
stopKeepalive?.()
}
if (peek) {
result = filterLegacyPeekResult(result, wait)
}
result = {
...result,
result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format'))
}
printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal))
const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds
if (compatibilityAck && compatibilityAck.length > 0) {
await flushOrchestrationStdout()
await client.call('orchestration.check', {
terminal,
compatibilityAck: JSON.stringify({
messageIds: compatibilityAck,
types: getOptionalStringFlag(flags, 'types')
?.split(',')
.map((type) => type.trim())
.filter(Boolean)
})
})
}
}
}
function filterLegacyPeekResult(
result: RuntimeRpcSuccess<CheckResult>,
wait: boolean
): RuntimeRpcSuccess<CheckResult> {
const rawRowCount = result.result.messages.length
const unreadOnly = result.result.messages.filter((message) => message.read !== 1)
const removedReadRows = unreadOnly.length !== rawRowCount
// Why: read rows prove a pre-peek runtime cannot honor wait, so fail instead of returning early.
if (wait && removedReadRows && unreadOnly.length === 0) {
throw new RuntimeClientError(
'peek_wait_unsupported',
'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.'
)
}
if (removedReadRows && rawRowCount >= 100) {
console.error(
'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.'
)
}
return {
...result,
result: {
...result.result,
// Why: a pre-peek runtime formats all rows, which no longer matches the filtered set.
...(removedReadRows ? { formatted: undefined } : {}),
messages: unreadOnly,
count: unreadOnly.length
}
}
}
@@ -0,0 +1,64 @@
import type { CommandHandler } from '../../dispatch'
import { printResult } from '../../format'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../../flags'
import {
formatMessageReadOnlyTag,
type OrchestrationMessageSummary as MessageSummary
} from '../../../shared/orchestration-check-output'
import { callOrchestrationMutation } from './mutation-request'
import { resolveOrchestrationTerminalHandle } from './terminal-identity'
export const ORCHESTRATION_INBOX_HANDLERS: Record<string, CommandHandler> = {
'orchestration reply': async ({ flags, client, cwd, json }) => {
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await callOrchestrationMutation<{ message: { id: string } }>(
client,
flags,
'orchestration.reply',
{
id: getRequiredStringFlag(flags, 'id'),
body: getRequiredStringFlag(flags, 'body'),
run: getOptionalStringFlag(flags, 'run'),
from
}
)
printResult(result, json, (value) => `Replied ${value.message.id}`)
},
'orchestration inbox': async ({ flags, client, json }) => {
const full = flags.has('full')
const result = await client.call<{
messages: MessageSummary[]
count: number
}>('orchestration.inbox', {
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
terminal: getOptionalStringFlag(flags, 'terminal')
})
printResult(result, json, (value) => {
if (value.count === 0) {
return 'No messages.'
}
// Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing.
return value.messages
.map((message) => {
const head = `${message.id}${formatMessageReadOnlyTag(message)} ${message.from_handle} -> ${message.to_handle ?? '?'}: "${message.subject}"`
if (!full) {
return head
}
const parts = [head]
if (message.body && message.body.length > 0) {
parts.push(message.body)
}
if (message.payload) {
parts.push(`[payload] ${message.payload}`)
}
return parts.join('\n')
})
.join(full ? '\n\n' : '\n')
})
}
}
@@ -0,0 +1,60 @@
import { getOptionalStringFlag } from '../../flags'
import { RuntimeClientError } from '../../runtime-client'
export function getOptionalStructuredMessagePayload(
flags: Map<string, string | boolean>
): string | undefined {
const rawPayload = getOptionalStringFlag(flags, 'payload')
const taskId = getOptionalStringFlag(flags, 'task-id')
const dispatchId = getOptionalStringFlag(flags, 'dispatch-id')
const outcome = getOptionalStringFlag(flags, 'outcome')
const filesModified = getOptionalStringFlag(flags, 'files-modified')
const reportPath = getOptionalStringFlag(flags, 'report-path')
const phase = getOptionalStringFlag(flags, 'phase')
const hasStructuredPayload =
taskId !== undefined ||
dispatchId !== undefined ||
outcome !== undefined ||
filesModified !== undefined ||
reportPath !== undefined ||
phase !== undefined
if (!hasStructuredPayload) {
return rawPayload
}
if (rawPayload !== undefined) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --payload or structured payload flags, not both.'
)
}
// Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting.
const payload: Record<string, string | string[]> = {}
if (taskId) {
payload.taskId = taskId
}
if (dispatchId) {
payload.dispatchId = dispatchId
}
if (outcome) {
if (outcome !== 'succeeded' && outcome !== 'failed') {
throw new RuntimeClientError(
'invalid_argument',
'Invalid --outcome. Expected succeeded or failed.'
)
}
payload.outcome = outcome
}
if (filesModified) {
payload.filesModified = filesModified
.split(',')
.map((file) => file.trim())
.filter(Boolean)
}
if (reportPath) {
payload.reportPath = reportPath
}
if (phase) {
payload.phase = phase
}
return JSON.stringify(payload)
}
@@ -0,0 +1,137 @@
import type { CommandHandler } from '../../dispatch'
import { printResult } from '../../format'
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
import { RuntimeClientError } from '../../runtime-client'
import { requireWorkerDoneSettlement } from '../orchestration-worker-settlement'
import { getOptionalStructuredMessagePayload } from './message-payload'
import { callOrchestrationMutation } from './mutation-request'
import { isDevCliInvocation } from './runtime-compatibility'
import {
resolveOrchestrationTerminalHandle,
throwNoActiveSenderTerminal
} from './terminal-identity'
type LifecycleSendResult =
| {
action: 'completed' | 'failed'
authority?: 'run_home' | 'worker_server_legacy'
}
| { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean }
| { action: 'rejected'; code: string; reason: string }
type SendRecipientWarning = {
code: string
recipient: string
message: string
}
type OrchestrationSendResult =
| {
message: { id: string; run_id?: string }
lifecycle?: LifecycleSendResult
warnings?: SendRecipientWarning[]
}
| {
messages: { id: string }[]
recipients: number
warnings?: SendRecipientWarning[]
}
| {
relay: {
messageId: string
sequence: number
dispatchId: string
destination?: 'run_home' | 'worker'
accepted: true
}
lifecycle?: LifecycleSendResult
warnings?: SendRecipientWarning[]
}
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
return `${type} messages belong to one exact Dispatch and cannot target a group address.`
}
function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void {
if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) {
throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type))
}
}
export const ORCHESTRATION_SEND_HANDLER: Record<string, CommandHandler> = {
'orchestration send': async ({ flags, client, cwd, json }) => {
const to = getOptionalStringFlag(flags, 'to')
const type = getOptionalStringFlag(flags, 'type')
if (to) {
rejectLifecycleGroupRecipient(type, to)
}
const outcome = getOptionalStringFlag(flags, 'outcome')
if (type !== 'worker_done' && outcome !== undefined) {
throw new RuntimeClientError(
'invalid_argument',
'--outcome is only valid with --type worker_done.'
)
}
if (
(type === 'worker_done' || type === 'heartbeat') &&
!getOptionalStringFlag(flags, 'from') &&
!process.env.ORCA_TERMINAL_HANDLE
) {
// Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker.
throwNoActiveSenderTerminal()
}
// Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes.
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const sendParams = {
from,
to,
run: getOptionalStringFlag(flags, 'run'),
subject: getRequiredStringFlag(flags, 'subject'),
body: getOptionalStringFlag(flags, 'body'),
type,
priority: getOptionalStringFlag(flags, 'priority'),
threadId: getOptionalStringFlag(flags, 'thread-id'),
payload: getOptionalStructuredMessagePayload(flags),
// Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it.
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
waitForLifecycleSettlement: type === 'worker_done' ? true : undefined,
devMode: isDevCliInvocation()
}
const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability')
const result = await callOrchestrationMutation<OrchestrationSendResult>(
client,
flags,
'orchestration.send',
sendParams,
dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined
)
await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result)
if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') {
throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason)
}
printResult(result, json, (value) => {
const warnings = 'warnings' in value ? (value.warnings ?? []) : []
const withWarnings = (line: string): string =>
warnings.length > 0
? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n')
: line
if ('message' in value) {
return withWarnings(`Sent ${value.message.id}`)
}
if ('relay' in value) {
if (value.relay.destination === 'worker') {
return withWarnings(
`Queued ${value.relay.messageId} for worker Dispatch ${value.relay.dispatchId}`
)
}
return withWarnings(
`Queued ${value.relay.messageId} for Run home (Dispatch ${value.relay.dispatchId})`
)
}
return withWarnings(
`Sent ${value.messages.length} messages to ${value.recipients} recipients`
)
})
}
}
@@ -0,0 +1,21 @@
import type { RuntimeClient } from '../../runtime-client'
import { getOptionalStringFlag } from '../../flags'
import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery'
export function callOrchestrationMutation<TResult>(
client: RuntimeClient,
flags: Map<string, string | boolean>,
method: string,
params: unknown,
options?: { timeoutMs?: number; orchestrationCapability?: string }
) {
const requestId = getOptionalStringFlag(flags, 'retry-request')
const result = requestId
? client.call<TResult>(method, params, { ...options, orchestrationRequestId: requestId })
: options
? client.call<TResult>(method, params, options)
: client.call<TResult>(method, params)
return result.catch((error) => {
throw orchestrationMutationRecoveryError(error)
})
}

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