feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)

Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
Cheng Zi Feng
2026-04-17 16:21:27 +08:00
committed by GitHub
parent b5c299f5d2
commit 72a2093cd6
64 changed files with 4138 additions and 312 deletions

View File

@@ -0,0 +1,17 @@
import { describe, expect, test } from 'bun:test'
import { isBridgeSafeCommand } from '../commands.js'
import clear from '../commands/clear/index.js'
import plan from '../commands/plan/index.js'
import proactive from '../commands/proactive.js'
describe('isBridgeSafeCommand', () => {
test('allows bridge-safe local-jsx commands', () => {
expect(isBridgeSafeCommand(plan)).toBe(true)
expect(isBridgeSafeCommand(proactive)).toBe(true)
})
test('continues allowing explicit local bridge-safe commands', () => {
expect(isBridgeSafeCommand(clear)).toBe(true)
})
})

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { createAbortController } from '../utils/abortController'
import { QueryGuard } from '../utils/QueryGuard'
import { handlePromptSubmit } from '../utils/handlePromptSubmit'
import { getCommandQueue, resetCommandQueue } from '../utils/messageQueueManager'
function createBaseParams() {
const queryGuard = new QueryGuard()
queryGuard.reserve()
return {
queryGuard,
helpers: {
setCursorOffset: mock((_offset: number) => {}),
clearBuffer: mock(() => {}),
resetHistory: mock(() => {}),
},
onInputChange: mock((_value: string) => {}),
setPastedContents: mock((_value: unknown) => {}),
setToolJSX: mock((_value: unknown) => {}),
getToolUseContext: mock(() => {
throw new Error('getToolUseContext should not be called in queued path')
}),
messages: [],
mainLoopModel: 'claude-sonnet-4-6',
ideSelection: undefined,
querySource: 'repl_main_thread' as any,
commands: [],
setUserInputOnProcessing: mock((_prompt?: string) => {}),
setAbortController: mock((_abortController: AbortController | null) => {}),
onQuery: mock(
async () => undefined,
) as unknown as (
...args: unknown[]
) => Promise<void>,
setAppState: mock((_updater: unknown) => {}),
}
}
describe('handlePromptSubmit', () => {
beforeEach(() => {
resetCommandQueue()
})
test('aborts the current turn when only cancel-interrupt tools are running', async () => {
const params = createBaseParams()
const abortController = createAbortController()
await handlePromptSubmit({
...params,
input: 'hello',
mode: 'prompt',
pastedContents: {},
abortController,
streamMode: 'normal' as any,
hasInterruptibleToolInProgress: true,
isExternalLoading: false,
})
expect(abortController.signal.aborted).toBe(true)
expect(abortController.signal.reason).toBe('interrupt')
expect(getCommandQueue()).toHaveLength(1)
expect(getCommandQueue()[0]).toMatchObject({
value: 'hello',
preExpansionValue: 'hello',
mode: 'prompt',
})
expect(params.onInputChange).toHaveBeenCalledWith('')
})
test('queues the input without aborting when a blocking tool is running', async () => {
const params = createBaseParams()
const abortController = createAbortController()
await handlePromptSubmit({
...params,
input: 'hello',
mode: 'prompt',
pastedContents: {},
abortController,
streamMode: 'normal' as any,
hasInterruptibleToolInProgress: false,
isExternalLoading: false,
})
expect(abortController.signal.aborted).toBe(false)
expect(getCommandQueue()).toHaveLength(1)
expect(getCommandQueue()[0]).toMatchObject({
value: 'hello',
preExpansionValue: 'hello',
mode: 'prompt',
})
})
test('preserves bridgeOrigin when a remote slash command is queued during external loading', async () => {
const params = createBaseParams()
const abortController = createAbortController()
await handlePromptSubmit({
...params,
input: '/proactive',
mode: 'prompt',
pastedContents: {},
abortController,
streamMode: 'normal' as any,
hasInterruptibleToolInProgress: true,
isExternalLoading: true,
skipSlashCommands: true,
bridgeOrigin: true,
})
expect(getCommandQueue()).toHaveLength(1)
expect(getCommandQueue()[0]).toMatchObject({
value: '/proactive',
preExpansionValue: '/proactive',
mode: 'prompt',
skipSlashCommands: true,
bridgeOrigin: true,
})
})
})

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from 'bun:test'
import {
shouldReportRunningForMessage,
shouldReportRunningForMessages,
} from '../bridgeMessaging.js'
import { createUserMessage } from '../../utils/messages.js'
describe('bridge running-state classification', () => {
test('treats real user prompts as turn-starting work', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({ content: 'please inspect the repo' }),
),
).toBe(true)
})
test('keeps tool-result style user messages eligible during mid-turn attach', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({
content: '<local-command-stdout>done</local-command-stdout>',
toolUseResult: { ok: true },
}),
),
).toBe(true)
})
test('ignores local slash-command scaffolding that should not reopen a turn', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({
content:
'<local-command-caveat>Caveat: hidden local command scaffolding</local-command-caveat>',
isMeta: true,
}),
),
).toBe(false)
expect(
shouldReportRunningForMessage(
createUserMessage({
content:
'<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts.\n</system-reminder>',
isMeta: true,
}),
),
).toBe(false)
})
test('still marks real automation triggers as running', () => {
expect(
shouldReportRunningForMessage(
createUserMessage({
content: '<tick>2:56:47 PM</tick>',
isMeta: true,
}),
),
).toBe(true)
expect(
shouldReportRunningForMessage(
createUserMessage({
content: 'scheduled job: refresh analytics cache',
isMeta: true,
}),
),
).toBe(true)
})
test('classifies batches by any work-starting message', () => {
const scaffoldingOnly = [
createUserMessage({
content:
'<local-command-caveat>Caveat: hidden local command scaffolding</local-command-caveat>',
isMeta: true,
}),
createUserMessage({
content:
'<system-reminder>\nProactive mode is now enabled.\n</system-reminder>',
isMeta: true,
}),
]
expect(shouldReportRunningForMessages(scaffoldingOnly)).toBe(false)
expect(
shouldReportRunningForMessages([
...scaffoldingOnly,
createUserMessage({
content: '<tick>2:57:17 PM</tick>',
isMeta: true,
}),
]),
).toBe(true)
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from 'bun:test'
import { parseBridgePermissionResponse } from '../bridgePermissionCallbacks.js'
import type { SDKControlResponse } from '../../entrypoints/sdk/controlTypes.js'
describe('parseBridgePermissionResponse', () => {
test('passes through allow responses', () => {
const message: SDKControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-1',
response: {
behavior: 'allow',
updatedPermissions: [
{ type: 'setMode', mode: 'acceptEdits', destination: 'session' },
],
},
},
}
expect(parseBridgePermissionResponse(message)).toEqual({
behavior: 'allow',
updatedPermissions: [
{ type: 'setMode', mode: 'acceptEdits', destination: 'session' },
],
})
})
test('maps error responses with feedback to deny', () => {
const message = {
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-2',
error: 'Permission denied by user',
response: { behavior: 'deny' },
message: 'Need more detail',
},
} as unknown as SDKControlResponse
expect(parseBridgePermissionResponse(message)).toEqual({
behavior: 'deny',
message: 'Need more detail',
})
})
test('falls back to error text when deny feedback is absent', () => {
const message = {
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-3',
error: 'Permission denied by user',
},
} as unknown as SDKControlResponse
expect(parseBridgePermissionResponse(message)).toEqual({
behavior: 'deny',
message: 'Permission denied by user',
})
})
test('returns null for unrelated control responses', () => {
const message = {
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-4',
error: '',
},
} as unknown as SDKControlResponse
expect(parseBridgePermissionResponse(message)).toBeNull()
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import {
hasPendingBridgeMessages,
isTranscriptResetResultReady,
shouldDeferBridgeResult,
} from '../bridgeResultScheduling.js'
describe('bridgeResultScheduling', () => {
test('detects pending mirrored messages', () => {
expect(hasPendingBridgeMessages(2, 3)).toBe(true)
expect(hasPendingBridgeMessages(3, 3)).toBe(false)
})
test('defers when the bridge handle is unavailable', () => {
expect(
shouldDeferBridgeResult({
hasHandle: false,
isConnected: true,
lastWrittenIndex: 3,
messageCount: 3,
}),
).toBe(true)
})
test('defers when the bridge is connected but transcript flush is pending', () => {
expect(
shouldDeferBridgeResult({
hasHandle: true,
isConnected: true,
lastWrittenIndex: 1,
messageCount: 2,
}),
).toBe(true)
})
test('sends immediately once the latest transcript is already mirrored', () => {
expect(
shouldDeferBridgeResult({
hasHandle: true,
isConnected: true,
lastWrittenIndex: 2,
messageCount: 2,
}),
).toBe(false)
})
test('treats transcript reset as ready only after the transcript is empty', () => {
expect(isTranscriptResetResultReady(true, 0)).toBe(true)
expect(isTranscriptResetResultReady(true, 1)).toBe(false)
expect(isTranscriptResetResultReady(false, 0)).toBe(false)
})
})

View File

@@ -0,0 +1,37 @@
import { feature } from 'bun:bundle'
import { afterEach, describe, expect, test } from 'bun:test'
import { handleRemoteInterrupt } from '../remoteInterruptHandling.js'
import {
activateProactive,
deactivateProactive,
isProactivePaused,
} from '../../proactive/index.js'
function isProactiveFeatureEnabled() {
if (feature('PROACTIVE')) return true
return feature('KAIROS') ? true : false
}
describe('handleRemoteInterrupt', () => {
afterEach(() => {
deactivateProactive()
})
test('always aborts the active request', () => {
const controller = new AbortController()
handleRemoteInterrupt(controller)
expect(controller.signal.aborted).toBe(true)
})
test('pauses proactive mode to return control to the user', () => {
activateProactive('test')
expect(isProactivePaused()).toBe(false)
handleRemoteInterrupt(new AbortController())
expect(isProactivePaused()).toBe(isProactiveFeatureEnabled())
})
})

View File

@@ -28,6 +28,18 @@ import { errorMessage } from '../utils/errors.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { jsonParse } from '../utils/slowOperations.js'
import type { ReplBridgeTransport } from './replBridgeTransport.js'
import {
BASH_INPUT_TAG,
CHANNEL_MESSAGE_TAG,
CROSS_SESSION_MESSAGE_TAG,
LOCAL_COMMAND_CAVEAT_TAG,
REMOTE_REVIEW_PROGRESS_TAG,
REMOTE_REVIEW_TAG,
TASK_NOTIFICATION_TAG,
TEAMMATE_MESSAGE_TAG,
TICK_TAG,
ULTRAPLAN_TAG,
} from '../constants/xml.js'
// ─── Type guards ─────────────────────────────────────────────────────────────
@@ -122,6 +134,85 @@ export function extractTitleText(m: Message): string | undefined {
return clean || undefined
}
const SYSTEM_REMINDER_TAG = 'system-reminder'
const XML_BLOCK_PATTERN =
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy
const RUNNING_STATE_META_TAGS = new Set([
BASH_INPUT_TAG,
CHANNEL_MESSAGE_TAG,
CROSS_SESSION_MESSAGE_TAG,
REMOTE_REVIEW_PROGRESS_TAG,
REMOTE_REVIEW_TAG,
TASK_NOTIFICATION_TAG,
TEAMMATE_MESSAGE_TAG,
TICK_TAG,
ULTRAPLAN_TAG,
])
function extractUserMessageText(message: Message): string {
const content = message.message?.content
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.filter(
(
block,
): block is {
type: 'text'
text: string
} =>
!!block &&
typeof block === 'object' &&
block.type === 'text' &&
typeof block.text === 'string',
)
.map(block => block.text)
.join('')
}
function getEnvelopeTagNames(text: string): string[] | null {
const trimmed = text.trim()
if (!trimmed) return null
XML_BLOCK_PATTERN.lastIndex = 0
const tags: string[] = []
while (XML_BLOCK_PATTERN.lastIndex < trimmed.length) {
const match = XML_BLOCK_PATTERN.exec(trimmed)
if (!match) return null
tags.push(match[1]!)
}
return tags.length > 0 ? tags : null
}
/**
* Remote Control uses user messages to infer "a turn is actively running" in
* places where the server does not derive that state for us. Hidden local
* slash-command scaffolding (for example `<local-command-caveat>` and pure
* `<system-reminder>` wrappers from `/proactive`) should not flip the session
* back to running after the command has already completed.
*/
export function shouldReportRunningForMessage(message: Message): boolean {
if (message.type !== 'user') return false
if (message.isVisibleInTranscriptOnly) return false
if (message.toolUseResult !== undefined) return true
if (!message.isMeta) return true
const tags = getEnvelopeTagNames(extractUserMessageText(message))
if (!tags) return true
return tags.some(
tag =>
tag !== LOCAL_COMMAND_CAVEAT_TAG &&
tag !== SYSTEM_REMINDER_TAG &&
RUNNING_STATE_META_TAGS.has(tag),
)
}
export function shouldReportRunningForMessages(
messages: readonly Message[],
): boolean {
return messages.some(shouldReportRunningForMessage)
}
// ─── Ingress routing ─────────────────────────────────────────────────────────
/**

View File

@@ -1,4 +1,5 @@
import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'
type BridgePermissionResponse = {
behavior: 'allow' | 'deny'
@@ -39,5 +40,65 @@ function isBridgePermissionResponse(
)
}
export { isBridgePermissionResponse }
function toBridgePermissionMessage(
controlResponse: Record<string, unknown>,
parsed: BridgePermissionResponse | undefined,
): string | undefined {
if (typeof controlResponse.message === 'string' && controlResponse.message) {
return controlResponse.message
}
if (typeof parsed?.message === 'string' && parsed.message) {
return parsed.message
}
if (typeof controlResponse.error === 'string' && controlResponse.error) {
return controlResponse.error
}
return undefined
}
/**
* Normalize a control_response from the bridge transport into the simplified
* allow/deny shape used by interactive permission handlers.
*/
function parseBridgePermissionResponse(
message: SDKControlResponse,
): BridgePermissionResponse | null {
const controlResponse = message.response
if (!controlResponse || typeof controlResponse !== 'object') return null
if (
controlResponse.subtype === 'success' &&
'response' in controlResponse &&
isBridgePermissionResponse(controlResponse.response)
) {
return controlResponse.response
}
if (controlResponse.subtype !== 'error') {
return null
}
const nested =
'response' in controlResponse &&
isBridgePermissionResponse(controlResponse.response)
? controlResponse.response
: undefined
const messageText = toBridgePermissionMessage(controlResponse, nested)
if (nested) {
return messageText ? { ...nested, message: messageText } : nested
}
if (messageText) {
return {
behavior: 'deny',
message: messageText,
}
}
return null
}
export { isBridgePermissionResponse, parseBridgePermissionResponse }
export type { BridgePermissionCallbacks, BridgePermissionResponse }

View File

@@ -0,0 +1,28 @@
export function hasPendingBridgeMessages(
lastWrittenIndex: number,
messageCount: number,
): boolean {
return lastWrittenIndex < messageCount
}
export function isTranscriptResetResultReady(
transcriptResetPending: boolean,
messageCount: number,
): boolean {
return transcriptResetPending && messageCount === 0
}
export function shouldDeferBridgeResult({
hasHandle,
isConnected,
lastWrittenIndex,
messageCount,
}: {
hasHandle: boolean
isConnected: boolean
lastWrittenIndex: number
messageCount: number
}): boolean {
if (!hasHandle || !isConnected) return true
return hasPendingBridgeMessages(lastWrittenIndex, messageCount)
}

View File

@@ -49,6 +49,8 @@ import {
makeResultMessage,
isEligibleBridgeMessage,
extractTitleText,
shouldReportRunningForMessage,
shouldReportRunningForMessages,
BoundedUUIDSet,
} from './bridgeMessaging.js'
import { logBridgeSkip } from './debugUtils.js'
@@ -72,6 +74,7 @@ import type {
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
/**
* StdoutMessage with optional session_id. The transport layer accepts
@@ -321,6 +324,18 @@ export async function initEnvLessBridgeCore(
})
}
// Mirror external metadata updates from the live REPL into the bridge's
// CCR worker channel. Without this, proactive wait/sleep only changes local
// UI state and the web session detail falls back to the generic working
// spinner because automation_state never reaches remote-control.
setSessionMetadataChangedListener(
metadata => {
if (tornDown) return
transport.reportMetadata(metadata)
},
{ replayCurrent: true },
)
// ── 5. JWT refresh scheduler ────────────────────────────────────────────
// Schedule a callback 5min before expiry (per response.expires_in). On fire,
// re-fetch /bridge with OAuth → rebuild transport with fresh credentials.
@@ -625,7 +640,7 @@ export async function initEnvLessBridgeCore(
...m,
session_id: sessionId,
})) as TransportMessage[]
if (msgs.some(m => m.type === 'user')) {
if (shouldReportRunningForMessages(msgs)) {
transport.reportState('running')
}
logForDebugging(
@@ -655,13 +670,13 @@ export async function initEnvLessBridgeCore(
})) as TransportMessage[]
if (events.length === 0) return
// Mid-turn init: if Remote Control is enabled while a query is running,
// the last eligible message is a user prompt or tool_result (both 'user'
// type). Without this the init PUT's 'idle' sticks until the next user-
// type message forwards via writeMessages — which for a pure-text turn
// is never (only assistant chunks stream post-init). Check eligible (pre-
// cap), not capped: the cap may truncate to a user message even when the
// actual trailing message is assistant.
if (eligible.at(-1)?.type === 'user') {
// the last eligible message may be a real user prompt or tool_result.
// Hidden slash-command scaffolding and pure reminder wrappers should not
// resurrect a completed turn into "running". Check eligible (pre-cap),
// not capped: the cap may truncate to a user message even when the actual
// trailing message is assistant.
const lastEligible = eligible.at(-1)
if (lastEligible && shouldReportRunningForMessage(lastEligible)) {
transport.reportState('running')
}
logForDebugging(`[remote-bridge] Flushing ${events.length} history events`)
@@ -817,10 +832,11 @@ export async function initEnvLessBridgeCore(
})) as TransportMessage[]
// v2 does not derive worker_status from events server-side (unlike v1
// session-ingress session_status_updater.go). Push it from here so the
// CCR web session list shows Running instead of stuck on Idle. A user
// message in the batch marks turn start. CCRClient.reportState dedupes
// consecutive same-state pushes.
if (filtered.some(m => m.type === 'user')) {
// CCR web session list shows Running instead of stuck on Idle. Only
// work-starting user messages mark turn start; hidden local-command
// scaffolding and pure reminders should not re-open a completed turn.
// CCRClient.reportState dedupes consecutive same-state pushes.
if (shouldReportRunningForMessages(filtered)) {
transport.reportState('running')
}
logForDebugging(`[remote-bridge] Sending ${filtered.length} message(s)`)

View File

@@ -0,0 +1,13 @@
import { feature } from 'bun:bundle'
export function handleRemoteInterrupt(
abortController: AbortController | null,
): void {
if (feature('PROACTIVE') || feature('KAIROS')) {
const { pauseProactive } =
require('../proactive/index.js') as typeof import('../proactive/index.js')
pauseProactive()
}
abortController?.abort()
}

View File

@@ -39,6 +39,7 @@ import {
createV2ReplTransport,
} from './replBridgeTransport.js'
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
import { validateBridgeId } from './bridgeApi.js'
import {
@@ -87,6 +88,7 @@ export type ReplBridgeHandle = {
sessionIngressUrl: string
writeMessages(messages: Message[]): void
writeSdkMessages(messages: SDKMessage[]): void
markTranscriptReset?(): void
sendControlRequest(request: SDKControlRequest): void
sendControlResponse(response: SDKControlResponse): void
sendControlCancelRequest(requestId: string): void
@@ -555,6 +557,17 @@ export async function initBridgeCore(
// server-driven via secret.use_code_sessions, with CLAUDE_BRIDGE_USE_CCR_V2
// as an ant-dev override.
let transport: ReplBridgeTransport | null = null
// Mirror external metadata updates from the active REPL into whichever
// transport currently owns the remote-control session. v1 ignores the call;
// v2 forwards it to CCR /worker external_metadata so standby/sleeping and
// other session metadata survive on web/mobile.
setSessionMetadataChangedListener(
metadata => {
if (pollController.signal.aborted) return
transport?.reportMetadata(metadata)
},
{ replayCurrent: true },
)
// Bumped on every onWorkReceived. Captured in createV2ReplTransport's .then()
// closure to detect stale resolutions: if two calls race while transport is
// null, both registerWorker() (bumping server epoch), and whichever resolves
@@ -1869,6 +1882,7 @@ export async function initBridgeCore(
)
return
}
transport.reportState('idle')
const resultMsg = {
...makeResultMessage(currentSessionId),
session_id: currentSessionId,

View File

@@ -162,9 +162,12 @@ export class RemoteIO extends StructuredIO {
setSessionStateChangedListener((state, details) => {
this.ccrClient?.reportState(state, details)
})
setSessionMetadataChangedListener(metadata => {
this.ccrClient?.reportMetadata(metadata)
})
setSessionMetadataChangedListener(
metadata => {
this.ccrClient?.reportMetadata(metadata)
},
{ replayCurrent: true },
)
}
// Start connection only after all callbacks are wired (setOnData above,

View File

@@ -487,6 +487,7 @@ export class CCRClient {
external_metadata: {
pending_action: null,
task_summary: null,
automation_state: null,
},
},
'PUT worker (init)',

View File

@@ -687,6 +687,7 @@ export const REMOTE_SAFE_COMMANDS: Set<Command> = new Set([
btw, // Quick note
feedback, // Send feedback
plan, // Plan mode toggle
proactive, // Toggle proactive mode
keybindings, // Keybinding management
statusline, // Status line toggle
stickers, // Stickers
@@ -727,9 +728,18 @@ export const BRIDGE_SAFE_COMMANDS: Set<Command> = new Set(
* BRIDGE_SAFE_COMMANDS; 'local-jsx' commands render Ink UI and stay blocked.
*/
export function isBridgeSafeCommand(cmd: Command): boolean {
if (cmd.type === 'local-jsx') return false
if (cmd.type === 'local-jsx') return cmd.bridgeSafe === true
if (cmd.type === 'prompt') return true
return BRIDGE_SAFE_COMMANDS.has(cmd)
return cmd.bridgeSafe === true || BRIDGE_SAFE_COMMANDS.has(cmd)
}
export function getBridgeCommandSafety(
cmd: Command,
args: string,
): { ok: true } | { ok: false; reason?: string } {
if (!isBridgeSafeCommand(cmd)) return { ok: false }
const reason = cmd.getBridgeInvocationError?.(args)
return reason ? { ok: false, reason } : { ok: true }
}
/**

View File

@@ -4,12 +4,14 @@
*/
import { feature } from 'bun:bundle'
import { randomUUID, type UUID } from 'crypto'
import { getReplBridgeHandle } from '../../bridge/replBridgeHandle.js'
import {
getLastMainRequestId,
getOriginalCwd,
getSessionId,
regenerateSessionId,
} from '../../bootstrap/state.js'
import type { SDKStatusMessage } from '../../entrypoints/sdk/coreTypes.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
@@ -46,6 +48,21 @@ import {
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
import { clearSessionCaches } from './caches.js'
function notifyRemoteConversationCleared(): void {
const handle = getReplBridgeHandle()
if (!handle) return
handle.markTranscriptReset?.()
const message: SDKStatusMessage = {
type: 'status',
subtype: 'status',
status: 'conversation_cleared',
message: 'conversation_cleared',
uuid: randomUUID(),
}
handle.writeSdkMessages([message])
}
export async function clearConversation({
setMessages,
readFileState,
@@ -107,6 +124,7 @@ export async function clearConversation({
}
setMessages(() => [])
notifyRemoteConversationCleared()
// Clear context-blocked flag so proactive ticks resume after /clear
if (feature('PROACTIVE') || feature('KAIROS')) {

View File

@@ -0,0 +1,16 @@
import { describe, expect, test } from 'bun:test'
import plan from './index.js'
describe('plan bridge invocation safety', () => {
test('allows headless plan mode operations over Remote Control', () => {
expect(plan.getBridgeInvocationError?.('')).toBeUndefined()
expect(plan.getBridgeInvocationError?.('write a migration plan')).toBeUndefined()
})
test('blocks /plan open over Remote Control', () => {
expect(plan.getBridgeInvocationError?.('open')).toBe(
"Opening the local editor via /plan open isn't available over Remote Control.",
)
})
})

View File

@@ -1,6 +1,14 @@
import type { Command } from '../../commands.js'
const plan = {
bridgeSafe: true,
getBridgeInvocationError(args: string) {
const subcommand = args.trim().split(/\s+/)[0]
if (subcommand === 'open') {
return "Opening the local editor via /plan open isn't available over Remote Control."
}
return undefined
},
type: 'local-jsx',
name: 'plan',
description: 'Enable plan mode or view the current session plan',

View File

@@ -13,6 +13,7 @@ import type {
} from '../types/command.js'
const proactive = {
bridgeSafe: true,
type: 'local-jsx',
name: 'proactive',
description: 'Toggle proactive (autonomous) mode',

View File

@@ -1,11 +1,17 @@
import { feature } from 'bun:bundle'
import { type FSWatcher, watch } from 'fs'
import React, { useCallback, useEffect, useRef } from 'react'
import { setMainLoopModelOverride } from '../bootstrap/state.js'
import {
type BridgePermissionCallbacks,
type BridgePermissionResponse,
isBridgePermissionResponse,
parseBridgePermissionResponse,
} from '../bridge/bridgePermissionCallbacks.js'
import { handleRemoteInterrupt } from '../bridge/remoteInterruptHandling.js'
import {
isTranscriptResetResultReady,
shouldDeferBridgeResult,
} from '../bridge/bridgeResultScheduling.js'
import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js'
import { extractInboundMessageFields } from '../bridge/inboundMessages.js'
import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js'
@@ -36,6 +42,10 @@ import {
createBridgeStatusMessage,
createSystemMessage,
} from '../utils/messages.js'
import {
buildTaskStateMessage,
getTaskStateSnapshotKey,
} from '../utils/taskStateMessage.js'
import {
getAutoModeUnavailableNotification,
getAutoModeUnavailableReason,
@@ -44,8 +54,17 @@ import {
transitionPermissionMode,
} from '../utils/permissions/permissionSetup.js'
import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'
import {
getTaskListId,
getTasksDir,
listTasks,
onTasksUpdated,
} from '../utils/tasks.js'
import { ContentBlockParam } from '@anthropic-ai/sdk/resources'
const TASK_STATE_DEBOUNCE_MS = 50
const TASK_STATE_POLL_MS = 5000
/** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */
export const BRIDGE_FAILURE_DISMISS_MS = 10_000
@@ -81,6 +100,8 @@ export function useReplBridge(
const handleRef = useRef<ReplBridgeHandle | null>(null)
const teardownPromiseRef = useRef<Promise<void> | undefined>(undefined)
const lastWrittenIndexRef = useRef(0)
const pendingResultAfterFlushRef = useRef(false)
const transcriptResetPendingRef = useRef(false)
// Tracks UUIDs already flushed as initial messages. Persists across
// bridge reconnections so Bridge #2+ only sends new messages — sending
// duplicate UUIDs causes the server to kill the WebSocket.
@@ -109,6 +130,10 @@ export function useReplBridge(
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s => s.replBridgeConnected)
: false
const replBridgeSessionActive = feature('BRIDGE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s => s.replBridgeSessionActive)
: false
const replBridgeOutboundOnly = feature('BRIDGE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAppState(s => s.replBridgeOutboundOnly)
@@ -454,25 +479,24 @@ export function useReplBridge(
)
return
}
pendingPermissionHandlers.delete(requestId)
// Extract the permission decision from the control_response payload
const inner = msg.response
if (
inner.subtype === 'success' &&
inner.response &&
isBridgePermissionResponse(inner.response)
) {
handler(inner.response)
const parsed = parseBridgePermissionResponse(msg)
if (!parsed) {
logForDebugging(
`[bridge:repl] Ignoring unrecognized control_response request_id=${requestId}`,
)
return
}
pendingPermissionHandlers.delete(requestId)
handler(parsed)
}
const handle = await initReplBridge({
const rawHandle = await initReplBridge({
outboundOnly,
tags: outboundOnly ? ['ccr-mirror'] : undefined,
onInboundMessage: handleInboundMessage,
onPermissionResponse: handlePermissionResponse,
onInterrupt() {
abortControllerRef.current?.abort()
handleRemoteInterrupt(abortControllerRef.current)
},
onSetModel(model) {
const resolved = model === 'default' ? null : (model ?? null)
@@ -565,6 +589,16 @@ export function useReplBridge(
initialName: replBridgeInitialName,
perpetual,
})
const handle = rawHandle
? {
...rawHandle,
markTranscriptReset() {
transcriptResetPendingRef.current = true
pendingResultAfterFlushRef.current = false
lastWrittenIndexRef.current = 0
},
}
: null
if (cancelled) {
// Effect was cancelled while initReplBridge was in flight.
// Tear down the handle to avoid leaking resources (poll loop,
@@ -816,6 +850,8 @@ export function useReplBridge(
}
})
lastWrittenIndexRef.current = 0
pendingResultAfterFlushRef.current = false
transcriptResetPendingRef.current = false
}
}
}, [
@@ -864,15 +900,152 @@ export function useReplBridge(
if (newMessages.length > 0) {
handle.writeMessages(newMessages)
transcriptResetPendingRef.current = false
}
if (
pendingResultAfterFlushRef.current &&
isTranscriptResetResultReady(
transcriptResetPendingRef.current,
messages.length,
)
) {
transcriptResetPendingRef.current = false
pendingResultAfterFlushRef.current = false
handle.sendResult()
return
}
if (
pendingResultAfterFlushRef.current &&
!transcriptResetPendingRef.current
) {
pendingResultAfterFlushRef.current = false
handle.sendResult()
}
}
}, [messages, replBridgeConnected])
useEffect(() => {
if (feature('BRIDGE_MODE')) {
if (!replBridgeSessionActive || replBridgeOutboundOnly) return
let cancelled = false
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let pollTimer: ReturnType<typeof setInterval> | undefined
let watcher: FSWatcher | null = null
let watchedDir: string | null = null
let lastPublishedSnapshotKey: string | null = null
let lastPublishedHandle: ReplBridgeHandle | null = null
const rewatch = (dir: string): void => {
if (dir === watchedDir && watcher !== null) return
watcher?.close()
watcher = null
watchedDir = dir
try {
watcher = watch(dir, schedulePublish)
watcher.unref()
} catch {
// Writers ensure the directory exists; if it does not yet, the
// poll timer and in-process task signal still converge the snapshot.
}
}
const publishTaskState = async (): Promise<void> => {
const handle = handleRef.current
if (!handle) return
const taskListId = getTaskListId()
rewatch(getTasksDir(taskListId))
try {
const tasks = await listTasks(taskListId)
if (cancelled || handleRef.current !== handle) return
const snapshotKey = getTaskStateSnapshotKey(taskListId, tasks)
if (
snapshotKey === lastPublishedSnapshotKey &&
handle === lastPublishedHandle
) {
return
}
handle.writeSdkMessages([buildTaskStateMessage(taskListId, tasks)])
lastPublishedSnapshotKey = snapshotKey
lastPublishedHandle = handle
} catch (err) {
logForDebugging(
`[bridge:repl] Failed to publish task_state: ${errorMessage(err)}`,
{ level: 'error' },
)
}
}
const schedulePublish = (): void => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = undefined
void publishTaskState()
}, TASK_STATE_DEBOUNCE_MS)
debounceTimer.unref?.()
}
void publishTaskState()
const unsubscribe = onTasksUpdated(schedulePublish)
pollTimer = setInterval(() => {
void publishTaskState()
}, TASK_STATE_POLL_MS)
pollTimer.unref?.()
return () => {
cancelled = true
unsubscribe()
if (debounceTimer) clearTimeout(debounceTimer)
if (pollTimer) clearInterval(pollTimer)
watcher?.close()
}
}
}, [replBridgeSessionActive, replBridgeOutboundOnly])
const sendBridgeResult = useCallback(() => {
if (feature('BRIDGE_MODE')) {
handleRef.current?.sendResult()
const handle = handleRef.current
if (!handle) {
pendingResultAfterFlushRef.current = true
return
}
if (
isTranscriptResetResultReady(
transcriptResetPendingRef.current,
messagesRef.current.length,
)
) {
transcriptResetPendingRef.current = false
pendingResultAfterFlushRef.current = false
handle.sendResult()
return
}
// Message mirroring happens in a separate effect. When the turn completes
// before that effect flushes the latest transcript rows, hold the result
// so remote state transitions after the final mirrored messages instead
// of bouncing back to "running" on local slash commands like /clear.
if (
transcriptResetPendingRef.current ||
shouldDeferBridgeResult({
hasHandle: true,
isConnected: replBridgeConnected,
lastWrittenIndex: lastWrittenIndexRef.current,
messageCount: messagesRef.current.length,
})
) {
pendingResultAfterFlushRef.current = true
return
}
handle.sendResult()
}
}, [])
}, [replBridgeConnected])
return { sendBridgeResult }
}

View File

@@ -269,6 +269,11 @@ export function convertSDKMessage(
logForDebugging('[sdkMessageAdapter] Ignoring rate_limit_event message')
return { type: 'ignored' }
case 'task_state':
// Bridge-only task snapshots are consumed by the web panel, not REPL UIs.
logForDebugging('[sdkMessageAdapter] Ignoring task_state message')
return { type: 'ignored' }
default: {
// Gracefully ignore unknown message types. The backend may send new
// types before the client is updated; logging helps with debugging

View File

@@ -207,6 +207,7 @@ const getCoordinatorUserContext: (
/* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
import useCanUseTool from '../hooks/useCanUseTool.js';
import type { ToolPermissionContext, Tool } from '../Tool.js';
import { notifyAutomationStateChanged } from '../utils/sessionState.js';
import {
applyPermissionUpdate,
applyPermissionUpdates,
@@ -341,6 +342,7 @@ import { useInboxPoller } from '../hooks/useInboxPoller.js';
const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null;
const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
const PROACTIVE_FALSE = () => false;
const PROACTIVE_NULL = (): number | null => null;
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive =
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
@@ -928,6 +930,10 @@ export function REPL({
proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE,
proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE,
);
const proactiveNextTickAt = React.useSyncExternalStore<number | null>(
proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE,
proactiveModule?.getNextTickAt ?? PROACTIVE_NULL,
);
// BriefTool.isEnabled() reads getUserMsgOptIn() from bootstrap state, which
// /brief flips mid-session alongside isBriefOnly. The memo below needs a
@@ -4944,6 +4950,48 @@ export function REPL({
onQueueTick: (command: QueuedCommand) => enqueue(command),
});
useEffect(() => {
if (!proactiveActive) {
notifyAutomationStateChanged(null);
return;
}
if (isLoading) {
return;
}
if (
proactiveNextTickAt !== null &&
queuedCommands.length === 0 &&
!isShowingLocalJSXCommand &&
toolPermissionContext.mode !== 'plan' &&
initialMessage === null
) {
notifyAutomationStateChanged({
enabled: true,
phase: 'standby',
next_tick_at: proactiveNextTickAt,
sleep_until: null,
});
return;
}
notifyAutomationStateChanged({
enabled: true,
phase: null,
next_tick_at: null,
sleep_until: null,
});
}, [
initialMessage,
isLoading,
isShowingLocalJSXCommand,
proactiveActive,
proactiveNextTickAt,
queuedCommands.length,
toolPermissionContext.mode,
]);
// Abort the current operation when a 'now' priority message arrives
// (e.g. from a chat UI client via UDS).
useEffect(() => {

View File

@@ -174,6 +174,18 @@ export type CommandAvailability =
export type CommandBase = {
availability?: CommandAvailability[]
/**
* Allows a local/local-jsx command to execute when it arrives over the
* Remote Control bridge. Only use for commands that do not require local
* interactive Ink UI and can safely complete headlessly.
*/
bridgeSafe?: boolean
/**
* Optional per-invocation validation for bridge-delivered slash commands.
* Return a user-facing rejection reason when specific arguments are unsafe
* to run headlessly over Remote Control.
*/
getBridgeInvocationError?: (args: string) => string | undefined
description: string
hasUserSpecifiedDescription?: boolean
/** Defaults to true. Only set when the command has conditional enablement (feature flags, env checks, etc). */

View File

@@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test'
import { isSlashCommand } from '../messageQueueManager.js'
describe('messageQueueManager.isSlashCommand', () => {
test('treats normal slash commands as slash commands', () => {
expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true)
})
test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => {
expect(
isSlashCommand({
value: '/proactive',
mode: 'prompt',
skipSlashCommands: true,
bridgeOrigin: true,
} as any),
).toBe(true)
})
test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => {
expect(
isSlashCommand({
value: '/proactive',
mode: 'prompt',
skipSlashCommands: true,
} as any),
).toBe(false)
})
})

View File

@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
notifyAutomationStateChanged,
notifySessionStateChanged,
notifySessionMetadataChanged,
resetSessionStateForTests,
setSessionMetadataChangedListener,
} from '../sessionState'
describe('sessionState metadata replay', () => {
beforeEach(() => {
resetSessionStateForTests()
})
test('replays cached automation state to listeners that attach later', () => {
const seen: Array<Record<string, unknown>> = []
notifyAutomationStateChanged({
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
automation_state: {
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
},
},
])
})
test('dedupes identical automation states after replay but forwards changes', () => {
const seen: Array<Record<string, unknown>> = []
notifyAutomationStateChanged({
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
notifyAutomationStateChanged({
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
})
notifyAutomationStateChanged({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
})
expect(seen).toEqual([
{
automation_state: {
enabled: true,
phase: 'standby',
next_tick_at: 123,
sleep_until: null,
},
},
{
automation_state: {
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
},
},
])
})
test('replays merged metadata snapshots instead of only the latest delta', () => {
const seen: Array<Record<string, unknown>> = []
notifySessionMetadataChanged({ model: 'claude-sonnet-4-6' })
notifyAutomationStateChanged({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
model: 'claude-sonnet-4-6',
automation_state: {
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: 456,
},
},
])
})
test('replays pending_action metadata cached through session-state transitions', () => {
const seen: Array<Record<string, unknown>> = []
notifySessionStateChanged('requires_action', {
tool_name: 'Edit',
action_description: 'Edit src/utils/sessionState.ts',
tool_use_id: 'toolu_123',
request_id: 'req_123',
input: { path: 'src/utils/sessionState.ts' },
})
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
pending_action: {
tool_name: 'Edit',
action_description: 'Edit src/utils/sessionState.ts',
tool_use_id: 'toolu_123',
request_id: 'req_123',
input: { path: 'src/utils/sessionState.ts' },
},
},
])
})
test('replays cleared task_summary metadata after returning to idle', () => {
const seen: Array<Record<string, unknown>> = []
notifySessionMetadataChanged({ task_summary: 'Running regression suite' })
notifySessionStateChanged('idle')
setSessionMetadataChangedListener(
metadata => {
seen.push(metadata as Record<string, unknown>)
},
{ replayCurrent: true },
)
expect(seen).toEqual([
{
task_summary: null,
},
])
})
})

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import {
buildTaskStateMessage,
getTaskStateSnapshotKey,
} from "../taskStateMessage";
describe("buildTaskStateMessage", () => {
test("filters internal tasks and preserves public task fields", () => {
const message = buildTaskStateMessage("tasklist", [
{
id: "1",
subject: "Visible task",
description: "Shown in web UI",
activeForm: "Doing visible task",
status: "in_progress",
owner: "agent-1",
blocks: ["2"],
blockedBy: [],
},
{
id: "2",
subject: "Internal task",
description: "Hidden from web UI",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { _internal: true },
},
]);
expect(message.type).toBe("task_state");
expect(message.task_list_id).toBe("tasklist");
expect(message.uuid).toEqual(expect.any(String));
expect(message.tasks).toEqual([
{
id: "1",
subject: "Visible task",
description: "Shown in web UI",
activeForm: "Doing visible task",
status: "in_progress",
owner: "agent-1",
blocks: ["2"],
blockedBy: [],
},
]);
});
test("builds a stable snapshot key for equivalent public tasks", () => {
const tasks = [
{
id: "2",
subject: "Second",
description: "Second task",
status: "pending",
blocks: [],
blockedBy: [],
},
{
id: "1",
subject: "First",
description: "First task",
status: "in_progress",
blocks: ["2"],
blockedBy: [],
},
{
id: "internal",
subject: "Internal task",
description: "Hidden",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { _internal: true },
},
];
const firstKey = getTaskStateSnapshotKey("tasklist", tasks as any);
const secondKey = getTaskStateSnapshotKey("tasklist", [...tasks].reverse() as any);
const message = buildTaskStateMessage("tasklist", tasks as any);
expect(firstKey).toBe(secondKey);
expect(message.tasks.map(task => task.id)).toEqual(["1", "2"]);
});
});

View File

@@ -121,6 +121,8 @@ export type HandlePromptSubmitParams = BaseExecutionParams & {
* trigger local slash commands or skills.
*/
skipSlashCommands?: boolean
/** Preserves that the input originated from Remote Control when queued. */
bridgeOrigin?: boolean
}
export async function handlePromptSubmit(
@@ -147,6 +149,7 @@ export async function handlePromptSubmit(
queuedCommands,
uuid,
skipSlashCommands,
bridgeOrigin,
} = params
const { setCursorOffset, clearBuffer, resetHistory } = helpers
@@ -345,6 +348,7 @@ export async function handlePromptSubmit(
mode,
pastedContents: hasImages ? pastedContents : undefined,
skipSlashCommands,
bridgeOrigin,
uuid,
})
@@ -368,6 +372,7 @@ export async function handlePromptSubmit(
mode,
pastedContents: hasImages ? pastedContents : undefined,
skipSlashCommands,
bridgeOrigin,
uuid,
}

View File

@@ -535,13 +535,14 @@ export function getCommandsByMaxPriority(
* Returns true if the command is a slash command that should be routed through
* processSlashCommand rather than sent to the model as text.
*
* Commands with `skipSlashCommands` (e.g. bridge/CCR messages) are NOT treated
* as slash commands — their text is meant for the model.
* Commands with `skipSlashCommands` are usually treated as plain text, except
* Remote Control bridge messages (`bridgeOrigin`) that are re-validated later
* through isBridgeSafeCommand().
*/
export function isSlashCommand(cmd: QueuedCommand): boolean {
return (
typeof cmd.value === 'string' &&
cmd.value.trim().startsWith('/') &&
!cmd.skipSlashCommands
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
)
}

View File

@@ -10,8 +10,8 @@ import { logEvent } from 'src/services/analytics/index.js'
import { getContentText } from 'src/utils/messages.js'
import {
findCommand,
getBridgeCommandSafety,
getCommandName,
isBridgeSafeCommand,
type LocalJSXCommandContext,
} from '../../commands.js'
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
@@ -432,10 +432,13 @@ async function processUserInputBase(
? findCommand(parsed.commandName, context.options.commands)
: undefined
if (cmd) {
if (isBridgeSafeCommand(cmd)) {
const safety = getBridgeCommandSafety(cmd, parsed?.args ?? '')
if (safety.ok) {
effectiveSkipSlash = false
} else {
const msg = `/${getCommandName(cmd)} isn't available over Remote Control.`
const msg =
safety.reason ??
`/${getCommandName(cmd)} isn't available over Remote Control.`
return {
messages: [
createUserMessage({ content: inputString, uuid }),

View File

@@ -19,12 +19,15 @@ type ProcessQueueResult = {
*/
function isSlashCommand(cmd: QueuedCommand): boolean {
if (typeof cmd.value === 'string') {
return cmd.value.trim().startsWith('/')
return cmd.value.trim().startsWith('/') && (!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
}
// For ContentBlockParam[], check the first text block
for (const block of cmd.value) {
if (block.type === 'text') {
return block.text.trim().startsWith('/')
return (
block.text.trim().startsWith('/') &&
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
)
}
}
return false

View File

@@ -1,5 +1,7 @@
export type SessionState = 'idle' | 'running' | 'requires_action'
import { isProactiveActive } from '../proactive/index.js'
/**
* Context carried with requires_action transitions so downstream
* surfaces (CCR sidebar, push notifications) can show what the
@@ -23,6 +25,15 @@ export type RequiresActionDetails = {
input?: Record<string, unknown>
}
export type AutomationStatePhase = 'standby' | 'sleeping'
export type AutomationStateMetadata = {
enabled: boolean
phase: AutomationStatePhase | null
next_tick_at: number | null
sleep_until: number | null
}
import { isEnvTruthy } from './envUtils.js'
import type { PermissionMode } from './permissions/PermissionMode.js'
import { enqueueSdkEvent } from './sdkEventQueue.js'
@@ -34,6 +45,7 @@ export type SessionExternalMetadata = {
is_ultraplan_mode?: boolean | null
model?: string | null
pending_action?: RequiresActionDetails | null
automation_state?: AutomationStateMetadata | null
// Opaque — typed at the emit site. Importing PostTurnSummaryOutput here
// would leak the import path string into sdk.d.ts via agentSdkBridge's
// re-export of SessionState.
@@ -52,6 +64,9 @@ type SessionMetadataChangedListener = (
metadata: SessionExternalMetadata,
) => void
type PermissionModeChangedListener = (mode: PermissionMode) => void
type SessionMetadataListenerOptions = {
replayCurrent?: boolean
}
let stateListener: SessionStateChangedListener | null = null
let metadataListener: SessionMetadataChangedListener | null = null
@@ -65,8 +80,19 @@ export function setSessionStateChangedListener(
export function setSessionMetadataChangedListener(
cb: SessionMetadataChangedListener | null,
options?: SessionMetadataListenerOptions,
): void {
metadataListener = cb
if (!cb || !options?.replayCurrent) {
return
}
const snapshot = getSessionMetadataSnapshot()
if (Object.keys(snapshot).length === 0) {
return
}
cb(snapshot)
}
/**
@@ -84,6 +110,61 @@ export function setPermissionModeChangedListener(
let hasPendingAction = false
let currentState: SessionState = 'idle'
let currentAutomationState: AutomationStateMetadata | null = null
let currentMetadata: SessionExternalMetadata = {}
function normalizeAutomationState(
state: AutomationStateMetadata | null | undefined,
): AutomationStateMetadata | null {
if (!state || state.enabled !== true) {
return null
}
return {
enabled: true,
phase:
state.phase === 'standby' || state.phase === 'sleeping'
? state.phase
: null,
next_tick_at:
typeof state.next_tick_at === 'number' ? state.next_tick_at : null,
sleep_until:
typeof state.sleep_until === 'number' ? state.sleep_until : null,
}
}
function automationStateKey(
state: AutomationStateMetadata | null,
): string {
return JSON.stringify(state)
}
function applyMetadataUpdate(
metadata: SessionExternalMetadata,
): void {
const nextMetadata = { ...currentMetadata }
for (const key of Object.keys(metadata) as Array<
keyof SessionExternalMetadata
>) {
const value = metadata[key]
if (value === undefined) {
delete nextMetadata[key]
continue
}
;(nextMetadata as Record<string, unknown>)[key] = value
}
currentMetadata = nextMetadata
}
export function getSessionMetadataSnapshot(): SessionExternalMetadata {
const snapshot: SessionExternalMetadata = { ...currentMetadata }
if (currentAutomationState) {
snapshot.automation_state = { ...currentAutomationState }
} else if ('automation_state' in currentMetadata) {
snapshot.automation_state = currentMetadata.automation_state ?? null
}
return snapshot
}
export function getSessionState(): SessionState {
return currentState
@@ -101,18 +182,31 @@ export function notifySessionStateChanged(
// null on the next non-blocked transition.
if (state === 'requires_action' && details) {
hasPendingAction = true
metadataListener?.({
notifySessionMetadataChanged({
pending_action: details,
})
} else if (hasPendingAction) {
hasPendingAction = false
metadataListener?.({ pending_action: null })
notifySessionMetadataChanged({ pending_action: null })
}
// task_summary is written mid-turn by the forked summarizer; clear it at
// idle so the next turn doesn't briefly show the previous turn's progress.
if (state === 'idle') {
metadataListener?.({ task_summary: null })
notifySessionMetadataChanged({ task_summary: null })
}
if (state !== 'idle') {
notifyAutomationStateChanged(
isProactiveActive()
? {
enabled: true,
phase: null,
next_tick_at: null,
sleep_until: null,
}
: null,
)
}
// Mirror to the SDK event stream so non-CCR consumers (scmuxd, VS Code)
@@ -136,9 +230,25 @@ export function notifySessionStateChanged(
export function notifySessionMetadataChanged(
metadata: SessionExternalMetadata,
): void {
applyMetadataUpdate(metadata)
metadataListener?.(metadata)
}
export function notifyAutomationStateChanged(
state: AutomationStateMetadata | null | undefined,
): void {
const nextState = normalizeAutomationState(state)
if (
automationStateKey(nextState) === automationStateKey(currentAutomationState)
) {
return
}
currentAutomationState = nextState
applyMetadataUpdate({ automation_state: nextState })
metadataListener?.({ automation_state: nextState })
}
/**
* Fired by onChangeAppState when toolPermissionContext.mode changes.
* Downstream listeners (CCR external_metadata PUT, SDK status stream) are
@@ -148,3 +258,13 @@ export function notifySessionMetadataChanged(
export function notifyPermissionModeChanged(mode: PermissionMode): void {
permissionModeListener?.(mode)
}
export function resetSessionStateForTests(): void {
stateListener = null
metadataListener = null
permissionModeListener = null
hasPendingAction = false
currentState = 'idle'
currentAutomationState = null
currentMetadata = {}
}

View File

@@ -0,0 +1,76 @@
import { randomUUID } from 'crypto'
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'
import type { Task } from './tasks.js'
export type TaskStateItem = Pick<
Task,
| 'id'
| 'subject'
| 'description'
| 'activeForm'
| 'status'
| 'owner'
| 'blocks'
| 'blockedBy'
>
export type TaskStateMessage = SDKMessage & {
type: 'task_state'
uuid: string
task_list_id: string
tasks: TaskStateItem[]
}
export type TaskStateSnapshot = Pick<
TaskStateMessage,
'task_list_id' | 'tasks'
>
function toTaskStateItem(task: Task): TaskStateItem {
return {
id: task.id,
subject: task.subject,
description: task.description,
activeForm: task.activeForm,
status: task.status,
owner: task.owner,
blocks: [...task.blocks],
blockedBy: [...task.blockedBy],
}
}
function compareTaskStateItems(a: TaskStateItem, b: TaskStateItem): number {
return a.id.localeCompare(b.id)
}
export function buildTaskStateSnapshot(
taskListId: string,
tasks: Task[],
): TaskStateSnapshot {
return {
task_list_id: taskListId,
tasks: tasks
.filter(task => !task.metadata?._internal)
.map(toTaskStateItem)
.sort(compareTaskStateItems),
}
}
export function getTaskStateSnapshotKey(
taskListId: string,
tasks: Task[],
): string {
return JSON.stringify(buildTaskStateSnapshot(taskListId, tasks))
}
export function buildTaskStateMessage(
taskListId: string,
tasks: Task[],
): TaskStateMessage {
const snapshot = buildTaskStateSnapshot(taskListId, tasks)
return {
type: 'task_state',
uuid: randomUUID(),
...snapshot,
}
}