From 09378ef5e409be8e580a36f2314dd35d4b490878 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Tue, 18 Aug 2026 19:29:36 -0700
Subject: [PATCH] refactor: split the four files the merge pushed over
max-lines
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both sides added to each of these, so the merged file cleared its cap. Split
rather than raise the cap.
- `agent-status-types.ts` -> `agent-subagent-snapshot.ts` takes the subagent
roster: its two types, the per-entry cap, and the structural equality stores
use to skip fanout.
- `stable-logical-rpc-client.ts` -> `logical-client-cutover-error.ts` takes the
cutover error. Several modules that retry on it are imported BY the client, so
the error belonging to the client was already the wrong way round.
- `tui-agent-startup.test.ts` -> `tui-agent-startup-hermes.test.ts`. Hermes is
the one agent whose launch Orca rewrites token by token; the two shell-script
decoders both files need move to a `.test-fixture`.
- the mobile session screen -> `DiffLineRow.tsx`. The row owns a whole
comment-draft interaction and reads `styles` from a module already split out.
Also two things the root typecheck could not see, because it does not cover
mobile and because the lane's import predated a main-side split:
- `registerActiveFailure` still assigned the raw `consecutiveFailures` field
main replaced with `RelayRecoveryFailureCount`. A structured reconnect signal
recomputes the streak rather than adding to it, so the counter gains
`recomputeTo` and the assignment routes through it — the recovery-attempt
reporter sees the change, which a bare field write did not do.
- the typing indicator imported `isCommandMarkerId` from `native-chat-pending`
after main moved it to `native-chat-command-marker`.
---
.../app/h/[hostId]/session/[worktreeId].tsx | 141 +---------
.../dictation/mobile-dictation-setup.test.ts | 2 +-
.../src/dictation/mobile-dictation-setup.ts | 2 +-
mobile/src/session/DiffLineRow.tsx | 150 +++++++++++
.../session/mobile-clipboard-image.test.ts | 2 +-
mobile/src/session/mobile-clipboard-image.ts | 2 +-
.../session/mobile-native-chat-send.test.ts | 2 +-
mobile/src/session/mobile-native-chat-send.ts | 2 +-
.../mobile-session-tab-activation.test.ts | 2 +-
.../session/mobile-session-tab-activation.ts | 2 +-
.../session/mobile-structured-outbox-entry.ts | 2 +-
.../session/use-mobile-native-chat-stop.ts | 2 +-
...use-mobile-structured-session-mutations.ts | 2 +-
mobile/src/session/use-quick-commands.test.ts | 2 +-
mobile/src/session/use-quick-commands.ts | 2 +-
.../tasks/worktree-create-capability.test.ts | 2 +-
.../src/tasks/worktree-create-capability.ts | 2 +-
.../src/tasks/worktree-create-retry.test.ts | 2 +-
mobile/src/tasks/worktree-create-retry.ts | 2 +-
.../transport/logical-client-cutover-error.ts | 17 ++
.../mobile-relay-reconnect-controller.ts | 4 +-
.../transport/relay-recovery-failure-count.ts | 7 +
.../runtime-capability-probe.test.ts | 2 +-
.../src/transport/runtime-capability-probe.ts | 2 +-
.../stable-logical-rpc-client.test.ts | 7 +-
.../transport/stable-logical-rpc-client.ts | 15 +-
.../worktree/home-host-worktree-fetch.test.ts | 6 +-
.../src/worktree/home-host-worktree-fetch.ts | 2 +-
.../restored-subagent-liveness-sweep.test.ts | 2 +-
...-claude-child-permission-lifecycle.test.ts | 2 +-
.../agent-hook-envelope-publication.test.ts | 2 +-
...ay-oversized-notification-survival.test.ts | 8 +-
.../native-chat-typing-indicator.ts | 2 +-
src/renderer/src/store/slices/agent-status.ts | 13 +-
...ent-hook-listener-claude-subagents.test.ts | 2 +-
...listener-startless-child-lifecycle.test.ts | 2 +-
src/shared/agent-hook-listener.ts | 9 +-
src/shared/agent-hook-relay.ts | 3 +-
src/shared/agent-status-types.test.ts | 18 +-
src/shared/agent-status-types.ts | 50 +---
src/shared/agent-subagent-snapshot.ts | 52 ++++
.../claude-background-task-inventory.ts | 2 +-
.../claude-background-task-status.test.ts | 2 +-
src/shared/claude-subagent-roster.test.ts | 2 +-
src/shared/claude-subagent-roster.ts | 2 +-
src/shared/codex-subagent-roster.test.ts | 7 +-
src/shared/codex-subagent-roster.ts | 9 +-
src/shared/tui-agent-startup-hermes.test.ts | 230 +++++++++++++++++
.../tui-agent-startup-script.test-fixture.ts | 24 ++
src/shared/tui-agent-startup.test.ts | 240 +-----------------
50 files changed, 539 insertions(+), 531 deletions(-)
create mode 100644 mobile/src/session/DiffLineRow.tsx
create mode 100644 mobile/src/transport/logical-client-cutover-error.ts
create mode 100644 src/shared/agent-subagent-snapshot.ts
create mode 100644 src/shared/tui-agent-startup-hermes.test.ts
create mode 100644 src/shared/tui-agent-startup-script.test-fixture.ts
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 2e162112604..06ce4a24185 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -287,6 +287,7 @@ import {
import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
+import { DiffLineRow } from '../../../../src/session/DiffLineRow'
import type { DiffComment } from '../../../../../src/shared/diff-comment-types'
import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types'
import type {
@@ -312,146 +313,6 @@ import type {
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
-function DiffLineRow({
- line,
- title,
- index,
- comments,
- activeCommentLine,
- commentDraft,
- commentsBusy,
- onStartComment,
- onCancelComment,
- onDraftChange,
- onSubmitComment,
- onDeleteComment
-}: {
- line: RenderableDiffLine
- title: string
- index: number
- comments: DiffComment[]
- activeCommentLine: number | null
- commentDraft: string
- commentsBusy: boolean
- onStartComment: (lineNumber: number) => void
- onCancelComment: () => void
- onDraftChange: (value: string) => void
- onSubmitComment: (lineNumber: number) => void
- onDeleteComment: (commentId: string) => void
-}) {
- const commentLine = line.newLineNumber
- const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
- const canComment = commentLine !== undefined
- // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
- const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
- return (
-
-
- {gutterLineNumber}
-
-
- {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
-
-
-
- {canComment ? (
- [
- styles.diffCommentAddButton,
- pressed && styles.diffCommentAddButtonPressed,
- commentsBusy && styles.diffCommentButtonDisabled
- ]}
- disabled={commentsBusy}
- onPress={() => {
- if (commentLine !== undefined) {
- onStartComment(commentLine)
- }
- }}
- accessibilityLabel={`Add note on line ${commentLine}`}
- >
-
-
- ) : null}
-
- {comments.length > 0 ? (
-
- {comments.map((comment) => (
-
-
-
- Line {comment.lineNumber}
- onDeleteComment(comment.id)}
- accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
- >
-
-
-
- {comment.body}
-
- ))}
-
- ) : null}
- {isCommenting ? (
-
-
-
-
- Cancel
-
- {
- if (commentLine !== undefined) {
- onSubmitComment(commentLine)
- }
- }}
- >
- Save note
-
-
-
- ) : null}
-
- )
-}
-
function FileReader({
doc,
title,
diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts
index f84e545af49..84ab66a1b0b 100644
--- a/mobile/src/dictation/mobile-dictation-setup.test.ts
+++ b/mobile/src/dictation/mobile-dictation-setup.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import {
diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts
index ca510ee4a31..e66d5d4eeb6 100644
--- a/mobile/src/dictation/mobile-dictation-setup.ts
+++ b/mobile/src/dictation/mobile-dictation-setup.ts
@@ -1,6 +1,6 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcSuccess } from '../transport/types'
export type MobileSpeechSetup = RuntimeSpeechSetupState
diff --git a/mobile/src/session/DiffLineRow.tsx b/mobile/src/session/DiffLineRow.tsx
new file mode 100644
index 00000000000..7419bd20f39
--- /dev/null
+++ b/mobile/src/session/DiffLineRow.tsx
@@ -0,0 +1,150 @@
+import { Pressable, Text, TextInput, View } from 'react-native'
+import { MessageSquare, Plus, X } from 'lucide-react-native'
+import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
+import { colors } from '../theme/mobile-theme'
+import { styles } from './mobile-session-styles'
+import type { RenderableDiffLine } from './mobile-session-route-types'
+import type { DiffComment } from '../../../src/shared/diff-comment-types'
+
+// One row of a rendered diff, with its inline note thread. Lifted out of the session screen
+// because the row owns a whole comment-draft interaction of its own.
+
+export function DiffLineRow({
+ line,
+ title,
+ index,
+ comments,
+ activeCommentLine,
+ commentDraft,
+ commentsBusy,
+ onStartComment,
+ onCancelComment,
+ onDraftChange,
+ onSubmitComment,
+ onDeleteComment
+}: {
+ line: RenderableDiffLine
+ title: string
+ index: number
+ comments: DiffComment[]
+ activeCommentLine: number | null
+ commentDraft: string
+ commentsBusy: boolean
+ onStartComment: (lineNumber: number) => void
+ onCancelComment: () => void
+ onDraftChange: (value: string) => void
+ onSubmitComment: (lineNumber: number) => void
+ onDeleteComment: (commentId: string) => void
+}) {
+ const commentLine = line.newLineNumber
+ const isCommenting = commentLine !== undefined && activeCommentLine === commentLine
+ const canComment = commentLine !== undefined
+ // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter.
+ const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? ''
+ return (
+
+
+ {gutterLineNumber}
+
+
+ {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '}
+
+
+
+ {canComment ? (
+ [
+ styles.diffCommentAddButton,
+ pressed && styles.diffCommentAddButtonPressed,
+ commentsBusy && styles.diffCommentButtonDisabled
+ ]}
+ disabled={commentsBusy}
+ onPress={() => {
+ if (commentLine !== undefined) {
+ onStartComment(commentLine)
+ }
+ }}
+ accessibilityLabel={`Add note on line ${commentLine}`}
+ >
+
+
+ ) : null}
+
+ {comments.length > 0 ? (
+
+ {comments.map((comment) => (
+
+
+
+ Line {comment.lineNumber}
+ onDeleteComment(comment.id)}
+ accessibilityLabel={`Delete note on line ${comment.lineNumber}`}
+ >
+
+
+
+ {comment.body}
+
+ ))}
+
+ ) : null}
+ {isCommenting ? (
+
+
+
+
+ Cancel
+
+ {
+ if (commentLine !== undefined) {
+ onSubmitComment(commentLine)
+ }
+ }}
+ >
+ Save note
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/mobile/src/session/mobile-clipboard-image.test.ts b/mobile/src/session/mobile-clipboard-image.test.ts
index cc563127631..c5b0af656fc 100644
--- a/mobile/src/session/mobile-clipboard-image.test.ts
+++ b/mobile/src/session/mobile-clipboard-image.test.ts
@@ -8,7 +8,7 @@ import {
saveMobileClipboardImageAsTempFile
} from './mobile-clipboard-image'
import type { RpcClient } from '../transport/rpc-client'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
function ok(id: string, result: unknown): RpcSuccess {
diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts
index 578dabefcb9..91355c998c6 100644
--- a/mobile/src/session/mobile-clipboard-image.ts
+++ b/mobile/src/session/mobile-clipboard-image.ts
@@ -1,5 +1,5 @@
import type { RpcClient } from '../transport/rpc-client'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcFailure, RpcSuccess } from '../transport/types'
export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024
diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts
index cc70e2e3b0f..127f33879db 100644
--- a/mobile/src/session/mobile-native-chat-send.test.ts
+++ b/mobile/src/session/mobile-native-chat-send.test.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import {
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
openMobileNativeChatSendBudget,
diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts
index 28f91759168..bb7a5dab0df 100644
--- a/mobile/src/session/mobile-native-chat-send.ts
+++ b/mobile/src/session/mobile-native-chat-send.ts
@@ -1,6 +1,6 @@
import type { RpcClient } from '../transport/rpc-client'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing'
diff --git a/mobile/src/session/mobile-session-tab-activation.test.ts b/mobile/src/session/mobile-session-tab-activation.test.ts
index 8c490dffca5..188d0630a9b 100644
--- a/mobile/src/session/mobile-session-tab-activation.test.ts
+++ b/mobile/src/session/mobile-session-tab-activation.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
diff --git a/mobile/src/session/mobile-session-tab-activation.ts b/mobile/src/session/mobile-session-tab-activation.ts
index ff8353459b4..c78464b8045 100644
--- a/mobile/src/session/mobile-session-tab-activation.ts
+++ b/mobile/src/session/mobile-session-tab-activation.ts
@@ -1,6 +1,6 @@
import type { TabActivationIntent } from '../../../src/shared/tab-activation-intent'
import type { RpcClient } from '../transport/rpc-client'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcResponse } from '../transport/types'
import {
getMobileTerminalDiagnosticErrorName,
diff --git a/mobile/src/session/mobile-structured-outbox-entry.ts b/mobile/src/session/mobile-structured-outbox-entry.ts
index 91871f679cd..d60371196eb 100644
--- a/mobile/src/session/mobile-structured-outbox-entry.ts
+++ b/mobile/src/session/mobile-structured-outbox-entry.ts
@@ -9,7 +9,7 @@ import {
} from '../../../src/shared/structured-agent-session-outbox'
import { createStructuredAgentSessionOperationId } from '../../../src/shared/structured-agent-session-mutation'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
export {
createStructuredAgentSessionOutboxEntry as createMobileStructuredOutboxEntry,
diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts
index 871d221abb2..4460cbd3720 100644
--- a/mobile/src/session/use-mobile-native-chat-stop.ts
+++ b/mobile/src/session/use-mobile-native-chat-stop.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
import { openMobileNativeChatSendBudget } from './mobile-native-chat-send'
diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts
index 0a0c4a470c0..d5d8a00753f 100644
--- a/mobile/src/session/use-mobile-structured-session-mutations.ts
+++ b/mobile/src/session/use-mobile-structured-session-mutations.ts
@@ -17,7 +17,7 @@ import {
} from '../../../src/shared/structured-agent-session-mutation'
import type { RpcClient } from '../transport/rpc-client'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
export type MobileStructuredPromptItem = AgentJournalRenderItem & {
body: AgentJournalApprovalItem | AgentJournalQuestionItem
diff --git a/mobile/src/session/use-quick-commands.test.ts b/mobile/src/session/use-quick-commands.test.ts
index b375a20bd97..e3402dc535e 100644
--- a/mobile/src/session/use-quick-commands.test.ts
+++ b/mobile/src/session/use-quick-commands.test.ts
@@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import type { RpcClient } from '../transport/rpc-client'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcResponse } from '../transport/types'
import { useQuickCommands } from './use-quick-commands'
diff --git a/mobile/src/session/use-quick-commands.ts b/mobile/src/session/use-quick-commands.ts
index 6537551be7a..4c70a62126d 100644
--- a/mobile/src/session/use-quick-commands.ts
+++ b/mobile/src/session/use-quick-commands.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import type { TerminalQuickCommand } from '../../../src/shared/terminal-quick-command-types'
import {
diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts
index 8b1e55efcae..5fc21606f2b 100644
--- a/mobile/src/tasks/worktree-create-capability.test.ts
+++ b/mobile/src/tasks/worktree-create-capability.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts
index 6083120c7a4..515708438c6 100644
--- a/mobile/src/tasks/worktree-create-capability.ts
+++ b/mobile/src/tasks/worktree-create-capability.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { RpcSuccess } from '../transport/types'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts
index 35fa050b620..74e7806a64f 100644
--- a/mobile/src/tasks/worktree-create-retry.test.ts
+++ b/mobile/src/tasks/worktree-create-retry.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
-import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import { createWorktreeWithNameRetry } from './worktree-create-retry'
type Attempt = { method: string; params: Record }
diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts
index 6cee659b89f..646c14f69ff 100644
--- a/mobile/src/tasks/worktree-create-retry.ts
+++ b/mobile/src/tasks/worktree-create-retry.ts
@@ -1,6 +1,6 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
diff --git a/mobile/src/transport/logical-client-cutover-error.ts b/mobile/src/transport/logical-client-cutover-error.ts
new file mode 100644
index 00000000000..3548eea1589
--- /dev/null
+++ b/mobile/src/transport/logical-client-cutover-error.ts
@@ -0,0 +1,17 @@
+// The error a pending RPC is rejected with when the logical client swaps physical sessions
+// mid-flight. Its own module so every caller that retries on a cutover can import it without
+// pulling in the client itself — several of them are imported BY the client.
+
+export class LogicalClientCutoverError extends Error {
+ constructor() {
+ super('RPC interrupted by connection migration')
+ }
+}
+
+// Why: instanceof can miss across bundle copies, so also match by message.
+export function isLogicalClientCutoverError(error: unknown): boolean {
+ return (
+ error instanceof LogicalClientCutoverError ||
+ (error instanceof Error && error.message === 'RPC interrupted by connection migration')
+ )
+}
diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts
index 7ef3e2f8e71..ef6098fc142 100644
--- a/mobile/src/transport/mobile-relay-reconnect-controller.ts
+++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts
@@ -207,7 +207,9 @@ export class RelayReconnectController {
return
}
const failure = this.activeSession?.getFailure()
- this.consecutiveFailures = relayStructuredFailures(this.consecutiveFailures, this.activeSession)
+ this.failureCount.recomputeTo(
+ relayStructuredFailures(this.failureCount.current(), this.activeSession)
+ )
this.activeSession = null
if (failure) {
// Why: active relay closes need the same cooldown as failed replacement dials.
diff --git a/mobile/src/transport/relay-recovery-failure-count.ts b/mobile/src/transport/relay-recovery-failure-count.ts
index 1bbadbcd400..a2cafa1fb4d 100644
--- a/mobile/src/transport/relay-recovery-failure-count.ts
+++ b/mobile/src/transport/relay-recovery-failure-count.ts
@@ -26,6 +26,13 @@ export class RelayRecoveryFailureCount {
this.update(0)
}
+ /** A structured reconnect signal recomputes the streak rather than adding to it: a stream that
+ * proved long-lived retires it, a background restart floors it at one. Routed through `update`
+ * so the recovery-attempt reporter sees the change. */
+ recomputeTo(count: number): void {
+ this.update(Math.max(0, count))
+ }
+
private update(count: number): void {
if (this.count === count) {
return
diff --git a/mobile/src/transport/runtime-capability-probe.test.ts b/mobile/src/transport/runtime-capability-probe.test.ts
index 2272c25610f..039efc0e2ce 100644
--- a/mobile/src/transport/runtime-capability-probe.test.ts
+++ b/mobile/src/transport/runtime-capability-probe.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startRuntimeCapabilityProbe } from './runtime-capability-probe'
-import { LogicalClientCutoverError } from './stable-logical-rpc-client'
+import { LogicalClientCutoverError } from './logical-client-cutover-error'
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
diff --git a/mobile/src/transport/runtime-capability-probe.ts b/mobile/src/transport/runtime-capability-probe.ts
index ef636552863..8ecc992a1bb 100644
--- a/mobile/src/transport/runtime-capability-probe.ts
+++ b/mobile/src/transport/runtime-capability-probe.ts
@@ -1,6 +1,6 @@
import type { RpcClient } from './rpc-client'
import type { RpcSuccess } from './types'
-import { isLogicalClientCutoverError } from './stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from './logical-client-cutover-error'
// Why: a relay→direct cutover or request timeout can reject an in-flight
// status.get without ever changing connState, so a one-shot probe would latch
diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts
index f7526683d03..1701e5377b5 100644
--- a/mobile/src/transport/stable-logical-rpc-client.test.ts
+++ b/mobile/src/transport/stable-logical-rpc-client.test.ts
@@ -2,11 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import type { ConnectionState, RpcResponse } from './types'
import type { RpcClient } from './rpc-client'
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
-import {
- createStableLogicalRpcClient,
- LogicalClientCutoverError,
- type MobileConnectionPath
-} from './stable-logical-rpc-client'
+import { createStableLogicalRpcClient, type MobileConnectionPath } from './stable-logical-rpc-client'
+import { LogicalClientCutoverError } from './logical-client-cutover-error'
class FakeSession implements RpcClient {
readonly sendRequest =
diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts
index edcc6cc8de9..48eb4d43d58 100644
--- a/mobile/src/transport/stable-logical-rpc-client.ts
+++ b/mobile/src/transport/stable-logical-rpc-client.ts
@@ -7,23 +7,10 @@ import {
import { waitForAuthenticated } from './replacement-session-authentication'
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
+import { LogicalClientCutoverError } from './logical-client-cutover-error'
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
-export class LogicalClientCutoverError extends Error {
- constructor() {
- super('RPC interrupted by connection migration')
- }
-}
-
-// Why: instanceof can miss across bundle copies, so also match by message.
-export function isLogicalClientCutoverError(error: unknown): boolean {
- return (
- error instanceof LogicalClientCutoverError ||
- (error instanceof Error && error.message === 'RPC interrupted by connection migration')
- )
-}
-
type SubscriptionRecord = {
method: string
params: unknown
diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts
index 5799de96366..4e6c6d23583 100644
--- a/mobile/src/worktree/home-host-worktree-fetch.test.ts
+++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts
@@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest'
import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate'
import type { RpcClient } from '../transport/rpc-client'
-import {
- createStableLogicalRpcClient,
- LogicalClientCutoverError
-} from '../transport/stable-logical-rpc-client'
+import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client'
+import { LogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import type { ConnectionState, RpcResponse } from '../transport/types'
import { fetchHomeHostWorktreeInfo } from './home-host-worktree-fetch'
import type { HostWorktreeInfo } from './home-worktree-info'
diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts
index 72b9e572ba1..88354dd5015 100644
--- a/mobile/src/worktree/home-host-worktree-fetch.ts
+++ b/mobile/src/worktree/home-host-worktree-fetch.ts
@@ -1,7 +1,7 @@
import { setCachedWorktrees } from '../cache/worktree-cache'
import { sendSingleFlightRequest } from '../transport/request-single-flight'
import type { RpcClient } from '../transport/rpc-client'
-import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { isLogicalClientCutoverError } from '../transport/logical-client-cutover-error'
import {
markHomeWorktreeCatalogUnavailable,
type HomeWorktreeSummary,
diff --git a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts
index 9811773eb07..c7be075074c 100644
--- a/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts
+++ b/src/main/agent-hooks/restored-subagent-liveness-sweep.test.ts
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { AgentSubagentSnapshot } from '../../shared/agent-status-types'
+import type { AgentSubagentSnapshot } from '../../shared/agent-subagent-snapshot'
import { makePaneKey } from '../../shared/stable-pane-id'
import { toAppSshPtyId } from '../../shared/ssh-pty-id'
import { AgentHookServer } from './server'
diff --git a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts
index 4b1c9196878..3caca081b55 100644
--- a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts
+++ b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-subagent-snapshot'
import { AgentHookServer, _internals } from './server'
import { buildBody, PANE } from './server.test-fixtures'
diff --git a/src/relay/agent-hook-envelope-publication.test.ts b/src/relay/agent-hook-envelope-publication.test.ts
index 164c36f9c7a..7f94e93eeba 100644
--- a/src/relay/agent-hook-envelope-publication.test.ts
+++ b/src/relay/agent-hook-envelope-publication.test.ts
@@ -6,7 +6,7 @@ import {
createShedSubagentsField
} from '../shared/agent-hook-relay'
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
-import type { AgentSubagentSnapshot } from '../shared/agent-status-types'
+import type { AgentSubagentSnapshot } from '../shared/agent-subagent-snapshot'
type BoundedClient = {
frames: Buffer[]
diff --git a/src/relay/relay-oversized-notification-survival.test.ts b/src/relay/relay-oversized-notification-survival.test.ts
index 41d3dd833e5..19af0be47b4 100644
--- a/src/relay/relay-oversized-notification-survival.test.ts
+++ b/src/relay/relay-oversized-notification-survival.test.ts
@@ -10,12 +10,8 @@ import { MAX_BATCHED_WATCHER_EVENTS } from '../main/ipc/filesystem-watcher-event
import { emitRelayWatcherEvents } from './relay-watcher-event-emitter'
import { publishAgentHookEnvelope } from './agent-hook-envelope-publication'
import type { WatcherProcessEvent } from '../main/ipc/parcel-watcher-process'
-import {
- AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
- AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
- AGENT_STATUS_MAX_SUBAGENTS,
- normalizeAgentStatusPayload
-} from '../shared/agent-status-types'
+import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, normalizeAgentStatusPayload } from '../shared/agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from '../shared/agent-subagent-snapshot'
// The relay runs on the REMOTE host, so the stream default is that host's Node major.
const NODE22_HWM = 64 * 1024 // Node >= 22 default
diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts
index 095e01983c3..a3574e1842c 100644
--- a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts
+++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts
@@ -13,7 +13,7 @@
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
-import { isCommandMarkerId } from './native-chat-pending'
+import { isCommandMarkerId } from './native-chat-command-marker'
/** A row carrying only tool activity — no prose. It is progress, not an answer. */
function isToolActivityOnlyRow(message: NativeChatMessage): boolean {
diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts
index e55ddcf57ce..282dc492713 100644
--- a/src/renderer/src/store/slices/agent-status.ts
+++ b/src/renderer/src/store/slices/agent-status.ts
@@ -1,17 +1,8 @@
/* eslint-disable max-lines -- Why: the agent-status slice co-locates live map, retained snapshots, retention-suppression, and tab-prefix sweep so the teardown contract stays readable end-to-end. Splitting across files would scatter the drop/remove/retain interactions that must stay in lockstep. */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
-import {
- AGENT_STATUS_STALE_AFTER_MS,
- AGENT_STATE_HISTORY_MAX,
- agentSubagentsEqual,
- type AgentStateHistoryEntry,
- type AgentStatusEntry,
- type AgentStatusOrchestrationContext,
- type AgentType,
- type MigrationUnsupportedPtyEntry,
- type ParsedAgentStatusPayload
-} from '../../../../shared/agent-status-types'
+import { AGENT_STATUS_STALE_AFTER_MS, AGENT_STATE_HISTORY_MAX, type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusOrchestrationContext, type AgentType, type MigrationUnsupportedPtyEntry, type ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
+import { agentSubagentsEqual } from '../../../../shared/agent-subagent-snapshot'
import type { AgentStatusObservation } from '../../../../shared/agent-status-observation'
import { rendererAgentStatusObservations } from '../../lib/renderer-agent-status-observations'
import {
diff --git a/src/shared/agent-hook-listener-claude-subagents.test.ts b/src/shared/agent-hook-listener-claude-subagents.test.ts
index 5278247cf8d..d8b204fa5fa 100644
--- a/src/shared/agent-hook-listener-claude-subagents.test.ts
+++ b/src/shared/agent-hook-listener-claude-subagents.test.ts
@@ -9,7 +9,7 @@ import {
type HookListenerState
} from './agent-hook-listener'
import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths'
-import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot'
import { makePaneKey } from './stable-pane-id'
import { PANE_KEY } from './agent-hook-listener-test-harness'
diff --git a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts
index aebade35d6c..8fdbbde2a8b 100644
--- a/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts
+++ b/src/shared/agent-hook-listener-startless-child-lifecycle.test.ts
@@ -5,7 +5,7 @@ import {
seedClaudeSubagentRosterFromSnapshots,
type HookListenerState
} from './agent-hook-listener'
-import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot'
import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts
index da80e171841..2110080223f 100644
--- a/src/shared/agent-hook-listener.ts
+++ b/src/shared/agent-hook-listener.ts
@@ -17,13 +17,8 @@ import {
} from 'node:fs'
import { isAbsolute, join } from 'node:path'
-import {
- AGENT_MODEL_MAX_LENGTH,
- normalizeAgentStatusPayload,
- type AgentStatusState,
- type AgentSubagentSnapshot,
- type ParsedAgentStatusPayload
-} from './agent-status-types'
+import { AGENT_MODEL_MAX_LENGTH, normalizeAgentStatusPayload, type AgentStatusState, type ParsedAgentStatusPayload } from './agent-status-types'
+import type { AgentSubagentSnapshot } from './agent-subagent-snapshot'
import { normalizeOptionalField } from './agent-status-field-normalization'
import { isAskUserQuestionTool } from './agent-question-answered-intent'
import {
diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts
index 0ea80999766..420b1e59f78 100644
--- a/src/shared/agent-hook-relay.ts
+++ b/src/shared/agent-hook-relay.ts
@@ -24,7 +24,8 @@
import { createHash } from 'node:crypto'
-import type { AgentSubagentSnapshot, ParsedAgentStatusPayload } from './agent-status-types'
+import type { ParsedAgentStatusPayload } from './agent-status-types'
+import type { AgentSubagentSnapshot } from './agent-subagent-snapshot'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { AgentHookTarget } from './agent-hook-types'
diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts
index f82932f3aeb..06370b52cc8 100644
--- a/src/shared/agent-status-types.test.ts
+++ b/src/shared/agent-status-types.test.ts
@@ -1,20 +1,6 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
-import {
- agentSubagentsEqual,
- isFreshNonDoneAgentStatus,
- parseAgentStatusPayload,
- normalizeAgentStatusPayload,
- pickParsedAgentStatusPayload,
- AGENT_STATUS_JSON_STRUCTURE_LIMITS,
- AGENT_STATUS_MAX_FIELD_LENGTH,
- AGENT_STATUS_MAX_SUBAGENTS,
- AGENT_STATUS_TOOL_NAME_MAX_LENGTH,
- AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
- AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
- AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
- AGENT_STATUS_STATES,
- AGENT_TYPE_MAX_LENGTH
-} from './agent-status-types'
+import { isFreshNonDoneAgentStatus, parseAgentStatusPayload, normalizeAgentStatusPayload, pickParsedAgentStatusPayload, AGENT_STATUS_JSON_STRUCTURE_LIMITS, AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_TOOL_NAME_MAX_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH, AGENT_STATUS_STATES, AGENT_TYPE_MAX_LENGTH } from './agent-status-types'
+import { agentSubagentsEqual, AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot'
afterEach(() => {
vi.restoreAllMocks()
diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts
index 4b56cb38bf2..e82e8a87727 100644
--- a/src/shared/agent-status-types.ts
+++ b/src/shared/agent-status-types.ts
@@ -15,6 +15,8 @@ import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit
export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
+import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-subagent-snapshot'
+
export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const
export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number]
// Why: agent types aren't a fixed set (custom agents exist); any non-empty string is
@@ -75,22 +77,6 @@ export type AgentStatusOrchestrationContext = {
orchestrationRunId?: string
}
-export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle'
-
-/** A live in-process child of the pane's provider session. Rendered as an
- * indented child row with no PTY of its own. */
-export type AgentSubagentSnapshot = {
- /** Provider-assigned lifecycle id. */
- id: string
- agentType?: string
- /** Provider model used by this child, when exposed by its lifecycle event. */
- model?: string
- description?: string
- state: AgentSubagentState
- /** Timestamp (ms) when this subagent was first observed. */
- startedAt: number
-}
-
export type AgentStatusEntry = {
state: AgentStatusState
/** The user's most recent prompt. Cached across the turn — later tool-use events
@@ -302,9 +288,6 @@ const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES)
export const AGENT_TYPE_MAX_LENGTH = 40
export const AGENT_MODEL_MAX_LENGTH = 120
-/** Maximum subagent child rows carried per status entry. Bounds per-pane cache
- * and IPC fanout against a runaway spawner. */
-export const AGENT_STATUS_MAX_SUBAGENTS = 32
export const AGENT_STATUS_JSON_STRUCTURE_LIMITS = {
structuralTokens: 4096,
nestingDepth: 16
@@ -359,35 +342,6 @@ function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | unde
return normalized.length > 0 ? normalized : undefined
}
-/** Structural equality for subagent lists so stores can reuse the previous
- * array reference (and skip fanout) when nothing actually changed. */
-export function agentSubagentsEqual(
- a: AgentSubagentSnapshot[] | undefined,
- b: AgentSubagentSnapshot[] | undefined
-): boolean {
- if (a === b) {
- return true
- }
- if (!a || !b || a.length !== b.length) {
- return !a && !b
- }
- for (let i = 0; i < a.length; i++) {
- const x = a[i]
- const y = b[i]
- if (
- x.id !== y.id ||
- x.state !== y.state ||
- x.startedAt !== y.startedAt ||
- x.agentType !== y.agentType ||
- x.model !== y.model ||
- x.description !== y.description
- ) {
- return false
- }
- }
- return true
-}
-
/**
* Normalize and validate an already-parsed agent status object. Shared by the
* JSON string entry point (`parseAgentStatusPayload`) and the object entry
diff --git a/src/shared/agent-subagent-snapshot.ts b/src/shared/agent-subagent-snapshot.ts
new file mode 100644
index 00000000000..67564a57831
--- /dev/null
+++ b/src/shared/agent-subagent-snapshot.ts
@@ -0,0 +1,52 @@
+// The subagent roster carried on a status entry: a provider session's in-process children, which
+// have no PTY of their own. Separate from `agent-status-types` so the entry's own shape and this
+// roster can each grow without the two colliding in one file.
+
+export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle'
+
+/** A live in-process child of the pane's provider session. Rendered as an
+ * indented child row with no PTY of its own. */
+export type AgentSubagentSnapshot = {
+ /** Provider-assigned lifecycle id. */
+ id: string
+ agentType?: string
+ /** Provider model used by this child, when exposed by its lifecycle event. */
+ model?: string
+ description?: string
+ state: AgentSubagentState
+ /** Timestamp (ms) when this subagent was first observed. */
+ startedAt: number
+}
+
+/** Maximum subagent child rows carried per status entry. Bounds per-pane cache
+ * and IPC fanout against a runaway spawner. */
+export const AGENT_STATUS_MAX_SUBAGENTS = 32
+
+/** Structural equality for subagent lists so stores can reuse the previous
+ * array reference (and skip fanout) when nothing actually changed. */
+export function agentSubagentsEqual(
+ a: AgentSubagentSnapshot[] | undefined,
+ b: AgentSubagentSnapshot[] | undefined
+): boolean {
+ if (a === b) {
+ return true
+ }
+ if (!a || !b || a.length !== b.length) {
+ return !a && !b
+ }
+ for (let i = 0; i < a.length; i++) {
+ const x = a[i]
+ const y = b[i]
+ if (
+ x.id !== y.id ||
+ x.state !== y.state ||
+ x.startedAt !== y.startedAt ||
+ x.agentType !== y.agentType ||
+ x.model !== y.model ||
+ x.description !== y.description
+ ) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/src/shared/claude-background-task-inventory.ts b/src/shared/claude-background-task-inventory.ts
index 33e38f64dfd..a26292b47af 100644
--- a/src/shared/claude-background-task-inventory.ts
+++ b/src/shared/claude-background-task-inventory.ts
@@ -1,4 +1,4 @@
-import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot'
const CLAUDE_TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
'idle',
diff --git a/src/shared/claude-background-task-status.test.ts b/src/shared/claude-background-task-status.test.ts
index ecf685706fe..bca10a077c2 100644
--- a/src/shared/claude-background-task-status.test.ts
+++ b/src/shared/claude-background-task-status.test.ts
@@ -9,7 +9,7 @@ import {
seedClaudeSubagentRosterFromSnapshots,
type HookListenerState
} from './agent-hook-listener'
-import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot'
import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory'
import { makePaneKey } from './stable-pane-id'
diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts
index df327cde829..30ba8bd6800 100644
--- a/src/shared/claude-subagent-roster.test.ts
+++ b/src/shared/claude-subagent-roster.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot'
import { readClaudeBackgroundAgentTasks } from './claude-background-task-inventory'
import {
claudeRosterHasRuntimeWorkingSubagent,
diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts
index 7891d19e767..5d767700cac 100644
--- a/src/shared/claude-subagent-roster.ts
+++ b/src/shared/claude-subagent-roster.ts
@@ -1,4 +1,4 @@
-import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-subagent-snapshot'
import type { ClaudeBackgroundAgentTask } from './claude-background-task-inventory'
/** Mirrors the wire-normalization id cap in agent-status-types. Enforced at
diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts
index 0cbb44830c9..a19f636aec4 100644
--- a/src/shared/codex-subagent-roster.test.ts
+++ b/src/shared/codex-subagent-roster.test.ts
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest'
-import {
- AGENT_MODEL_MAX_LENGTH,
- AGENT_STATUS_MAX_SUBAGENTS,
- AGENT_TYPE_MAX_LENGTH
-} from './agent-status-types'
+import { AGENT_MODEL_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS } from './agent-subagent-snapshot'
import {
codexRosterToSnapshots,
finishCodexSubagent,
diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts
index 5592ed8711f..1929f5676f4 100644
--- a/src/shared/codex-subagent-roster.ts
+++ b/src/shared/codex-subagent-roster.ts
@@ -1,10 +1,5 @@
-import {
- AGENT_MODEL_MAX_LENGTH,
- AGENT_STATUS_MAX_SUBAGENTS,
- AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
- AGENT_TYPE_MAX_LENGTH,
- type AgentSubagentSnapshot
-} from './agent-status-types'
+import { AGENT_MODEL_MAX_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types'
+import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-subagent-snapshot'
import { normalizeOptionalField } from './agent-status-field-normalization'
const CODEX_SUBAGENT_ID_MAX_LENGTH = 64
diff --git a/src/shared/tui-agent-startup-hermes.test.ts b/src/shared/tui-agent-startup-hermes.test.ts
new file mode 100644
index 00000000000..bcf5f43750c
--- /dev/null
+++ b/src/shared/tui-agent-startup-hermes.test.ts
@@ -0,0 +1,230 @@
+import { describe, expect, it } from 'vitest'
+import { buildAgentStartupPlan } from './tui-agent-startup'
+import {
+ unwrapPosixShellScript,
+ unwrapPowerShellScript
+} from './tui-agent-startup-script.test-fixture'
+
+// Hermes is the only agent whose launch Orca rewrites token by token — it owns the startup query,
+// the TUI mode, and where an override's flags may sit relative to the chat subcommand. Its cases
+// outgrew the general startup-plan file.
+
+describe('hermes startup plans', () => {
+ it('moves Hermes command override flags after the chat subcommand', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run privately',
+ cmdOverrides: { hermes: 'hermes --tui --provider anthropic' },
+ agentArgs: '--yolo',
+ platform: 'linux'
+ })
+
+ const script = unwrapPosixShellScript(plan?.launchCommand)
+ expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'")
+ expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately')
+ })
+
+ it.each([
+ {
+ shell: 'powershell' as const,
+ override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui',
+ expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'"
+ },
+ {
+ shell: 'cmd' as const,
+ override: 'C:\\Tools\\hermes.exe --tui',
+ expected: "& 'C:\\Tools\\hermes.exe' 'chat'"
+ }
+ ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: testCase.override },
+ platform: 'win32',
+ shell: testCase.shell
+ })
+
+ expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected)
+ })
+
+ it('removes a configured duplicate chat subcommand', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' },
+ agentArgs: '--provider copilot chat --yolo',
+ platform: 'linux'
+ })
+
+ const script = unwrapPosixShellScript(plan?.launchCommand)
+ expect(script.match(/'chat'/g)).toHaveLength(1)
+ expect(script).toContain("'--provider' 'copilot' '--yolo'")
+ })
+
+ it('preserves an option value named chat', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'hermes --profile chat --tui' },
+ platform: 'linux'
+ })
+
+ expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'")
+ })
+
+ it('keeps Orca ownership of the Hermes startup query and TUI mode', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'automation prompt',
+ cmdOverrides: { hermes: 'hermes --query override --cli' },
+ agentArgs: '-q=second-override --query=third-override -qfourth-override --tui',
+ platform: 'linux'
+ })
+
+ const script = unwrapPosixShellScript(plan?.launchCommand)
+ expect(script.match(/--query=/g)).toHaveLength(1)
+ expect(script).not.toContain('override')
+ expect(script).not.toContain("'--cli'")
+ expect(script.match(/'--tui'/g)).toHaveLength(1)
+ expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt')
+ })
+
+ it('preserves wrapper tokens before the Hermes executable', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'uv run hermes --tui' },
+ platform: 'linux'
+ })
+
+ expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'")
+ })
+
+ it('selects the final Hermes executable token in a wrapper', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' },
+ platform: 'linux'
+ })
+
+ expect(unwrapPosixShellScript(plan?.launchCommand)).toContain(
+ "'sudo' '-u' 'hermes' 'hermes' 'chat'"
+ )
+ })
+
+ it('selects the wrapped executable when the wrapper and command both name Hermes', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' },
+ platform: 'linux'
+ })
+
+ const script = unwrapPosixShellScript(plan?.launchCommand)
+ expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'")
+ expect(script.match(/'chat'/g)).toHaveLength(1)
+ })
+
+ it('does not mistake a Hermes option value for a wrapped executable', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' },
+ platform: 'linux'
+ })
+
+ const script = unwrapPosixShellScript(plan?.launchCommand)
+ expect(script.match(/'chat'/g)).toHaveLength(1)
+ expect(script).toContain("'--resume' 'hermes'")
+ })
+
+ it('preserves POSIX environment-assignment command prefixes', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' },
+ platform: 'linux'
+ })
+
+ expect(unwrapPosixShellScript(plan?.launchCommand)).toContain(
+ "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'"
+ )
+ })
+
+ it('rejects a Hermes command override with no identifiable executable', () => {
+ expect(
+ buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run it',
+ cmdOverrides: { hermes: 'custom-agent --tui' },
+ platform: 'linux'
+ })
+ ).toBeNull()
+ })
+
+ it('rejects Hermes queries that exceed the safe Windows environment limit', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'x'.repeat(24_000),
+ cmdOverrides: {},
+ platform: 'win32'
+ })
+
+ expect(plan).toBeNull()
+ })
+
+ it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])(
+ 'keeps a complex cmd Hermes query out of command text: %s',
+ (prompt) => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt,
+ cmdOverrides: {},
+ platform: 'win32',
+ shell: 'cmd'
+ })
+
+ expect(plan?.launchCommand).not.toContain(prompt)
+ expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt)
+ }
+ )
+
+ it('uses the Windows remote default shell for SSH Hermes queries', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: 'run remotely',
+ cmdOverrides: {},
+ platform: 'win32',
+ isRemote: true
+ })
+
+ expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(
+ "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'"
+ )
+ })
+
+ it('measures POSIX Hermes query limits in UTF-8 bytes', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: '界'.repeat(50_000),
+ cmdOverrides: {},
+ platform: 'linux'
+ })
+
+ expect(plan).toBeNull()
+ })
+
+ it('keeps empty Hermes launches on the interactive TUI command', () => {
+ const plan = buildAgentStartupPlan({
+ agent: 'hermes',
+ prompt: '',
+ cmdOverrides: {},
+ platform: 'linux',
+ allowEmptyPromptLaunch: true
+ })
+
+ expect(plan?.launchCommand).toBe('hermes --tui')
+ expect(plan?.followupPrompt).toBeNull()
+ })
+})
diff --git a/src/shared/tui-agent-startup-script.test-fixture.ts b/src/shared/tui-agent-startup-script.test-fixture.ts
new file mode 100644
index 00000000000..b76197edcaa
--- /dev/null
+++ b/src/shared/tui-agent-startup-script.test-fixture.ts
@@ -0,0 +1,24 @@
+import { expect } from 'vitest'
+import { tokenizeStartupCommand } from './tui-agent-startup-shell'
+
+// A startup plan's launch command carries the real script encoded for the target shell — octal
+// escapes fed to `printf %b` on POSIX, UTF-16 base64 for PowerShell. Assertions want the script,
+// so these decode it back.
+
+export function unwrapPosixShellScript(command: string | undefined): string {
+ const tokenized = tokenizeStartupCommand(command ?? '', 'posix')
+ expect(tokenized.ok).toBe(true)
+ const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : ''
+ const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1]
+ if (!encoded) {
+ return wrapper
+ }
+ const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8))
+ return new TextDecoder().decode(new Uint8Array(bytes))
+}
+
+export function unwrapPowerShellScript(command: string | undefined): string {
+ const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1]
+ expect(encoded).toBeDefined()
+ return Buffer.from(encoded!, 'base64').toString('utf16le')
+}
diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts
index 44cadb7d914..e5e26f46802 100644
--- a/src/shared/tui-agent-startup.test.ts
+++ b/src/shared/tui-agent-startup.test.ts
@@ -9,24 +9,10 @@ import {
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
-
-function unwrapPosixShellScript(command: string | undefined): string {
- const tokenized = tokenizeStartupCommand(command ?? '', 'posix')
- expect(tokenized.ok).toBe(true)
- const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : ''
- const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1]
- if (!encoded) {
- return wrapper
- }
- const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8))
- return new TextDecoder().decode(new Uint8Array(bytes))
-}
-
-function unwrapPowerShellScript(command: string | undefined): string {
- const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1]
- expect(encoded).toBeDefined()
- return Buffer.from(encoded!, 'base64').toString('utf16le')
-}
+import {
+ unwrapPosixShellScript,
+ unwrapPowerShellScript
+} from './tui-agent-startup-script.test-fixture'
describe('draft prefill teardown ordering (#14975)', () => {
// Why pinned: the teardown mutates the calling shell, so it must reference
@@ -212,224 +198,6 @@ describe('tui agent startup plans', () => {
expect(tokens.ok && tokens.tokens.at(-1)).toMatch(/\\0[0-7]{3}/)
})
- it('moves Hermes command override flags after the chat subcommand', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run privately',
- cmdOverrides: { hermes: 'hermes --tui --provider anthropic' },
- agentArgs: '--yolo',
- platform: 'linux'
- })
-
- const script = unwrapPosixShellScript(plan?.launchCommand)
- expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'")
- expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately')
- })
-
- it.each([
- {
- shell: 'powershell' as const,
- override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui',
- expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'"
- },
- {
- shell: 'cmd' as const,
- override: 'C:\\Tools\\hermes.exe --tui',
- expected: "& 'C:\\Tools\\hermes.exe' 'chat'"
- }
- ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: testCase.override },
- platform: 'win32',
- shell: testCase.shell
- })
-
- expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected)
- })
-
- it('removes a configured duplicate chat subcommand', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' },
- agentArgs: '--provider copilot chat --yolo',
- platform: 'linux'
- })
-
- const script = unwrapPosixShellScript(plan?.launchCommand)
- expect(script.match(/'chat'/g)).toHaveLength(1)
- expect(script).toContain("'--provider' 'copilot' '--yolo'")
- })
-
- it('preserves an option value named chat', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'hermes --profile chat --tui' },
- platform: 'linux'
- })
-
- expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'")
- })
-
- it('keeps Orca ownership of the Hermes startup query and TUI mode', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'automation prompt',
- cmdOverrides: { hermes: 'hermes --query override --cli' },
- agentArgs: '-q=second-override --query=third-override -qfourth-override --tui',
- platform: 'linux'
- })
-
- const script = unwrapPosixShellScript(plan?.launchCommand)
- expect(script.match(/--query=/g)).toHaveLength(1)
- expect(script).not.toContain('override')
- expect(script).not.toContain("'--cli'")
- expect(script.match(/'--tui'/g)).toHaveLength(1)
- expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt')
- })
-
- it('preserves wrapper tokens before the Hermes executable', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'uv run hermes --tui' },
- platform: 'linux'
- })
-
- expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'")
- })
-
- it('selects the final Hermes executable token in a wrapper', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' },
- platform: 'linux'
- })
-
- expect(unwrapPosixShellScript(plan?.launchCommand)).toContain(
- "'sudo' '-u' 'hermes' 'hermes' 'chat'"
- )
- })
-
- it('selects the wrapped executable when the wrapper and command both name Hermes', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' },
- platform: 'linux'
- })
-
- const script = unwrapPosixShellScript(plan?.launchCommand)
- expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'")
- expect(script.match(/'chat'/g)).toHaveLength(1)
- })
-
- it('does not mistake a Hermes option value for a wrapped executable', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' },
- platform: 'linux'
- })
-
- const script = unwrapPosixShellScript(plan?.launchCommand)
- expect(script.match(/'chat'/g)).toHaveLength(1)
- expect(script).toContain("'--resume' 'hermes'")
- })
-
- it('preserves POSIX environment-assignment command prefixes', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' },
- platform: 'linux'
- })
-
- expect(unwrapPosixShellScript(plan?.launchCommand)).toContain(
- "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'"
- )
- })
-
- it('rejects a Hermes command override with no identifiable executable', () => {
- expect(
- buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run it',
- cmdOverrides: { hermes: 'custom-agent --tui' },
- platform: 'linux'
- })
- ).toBeNull()
- })
-
- it('rejects Hermes queries that exceed the safe Windows environment limit', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'x'.repeat(24_000),
- cmdOverrides: {},
- platform: 'win32'
- })
-
- expect(plan).toBeNull()
- })
-
- it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])(
- 'keeps a complex cmd Hermes query out of command text: %s',
- (prompt) => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt,
- cmdOverrides: {},
- platform: 'win32',
- shell: 'cmd'
- })
-
- expect(plan?.launchCommand).not.toContain(prompt)
- expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt)
- }
- )
-
- it('uses the Windows remote default shell for SSH Hermes queries', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: 'run remotely',
- cmdOverrides: {},
- platform: 'win32',
- isRemote: true
- })
-
- expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(
- "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'"
- )
- })
-
- it('measures POSIX Hermes query limits in UTF-8 bytes', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: '界'.repeat(50_000),
- cmdOverrides: {},
- platform: 'linux'
- })
-
- expect(plan).toBeNull()
- })
-
- it('keeps empty Hermes launches on the interactive TUI command', () => {
- const plan = buildAgentStartupPlan({
- agent: 'hermes',
- prompt: '',
- cmdOverrides: {},
- platform: 'linux',
- allowEmptyPromptLaunch: true
- })
-
- expect(plan?.launchCommand).toBe('hermes --tui')
- expect(plan?.followupPrompt).toBeNull()
- })
-
it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => {
const plan = buildAgentStartupPlan({
agent: 'codex',