mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
17
src/__tests__/commandsBridgeSafety.test.ts
Normal file
17
src/__tests__/commandsBridgeSafety.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
121
src/__tests__/handlePromptSubmit.test.ts
Normal file
121
src/__tests__/handlePromptSubmit.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
96
src/bridge/__tests__/bridgeMessaging.test.ts
Normal file
96
src/bridge/__tests__/bridgeMessaging.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
76
src/bridge/__tests__/bridgePermissionCallbacks.test.ts
Normal file
76
src/bridge/__tests__/bridgePermissionCallbacks.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
53
src/bridge/__tests__/bridgeResultScheduling.test.ts
Normal file
53
src/bridge/__tests__/bridgeResultScheduling.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
37
src/bridge/__tests__/remoteInterruptHandling.test.ts
Normal file
37
src/bridge/__tests__/remoteInterruptHandling.test.ts
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
|
||||
28
src/bridge/bridgeResultScheduling.ts
Normal file
28
src/bridge/bridgeResultScheduling.ts
Normal 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)
|
||||
}
|
||||
@@ -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)`)
|
||||
|
||||
13
src/bridge/remoteInterruptHandling.ts
Normal file
13
src/bridge/remoteInterruptHandling.ts
Normal 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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -487,6 +487,7 @@ export class CCRClient {
|
||||
external_metadata: {
|
||||
pending_action: null,
|
||||
task_summary: null,
|
||||
automation_state: null,
|
||||
},
|
||||
},
|
||||
'PUT worker (init)',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
16
src/commands/plan/index.test.ts
Normal file
16
src/commands/plan/index.test.ts
Normal 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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '../types/command.js'
|
||||
|
||||
const proactive = {
|
||||
bridgeSafe: true,
|
||||
type: 'local-jsx',
|
||||
name: 'proactive',
|
||||
description: 'Toggle proactive (autonomous) mode',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
30
src/utils/__tests__/messageQueueManager.test.ts
Normal file
30
src/utils/__tests__/messageQueueManager.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
174
src/utils/__tests__/sessionState.test.ts
Normal file
174
src/utils/__tests__/sessionState.test.ts
Normal 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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
84
src/utils/__tests__/taskStateMessage.test.ts
Normal file
84
src/utils/__tests__/taskStateMessage.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
}
|
||||
|
||||
76
src/utils/taskStateMessage.ts
Normal file
76
src/utils/taskStateMessage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user