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

10
.gitignore vendored
View File

@@ -13,7 +13,6 @@ src/utils/vendor/
# AI tool runtime directories
.agents/
.claude/
.codex/
.omx/
.docs/task/
# Binary / screenshot files (root only)
@@ -30,3 +29,12 @@ __pycache__/
logs
data
.omc
.codex/*
!.codex/agents/
!.codex/agents/**
!.codex/skills/
!.codex/skills/**
.codex/skills/.system/**
!.codex/prompts/
!.codex/prompts/**

View File

@@ -1,7 +1,7 @@
# KAIROS — 常驻助手模式
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature
> 实现状态:核心框架完整,部分子模块为 stub
> 实现状态:核心框架完整,部分子模块为 stubproactive/sleep 节奏控制已可用
> 引用数154全库最大
## 一、功能概述
@@ -74,8 +74,9 @@ KAIROS 在系统提示中注入两大段落:
SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念:
- 工具名:`Sleep`
- 功能:等待指定时间后响应 tick prompt
- 功能:等待指定时间后响应 tick prompt;若队列出现新工作或 proactive 被关闭,会提前唤醒
-`<tick_tag>` 配合实现心跳式自主工作
- 远程控制 surfaces 可通过 `automation_state` 看到 `standby` / `sleeping` 两种状态
### 3.3 Bridge 集成
@@ -172,8 +173,10 @@ FEATURE_KAIROS=1 FEATURE_TOKEN_BUDGET=1 bun run dev
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UIstub |
| `src/tools/BriefTool/` | — | BriefTool 实现stub |
| `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 |
| `src/tools/SleepTool/SleepTool.ts` | ~200 | 休眠/唤醒与 automation metadata |
| `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入stub |
| `src/memdir/memdir.ts` | — | 记忆目录管理stub |
| `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 |
| `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务stub |
| `src/proactive/index.ts` | — | Proactive 核心(stubKAIROS 共享) |
| `src/proactive/index.ts` | — | Proactive 核心KAIROS 共享) |
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |

View File

@@ -1,7 +1,7 @@
# PROACTIVE — 主动模式
> Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能)
> 实现状态:核心模块全部 Stub布线完整
> 实现状态:核心循环与 SleepTool 已落地,部分外围文档仍在补齐
> 引用数37
## 一、功能概述
@@ -21,13 +21,13 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
| 模块 | 文件 | 状态 | 说明 |
|------|------|------|------|
| 核心逻辑 | `src/proactive/index.ts` | **Stub** | `activateProactive()``deactivateProactive()``isProactiveActive() => false` |
| 核心逻辑 | `src/proactive/index.ts` | **已实现** | `activateProactive()``deactivateProactive()``pause/resume``nextTickAt` 调度状态 |
| SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep` |
| 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` |
| 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 |
| REPL 集成 | `src/screens/REPL.tsx` | **布线** | tick 驱动逻辑、占位符、页脚 UI |
| REPL 集成 | `src/screens/REPL.tsx` | **已实现** | tick 驱动、standby/sleeping 状态、页脚与 bridge automation metadata 上报 |
| 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt |
| 会话存储 | `src/utils/sessionStorage.ts:4892-4912` | **布线** | tick 消息注入对话流 |
| 远控状态镜像 | `src/utils/sessionState.ts` | **已实现** | 向 remote-control/CCR 暴露 `automation_state` 元数据 |
### 2.2 系统提示内容
@@ -46,7 +46,7 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
### 2.3 数据流
```
activateProactive() [需要实现]
activateProactive()
Tick 调度器启动
@@ -62,20 +62,22 @@ Tick 调度器启动
└── 无事可做 → 必须调用 SleepTool
SleepTool 等待 [需要实现]
SleepTool 等待
├── 用户插入新工作 / 队列中有命令 → 立即唤醒
├── proactive 被关闭 → 立即中断
└── 进入休眠时向远端 surfaces 上报 `automation_state = sleeping`
下一个 tick 到达
```
## 三、需要补全的内容
## 三、当前行为补充
| 优先级 | 模块 | 工作量 | 说明 |
|--------|------|--------|------|
| 1 | `src/proactive/index.ts` | 中 | Tick 调度器、activate/deactivate 状态机、pause/resume |
| 2 | `src/tools/SleepTool/SleepTool.ts` | 小 | 工具执行(等待指定时间后触发 tick |
| 3 | `src/commands/proactive.js` | 小 | `/proactive` 斜杠命令处理器 |
| 4 | `src/hooks/useProactive.ts` | 中 | React hookREPL 引用但不存在) |
- `standby`proactive 已开启,当前没有执行中的 turn且已调度下一个 tick。
- `sleeping`:模型显式调用 `SleepTool` 进入等待窗口。
- remote-control/CCR 通过 `external_metadata.automation_state` 接收这两个状态,用于 Web UI 的 Autopilot 状态显示。
- `SleepTool` 现在不是纯定时器;它会在共享命令队列出现新工作时提前醒来。
## 四、关键设计决策
@@ -101,9 +103,11 @@ FEATURE_PROACTIVE=1 FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 bun run dev
| 文件 | 职责 |
|------|------|
| `src/proactive/index.ts` | 核心逻辑stub |
| `src/proactive/index.ts` | 核心逻辑与 next-tick 状态 |
| `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 |
| `src/tools/SleepTool/SleepTool.ts` | 休眠/唤醒执行逻辑 |
| `src/constants/prompts.ts:860-914` | 自主工作系统提示 |
| `src/screens/REPL.tsx` | REPL tick 集成 |
| `src/screens/REPL.tsx` | REPL tick 集成与 automation 状态上报 |
| `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 |
| `src/utils/sessionState.ts` | bridge/CCR metadata 镜像 |
| `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 |

View File

@@ -174,6 +174,8 @@ claude bridge
- 查看已注册的运行环境environment 模式)
- 创建和管理会话
- 实时查看对话消息和工具调用
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
- 查看 authoritative task snapshots 驱动的 Tasks 面板
- 审批 Claude Code 的工具权限请求
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
@@ -215,6 +217,7 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
9. 双向通信
CLI ──消息/工具调用结果──► RCS ──► Browser
CLI ◄──权限审批/指令───── RCS ◄──── Browser
CLI ──automation_state / task_state──► RCS ──► Browser
10. 心跳保活(每 20 秒)
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
@@ -224,6 +227,13 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
## 故障排查
### Web UI 看不到当前 Autopilot 状态
- `standby`proactive 已开启,正在等待下一个 tick
- `sleeping`:模型正在 `SleepTool` 等待窗口中
这两个状态通过 worker `external_metadata.automation_state` 上报。如果页面只显示普通 working spinner优先检查 CLI 和 RCS 之间的 worker metadata PUT 是否成功。
### CLI 无法连接
```

View File

@@ -3,8 +3,11 @@ import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { notifyAutomationStateChanged } from 'src/utils/sessionState.js'
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
const SLEEP_WAKE_CHECK_INTERVAL_MS = 500
const inputSchema = lazySchema(() =>
z.strictObject({
duration_seconds: z
@@ -19,6 +22,36 @@ type SleepInput = z.infer<InputSchema>
type SleepOutput = { slept_seconds: number; interrupted: boolean }
function isProactiveAutomationEnabled(): boolean {
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
return false
}
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
return mod.isProactiveActive()
}
function isProactiveSleepAllowed(): boolean {
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
return true
}
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
return mod.isProactiveActive()
}
function hasQueuedWakeSignal(): boolean {
const queue =
require('src/utils/messageQueueManager.js') as typeof import('src/utils/messageQueueManager.js')
return queue.hasCommandsInQueue()
}
function shouldInterruptSleep(): boolean {
return !isProactiveSleepAllowed() || hasQueuedWakeSignal()
}
export const SleepTool = buildTool({
name: SLEEP_TOOL_NAME,
searchHint: 'wait pause sleep rest idle duration timer',
@@ -42,6 +75,9 @@ export const SleepTool = buildTool({
isReadOnly() {
return true
},
interruptBehavior() {
return 'cancel'
},
userFacingName() {
return SLEEP_TOOL_NAME
@@ -67,53 +103,84 @@ export const SleepTool = buildTool({
},
async call(input: SleepInput, context) {
// Refuse to sleep when proactive mode is off — prevents the model from
// re-issuing Sleep after an interruption caused by /proactive disable.
if (feature('PROACTIVE') || feature('KAIROS')) {
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
if (!mod.isProactiveActive()) {
return {
data: {
slept_seconds: 0,
interrupted: true,
},
}
// Don't enter sleep if proactive was disabled or new work arrived while
// the model was deciding to wait.
if (shouldInterruptSleep()) {
return {
data: {
slept_seconds: 0,
interrupted: true,
},
}
}
const { duration_seconds } = input
const startTime = Date.now()
const sleepUntil = startTime + duration_seconds * 1000
if (isProactiveAutomationEnabled()) {
notifyAutomationStateChanged({
enabled: true,
phase: 'sleeping',
next_tick_at: null,
sleep_until: sleepUntil,
})
}
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, duration_seconds * 1000)
let timer: ReturnType<typeof setTimeout> | null = null
let wakeCheck: ReturnType<typeof setInterval> | null = null
let settled = false
const cleanup = () => {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
if (wakeCheck !== null) {
clearInterval(wakeCheck)
wakeCheck = null
}
context.abortController.signal.removeEventListener('abort', onAbort)
}
const finish = () => {
if (settled) return
settled = true
cleanup()
resolve()
}
const interrupt = () => {
if (settled) return
settled = true
cleanup()
reject(new Error('interrupted'))
}
const onAbort = () => {
interrupt()
}
timer = setTimeout(finish, duration_seconds * 1000)
// Abort via user interrupt
context.abortController.signal.addEventListener(
'abort',
() => {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
},
{ once: true },
)
if (context.abortController.signal.aborted) {
interrupt()
return
}
context.abortController.signal.addEventListener('abort', onAbort, {
once: true,
})
// Poll proactive state — if deactivated mid-sleep, interrupt early
// so the user doesn't have to wait for the full duration.
const proactiveCheck =
feature('PROACTIVE') || feature('KAIROS')
? setInterval(() => {
const mod =
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
if (!mod.isProactiveActive()) {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
}
}, 500)
: (null as unknown as ReturnType<typeof setInterval>)
// Poll proactive state and the shared command queue so new work can
// wake Sleep without waiting for the full duration.
wakeCheck = setInterval(() => {
if (shouldInterruptSleep()) {
interrupt()
}
}, SLEEP_WAKE_CHECK_INTERVAL_MS)
})
return {
data: {
@@ -129,6 +196,17 @@ export const SleepTool = buildTool({
interrupted: true,
},
}
} finally {
notifyAutomationStateChanged(
isProactiveAutomationEnabled()
? {
enabled: true,
phase: null,
next_tick_at: null,
sleep_until: null,
}
: null,
)
}
},
})

View File

@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { SleepTool } from '../SleepTool'
import {
enqueue,
getCommandQueue,
resetCommandQueue,
} from 'src/utils/messageQueueManager.js'
describe('SleepTool', () => {
beforeEach(() => {
resetCommandQueue()
})
test('declares cancel interrupt behavior', () => {
expect(SleepTool.interruptBehavior()).toBe('cancel')
})
test('wakes early when queued work arrives', async () => {
const sleepPromise = SleepTool.call(
{ duration_seconds: 10 },
{ abortController: new AbortController() } as any,
)
setTimeout(() => {
enqueue({
value: 'wake up',
mode: 'prompt',
})
}, 20)
const result = await sleepPromise
expect(result.data.interrupted).toBe(true)
expect(result.data.slept_seconds).toBeLessThan(10)
expect(getCommandQueue()).toHaveLength(1)
expect(getCommandQueue()[0]).toMatchObject({
value: 'wake up',
mode: 'prompt',
})
})
})

View File

@@ -678,6 +678,44 @@ describe("Web Session Routes", () => {
expect(getRes.status).toBe(200);
});
test("GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
storeBindSession(id, "user-1");
await app.request(`/v1/code/sessions/${id}/worker`, {
method: "PUT",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
worker_epoch: 1,
external_metadata: {
automation_state: {
enabled: true,
phase: "standby",
next_tick_at: 123456,
sleep_until: null,
},
},
}),
});
const getRes = await app.request(`/web/sessions/${toWebSessionId(id)}?uuid=user-1`);
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.automation_state).toEqual({
enabled: true,
phase: "standby",
next_tick_at: 123456,
sleep_until: null,
});
});
test("GET /web/sessions/:id — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
@@ -704,6 +742,35 @@ describe("Web Session Routes", () => {
expect(body.events).toEqual([]);
});
test("GET /web/sessions/:id/history — returns task_state snapshots", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
publishSessionEvent(
id,
"task_state",
{
task_list_id: "team-alpha",
tasks: [{ id: "1", subject: "Investigate", status: "pending" }],
},
"inbound",
);
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
expect(histRes.status).toBe(200);
const body = await histRes.json();
expect(body.events).toHaveLength(1);
expect(body.events[0]?.type).toBe("task_state");
expect(body.events[0]?.payload.task_list_id).toBe("team-alpha");
expect(body.events[0]?.payload.tasks).toEqual([
{ id: "1", subject: "Investigate", status: "pending" },
]);
});
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
@@ -1218,7 +1285,15 @@ describe("V2 Worker Events Routes", () => {
body: JSON.stringify({
worker_epoch: 1,
worker_status: "running",
external_metadata: { permission_mode: "default" },
external_metadata: {
permission_mode: "default",
automation_state: {
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
},
},
}),
});
expect(putRes.status).toBe(200);
@@ -1230,6 +1305,21 @@ describe("V2 Worker Events Routes", () => {
const body = await getRes.json();
expect(body.worker.worker_status).toBe("running");
expect(body.worker.external_metadata.permission_mode).toBe("default");
expect(body.worker.external_metadata.automation_state).toEqual({
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
});
const events = getEventBus(id).getEventsSince(0);
expect(events.some((event) => event.type === "automation_state")).toBe(true);
expect(events.at(-1)?.payload).toEqual({
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 123456,
});
});
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
@@ -1284,6 +1374,123 @@ describe("V2 Worker Events Routes", () => {
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "permission_response",
approved: true,
request_id: "req-1",
}),
});
expect(controlRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"permission_response\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
expect(frame).toContain("\"request_id\":\"req-1\"");
expect(frame).toContain("\"behavior\":\"allow\"");
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "permission_response",
approved: false,
request_id: "req-2",
message: "Need more detail",
}),
});
expect(controlRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"permission_response\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
expect(frame).toContain("\"request_id\":\"req-2\"");
expect(frame).toContain("\"subtype\":\"error\"");
expect(frame).toContain("\"behavior\":\"deny\"");
expect(frame).toContain("\"message\":\"Need more detail\"");
reader.cancel();
});
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
await reader.read(); // initial keepalive
const interruptRes = await app.request(`/web/sessions/${id}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(interruptRes.status).toBe(200);
const chunk = await reader.read();
const frame = new TextDecoder().decode(chunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"event_type\":\"interrupt\"");
expect(frame).toContain("\"payload\":{\"type\":\"control_request\"");
expect(frame).toContain("\"subtype\":\"interrupt\"");
reader.cancel();
});
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
const sessRes = await app.request("/v1/sessions", {
method: "POST",

View File

@@ -353,6 +353,14 @@ describe("Transport Service", () => {
expect(result.uuid).toBe("msg_123");
});
test("preserves isSynthetic field", () => {
const result = normalizePayload("user", {
content: "scheduled job: refresh analytics cache",
isSynthetic: true,
});
expect(result.isSynthetic).toBe(true);
});
test("uses name as tool_name fallback", () => {
const result = normalizePayload("tool", { name: "Read" });
expect(result.tool_name).toBe("Read");
@@ -370,6 +378,28 @@ describe("Transport Service", () => {
expect(result.content).toBe("");
});
test("preserves task_state fields", () => {
const result = normalizePayload("task_state", {
task_list_id: "team-alpha",
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
});
expect(result.task_list_id).toBe("team-alpha");
expect(result.tasks).toEqual([
{ id: "1", subject: "Task 1", status: "pending" },
]);
});
test("preserves status metadata for conversation reset events", () => {
const result = normalizePayload("status", {
status: "conversation_cleared",
subtype: "status",
message: "conversation_cleared",
});
expect(result.status).toBe("conversation_cleared");
expect(result.subtype).toBe("status");
expect(result.message).toBe("conversation_cleared");
});
test("handles undefined payload", () => {
const result = normalizePayload("user", undefined);
expect(result.content).toBe("");

View File

@@ -69,6 +69,19 @@ describe("ws-handler", () => {
expect((events[0] as any).direction).toBe("inbound");
});
test("preserves synthetic flag on inbound user messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
bus.subscribe((e) => events.push(e));
ingestBridgeMessage("s1", {
message: { role: "user", content: "scheduled job: refresh analytics cache" },
uuid: "u_synth",
isSynthetic: true,
});
expect(events).toHaveLength(1);
expect((events[0] as any).payload.isSynthetic).toBe(true);
});
test("derives type from message.role for assistant messages", () => {
const bus = getEventBus("s1");
const events: unknown[] = [];
@@ -163,6 +176,24 @@ describe("ws-handler", () => {
expect(msg.type).toBe("user");
});
test("replays synthetic user metadata back to the bridge", () => {
const bus = getEventBus("s3");
bus.publish({
id: "e1",
sessionId: "s3",
type: "user",
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
direction: "outbound",
});
const ws = createMockWs();
handleWebSocketOpen(ws, "s3");
const msg = JSON.parse(ws.getSentData()[0]);
expect(msg.type).toBe("user");
expect(msg.isSynthetic).toBe(true);
});
test("replaces existing connection for same session", () => {
const ws1 = createMockWs();
const ws2 = createMockWs();

View File

@@ -1,7 +1,13 @@
import { Hono } from "hono";
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
import {
automationStatesEqual,
getAutomationStateEventPayload,
} from "../../services/automationState";
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
import { getEventBus } from "../../transport/event-bus";
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
import { v4 as uuid } from "uuid";
const app = new Hono();
@@ -33,6 +39,9 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
}
const body = await c.req.json();
const prevAutomationState = getAutomationStateEventPayload(
storeGetSessionWorker(sessionId)?.externalMetadata,
);
if (body.worker_status) {
updateSessionStatus(sessionId, body.worker_status);
} else {
@@ -44,6 +53,17 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
externalMetadata: body.external_metadata,
requiresActionDetails: body.requires_action_details,
});
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
getEventBus(sessionId).publish({
id: uuid(),
sessionId,
type: "automation_state",
payload: nextAutomationState,
direction: "inbound",
});
}
return c.json({
status: "ok",

View File

@@ -1,6 +1,7 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware";
import { getAutomationStateSnapshot } from "../../services/automationState";
import {
createSession,
getSession,
@@ -10,7 +11,7 @@ import {
resolveOwnedWebSessionId,
toWebSessionResponse,
} from "../../services/session";
import { storeBindSession } from "../../store";
import { storeBindSession, storeGetSessionWorker } from "../../store";
import { createWorkItem } from "../../services/work-dispatch";
import { createSSEStream } from "../../transport/sse-writer";
import { getEventBus } from "../../transport/event-bus";
@@ -68,7 +69,13 @@ app.get("/sessions/:id", uuidAuth, async (c) => {
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
return c.json(toWebSessionResponse(session), 200);
const worker = storeGetSessionWorker(sessionId);
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
const response = toWebSessionResponse(session);
return c.json(
automationState === undefined ? response : { ...response, automation_state: automationState },
200,
);
});
/** GET /web/sessions/:id/history — Historical events for session */

View File

@@ -0,0 +1,64 @@
import type { AutomationStateResponse } from "../types/api";
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
});
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
return { ...state };
}
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
if (!raw || typeof raw !== "object") {
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
}
const state = raw as Record<string, unknown>;
return {
enabled: state.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 readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
if (!metadata || typeof metadata !== "object") {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
return undefined;
}
return metadata.automation_state;
}
export function getAutomationStateSnapshot(
metadata: Record<string, unknown> | null | undefined,
): AutomationStateResponse | undefined {
const raw = readAutomationStateValue(metadata);
if (raw === undefined) {
return undefined;
}
return normalizeAutomationState(raw);
}
export function getAutomationStateEventPayload(
metadata: Record<string, unknown> | null | undefined,
): AutomationStateResponse {
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
}
export function automationStatesEqual(
a: AutomationStateResponse,
b: AutomationStateResponse,
): boolean {
return (
a.enabled === b.enabled &&
a.phase === b.phase &&
a.next_tick_at === b.next_tick_at &&
a.sleep_until === b.sleep_until
);
}

View File

@@ -52,6 +52,9 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
};
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
if (typeof p.status === "string") normalized.status = p.status;
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
// Preserve tool fields
if (p.tool_name) normalized.tool_name = p.tool_name;
@@ -68,6 +71,12 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
// Preserve message field for backward compat
if (p.message) normalized.message = p.message;
if (type === "task_state") {
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
}
return normalized;
}

View File

@@ -0,0 +1,80 @@
import type { SessionEvent } from "./event-bus";
/**
* Convert an internal session event into the SDK/control message shape that
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
*/
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
const payload = event.payload as Record<string, unknown> | null;
const messageUuid =
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
if (event.type === "user" || event.type === "user_message") {
return {
type: "user",
uuid: messageUuid,
session_id: event.sessionId,
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
message: {
role: "user",
content: payload?.content ?? payload?.message ?? "",
},
};
}
if (event.type === "permission_response" || event.type === "control_response") {
const approved = !!payload?.approved;
const existingResponse = payload?.response as Record<string, unknown> | undefined;
if (existingResponse) {
return { type: "control_response", response: existingResponse };
}
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
const feedbackMessage = payload?.message as string | undefined;
return {
type: "control_response",
response: {
subtype: approved ? "success" : "error",
request_id: payload?.request_id ?? "",
...(approved
? {
response: {
behavior: "allow" as const,
...(updatedInput ? { updatedInput } : {}),
...(updatedPermissions ? { updatedPermissions } : {}),
},
}
: {
error: "Permission denied by user",
response: { behavior: "deny" as const },
...(feedbackMessage ? { message: feedbackMessage } : {}),
}),
},
};
}
if (event.type === "interrupt") {
return {
type: "control_request",
request_id: event.id,
request: { subtype: "interrupt" },
};
}
if (event.type === "control_request") {
return {
type: "control_request",
request_id: payload?.request_id ?? event.id,
request: payload?.request ?? payload,
};
}
return {
type: event.type,
uuid: messageUuid,
session_id: event.sessionId,
message: payload,
};
}

View File

@@ -2,6 +2,7 @@ import { log, error as logError } from "../logger";
import type { Context } from "hono";
import type { SessionEvent } from "./event-bus";
import { getEventBus } from "./event-bus";
import { toClientPayload } from "./client-payload";
export interface SSEWriter {
send(event: SessionEvent): void;
@@ -118,6 +119,15 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
}
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
if (
event.type === "permission_response" ||
event.type === "control_response" ||
event.type === "control_request" ||
event.type === "interrupt"
) {
return toClientPayload(event);
}
const normalized =
event.payload && typeof event.payload === "object"
? (event.payload as Record<string, unknown>)

View File

@@ -3,6 +3,7 @@ import { getEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus";
import { publishSessionEvent } from "../services/transport";
import { log, error as logError } from "../logger";
import { toClientPayload } from "./client-payload";
// Per-connection cleanup, keyed by sessionId (only one WS per session)
interface CleanupEntry {
@@ -24,75 +25,9 @@ const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
* Convert internal EventBus event -> SDK message for bridge client.
*/
function toSDKMessage(event: SessionEvent): string {
const payload = event.payload as Record<string, unknown> | null;
const messageUuid = typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
let msg: Record<string, unknown>;
if (event.type === "user" || event.type === "user_message") {
msg = {
type: "user",
uuid: messageUuid,
session_id: event.sessionId,
message: {
role: "user",
content: payload?.content ?? payload?.message ?? "",
},
};
} else if (event.type === "permission_response" || event.type === "control_response") {
const approved = !!payload?.approved;
const existingResponse = payload?.response as Record<string, unknown> | undefined;
if (existingResponse) {
msg = { type: "control_response", response: existingResponse };
} else {
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
const feedbackMessage = payload?.message as string | undefined;
msg = {
type: "control_response",
response: {
subtype: approved ? "success" : "error",
request_id: payload?.request_id ?? "",
...(approved
? {
response: {
behavior: "allow" as const,
...(updatedInput ? { updatedInput } : {}),
...(updatedPermissions ? { updatedPermissions } : {}),
},
}
: {
error: "Permission denied by user",
response: { behavior: "deny" as const },
...(feedbackMessage ? { message: feedbackMessage } : {}),
}),
},
};
}
} else if (event.type === "interrupt") {
msg = {
type: "control_request",
request_id: event.id,
request: { subtype: "interrupt" },
};
} else if (event.type === "control_request") {
msg = {
type: "control_request",
request_id: payload?.request_id ?? event.id,
request: payload?.request ?? payload,
};
} else {
msg = {
type: event.type,
uuid: messageUuid,
session_id: event.sessionId,
message: payload,
};
}
// NDJSON format: each message MUST end with \n so the child process's
// line-based parser can split messages correctly.
return JSON.stringify(msg) + "\n";
return JSON.stringify(toClientPayload(event)) + "\n";
}
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
@@ -236,7 +171,11 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
}
payload = { message: msg.message, uuid: msg.uuid, content: text };
} else if (eventType === "user" || eventType === "system") {
payload = { message: msg.message, uuid: msg.uuid };
payload = {
message: msg.message,
uuid: msg.uuid,
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
};
} else if (eventType === "control_request") {
payload = { request_id: msg.request_id, request: msg.request };
} else if (eventType === "control_response") {

View File

@@ -70,6 +70,14 @@ export interface SessionResponse {
username: string | null;
created_at: number;
updated_at: number;
automation_state?: AutomationStateResponse;
}
export interface AutomationStateResponse {
enabled: boolean;
phase: "standby" | "sleeping" | null;
next_tick_at: number | null;
sleep_until: number | null;
}
// --- v2 Code Sessions ---

View File

@@ -36,6 +36,7 @@ export interface ControlRequest extends SDKMessage {
export type SessionEventType =
| "user"
| "assistant"
| "automation_state"
| "permission_request"
| "permission_response"
| "control_request"
@@ -49,6 +50,7 @@ export type SessionEventType =
export interface NormalizedEventPayload {
content: string;
raw?: unknown;
isSynthetic?: boolean;
[key: string]: unknown;
}

View File

@@ -4,8 +4,24 @@
*/
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
import { connectSSE, disconnectSSE } from "./sse.js";
import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
import {
appendEvent,
getActivityMode,
removeLoading,
resetReplayState,
renderReplayPendingRequests,
setAutomationActivity,
showLoading,
} from "./render.js";
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
import {
createAutomationState,
getAutomationActivity,
getAutomationIndicator,
reduceAutomationState,
renderAutomationIcon,
shouldPulseAutomationIndicator,
} from "./automation.js";
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
// ============================================================
@@ -16,6 +32,8 @@ let currentSessionId = null;
let currentSessionStatus = null;
let dashboardInterval = null;
let cachedEnvs = [];
let automationState = createAutomationState();
let automationPulseTimer = null;
function generateMessageUuid() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -24,6 +42,82 @@ function generateMessageUuid() {
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function renderAutomationIndicator() {
const indicatorEl = document.getElementById("session-automation");
if (!indicatorEl) return;
const indicator = getAutomationIndicator(automationState);
if (!indicator.visible) {
indicatorEl.className = "automation-pill hidden";
indicatorEl.dataset.pulsing = "false";
indicatorEl.innerHTML = "";
indicatorEl.removeAttribute("title");
return;
}
indicatorEl.className = `automation-pill automation-pill-${indicator.tone}`;
if (indicatorEl.dataset.pulsing === "true") {
indicatorEl.classList.add("is-pulsing");
}
indicatorEl.innerHTML = `
${renderAutomationIcon(indicator.iconVariant, { className: "automation-pill-icon" })}
<span class="automation-pill-label">${esc(indicator.label)}</span>
`;
indicatorEl.title = indicator.title;
}
function syncAutomationUI() {
renderAutomationIndicator();
setAutomationActivity(getAutomationActivity(automationState));
}
function stopAutomationPulse() {
if (automationPulseTimer) {
clearTimeout(automationPulseTimer);
automationPulseTimer = null;
}
const indicatorEl = document.getElementById("session-automation");
if (indicatorEl) {
indicatorEl.dataset.pulsing = "false";
indicatorEl.classList.remove("is-pulsing");
}
}
function pulseAutomationIndicator() {
if (!getAutomationIndicator(automationState).visible) return;
stopAutomationPulse();
const indicatorEl = document.getElementById("session-automation");
if (!indicatorEl) return;
indicatorEl.dataset.pulsing = "true";
indicatorEl.classList.add("is-pulsing");
automationPulseTimer = setTimeout(() => {
indicatorEl.dataset.pulsing = "false";
indicatorEl.classList.remove("is-pulsing");
automationPulseTimer = null;
}, 1200);
}
function resetAutomationIndicator() {
automationState = createAutomationState();
stopAutomationPulse();
syncAutomationUI();
}
function applyAutomationEvent(event, { replay = false } = {}) {
automationState = reduceAutomationState(automationState, event);
syncAutomationUI();
if (!replay && shouldPulseAutomationIndicator(event)) {
pulseAutomationIndicator();
}
}
function applyAutomationSnapshot(snapshot) {
if (snapshot === undefined) return;
applyAutomationEvent({ type: "automation_state", payload: snapshot }, { replay: true });
}
// ============================================================
// Router
// ============================================================
@@ -75,7 +169,7 @@ function applySessionStatus(status) {
if (closed) {
removeLoading();
window.__updateActionBtn?.(false);
window.__updateActionBtn?.("idle");
}
}
@@ -86,6 +180,7 @@ function handleSessionEvent(event) {
disconnectSSE();
}
}
applyAutomationEvent(event);
appendEvent(event);
}
@@ -104,7 +199,9 @@ async function syncClosedSessionState(err, actionLabel) {
const session = await apiFetchSession(currentSessionId);
applySessionStatus(session.status);
if (isClosedSessionStatus(session.status)) {
appendEvent({ type: "session_status", payload: { status: session.status } });
const closedEvent = { type: "session_status", payload: { status: session.status } };
applyAutomationEvent(closedEvent);
appendEvent(closedEvent);
return;
}
} catch {
@@ -159,6 +256,7 @@ async function handleRoute() {
// Default: /code → dashboard
currentSessionId = null;
currentSessionStatus = null;
resetAutomationIndicator();
showPage("dashboard");
disconnectSSE();
renderDashboard();
@@ -233,6 +331,8 @@ function stopDashboardRefresh() {
async function renderSessionDetail(id) {
currentSessionId = id;
resetAutomationIndicator();
let session = null;
// Reset task state for new session and init panel
resetTaskState();
@@ -240,7 +340,7 @@ async function renderSessionDetail(id) {
if (taskPanelEl) initTaskPanel(taskPanelEl);
try {
const session = await apiFetchSession(id);
session = await apiFetchSession(id);
document.getElementById("session-title").textContent = session.title || session.id;
document.getElementById("session-id").textContent = session.id;
document.getElementById("session-env").textContent = session.environment_id || "";
@@ -254,6 +354,7 @@ async function renderSessionDetail(id) {
document.getElementById("event-stream").innerHTML = "";
document.getElementById("permission-area").innerHTML = "";
document.getElementById("permission-area").classList.add("hidden");
applyAutomationSnapshot(session?.automation_state);
// Load historical events before connecting to live stream
resetReplayState();
@@ -262,6 +363,7 @@ async function renderSessionDetail(id) {
const { events } = await apiFetchSessionHistory(id);
if (events && events.length > 0) {
for (const event of events) {
applyAutomationEvent(event, { replay: true });
appendEvent(event, { replay: true });
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
}
@@ -273,7 +375,9 @@ async function renderSessionDetail(id) {
renderReplayPendingRequests();
if (isClosedSessionStatus(currentSessionStatus)) {
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
const closedEvent = { type: "session_status", payload: { status: currentSessionStatus } };
applyAutomationEvent(closedEvent);
appendEvent(closedEvent);
disconnectSSE();
return;
}
@@ -291,17 +395,20 @@ function setupControlBar() {
const iconSend = document.getElementById("action-icon-send");
const iconStop = document.getElementById("action-icon-stop");
function setBtnState(loading) {
actionBtn.classList.toggle("loading", loading);
actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send");
iconSend.classList.toggle("hidden", loading);
iconStop.classList.toggle("hidden", !loading);
function setBtnState(mode) {
const working = mode === "working";
actionBtn.classList.toggle("loading", working);
actionBtn.dataset.mode = mode || "idle";
actionBtn.setAttribute("aria-label", working ? "Stop" : "Send");
iconSend.classList.toggle("hidden", working);
iconStop.classList.toggle("hidden", !working);
}
window.__updateActionBtn = setBtnState;
setBtnState(getActivityMode());
actionBtn.addEventListener("click", () => {
if (isLoading()) {
if (getActivityMode() === "working") {
doInterrupt();
} else {
sendMessage();
@@ -319,7 +426,6 @@ async function doInterrupt() {
btn.disabled = true;
try {
await apiInterrupt(currentSessionId);
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
} catch (err) {
await syncClosedSessionState(err, "Interrupt failed");
} finally {
@@ -460,11 +566,28 @@ window._submitAnswers = async function (requestId, btn) {
function removePermissionPrompt(btn) {
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
const requestId = prompt?.dataset?.requestId || null;
if (prompt) prompt.remove();
if (requestId) {
const stream = document.getElementById("event-stream");
stream?.querySelectorAll("[data-pending-request-id]").forEach((row) => {
if (row.dataset.pendingRequestId === requestId) row.remove();
});
}
const area = document.getElementById("permission-area");
if (area && area.children.length === 0) area.classList.add("hidden");
}
function appendLocalSystemMessage(text) {
const stream = document.getElementById("event-stream");
if (!stream) return;
const row = document.createElement("div");
row.className = "msg-row system";
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
stream.appendChild(row);
stream.scrollTop = stream.scrollHeight;
}
// ============================================================
// ExitPlanMode interactions
// ============================================================
@@ -509,6 +632,7 @@ window._submitPlanResponse = async function (requestId, btn) {
...(feedback ? { message: feedback } : {}),
});
removePermissionPrompt(btn);
appendLocalSystemMessage("Feedback sent. Continuing in plan mode.");
} else {
// Approval with permission mode
const modeMap = {

View File

@@ -0,0 +1,380 @@
/**
* Remote Control — Automation helpers
*
* Centralizes detection of non-human inputs so the web UI can hide
* internal prompts while still surfacing session state.
*/
export const PROACTIVE_ENABLED_TEXT =
"Proactive mode enabled — model will work autonomously between ticks";
export const PROACTIVE_DISABLED_TEXT = "Proactive mode disabled";
const CLOSED_SESSION_STATUSES = new Set(["archived", "inactive"]);
const HIDDEN_AUTOMATION_TAGS = new Set([
"bash-input",
"bash-stderr",
"bash-stdout",
"channel",
"channel-message",
"command-args",
"command-message",
"command-name",
"cross-session-message",
"fork-boilerplate",
"local-command-caveat",
"local-command-stderr",
"local-command-stdout",
"output-file",
"reason",
"remote-review",
"remote-review-progress",
"status",
"summary",
"system-reminder",
"task-id",
"task-notification",
"task-type",
"teammate-message",
"tick",
"tool-use-id",
"ultraplan",
"worktree",
"worktreeBranch",
"worktreePath",
]);
const PRIMARY_AUTOMATION_TAGS = new Set([
"bash-input",
"bash-stderr",
"bash-stdout",
"channel-message",
"command-args",
"command-message",
"command-name",
"cross-session-message",
"fork-boilerplate",
"local-command-caveat",
"local-command-stderr",
"local-command-stdout",
"remote-review",
"remote-review-progress",
"system-reminder",
"task-notification",
"teammate-message",
"tick",
"ultraplan",
]);
const WORKING_AUTOMATION_TAGS = new Set(
[...PRIMARY_AUTOMATION_TAGS].filter(
(tag) => tag !== "local-command-caveat" && tag !== "system-reminder",
),
);
const XML_ONLY_BLOCK_PATTERN =
/^(?:\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*)+$/;
const XML_BLOCK_PATTERN =
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy;
function normalizeAutomationStatePayload(payload) {
if (!payload || typeof payload !== "object") {
return {
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
};
}
return {
enabled: payload.enabled === true,
phase: payload.phase === "standby" || payload.phase === "sleeping" ? payload.phase : null,
next_tick_at: typeof payload.next_tick_at === "number" ? payload.next_tick_at : null,
sleep_until: typeof payload.sleep_until === "number" ? payload.sleep_until : null,
};
}
export function extractEventText(payload) {
if (!payload) return "";
if (typeof payload.content === "string" && payload.content) return payload.content;
const msg = payload.message;
if (msg && typeof msg === "object") {
const mc = msg.content;
if (typeof mc === "string") return mc;
if (Array.isArray(mc)) {
return mc
.filter((block) => block && typeof block === "object" && block.type === "text")
.map((block) => block.text || "")
.join("");
}
}
return typeof payload === "string" ? payload : JSON.stringify(payload);
}
function getOpeningTagNames(text) {
const trimmed = String(text).trim();
if (!trimmed) return [];
XML_BLOCK_PATTERN.lastIndex = 0;
const tags = [];
while (XML_BLOCK_PATTERN.lastIndex < trimmed.length) {
const match = XML_BLOCK_PATTERN.exec(trimmed);
if (!match) return [];
tags.push(match[1]);
}
return tags;
}
export function isAutomationEnvelopeText(text) {
const trimmed = typeof text === "string" ? text.trim() : "";
if (!trimmed) return false;
if (!XML_ONLY_BLOCK_PATTERN.test(trimmed)) return false;
const tagNames = getOpeningTagNames(trimmed);
return (
tagNames.length > 0 &&
tagNames.every((tagName) => HIDDEN_AUTOMATION_TAGS.has(tagName)) &&
tagNames.some((tagName) => PRIMARY_AUTOMATION_TAGS.has(tagName))
);
}
export function isHiddenAutomationUserPayload(payload) {
if (!payload || typeof payload !== "object") return false;
if (payload.isSynthetic === true) return true;
return isAutomationEnvelopeText(extractEventText(payload));
}
export function shouldHideAutomationUserEvent(payload, direction = "inbound") {
return direction === "inbound" && isHiddenAutomationUserPayload(payload);
}
export function shouldStartAutomationWorkFromUserEvent(payload, direction = "inbound") {
if (!shouldHideAutomationUserEvent(payload, direction)) {
return false;
}
const text = extractEventText(payload).trim();
if (!text || !XML_ONLY_BLOCK_PATTERN.test(text)) {
return payload?.isSynthetic === true;
}
const tagNames = getOpeningTagNames(text);
return tagNames.some((tagName) => WORKING_AUTOMATION_TAGS.has(tagName));
}
export function createAutomationState() {
return {
proactive: false,
autoRun: false,
hasAuthority: false,
enabled: false,
phase: null,
nextTickAt: null,
sleepUntil: null,
};
}
function applyAuthoritativeAutomationState(state, payload) {
const normalized = normalizeAutomationStatePayload(payload);
state.hasAuthority = true;
state.enabled = normalized.enabled;
state.phase = normalized.phase;
state.nextTickAt = normalized.next_tick_at;
state.sleepUntil = normalized.sleep_until;
state.proactive = normalized.enabled;
state.autoRun = false;
return state;
}
export function reduceAutomationState(state, event) {
const next = state ? { ...state } : createAutomationState();
if (!event || typeof event !== "object") return next;
const type = event.type || "unknown";
const payload = event.payload || {};
const direction = event.direction || "inbound";
if (type === "automation_state") {
return applyAuthoritativeAutomationState(next, payload);
}
if (type === "session_status") {
if (CLOSED_SESSION_STATUSES.has(payload.status)) {
if (next.hasAuthority) {
return applyAuthoritativeAutomationState(next, null);
}
next.proactive = false;
next.autoRun = false;
}
return next;
}
if (next.hasAuthority) {
return next;
}
if (type === "assistant") {
const text = extractEventText(payload).trim();
if (text === PROACTIVE_ENABLED_TEXT) {
next.proactive = true;
next.autoRun = false;
return next;
}
if (text === PROACTIVE_DISABLED_TEXT) {
next.proactive = false;
next.autoRun = false;
return next;
}
next.autoRun = false;
return next;
}
if (type === "result" || type === "result_success" || type === "error" || type === "interrupt") {
next.autoRun = false;
return next;
}
if (type === "user" && shouldHideAutomationUserEvent(payload, direction)) {
next.autoRun = true;
}
return next;
}
export function shouldPulseAutomationIndicator(event) {
if (!event || typeof event !== "object") return false;
if (event.type === "automation_state") {
return event.payload?.enabled === true;
}
if (event.type === "assistant") {
const text = extractEventText(event.payload || {}).trim();
return text === PROACTIVE_ENABLED_TEXT;
}
return event.type === "user" && shouldHideAutomationUserEvent(event.payload || {}, event.direction || "inbound");
}
export function getAutomationIndicator(state) {
if (state?.hasAuthority) {
if (!state.enabled) {
return {
visible: false,
label: "",
tone: "",
title: "",
iconVariant: "active",
};
}
if (state.phase === "sleeping") {
return {
visible: true,
label: "Autopilot",
tone: "sleeping",
title: "Claude Code is in proactive mode and currently sleeping until the next wake-up or user message.",
iconVariant: "sleeping",
};
}
if (state.phase === "standby") {
return {
visible: true,
label: "Autopilot",
tone: "proactive",
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
iconVariant: "standby",
};
}
return {
visible: true,
label: "Autopilot",
tone: "proactive",
title: "Claude Code is in proactive mode and may continue working between user messages.",
iconVariant: "active",
};
}
if (state?.proactive) {
return {
visible: true,
label: "Autopilot",
tone: "proactive",
title: "Claude Code is in proactive mode and may continue working between user messages.",
iconVariant: "active",
};
}
if (state?.autoRun) {
return {
visible: true,
label: "Auto Run",
tone: "auto-run",
title: "Claude Code is processing an automatic background trigger.",
iconVariant: "active",
};
}
return {
visible: false,
label: "",
tone: "",
title: "",
iconVariant: "active",
};
}
export function getAutomationActivity(state) {
if (!state?.hasAuthority || !state.enabled) {
return null;
}
if (state.phase === "standby") {
return {
mode: "standby",
label: "standby",
endsAt: state.nextTickAt,
iconVariant: "standby",
};
}
if (state.phase === "sleeping") {
return {
mode: "sleeping",
label: "sleeping",
endsAt: state.sleepUntil,
iconVariant: "sleeping",
};
}
return null;
}
export function renderAutomationIcon(variant = "active", { className = "", decorative = true } = {}) {
const classes = ["clawd-icon", `clawd-icon-${variant}`, className].filter(Boolean).join(" ");
const ariaAttrs = decorative ? 'aria-hidden="true"' : 'role="img" aria-label="Claude Code status"';
return `
<span class="${classes}" ${ariaAttrs}>
<svg viewBox="0 0 40 30" fill="none">
<path class="clawd-arm clawd-arm-left" d="M8.5 13.4C6.6 12.8 5.4 11.4 4.8 9.4C4.6 8.6 4.9 7.7 5.6 7.3C6.3 6.9 7.2 7 7.8 7.6L10.8 10.6L8.5 13.4Z" />
<path class="clawd-arm clawd-arm-right" d="M31.5 13.4C33.4 12.8 34.6 11.4 35.2 9.4C35.4 8.6 35.1 7.7 34.4 7.3C33.7 6.9 32.8 7 32.2 7.6L29.2 10.6L31.5 13.4Z" />
<path class="clawd-shell" d="M10 12.2C10 7.9 13.5 4.4 17.8 4.4H22.2C26.5 4.4 30 7.9 30 12.2V17.3C30 21 27 24 23.3 24H16.7C13 24 10 21 10 17.3V12.2Z" />
<circle class="clawd-eye clawd-eye-left" cx="17.2" cy="13.4" r="1.55" />
<circle class="clawd-eye clawd-eye-right" cx="22.8" cy="13.4" r="1.55" />
<path class="clawd-eye-line clawd-eye-line-left" d="M15.9 13.6C16.3 12.8 17 12.4 17.9 12.4" />
<path class="clawd-eye-line clawd-eye-line-right" d="M22.1 12.4C23 12.4 23.7 12.8 24.1 13.6" />
<path class="clawd-foot clawd-foot-left" d="M14.3 25.1C14.3 24 15.2 23.1 16.3 23.1C17.4 23.1 18.3 24 18.3 25.1V25.8H14.3V25.1Z" />
<path class="clawd-foot clawd-foot-right" d="M21.7 25.1C21.7 24 22.6 23.1 23.7 23.1C24.8 23.1 25.7 24 25.7 25.1V25.8H21.7V25.1Z" />
</svg>
<span class="clawd-z clawd-z-1">Z</span>
<span class="clawd-z clawd-z-2">Z</span>
</span>
`;
}

View File

@@ -0,0 +1,207 @@
import { describe, expect, test } from "bun:test";
import {
PROACTIVE_DISABLED_TEXT,
PROACTIVE_ENABLED_TEXT,
createAutomationState,
getAutomationActivity,
getAutomationIndicator,
isAutomationEnvelopeText,
reduceAutomationState,
shouldHideAutomationUserEvent,
shouldStartAutomationWorkFromUserEvent,
} from "./automation.js";
describe("automation helpers", () => {
test("keeps real user text visible", () => {
expect(shouldHideAutomationUserEvent({ content: "hello from a human" }, "inbound")).toBe(false);
});
test("hides internal xml wrappers without synthetic metadata", () => {
expect(isAutomationEnvelopeText("<tick>2:56:47 PM</tick>")).toBe(true);
expect(isAutomationEnvelopeText("<system-reminder>\nDo useful work.\n</system-reminder>")).toBe(true);
expect(
isAutomationEnvelopeText(
"<task-notification><summary>Finished</summary><output-file>/tmp/out.log</output-file></task-notification>",
),
).toBe(true);
expect(
shouldHideAutomationUserEvent(
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
"inbound",
),
).toBe(true);
});
test("does not treat slash-command scaffolding as active work", () => {
expect(
shouldStartAutomationWorkFromUserEvent(
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
"inbound",
),
).toBe(false);
expect(
shouldStartAutomationWorkFromUserEvent(
{
content:
"<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts.\n</system-reminder>",
isSynthetic: true,
},
"inbound",
),
).toBe(false);
});
test("keeps true automatic triggers eligible for loading state", () => {
expect(
shouldStartAutomationWorkFromUserEvent(
{ content: "<tick>2:56:47 PM</tick>", isSynthetic: true },
"inbound",
),
).toBe(true);
expect(
shouldStartAutomationWorkFromUserEvent(
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
"inbound",
),
).toBe(true);
});
test("hides synthetic automatic prompts even when they are plain text", () => {
expect(
shouldHideAutomationUserEvent(
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
"inbound",
),
).toBe(true);
});
test("keeps mixed human text with tags visible", () => {
expect(
shouldHideAutomationUserEvent(
{ content: "Please keep this: <system-reminder>not metadata</system-reminder>" },
"inbound",
),
).toBe(false);
});
test("shows autopilot while proactive mode remains active", () => {
let state = createAutomationState();
state = reduceAutomationState(state, {
type: "assistant",
payload: { content: PROACTIVE_ENABLED_TEXT },
});
expect(getAutomationIndicator(state)).toEqual({
visible: true,
label: "Autopilot",
tone: "proactive",
title: "Claude Code is in proactive mode and may continue working between user messages.",
iconVariant: "active",
});
state = reduceAutomationState(state, {
type: "user",
direction: "inbound",
payload: { content: "<tick>3:15:00 PM</tick>" },
});
expect(getAutomationIndicator(state).label).toBe("Autopilot");
state = reduceAutomationState(state, {
type: "assistant",
payload: { content: "Working on background maintenance." },
});
expect(getAutomationIndicator(state).label).toBe("Autopilot");
state = reduceAutomationState(state, {
type: "assistant",
payload: { content: PROACTIVE_DISABLED_TEXT },
});
expect(getAutomationIndicator(state).visible).toBe(false);
});
test("shows auto run until an automatic trigger settles", () => {
let state = createAutomationState();
state = reduceAutomationState(state, {
type: "user",
direction: "inbound",
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
});
expect(getAutomationIndicator(state).label).toBe("Auto Run");
expect(getAutomationIndicator(state).iconVariant).toBe("active");
state = reduceAutomationState(state, {
type: "assistant",
payload: { content: "Completed scheduled refresh." },
});
expect(getAutomationIndicator(state).visible).toBe(false);
});
test("authoritative automation_state drives standby and sleeping states", () => {
let state = createAutomationState();
state = reduceAutomationState(state, {
type: "automation_state",
payload: {
enabled: true,
phase: "standby",
next_tick_at: 123456,
sleep_until: null,
},
});
expect(getAutomationIndicator(state)).toEqual({
visible: true,
label: "Autopilot",
tone: "proactive",
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
iconVariant: "standby",
});
expect(getAutomationActivity(state)).toEqual({
mode: "standby",
label: "standby",
endsAt: 123456,
iconVariant: "standby",
});
state = reduceAutomationState(state, {
type: "automation_state",
payload: {
enabled: true,
phase: "sleeping",
next_tick_at: null,
sleep_until: 999999,
},
});
expect(getAutomationIndicator(state).tone).toBe("sleeping");
expect(getAutomationIndicator(state).iconVariant).toBe("sleeping");
expect(getAutomationActivity(state)).toEqual({
mode: "sleeping",
label: "sleeping",
endsAt: 999999,
iconVariant: "sleeping",
});
});
test("authoritative disabled snapshot suppresses heuristic auto-run fallback", () => {
let state = createAutomationState();
state = reduceAutomationState(state, {
type: "automation_state",
payload: {
enabled: false,
phase: null,
next_tick_at: null,
sleep_until: null,
},
});
state = reduceAutomationState(state, {
type: "user",
direction: "inbound",
payload: { content: "<tick>3:15:00 PM</tick>" },
});
expect(getAutomationIndicator(state).visible).toBe(false);
expect(getAutomationActivity(state)).toBeNull();
});
});

View File

@@ -81,6 +81,7 @@
<div class="session-meta-row">
<span id="session-id" class="meta-item"></span>
<span id="session-status" class="status-badge"></span>
<span id="session-automation" class="automation-pill hidden" aria-live="polite"></span>
<span id="session-env" class="meta-item"></span>
<span id="session-time" class="meta-item"></span>
<button id="task-panel-toggle" class="nav-link btn-text" title="Tasks & Todos">

View File

@@ -24,6 +24,7 @@
.msg-row.user { align-self: flex-end; }
.msg-row.assistant { align-self: flex-start; }
.msg-row.tool { align-self: flex-start; max-width: 95%; }
.msg-row.tool-trace-row { align-self: flex-start; max-width: 92%; }
.msg-row.system { align-self: center; }
.msg-row.result { align-self: center; }
@@ -51,6 +52,124 @@
box-shadow: var(--shadow-sm);
}
.assistant-turn {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.assistant-turn-orphan {
gap: 0;
}
.assistant-trace {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.assistant-trace.hidden {
display: none;
}
.assistant-trace-toggle {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid rgba(160, 120, 96, 0.16);
background: rgba(245, 243, 239, 0.78);
color: var(--text-secondary);
border-radius: 999px;
padding: 6px 10px 6px 8px;
font-size: 0.76rem;
font-weight: 600;
line-height: 1;
transition: all var(--transition-fast);
backdrop-filter: blur(8px);
}
.assistant-trace-toggle:hover {
color: var(--text-primary);
border-color: rgba(217, 119, 87, 0.28);
background: rgba(250, 247, 242, 0.98);
}
.assistant-trace-toggle.has-error {
color: var(--red);
border-color: rgba(196, 64, 64, 0.24);
background: rgba(252, 238, 238, 0.88);
}
.assistant-trace-glyph {
display: inline-flex;
align-items: flex-end;
gap: 2px;
min-width: 14px;
}
.assistant-trace-glyph span {
display: block;
width: 3px;
border-radius: 999px;
background: currentColor;
opacity: 0.82;
}
.assistant-trace-glyph span:nth-child(1) { height: 7px; }
.assistant-trace-glyph span:nth-child(2) { height: 10px; }
.assistant-trace-glyph span:nth-child(3) { height: 5px; }
.assistant-trace-count {
min-width: 1ch;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.assistant-trace-chevron {
font-size: 0.9rem;
transition: transform var(--transition-fast);
}
.assistant-trace-toggle.is-open .assistant-trace-chevron {
transform: rotate(90deg);
}
.assistant-trace-panel {
width: min(100%, 720px);
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 16px;
border: 1px solid rgba(160, 120, 96, 0.14);
background:
linear-gradient(180deg, rgba(250, 247, 242, 0.98), rgba(245, 243, 239, 0.92)),
var(--bg-card);
box-shadow: var(--shadow-sm);
}
.assistant-trace-panel.hidden {
display: none;
}
.assistant-trace-card {
box-shadow: none;
}
.assistant-trace-card:hover {
border-color: rgba(217, 119, 87, 0.24);
}
.assistant-trace-card-error {
border-color: rgba(196, 64, 64, 0.24);
}
.assistant-trace-card-error:hover {
border-color: rgba(196, 64, 64, 0.4);
}
.msg-row.system .msg-bubble {
background: transparent;
color: var(--text-muted);
@@ -98,6 +217,7 @@
font-size: 0.7rem;
transition: transform var(--transition-fast);
}
.tool-card-header.is-open .tool-icon,
.tool-card-header:hover .tool-icon { transform: rotate(90deg); }
.tool-card-body {
@@ -329,15 +449,51 @@
line-height: 1.6;
color: var(--text-primary);
}
.plan-panel .plan-content > :first-child { margin-top: 0; }
.plan-panel .plan-content > :last-child { margin-bottom: 0; }
.plan-panel .plan-content h1,
.plan-panel .plan-content h2,
.plan-panel .plan-content h3,
.plan-panel .plan-content h4,
.plan-panel .plan-content h5,
.plan-panel .plan-content h6 {
margin: 0 0 10px;
line-height: 1.3;
font-family: var(--font-display);
font-weight: 600;
}
.plan-panel .plan-content h1 { font-size: 1.15rem; }
.plan-panel .plan-content h2 { font-size: 1.05rem; }
.plan-panel .plan-content h3,
.plan-panel .plan-content h4,
.plan-panel .plan-content h5,
.plan-panel .plan-content h6 { font-size: 0.95rem; }
.plan-panel .plan-content p {
margin: 0 0 10px;
}
.plan-panel .plan-content ul,
.plan-panel .plan-content ol {
margin: 0 0 12px 1.35em;
padding: 0;
}
.plan-panel .plan-content li + li {
margin-top: 4px;
}
.plan-panel .plan-content pre {
background: var(--bg-tool-card);
padding: 10px;
border-radius: 6px;
overflow-x: auto;
margin: 6px 0;
margin: 10px 0;
font-family: var(--font-mono);
font-size: 0.82rem;
}
.plan-panel .plan-content pre code {
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
}
.plan-panel .plan-content code {
background: var(--bg-tool-card);
padding: 2px 5px;
@@ -479,3 +635,58 @@
font-family: var(--font-mono);
margin-left: auto;
}
.automation-activity-row {
align-self: flex-start;
max-width: 92%;
}
.automation-activity-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 18px;
border: 1px solid rgba(217, 119, 87, 0.16);
background:
linear-gradient(135deg, rgba(217, 119, 87, 0.08), rgba(250, 247, 242, 0.94)),
var(--bg-card);
box-shadow: var(--shadow-sm);
}
.automation-activity-standby .automation-activity-card {
color: var(--accent-hover);
}
.automation-activity-sleeping .automation-activity-card {
color: var(--green);
border-color: rgba(59, 138, 106, 0.16);
background:
linear-gradient(135deg, rgba(59, 138, 106, 0.08), rgba(250, 247, 242, 0.94)),
var(--bg-card);
}
.automation-activity-icon {
width: 34px;
height: 26px;
}
.automation-activity-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.automation-activity-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.automation-activity-countdown {
margin-left: auto;
padding: 5px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(160, 120, 96, 0.14);
font-family: var(--font-mono);
font-size: 0.78rem;
color: currentColor;
flex-shrink: 0;
}

View File

@@ -234,6 +234,164 @@
gap: 12px;
align-items: center;
}
.automation-pill {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px 10px 4px 8px;
border-radius: 999px;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
border: 1px solid transparent;
box-shadow: var(--shadow-sm);
transition: transform var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.automation-pill-icon { width: 24px; height: 18px; flex-shrink: 0; }
.automation-pill-label { line-height: 1; }
.automation-pill-proactive {
color: var(--accent-hover);
background:
linear-gradient(135deg, rgba(217, 119, 87, 0.12), rgba(217, 119, 87, 0.06)),
var(--bg-card);
border-color: rgba(217, 119, 87, 0.18);
}
.automation-pill-sleeping {
color: var(--green);
background:
linear-gradient(135deg, rgba(59, 138, 106, 0.14), rgba(59, 138, 106, 0.05)),
var(--bg-card);
border-color: rgba(59, 138, 106, 0.18);
}
.automation-pill-auto-run {
color: var(--green);
background:
linear-gradient(135deg, rgba(59, 138, 106, 0.12), rgba(59, 138, 106, 0.05)),
var(--bg-card);
border-color: rgba(59, 138, 106, 0.18);
}
.automation-pill.is-pulsing {
animation: automationPillPulse 1.2s ease-out;
}
.automation-pill.is-pulsing .clawd-icon {
animation: automationDotPulse 1.2s ease-out;
}
@keyframes automationPillPulse {
0% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
35% { transform: translateY(-1px) scale(1.02); box-shadow: var(--shadow-md); }
100% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
}
@keyframes automationDotPulse {
0% { transform: scale(1); opacity: 0.9; }
35% { transform: scale(1.5); opacity: 1; }
100% { transform: scale(1); opacity: 0.92; }
}
.clawd-icon {
position: relative;
display: inline-flex;
width: 30px;
height: 22px;
flex-shrink: 0;
color: inherit;
}
.clawd-icon svg {
width: 100%;
height: 100%;
overflow: visible;
}
.clawd-shell,
.clawd-foot { fill: currentColor; }
.clawd-shell { opacity: 0.9; }
.clawd-arm { fill: currentColor; opacity: 0.74; }
.clawd-eye {
fill: var(--text-primary);
transform-box: fill-box;
transform-origin: center;
}
.clawd-eye-line {
display: none;
stroke: var(--text-primary);
stroke-width: 1.8;
stroke-linecap: round;
}
.clawd-z {
position: absolute;
top: -3px;
right: -2px;
font-family: var(--font-mono);
font-size: 0.56rem;
font-weight: 700;
color: currentColor;
opacity: 0;
pointer-events: none;
}
.clawd-z-2 {
top: -9px;
right: 4px;
font-size: 0.48rem;
}
.clawd-icon-standby svg {
animation: clawdStandbyBob 2.4s ease-in-out infinite;
}
.clawd-icon-standby .clawd-eye-left {
animation: clawdLookLeft 2.4s ease-in-out infinite;
}
.clawd-icon-standby .clawd-eye-right {
animation: clawdLookRight 2.4s ease-in-out infinite;
}
.clawd-icon-sleeping svg {
animation: clawdSleepFloat 3.2s ease-in-out infinite;
}
.clawd-icon-sleeping .clawd-eye {
display: none;
}
.clawd-icon-sleeping .clawd-eye-line {
display: block;
}
.clawd-icon-sleeping .clawd-z {
opacity: 0.88;
}
.clawd-icon-sleeping .clawd-z-1 {
animation: clawdSleepZ 2.7s ease-in-out infinite;
}
.clawd-icon-sleeping .clawd-z-2 {
animation: clawdSleepZ 2.7s ease-in-out infinite 0.45s;
}
@keyframes clawdStandbyBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1px); }
}
@keyframes clawdLookLeft {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-0.8px); }
55% { transform: translateX(0.6px); }
}
@keyframes clawdLookRight {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-0.6px); }
55% { transform: translateX(0.8px); }
}
@keyframes clawdSleepFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(1px); }
}
@keyframes clawdSleepZ {
0% { transform: translate(0, 0) scale(0.94); opacity: 0; }
20% { opacity: 0.88; }
100% { transform: translate(4px, -8px) scale(1.04); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.automation-pill.is-pulsing,
.automation-pill.is-pulsing .clawd-icon,
.clawd-icon-standby svg,
.clawd-icon-standby .clawd-eye-left,
.clawd-icon-standby .clawd-eye-right,
.clawd-icon-sleeping svg,
.clawd-icon-sleeping .clawd-z-1,
.clawd-icon-sleeping .clawd-z-2 {
animation: none;
}
}
.meta-item {
font-size: 0.8rem;
color: var(--text-secondary);

View File

@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import {
formatCountdownRemaining,
resolveActivityMode,
shouldRenderTranscriptActivity,
} from "./render.js";
describe("render activity helpers", () => {
test("authoritative standby and sleeping states override stale working spinners", () => {
expect(resolveActivityMode(true, { mode: "standby" })).toBe("standby");
expect(resolveActivityMode(true, { mode: "sleeping" })).toBe("sleeping");
expect(resolveActivityMode(true, null)).toBe("working");
expect(resolveActivityMode(false, null)).toBe("idle");
});
test("formats countdowns compactly", () => {
expect(formatCountdownRemaining(35_000, 0)).toBe("35s");
expect(formatCountdownRemaining(185_000, 0)).toBe("3m 5s");
expect(formatCountdownRemaining(3_900_000, 0)).toBe("1h 5m");
expect(formatCountdownRemaining(null, 0)).toBe("");
});
test("renders transcript activity only for active work", () => {
expect(shouldRenderTranscriptActivity("working")).toBe(true);
expect(shouldRenderTranscriptActivity("standby")).toBe(false);
expect(shouldRenderTranscriptActivity("sleeping")).toBe(false);
expect(shouldRenderTranscriptActivity("idle")).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { formatPlanContent } from "./render.js";
describe("formatPlanContent", () => {
test("renders headings, paragraphs, and lists for plan panels", () => {
const html = formatPlanContent(`## Summary
Line one
Line two
- First item
- Second item
1. Step one
2. Step two`);
expect(html).toContain("<h2>Summary</h2>");
expect(html).toContain("<p>Line one<br>Line two</p>");
expect(html).toContain("<ul><li>First item</li><li>Second item</li></ul>");
expect(html).toContain("<ol><li>Step one</li><li>Step two</li></ol>");
});
test("escapes unsafe markup and preserves inline formatting plus code blocks", () => {
const html = formatPlanContent(`**Bold** with \`inline\` and <script>alert(1)</script>
\`\`\`js
const markup = "<div>";
\`\`\``);
expect(html).toContain("<strong>Bold</strong>");
expect(html).toContain("<code");
expect(html).toContain("inline</code>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).toContain("<pre><code>const markup = &quot;&lt;div&gt;&quot;;</code></pre>");
});
});

View File

@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { isConversationClearedStatus } from "./render.js";
describe("status helpers", () => {
test("detects direct conversation reset markers", () => {
expect(isConversationClearedStatus({ status: "conversation_cleared" })).toBe(true);
});
test("detects nested raw conversation reset markers", () => {
expect(
isConversationClearedStatus({
status: "",
raw: { status: "conversation_cleared" },
}),
).toBe(true);
});
test("ignores unrelated status payloads", () => {
expect(isConversationClearedStatus({ status: "running" })).toBe(false);
expect(isConversationClearedStatus({})).toBe(false);
expect(isConversationClearedStatus(null)).toBe(false);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test";
import {
addAssistantToolTraceHost,
addToolTraceEntry,
clearActiveToolTraceHost,
createToolTraceState,
} from "./render.js";
describe("tool trace grouping state", () => {
test("keeps tool entries attached to the current assistant turn", () => {
let state = createToolTraceState();
const assistant = addAssistantToolTraceHost(state, "Checking the repo");
state = assistant.state;
const toolUse = addToolTraceEntry(state, "use");
state = toolUse.state;
const toolResult = addToolTraceEntry(state, "result");
state = toolResult.state;
expect(assistant.host).toEqual({
id: "trace-1",
kind: "assistant",
assistantContent: "Checking the repo",
entryKinds: [],
});
expect(toolUse.createdHost).toBeNull();
expect(toolResult.createdHost).toBeNull();
expect(state.hosts).toEqual([
{
id: "trace-1",
kind: "assistant",
assistantContent: "Checking the repo",
entryKinds: ["use", "result"],
},
]);
});
test("creates an orphan trace host when tool activity has no assistant turn", () => {
const result = addToolTraceEntry(createToolTraceState(), "use");
expect(result.createdHost).toEqual({
id: "trace-1",
kind: "orphan",
assistantContent: "",
entryKinds: ["use"],
});
expect(result.state.hosts).toEqual([
{
id: "trace-1",
kind: "orphan",
assistantContent: "",
entryKinds: ["use"],
},
]);
});
test("starts a new orphan host after a visible user turn clears the active assistant host", () => {
let state = createToolTraceState();
state = addAssistantToolTraceHost(state, "Running tools").state;
state = addToolTraceEntry(state, "use").state;
state = clearActiveToolTraceHost(state);
const nextResult = addToolTraceEntry(state, "result");
expect(nextResult.createdHost).toEqual({
id: "trace-2",
kind: "orphan",
assistantContent: "",
entryKinds: ["result"],
});
expect(nextResult.state.hosts).toEqual([
{
id: "trace-1",
kind: "assistant",
assistantContent: "Running tools",
entryKinds: ["use"],
},
{
id: "trace-2",
kind: "orphan",
assistantContent: "",
entryKinds: ["result"],
},
]);
});
});

View File

@@ -5,7 +5,13 @@
*/
import { esc } from "./utils.js";
import { processAssistantEvent } from "./task-panel.js";
import {
extractEventText,
renderAutomationIcon,
shouldHideAutomationUserEvent,
shouldStartAutomationWorkFromUserEvent,
} from "./automation.js";
import { applyTaskStateEvent, processAssistantEvent } from "./task-panel.js";
// ============================================================
// Replay state — tracks unresolved permission requests during history replay
@@ -14,12 +20,116 @@ import { processAssistantEvent } from "./task-panel.js";
const replayPendingRequests = new Map(); // request_id → event data (unresolved)
const replayRespondedRequests = new Set(); // request_ids that have a response
const renderedUserUuids = new Set();
const traceHostElements = new Map(); // host_id → DOM refs for inline tool traces
export function createToolTraceState() {
return {
nextHostId: 1,
activeHostId: null,
hosts: [],
};
}
function cloneToolTraceState(state) {
return {
nextHostId: state.nextHostId,
activeHostId: state.activeHostId,
hosts: state.hosts.map((host) => ({
...host,
entryKinds: [...host.entryKinds],
})),
};
}
function createToolTraceHost(nextState, kind, assistantContent = "") {
const host = {
id: `trace-${nextState.nextHostId}`,
kind,
assistantContent,
entryKinds: [],
};
nextState.nextHostId += 1;
nextState.activeHostId = host.id;
nextState.hosts.push(host);
return host;
}
export function addAssistantToolTraceHost(state, content) {
const nextState = cloneToolTraceState(state);
const host = createToolTraceHost(nextState, "assistant", content);
return { state: nextState, host };
}
export function clearActiveToolTraceHost(state) {
if (!state.activeHostId) return state;
const nextState = cloneToolTraceState(state);
nextState.activeHostId = null;
return nextState;
}
export function addToolTraceEntry(state, entryKind) {
const nextState = cloneToolTraceState(state);
let host = nextState.hosts.find((item) => item.id === nextState.activeHostId);
let createdHost = null;
if (!host) {
createdHost = createToolTraceHost(nextState, "orphan");
host = createdHost;
}
host.entryKinds.push(entryKind);
return { state: nextState, host, createdHost };
}
let toolTraceState = createToolTraceState();
function resetToolTraceRuntime() {
toolTraceState = createToolTraceState();
traceHostElements.clear();
}
/** Clear replay tracking state (call before each history load) */
export function resetReplayState() {
replayPendingRequests.clear();
replayRespondedRequests.clear();
renderedUserUuids.clear();
resetToolTraceRuntime();
}
export function isConversationClearedStatus(payload) {
if (!payload || typeof payload !== "object") return false;
if (payload.status === "conversation_cleared") return true;
const raw = payload.raw;
return !!raw && typeof raw === "object" && raw.status === "conversation_cleared";
}
function clearTranscriptView() {
const stream = document.getElementById("event-stream");
if (!stream) return;
let preservedClearCommand = null;
for (let i = stream.children.length - 1; i >= 0; i -= 1) {
const row = stream.children[i];
if (!row || typeof row.textContent !== "string") continue;
if (row.textContent.trim() === "/clear") {
preservedClearCommand = row.cloneNode(true);
break;
}
}
stream.innerHTML = "";
if (preservedClearCommand) {
stream.appendChild(preservedClearCommand);
}
const permissionArea = document.getElementById("permission-area");
if (permissionArea) {
permissionArea.innerHTML = "";
permissionArea.classList.add("hidden");
}
removeLoading();
resetReplayState();
}
/** After replay finishes, render any still-unresolved permission prompts */
@@ -50,27 +160,15 @@ function truncate(str, max) {
* Server-side normalization guarantees payload.content is a string.
* Falls back to raw/message parsing for backward compat.
*/
export function extractText(payload) {
if (!payload) return "";
export const extractText = extractEventText;
// Normalized format (server standardized)
if (typeof payload.content === "string" && payload.content) return payload.content;
// Fallback: raw message.content (child process format)
const msg = payload.message;
if (msg && typeof msg === "object") {
const mc = msg.content;
if (typeof mc === "string") return mc;
if (Array.isArray(mc)) {
return mc
.filter((b) => b && typeof b === "object" && b.type === "text")
.map((b) => b.text || "")
.join("");
}
}
// Final fallback
return typeof payload === "string" ? payload : JSON.stringify(payload);
function formatInlineContent(content) {
let html = esc(content);
// Inline code: `...`
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
// Bold: **...**
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
return html;
}
function formatAssistantContent(content) {
@@ -79,13 +177,106 @@ function formatAssistantContent(content) {
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
return `<pre style="background:var(--bg-tool-card);padding:10px;border-radius:6px;overflow-x:auto;margin:6px 0;font-family:var(--font-mono);font-size:0.82rem;">${code.trim()}</pre>`;
});
// Inline code: `...`
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
// Bold: **...**
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = formatInlineContent(html);
return html;
}
function renderPlanCodeBlock(code) {
return `<pre><code>${esc(code.trim())}</code></pre>`;
}
function formatPlanTextBlock(content) {
const blocks = [];
const lines = content.split(/\r?\n/);
let paragraph = [];
let listType = null;
let listItems = [];
function flushParagraph() {
if (paragraph.length === 0) return;
blocks.push(`<p>${paragraph.map(line => formatInlineContent(line)).join("<br>")}</p>`);
paragraph = [];
}
function flushList() {
if (!listType || listItems.length === 0) return;
blocks.push(`<${listType}>${listItems.map(item => `<li>${item}</li>`).join("")}</${listType}>`);
listType = null;
listItems = [];
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
continue;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
flushParagraph();
flushList();
const level = Math.min(headingMatch[1].length, 6);
blocks.push(`<h${level}>${formatInlineContent(headingMatch[2])}</h${level}>`);
continue;
}
const unorderedMatch = trimmed.match(/^[-*]\s+(.*)$/);
if (unorderedMatch) {
flushParagraph();
if (listType !== "ul") {
flushList();
listType = "ul";
}
listItems.push(formatInlineContent(unorderedMatch[1]));
continue;
}
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
if (orderedMatch) {
flushParagraph();
if (listType !== "ol") {
flushList();
listType = "ol";
}
listItems.push(formatInlineContent(orderedMatch[1]));
continue;
}
flushList();
paragraph.push(trimmed);
}
flushParagraph();
flushList();
return blocks.join("");
}
export function formatPlanContent(content) {
const parts = [];
const codeBlockPattern = /```(\w*)\n?([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = codeBlockPattern.exec(content)) !== null) {
const precedingText = content.slice(lastIndex, match.index);
if (precedingText.trim()) {
parts.push(formatPlanTextBlock(precedingText));
}
parts.push(renderPlanCodeBlock(match[2]));
lastIndex = codeBlockPattern.lastIndex;
}
const trailingText = content.slice(lastIndex);
if (trailingText.trim()) {
parts.push(formatPlanTextBlock(trailingText));
}
return parts.join("");
}
function getUserUuid(payload) {
if (!payload || typeof payload !== "object") return null;
if (typeof payload.uuid === "string" && payload.uuid) return payload.uuid;
@@ -95,7 +286,7 @@ function getUserUuid(payload) {
return null;
}
function shouldRenderUserEvent(payload, direction, replay) {
function shouldProcessUserEvent(payload, direction) {
const uuid = getUserUuid(payload);
if (uuid) {
if (renderedUserUuids.has(uuid)) return false;
@@ -103,10 +294,10 @@ function shouldRenderUserEvent(payload, direction, replay) {
return true;
}
// Legacy fallback with no uuid: keep the previous no-duplicate behavior.
// Live inbound user events without a uuid are most likely echoes of a web-
// sent message; replay keeps the prior "outbound only" rule as well.
return direction === "outbound";
// Legacy fallback with no uuid: inbound human messages are usually echoes
// of a web-sent prompt, but hidden automation inputs still need to drive
// loading state and the session status marker.
return direction === "outbound" || shouldHideAutomationUserEvent(payload, direction);
}
function getMessageContentBlocks(payload) {
@@ -116,27 +307,8 @@ function getMessageContentBlocks(payload) {
return msg.content.filter((block) => block && typeof block === "object");
}
function renderEmbeddedToolUseBlocks(payload) {
return getMessageContentBlocks(payload)
.filter((block) => block.type === "tool_use")
.map((block) =>
renderToolUse({
tool_name: block.name || "tool",
tool_input: block.input || {},
}),
);
}
function renderEmbeddedToolResultBlocks(payload) {
return getMessageContentBlocks(payload)
.filter((block) => block.type === "tool_result")
.map((block) =>
renderToolResult({
content: block.content || "",
output: block.content || "",
is_error: !!block.is_error,
}),
);
function getEmbeddedToolBlocks(payload, blockType) {
return getMessageContentBlocks(payload).filter((block) => block.type === blockType);
}
// ============================================================
@@ -162,30 +334,63 @@ export function appendEvent(data, { replay = false } = {}) {
switch (type) {
case "user":
{
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
if (toolResultEls.length > 0) {
histEls.push(...toolResultEls);
const toolResultBlocks = getEmbeddedToolBlocks(payload, "tool_result");
if (toolResultBlocks.length > 0) {
for (const block of toolResultBlocks) {
appendToolEntryToActiveTrace(
"result",
{
content: block.content || "",
output: block.content || "",
is_error: !!block.is_error,
},
histEls,
);
}
break;
}
if (shouldRenderUserEvent(payload, direction, true)) {
if (shouldProcessUserEvent(payload, direction)) {
if (shouldHideAutomationUserEvent(payload, direction)) {
break;
}
toolTraceState = clearActiveToolTraceHost(toolTraceState);
histEls.push(renderUserMessage(payload, direction));
}
}
break;
case "assistant":
{
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
const text = extractText(payload);
const toolUseBlocks = getEmbeddedToolBlocks(payload, "tool_use");
if (text && text.trim()) histEls.push(renderAssistantMessage(payload));
if (toolUseEls.length > 0) histEls.push(...toolUseEls);
for (const block of toolUseBlocks) {
appendToolEntryToActiveTrace(
"use",
{
tool_name: block.name || "tool",
tool_input: block.input || {},
},
histEls,
);
}
processAssistantEvent(payload);
}
break;
case "task_state":
applyTaskStateEvent(payload);
return;
case "automation_state":
return;
case "status":
if (isConversationClearedStatus(payload)) {
clearTranscriptView();
}
return;
case "tool_use":
histEls.push(renderToolUse(payload));
appendToolEntryToActiveTrace("use", payload, histEls);
break;
case "tool_result":
histEls.push(renderToolResult(payload));
appendToolEntryToActiveTrace("result", payload, histEls);
break;
case "error":
histEls.push(renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`));
@@ -230,17 +435,32 @@ export function appendEvent(data, { replay = false } = {}) {
const els = [];
let needLoading = false;
switch (type) {
switch (type) {
case "user":
{
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
if (toolResultEls.length > 0) {
els.push(...toolResultEls);
const toolResultBlocks = getEmbeddedToolBlocks(payload, "tool_result");
if (toolResultBlocks.length > 0) {
for (const block of toolResultBlocks) {
appendToolEntryToActiveTrace(
"result",
{
content: block.content || "",
output: block.content || "",
is_error: !!block.is_error,
},
els,
);
}
break;
}
if (!shouldRenderUserEvent(payload, direction, false)) return;
els.push(renderUserMessage(payload, direction));
needLoading = true;
if (!shouldProcessUserEvent(payload, direction)) return;
if (!shouldHideAutomationUserEvent(payload, direction)) {
toolTraceState = clearActiveToolTraceHost(toolTraceState);
els.push(renderUserMessage(payload, direction));
needLoading = true;
} else {
needLoading = shouldStartAutomationWorkFromUserEvent(payload, direction);
}
}
break;
case "partial_assistant":
@@ -249,26 +469,40 @@ export function appendEvent(data, { replay = false } = {}) {
return;
case "assistant":
{
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
const text = extractText(payload);
const toolUseBlocks = getEmbeddedToolBlocks(payload, "tool_use");
if (text && text.trim()) {
removeLoading();
els.push(renderAssistantMessage(payload));
}
if (toolUseEls.length > 0) els.push(...toolUseEls);
for (const block of toolUseBlocks) {
appendToolEntryToActiveTrace(
"use",
{
tool_name: block.name || "tool",
tool_input: block.input || {},
},
els,
);
}
processAssistantEvent(payload);
}
break;
case "task_state":
applyTaskStateEvent(payload);
return;
case "automation_state":
return;
case "result":
case "result_success":
removeLoading();
// Skip result — it just repeats the assistant message content
return;
case "tool_use":
els.push(renderToolUse(payload));
appendToolEntryToActiveTrace("use", payload, els);
break;
case "tool_result":
els.push(renderToolResult(payload));
appendToolEntryToActiveTrace("result", payload, els);
break;
case "control_request":
case "permission_request":
@@ -305,6 +539,10 @@ export function appendEvent(data, { replay = false } = {}) {
return;
case "status":
// Skip connecting/waiting status noise from bridge
if (isConversationClearedStatus(payload)) {
clearTranscriptView();
return;
}
{
const msg = payload.message || payload.content || "";
const fullText = typeof payload === "string" ? payload : JSON.stringify(payload);
@@ -359,14 +597,92 @@ function renderUserMessage(payload, direction) {
return row;
}
function renderAssistantMessage(payload) {
const content = extractText(payload);
function renderTraceToggleGlyph() {
return `
<span class="assistant-trace-glyph" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>`;
}
function bindTraceToggle(toggleEl, panelEl, traceEl) {
if (!toggleEl || !panelEl || !traceEl) return;
toggleEl.addEventListener("click", () => {
const expanded = toggleEl.getAttribute("aria-expanded") === "true";
toggleEl.setAttribute("aria-expanded", expanded ? "false" : "true");
toggleEl.classList.toggle("is-open", !expanded);
traceEl.classList.toggle("is-expanded", !expanded);
panelEl.classList.toggle("hidden", expanded);
});
}
function updateTraceHostDisplay(refs) {
if (!refs) return;
refs.traceEl.classList.toggle("hidden", refs.entryCount === 0);
refs.countEl.textContent = String(refs.entryCount);
refs.toggleEl.classList.toggle("has-error", refs.hasError);
refs.row.classList.toggle("has-tool-error", refs.hasError);
refs.toggleEl.title = refs.hasError ? "Tool trace (contains errors)" : "Tool trace";
}
function createTraceHostRow(host, content = "") {
const row = document.createElement("div");
row.className = "msg-row assistant";
row.innerHTML = `<div class="msg-bubble">${formatAssistantContent(content)}</div>`;
row.className = host.kind === "assistant" ? "msg-row assistant" : "msg-row tool-trace-row";
row.dataset.traceHostId = host.id;
row.innerHTML = `
<div class="assistant-turn${host.kind === "orphan" ? " assistant-turn-orphan" : ""}">
${content ? `<div class="msg-bubble">${formatAssistantContent(content)}</div>` : ""}
<div class="assistant-trace hidden">
<button type="button" class="assistant-trace-toggle" aria-expanded="false">
${renderTraceToggleGlyph()}
<span class="assistant-trace-count">0</span>
<span class="assistant-trace-chevron" aria-hidden="true"></span>
</button>
<div class="assistant-trace-panel hidden"></div>
</div>
</div>`;
const traceEl = row.querySelector(".assistant-trace");
const panelEl = row.querySelector(".assistant-trace-panel");
const toggleEl = row.querySelector(".assistant-trace-toggle");
const countEl = row.querySelector(".assistant-trace-count");
bindTraceToggle(toggleEl, panelEl, traceEl);
const refs = {
hostId: host.id,
row,
traceEl,
panelEl,
toggleEl,
countEl,
entryCount: host.entryKinds.length,
hasError: false,
};
traceHostElements.set(host.id, refs);
updateTraceHostDisplay(refs);
return row;
}
function ensureTraceHostRow(host, rows = null, content = "") {
const existing = traceHostElements.get(host.id);
if (existing) return existing.row;
const row = createTraceHostRow(host, content || host.assistantContent || "");
if (Array.isArray(rows)) {
rows.push(row);
}
return row;
}
function renderAssistantMessage(payload) {
const content = extractText(payload).trim();
const result = addAssistantToolTraceHost(toolTraceState, content);
toolTraceState = result.state;
return ensureTraceHostRow(result.host, null, content);
}
function renderResult(payload) {
const text = payload.result || payload.subtype || "Session completed";
const row = document.createElement("div");
@@ -375,37 +691,64 @@ function renderResult(payload) {
return row;
}
function renderToolCard({ titleHtml, body, isError = false }) {
const card = document.createElement("div");
card.className = `tool-card assistant-trace-card${isError ? " assistant-trace-card-error" : ""}`;
card.innerHTML = `
<div class="tool-card-header">
<span class="tool-icon">&#9654;</span>
${titleHtml}
</div>
<div class="tool-card-body collapsed">${esc(body)}</div>`;
const header = card.querySelector(".tool-card-header");
const bodyEl = card.querySelector(".tool-card-body");
header?.addEventListener("click", () => {
bodyEl?.classList.toggle("collapsed");
header.classList.toggle("is-open", !bodyEl?.classList.contains("collapsed"));
});
return card;
}
function renderToolUse(payload) {
const name = payload.tool_name || payload.name || "tool";
const input = payload.tool_input || payload.input || {};
const inputStr = typeof input === "string" ? input : JSON.stringify(input, null, 2);
const card = document.createElement("div");
card.className = "msg-row tool";
card.innerHTML = `
<div class="tool-card">
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
<span class="tool-icon">&#9654;</span> Tool: <strong>${esc(name)}</strong>
</div>
<div class="tool-card-body collapsed">${esc(truncate(inputStr, 2000))}</div>
</div>`;
return card;
return renderToolCard({
titleHtml: `Tool: <strong>${esc(name)}</strong>`,
body: inputStr || "",
});
}
function renderToolResult(payload) {
const content = payload.content || payload.output || "";
const contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
return renderToolCard({
titleHtml: payload.is_error ? "<strong>Tool Error</strong>" : "Tool Result",
body: contentStr || "",
isError: !!payload.is_error,
});
}
const card = document.createElement("div");
card.className = "msg-row tool";
card.innerHTML = `
<div class="tool-card">
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
<span class="tool-icon">&#9654;</span> Tool Result
</div>
<div class="tool-card-body collapsed">${esc(truncate(contentStr, 2000))}</div>
</div>`;
return card;
function appendToolEntryToActiveTrace(entryKind, payload, rows) {
const result = addToolTraceEntry(toolTraceState, entryKind);
toolTraceState = result.state;
if (result.createdHost) {
ensureTraceHostRow(result.createdHost, rows);
}
const refs = traceHostElements.get(result.host.id);
if (!refs) return;
const card = entryKind === "use" ? renderToolUse(payload) : renderToolResult(payload);
refs.panelEl.appendChild(card);
refs.entryCount += 1;
if (entryKind === "result" && payload.is_error) {
refs.hasError = true;
}
updateTraceHostDisplay(refs);
}
export function renderPermissionRequest(payload) {
@@ -516,7 +859,9 @@ export function renderAskUserQuestion(payload) {
el._answers = {};
el._questions = questions;
return renderSystemMessage("Waiting for your response...");
const status = renderSystemMessage("Waiting for your response...");
status.dataset.pendingRequestId = requestId;
return status;
}
export function renderExitPlanMode(payload) {
@@ -551,7 +896,7 @@ export function renderExitPlanMode(payload) {
} else {
el.innerHTML = `
<div class="plan-title">Ready to code?</div>
<div class="plan-content">${formatAssistantContent(planContent)}</div>
<div class="plan-content">${formatPlanContent(planContent)}</div>
<div class="plan-options">
<button class="plan-option" data-value="yes-accept-edits" onclick="window._selectPlanOption(this, 'yes-accept-edits')">
<span class="plan-option-label">Yes, auto-accept edits</span>
@@ -580,7 +925,9 @@ export function renderExitPlanMode(payload) {
el._planContent = planContent;
el._isEmpty = isEmpty;
return renderSystemMessage("Waiting for your response...");
const status = renderSystemMessage("Waiting for your response...");
status.dataset.pendingRequestId = requestId;
return status;
}
function renderSystemMessage(text) {
@@ -594,7 +941,7 @@ function renderSystemMessage(text) {
// Loading Indicator — TUI star spinner style
// ============================================================
const LOADING_ID = "loading-indicator";
const ACTIVITY_ID = "session-activity-indicator";
// TUI star spinner frames (same as Claude Code CLI)
const SPINNER_FRAMES = ["·", "✢", "✱", "✶", "✻", "✽"];
@@ -640,35 +987,85 @@ const SPINNER_VERBS = [
let spinnerInterval = null;
let timerInterval = null;
let stalledCheckInterval = null;
let activityCountdownInterval = null;
let spinnerFrame = 0;
let loadingStartTime = 0;
let lastActivityTime = 0;
let isStalled = false;
let loadingActive = false;
let workingActive = false;
let automationActivity = null;
export function resolveActivityMode(working, activity) {
if (activity?.mode === "standby" || activity?.mode === "sleeping") {
return activity.mode;
}
return working ? "working" : "idle";
}
export function shouldRenderTranscriptActivity(mode) {
return mode === "working";
}
export function formatCountdownRemaining(endsAt, now = Date.now()) {
if (typeof endsAt !== "number") return "";
const remainingSeconds = Math.max(0, Math.ceil((endsAt - now) / 1000));
const hours = Math.floor(remainingSeconds / 3600);
const minutes = Math.floor((remainingSeconds % 3600) / 60);
const seconds = remainingSeconds % 60;
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
if (minutes > 0) {
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
return `${seconds}s`;
}
function getActivityModeInternal() {
return resolveActivityMode(workingActive, automationActivity);
}
export function isLoading() {
return loadingActive;
return getActivityModeInternal() === "working";
}
function syncActionBtn(state) {
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(state);
export function getActivityMode() {
return getActivityModeInternal();
}
export function showLoading() {
removeLoading();
const stream = document.getElementById("event-stream");
if (!stream) return;
function syncActionBtn(mode) {
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(mode);
}
loadingActive = true;
syncActionBtn(true);
function clearWorkingTimers() {
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
isStalled = false;
}
function clearActivityCountdownTimer() {
if (activityCountdownInterval) {
clearInterval(activityCountdownInterval);
activityCountdownInterval = null;
}
}
function removeActivityElement() {
const el = document.getElementById(ACTIVITY_ID);
if (el) el.remove();
}
function renderWorkingIndicator(stream) {
const verb = SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)];
loadingStartTime = Date.now();
lastActivityTime = Date.now();
isStalled = false;
const el = document.createElement("div");
el.id = LOADING_ID;
el.id = ACTIVITY_ID;
el.className = "msg-row loading-row";
el.innerHTML = `<span class="tui-spinner">${SPINNER_CYCLE[0]}</span><span class="tui-verb glimmer-text">${esc(verb)}…</span><span class="tui-timer">0s</span>`;
stream.appendChild(el);
@@ -678,14 +1075,12 @@ export function showLoading() {
const timerEl = el.querySelector(".tui-timer");
const loadingEl = el;
// Spinner animation — 120ms interval, same as TUI
spinnerFrame = 0;
spinnerInterval = setInterval(() => {
spinnerFrame = (spinnerFrame + 1) % SPINNER_CYCLE.length;
if (spinnerEl) spinnerEl.textContent = SPINNER_CYCLE[spinnerFrame];
}, 120);
// Timer — update every second
timerInterval = setInterval(() => {
if (timerEl) {
const elapsed = Math.floor((Date.now() - loadingStartTime) / 1000);
@@ -693,7 +1088,6 @@ export function showLoading() {
}
}, 1000);
// Stalled detection — check every 120ms (aligned with spinner)
stalledCheckInterval = setInterval(() => {
if (!isStalled && Date.now() - lastActivityTime > 3000) {
isStalled = true;
@@ -702,15 +1096,62 @@ export function showLoading() {
}, 120);
}
function renderAutomationIndicator(stream, activity) {
const el = document.createElement("div");
el.id = ACTIVITY_ID;
el.className = `msg-row automation-activity-row automation-activity-${activity.mode}`;
el.innerHTML = `
<div class="automation-activity-card">
${renderAutomationIcon(activity.iconVariant, { className: "automation-activity-icon" })}
<div class="automation-activity-copy">
<span class="automation-activity-label">${esc(activity.label)}</span>
</div>
<span class="automation-activity-countdown"></span>
</div>`;
stream.appendChild(el);
stream.scrollTop = stream.scrollHeight;
const countdownEl = el.querySelector(".automation-activity-countdown");
const updateCountdown = () => {
if (countdownEl) {
countdownEl.textContent = formatCountdownRemaining(activity.endsAt);
}
};
updateCountdown();
activityCountdownInterval = setInterval(updateCountdown, 1000);
}
function renderActivityIndicator() {
clearWorkingTimers();
clearActivityCountdownTimer();
removeActivityElement();
const mode = getActivityModeInternal();
syncActionBtn(mode);
const stream = document.getElementById("event-stream");
if (!stream) return;
if (shouldRenderTranscriptActivity(mode)) {
renderWorkingIndicator(stream);
}
}
export function setAutomationActivity(activity) {
automationActivity = activity ? { ...activity } : null;
renderActivityIndicator();
}
export function showLoading() {
automationActivity = null;
workingActive = true;
renderActivityIndicator();
}
export function removeLoading() {
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
isStalled = false;
loadingActive = false;
syncActionBtn(false);
const el = document.getElementById(LOADING_ID);
if (el) el.remove();
workingActive = false;
renderActivityIndicator();
}
/** Reset stalled timer — call when SSE events arrive */
@@ -718,7 +1159,7 @@ export function refreshLoadingActivity() {
lastActivityTime = Date.now();
if (isStalled) {
isStalled = false;
const loadingEl = document.getElementById(LOADING_ID);
const loadingEl = document.getElementById(ACTIVITY_ID);
if (loadingEl) loadingEl.classList.remove("stalled");
}
}

View File

@@ -19,6 +19,9 @@ let todos = [];
/** @type {boolean} Panel visibility */
let panelVisible = false;
/** @type {boolean} Whether V2 tasks came from an authoritative snapshot */
let hasAuthoritativeTasks = false;
/** @type {HTMLElement|null} Panel root element */
let panelEl = null;
@@ -71,11 +74,15 @@ export function processAssistantEvent(payload) {
const input = block.input || {};
if (name === "TaskCreate") {
handleTaskCreate(input);
changed = true;
if (!hasAuthoritativeTasks) {
handleTaskCreate(input);
changed = true;
}
} else if (name === "TaskUpdate") {
handleTaskUpdate(input);
changed = true;
if (!hasAuthoritativeTasks) {
handleTaskUpdate(input);
changed = true;
}
} else if (name === "TodoWrite") {
handleTodoWrite(input);
changed = true;
@@ -167,6 +174,42 @@ function handleTodoWrite(input) {
}));
}
function replaceTasks(nextTasks) {
tasks.clear();
for (const task of nextTasks) {
if (!task || typeof task !== "object" || !task.id) continue;
tasks.set(task.id, {
id: task.id,
subject: task.subject || "Untitled task",
description: task.description || "",
activeForm: task.activeForm,
status: task.status || "pending",
owner: task.owner,
blocks: Array.isArray(task.blocks) ? [...task.blocks] : [],
blockedBy: Array.isArray(task.blockedBy) ? [...task.blockedBy] : [],
});
}
}
/**
* Apply an authoritative task_state event from the bridge.
* @param {{ tasks?: TaskItem[], raw?: { tasks?: TaskItem[] } }} payload
*/
export function applyTaskStateEvent(payload) {
const nextTasks = Array.isArray(payload?.tasks)
? payload.tasks
: Array.isArray(payload?.raw?.tasks)
? payload.raw.tasks
: null;
if (!nextTasks) return;
hasAuthoritativeTasks = true;
replaceTasks(nextTasks);
renderPanel();
updateBadge();
}
// ============================================================
// Public API
// ============================================================
@@ -177,6 +220,7 @@ function handleTodoWrite(input) {
export function resetTaskState() {
tasks.clear();
todos = [];
hasAuthoritativeTasks = false;
if (panelEl) panelEl.innerHTML = "";
updateBadge();
}
@@ -185,7 +229,7 @@ export function resetTaskState() {
* Get current state for debugging.
*/
export function getTaskState() {
return { tasks: [...tasks.values()], todos };
return { tasks: [...tasks.values()], todos, hasAuthoritativeTasks };
}
/**

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, test } from "bun:test";
import {
applyTaskStateEvent,
getTaskState,
processAssistantEvent,
resetTaskState,
} from "./task-panel.js";
describe("task panel state", () => {
beforeEach(() => {
resetTaskState();
});
test("falls back to assistant tool_use parsing before an authoritative snapshot arrives", () => {
processAssistantEvent({
message: {
content: [
{
type: "tool_use",
name: "TaskUpdate",
input: { taskId: "1", subject: "Plan fix", status: "in_progress" },
},
],
},
});
expect(getTaskState()).toEqual({
tasks: [
{
id: "1",
subject: "Plan fix",
description: "",
activeForm: undefined,
status: "in_progress",
owner: undefined,
blocks: [],
blockedBy: [],
},
],
todos: [],
hasAuthoritativeTasks: false,
});
});
test("authoritative task_state snapshots replace tasks and stop transcript-derived task mutations", () => {
applyTaskStateEvent({
task_list_id: "team-alpha",
tasks: [
{
id: "7",
subject: "Real task",
description: "Pulled from task list",
status: "pending",
blocks: [],
blockedBy: [],
},
],
});
processAssistantEvent({
message: {
content: [
{
type: "tool_use",
name: "TaskUpdate",
input: { taskId: "99", subject: "Synthetic task", status: "completed" },
},
{
type: "tool_use",
name: "TodoWrite",
input: {
todos: [{ content: "Keep todo parsing", status: "pending", activeForm: "Keeping todo parsing" }],
},
},
],
},
});
expect(getTaskState()).toEqual({
tasks: [
{
id: "7",
subject: "Real task",
description: "Pulled from task list",
activeForm: undefined,
status: "pending",
owner: undefined,
blocks: [],
blockedBy: [],
},
],
todos: [
{
content: "Keep todo parsing",
status: "pending",
activeForm: "Keeping todo parsing",
},
],
hasAuthoritativeTasks: true,
});
});
});

View File

@@ -2,11 +2,23 @@
* Remote Control — Shared Utilities
*/
const HTML_ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
export function esc(str) {
if (!str) return "";
const div = document.createElement("div");
div.textContent = String(str);
return div.innerHTML;
const value = String(str);
if (typeof document !== "undefined" && typeof document.createElement === "function") {
const div = document.createElement("div");
div.textContent = value;
return div.innerHTML;
}
return value.replace(/[&<>"']/g, (char) => HTML_ESCAPE_MAP[char]);
}
export function formatTime(ts) {

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,
}
}