mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat(remote-control): 优化 Web 展示、状态同步与桥接控制流程 (#288)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -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/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# KAIROS — 常驻助手模式
|
||||
|
||||
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature)
|
||||
> 实现状态:核心框架完整,部分子模块为 stub
|
||||
> 实现状态:核心框架完整,部分子模块为 stub;proactive/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 选择 UI(stub) |
|
||||
| `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 核心(stub,KAIROS 共享) |
|
||||
| `src/proactive/index.ts` | — | Proactive 核心(KAIROS 共享) |
|
||||
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |
|
||||
|
||||
@@ -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 hook(REPL 引用但不存在) |
|
||||
- `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 状态 |
|
||||
|
||||
@@ -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 无法连接
|
||||
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
380
packages/remote-control-server/web/automation.js
Normal file
380
packages/remote-control-server/web/automation.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
207
packages/remote-control-server/web/automation.test.js
Normal file
207
packages/remote-control-server/web/automation.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
30
packages/remote-control-server/web/render-activity.test.js
Normal file
30
packages/remote-control-server/web/render-activity.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
36
packages/remote-control-server/web/render-plan.test.js
Normal file
36
packages/remote-control-server/web/render-plan.test.js
Normal 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("<script>alert(1)</script>");
|
||||
expect(html).toContain("<pre><code>const markup = "<div>";</code></pre>");
|
||||
});
|
||||
});
|
||||
24
packages/remote-control-server/web/render-status.test.js
Normal file
24
packages/remote-control-server/web/render-status.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
90
packages/remote-control-server/web/render-trace.test.js
Normal file
90
packages/remote-control-server/web/render-trace.test.js
Normal 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"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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">▶</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">▶</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">▶</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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
103
packages/remote-control-server/web/task-panel.test.js
Normal file
103
packages/remote-control-server/web/task-panel.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,23 @@
|
||||
* Remote Control — Shared Utilities
|
||||
*/
|
||||
|
||||
const HTML_ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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) {
|
||||
|
||||
17
src/__tests__/commandsBridgeSafety.test.ts
Normal file
17
src/__tests__/commandsBridgeSafety.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { isBridgeSafeCommand } from '../commands.js'
|
||||
import clear from '../commands/clear/index.js'
|
||||
import plan from '../commands/plan/index.js'
|
||||
import proactive from '../commands/proactive.js'
|
||||
|
||||
describe('isBridgeSafeCommand', () => {
|
||||
test('allows bridge-safe local-jsx commands', () => {
|
||||
expect(isBridgeSafeCommand(plan)).toBe(true)
|
||||
expect(isBridgeSafeCommand(proactive)).toBe(true)
|
||||
})
|
||||
|
||||
test('continues allowing explicit local bridge-safe commands', () => {
|
||||
expect(isBridgeSafeCommand(clear)).toBe(true)
|
||||
})
|
||||
})
|
||||
121
src/__tests__/handlePromptSubmit.test.ts
Normal file
121
src/__tests__/handlePromptSubmit.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { createAbortController } from '../utils/abortController'
|
||||
import { QueryGuard } from '../utils/QueryGuard'
|
||||
import { handlePromptSubmit } from '../utils/handlePromptSubmit'
|
||||
import { getCommandQueue, resetCommandQueue } from '../utils/messageQueueManager'
|
||||
|
||||
function createBaseParams() {
|
||||
const queryGuard = new QueryGuard()
|
||||
queryGuard.reserve()
|
||||
|
||||
return {
|
||||
queryGuard,
|
||||
helpers: {
|
||||
setCursorOffset: mock((_offset: number) => {}),
|
||||
clearBuffer: mock(() => {}),
|
||||
resetHistory: mock(() => {}),
|
||||
},
|
||||
onInputChange: mock((_value: string) => {}),
|
||||
setPastedContents: mock((_value: unknown) => {}),
|
||||
setToolJSX: mock((_value: unknown) => {}),
|
||||
getToolUseContext: mock(() => {
|
||||
throw new Error('getToolUseContext should not be called in queued path')
|
||||
}),
|
||||
messages: [],
|
||||
mainLoopModel: 'claude-sonnet-4-6',
|
||||
ideSelection: undefined,
|
||||
querySource: 'repl_main_thread' as any,
|
||||
commands: [],
|
||||
setUserInputOnProcessing: mock((_prompt?: string) => {}),
|
||||
setAbortController: mock((_abortController: AbortController | null) => {}),
|
||||
onQuery: mock(
|
||||
async () => undefined,
|
||||
) as unknown as (
|
||||
...args: unknown[]
|
||||
) => Promise<void>,
|
||||
setAppState: mock((_updater: unknown) => {}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('handlePromptSubmit', () => {
|
||||
beforeEach(() => {
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
test('aborts the current turn when only cancel-interrupt tools are running', async () => {
|
||||
const params = createBaseParams()
|
||||
const abortController = createAbortController()
|
||||
|
||||
await handlePromptSubmit({
|
||||
...params,
|
||||
input: 'hello',
|
||||
mode: 'prompt',
|
||||
pastedContents: {},
|
||||
abortController,
|
||||
streamMode: 'normal' as any,
|
||||
hasInterruptibleToolInProgress: true,
|
||||
isExternalLoading: false,
|
||||
})
|
||||
|
||||
expect(abortController.signal.aborted).toBe(true)
|
||||
expect(abortController.signal.reason).toBe('interrupt')
|
||||
expect(getCommandQueue()).toHaveLength(1)
|
||||
expect(getCommandQueue()[0]).toMatchObject({
|
||||
value: 'hello',
|
||||
preExpansionValue: 'hello',
|
||||
mode: 'prompt',
|
||||
})
|
||||
expect(params.onInputChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
test('queues the input without aborting when a blocking tool is running', async () => {
|
||||
const params = createBaseParams()
|
||||
const abortController = createAbortController()
|
||||
|
||||
await handlePromptSubmit({
|
||||
...params,
|
||||
input: 'hello',
|
||||
mode: 'prompt',
|
||||
pastedContents: {},
|
||||
abortController,
|
||||
streamMode: 'normal' as any,
|
||||
hasInterruptibleToolInProgress: false,
|
||||
isExternalLoading: false,
|
||||
})
|
||||
|
||||
expect(abortController.signal.aborted).toBe(false)
|
||||
expect(getCommandQueue()).toHaveLength(1)
|
||||
expect(getCommandQueue()[0]).toMatchObject({
|
||||
value: 'hello',
|
||||
preExpansionValue: 'hello',
|
||||
mode: 'prompt',
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves bridgeOrigin when a remote slash command is queued during external loading', async () => {
|
||||
const params = createBaseParams()
|
||||
const abortController = createAbortController()
|
||||
|
||||
await handlePromptSubmit({
|
||||
...params,
|
||||
input: '/proactive',
|
||||
mode: 'prompt',
|
||||
pastedContents: {},
|
||||
abortController,
|
||||
streamMode: 'normal' as any,
|
||||
hasInterruptibleToolInProgress: true,
|
||||
isExternalLoading: true,
|
||||
skipSlashCommands: true,
|
||||
bridgeOrigin: true,
|
||||
})
|
||||
|
||||
expect(getCommandQueue()).toHaveLength(1)
|
||||
expect(getCommandQueue()[0]).toMatchObject({
|
||||
value: '/proactive',
|
||||
preExpansionValue: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
bridgeOrigin: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
96
src/bridge/__tests__/bridgeMessaging.test.ts
Normal file
96
src/bridge/__tests__/bridgeMessaging.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
shouldReportRunningForMessage,
|
||||
shouldReportRunningForMessages,
|
||||
} from '../bridgeMessaging.js'
|
||||
import { createUserMessage } from '../../utils/messages.js'
|
||||
|
||||
describe('bridge running-state classification', () => {
|
||||
test('treats real user prompts as turn-starting work', () => {
|
||||
expect(
|
||||
shouldReportRunningForMessage(
|
||||
createUserMessage({ content: 'please inspect the repo' }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps tool-result style user messages eligible during mid-turn attach', () => {
|
||||
expect(
|
||||
shouldReportRunningForMessage(
|
||||
createUserMessage({
|
||||
content: '<local-command-stdout>done</local-command-stdout>',
|
||||
toolUseResult: { ok: true },
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('ignores local slash-command scaffolding that should not reopen a turn', () => {
|
||||
expect(
|
||||
shouldReportRunningForMessage(
|
||||
createUserMessage({
|
||||
content:
|
||||
'<local-command-caveat>Caveat: hidden local command scaffolding</local-command-caveat>',
|
||||
isMeta: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldReportRunningForMessage(
|
||||
createUserMessage({
|
||||
content:
|
||||
'<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts.\n</system-reminder>',
|
||||
isMeta: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('still marks real automation triggers as running', () => {
|
||||
expect(
|
||||
shouldReportRunningForMessage(
|
||||
createUserMessage({
|
||||
content: '<tick>2:56:47 PM</tick>',
|
||||
isMeta: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
shouldReportRunningForMessage(
|
||||
createUserMessage({
|
||||
content: 'scheduled job: refresh analytics cache',
|
||||
isMeta: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('classifies batches by any work-starting message', () => {
|
||||
const scaffoldingOnly = [
|
||||
createUserMessage({
|
||||
content:
|
||||
'<local-command-caveat>Caveat: hidden local command scaffolding</local-command-caveat>',
|
||||
isMeta: true,
|
||||
}),
|
||||
createUserMessage({
|
||||
content:
|
||||
'<system-reminder>\nProactive mode is now enabled.\n</system-reminder>',
|
||||
isMeta: true,
|
||||
}),
|
||||
]
|
||||
expect(shouldReportRunningForMessages(scaffoldingOnly)).toBe(false)
|
||||
|
||||
expect(
|
||||
shouldReportRunningForMessages([
|
||||
...scaffoldingOnly,
|
||||
createUserMessage({
|
||||
content: '<tick>2:57:17 PM</tick>',
|
||||
isMeta: true,
|
||||
}),
|
||||
]),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
76
src/bridge/__tests__/bridgePermissionCallbacks.test.ts
Normal file
76
src/bridge/__tests__/bridgePermissionCallbacks.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { parseBridgePermissionResponse } from '../bridgePermissionCallbacks.js'
|
||||
import type { SDKControlResponse } from '../../entrypoints/sdk/controlTypes.js'
|
||||
|
||||
describe('parseBridgePermissionResponse', () => {
|
||||
test('passes through allow responses', () => {
|
||||
const message: SDKControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-1',
|
||||
response: {
|
||||
behavior: 'allow',
|
||||
updatedPermissions: [
|
||||
{ type: 'setMode', mode: 'acceptEdits', destination: 'session' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(parseBridgePermissionResponse(message)).toEqual({
|
||||
behavior: 'allow',
|
||||
updatedPermissions: [
|
||||
{ type: 'setMode', mode: 'acceptEdits', destination: 'session' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('maps error responses with feedback to deny', () => {
|
||||
const message = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-2',
|
||||
error: 'Permission denied by user',
|
||||
response: { behavior: 'deny' },
|
||||
message: 'Need more detail',
|
||||
},
|
||||
} as unknown as SDKControlResponse
|
||||
|
||||
expect(parseBridgePermissionResponse(message)).toEqual({
|
||||
behavior: 'deny',
|
||||
message: 'Need more detail',
|
||||
})
|
||||
})
|
||||
|
||||
test('falls back to error text when deny feedback is absent', () => {
|
||||
const message = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-3',
|
||||
error: 'Permission denied by user',
|
||||
},
|
||||
} as unknown as SDKControlResponse
|
||||
|
||||
expect(parseBridgePermissionResponse(message)).toEqual({
|
||||
behavior: 'deny',
|
||||
message: 'Permission denied by user',
|
||||
})
|
||||
})
|
||||
|
||||
test('returns null for unrelated control responses', () => {
|
||||
const message = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-4',
|
||||
error: '',
|
||||
},
|
||||
} as unknown as SDKControlResponse
|
||||
|
||||
expect(parseBridgePermissionResponse(message)).toBeNull()
|
||||
})
|
||||
})
|
||||
53
src/bridge/__tests__/bridgeResultScheduling.test.ts
Normal file
53
src/bridge/__tests__/bridgeResultScheduling.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
hasPendingBridgeMessages,
|
||||
isTranscriptResetResultReady,
|
||||
shouldDeferBridgeResult,
|
||||
} from '../bridgeResultScheduling.js'
|
||||
|
||||
describe('bridgeResultScheduling', () => {
|
||||
test('detects pending mirrored messages', () => {
|
||||
expect(hasPendingBridgeMessages(2, 3)).toBe(true)
|
||||
expect(hasPendingBridgeMessages(3, 3)).toBe(false)
|
||||
})
|
||||
|
||||
test('defers when the bridge handle is unavailable', () => {
|
||||
expect(
|
||||
shouldDeferBridgeResult({
|
||||
hasHandle: false,
|
||||
isConnected: true,
|
||||
lastWrittenIndex: 3,
|
||||
messageCount: 3,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('defers when the bridge is connected but transcript flush is pending', () => {
|
||||
expect(
|
||||
shouldDeferBridgeResult({
|
||||
hasHandle: true,
|
||||
isConnected: true,
|
||||
lastWrittenIndex: 1,
|
||||
messageCount: 2,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('sends immediately once the latest transcript is already mirrored', () => {
|
||||
expect(
|
||||
shouldDeferBridgeResult({
|
||||
hasHandle: true,
|
||||
isConnected: true,
|
||||
lastWrittenIndex: 2,
|
||||
messageCount: 2,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('treats transcript reset as ready only after the transcript is empty', () => {
|
||||
expect(isTranscriptResetResultReady(true, 0)).toBe(true)
|
||||
expect(isTranscriptResetResultReady(true, 1)).toBe(false)
|
||||
expect(isTranscriptResetResultReady(false, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
37
src/bridge/__tests__/remoteInterruptHandling.test.ts
Normal file
37
src/bridge/__tests__/remoteInterruptHandling.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import { handleRemoteInterrupt } from '../remoteInterruptHandling.js'
|
||||
import {
|
||||
activateProactive,
|
||||
deactivateProactive,
|
||||
isProactivePaused,
|
||||
} from '../../proactive/index.js'
|
||||
|
||||
function isProactiveFeatureEnabled() {
|
||||
if (feature('PROACTIVE')) return true
|
||||
return feature('KAIROS') ? true : false
|
||||
}
|
||||
|
||||
describe('handleRemoteInterrupt', () => {
|
||||
afterEach(() => {
|
||||
deactivateProactive()
|
||||
})
|
||||
|
||||
test('always aborts the active request', () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
handleRemoteInterrupt(controller)
|
||||
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test('pauses proactive mode to return control to the user', () => {
|
||||
activateProactive('test')
|
||||
expect(isProactivePaused()).toBe(false)
|
||||
|
||||
handleRemoteInterrupt(new AbortController())
|
||||
|
||||
expect(isProactivePaused()).toBe(isProactiveFeatureEnabled())
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,18 @@ import { errorMessage } from '../utils/errors.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import type { ReplBridgeTransport } from './replBridgeTransport.js'
|
||||
import {
|
||||
BASH_INPUT_TAG,
|
||||
CHANNEL_MESSAGE_TAG,
|
||||
CROSS_SESSION_MESSAGE_TAG,
|
||||
LOCAL_COMMAND_CAVEAT_TAG,
|
||||
REMOTE_REVIEW_PROGRESS_TAG,
|
||||
REMOTE_REVIEW_TAG,
|
||||
TASK_NOTIFICATION_TAG,
|
||||
TEAMMATE_MESSAGE_TAG,
|
||||
TICK_TAG,
|
||||
ULTRAPLAN_TAG,
|
||||
} from '../constants/xml.js'
|
||||
|
||||
// ─── Type guards ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,6 +134,85 @@ export function extractTitleText(m: Message): string | undefined {
|
||||
return clean || undefined
|
||||
}
|
||||
|
||||
const SYSTEM_REMINDER_TAG = 'system-reminder'
|
||||
const XML_BLOCK_PATTERN =
|
||||
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy
|
||||
const RUNNING_STATE_META_TAGS = new Set([
|
||||
BASH_INPUT_TAG,
|
||||
CHANNEL_MESSAGE_TAG,
|
||||
CROSS_SESSION_MESSAGE_TAG,
|
||||
REMOTE_REVIEW_PROGRESS_TAG,
|
||||
REMOTE_REVIEW_TAG,
|
||||
TASK_NOTIFICATION_TAG,
|
||||
TEAMMATE_MESSAGE_TAG,
|
||||
TICK_TAG,
|
||||
ULTRAPLAN_TAG,
|
||||
])
|
||||
|
||||
function extractUserMessageText(message: Message): string {
|
||||
const content = message.message?.content
|
||||
if (typeof content === 'string') return content
|
||||
if (!Array.isArray(content)) return ''
|
||||
return content
|
||||
.filter(
|
||||
(
|
||||
block,
|
||||
): block is {
|
||||
type: 'text'
|
||||
text: string
|
||||
} =>
|
||||
!!block &&
|
||||
typeof block === 'object' &&
|
||||
block.type === 'text' &&
|
||||
typeof block.text === 'string',
|
||||
)
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function getEnvelopeTagNames(text: string): string[] | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
XML_BLOCK_PATTERN.lastIndex = 0
|
||||
const tags: string[] = []
|
||||
while (XML_BLOCK_PATTERN.lastIndex < trimmed.length) {
|
||||
const match = XML_BLOCK_PATTERN.exec(trimmed)
|
||||
if (!match) return null
|
||||
tags.push(match[1]!)
|
||||
}
|
||||
return tags.length > 0 ? tags : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote Control uses user messages to infer "a turn is actively running" in
|
||||
* places where the server does not derive that state for us. Hidden local
|
||||
* slash-command scaffolding (for example `<local-command-caveat>` and pure
|
||||
* `<system-reminder>` wrappers from `/proactive`) should not flip the session
|
||||
* back to running after the command has already completed.
|
||||
*/
|
||||
export function shouldReportRunningForMessage(message: Message): boolean {
|
||||
if (message.type !== 'user') return false
|
||||
if (message.isVisibleInTranscriptOnly) return false
|
||||
if (message.toolUseResult !== undefined) return true
|
||||
if (!message.isMeta) return true
|
||||
|
||||
const tags = getEnvelopeTagNames(extractUserMessageText(message))
|
||||
if (!tags) return true
|
||||
|
||||
return tags.some(
|
||||
tag =>
|
||||
tag !== LOCAL_COMMAND_CAVEAT_TAG &&
|
||||
tag !== SYSTEM_REMINDER_TAG &&
|
||||
RUNNING_STATE_META_TAGS.has(tag),
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldReportRunningForMessages(
|
||||
messages: readonly Message[],
|
||||
): boolean {
|
||||
return messages.some(shouldReportRunningForMessage)
|
||||
}
|
||||
|
||||
// ─── Ingress routing ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
|
||||
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'
|
||||
|
||||
type BridgePermissionResponse = {
|
||||
behavior: 'allow' | 'deny'
|
||||
@@ -39,5 +40,65 @@ function isBridgePermissionResponse(
|
||||
)
|
||||
}
|
||||
|
||||
export { isBridgePermissionResponse }
|
||||
function toBridgePermissionMessage(
|
||||
controlResponse: Record<string, unknown>,
|
||||
parsed: BridgePermissionResponse | undefined,
|
||||
): string | undefined {
|
||||
if (typeof controlResponse.message === 'string' && controlResponse.message) {
|
||||
return controlResponse.message
|
||||
}
|
||||
if (typeof parsed?.message === 'string' && parsed.message) {
|
||||
return parsed.message
|
||||
}
|
||||
if (typeof controlResponse.error === 'string' && controlResponse.error) {
|
||||
return controlResponse.error
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a control_response from the bridge transport into the simplified
|
||||
* allow/deny shape used by interactive permission handlers.
|
||||
*/
|
||||
function parseBridgePermissionResponse(
|
||||
message: SDKControlResponse,
|
||||
): BridgePermissionResponse | null {
|
||||
const controlResponse = message.response
|
||||
if (!controlResponse || typeof controlResponse !== 'object') return null
|
||||
|
||||
if (
|
||||
controlResponse.subtype === 'success' &&
|
||||
'response' in controlResponse &&
|
||||
isBridgePermissionResponse(controlResponse.response)
|
||||
) {
|
||||
return controlResponse.response
|
||||
}
|
||||
|
||||
if (controlResponse.subtype !== 'error') {
|
||||
return null
|
||||
}
|
||||
|
||||
const nested =
|
||||
'response' in controlResponse &&
|
||||
isBridgePermissionResponse(controlResponse.response)
|
||||
? controlResponse.response
|
||||
: undefined
|
||||
|
||||
const messageText = toBridgePermissionMessage(controlResponse, nested)
|
||||
|
||||
if (nested) {
|
||||
return messageText ? { ...nested, message: messageText } : nested
|
||||
}
|
||||
|
||||
if (messageText) {
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: messageText,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export { isBridgePermissionResponse, parseBridgePermissionResponse }
|
||||
export type { BridgePermissionCallbacks, BridgePermissionResponse }
|
||||
|
||||
28
src/bridge/bridgeResultScheduling.ts
Normal file
28
src/bridge/bridgeResultScheduling.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function hasPendingBridgeMessages(
|
||||
lastWrittenIndex: number,
|
||||
messageCount: number,
|
||||
): boolean {
|
||||
return lastWrittenIndex < messageCount
|
||||
}
|
||||
|
||||
export function isTranscriptResetResultReady(
|
||||
transcriptResetPending: boolean,
|
||||
messageCount: number,
|
||||
): boolean {
|
||||
return transcriptResetPending && messageCount === 0
|
||||
}
|
||||
|
||||
export function shouldDeferBridgeResult({
|
||||
hasHandle,
|
||||
isConnected,
|
||||
lastWrittenIndex,
|
||||
messageCount,
|
||||
}: {
|
||||
hasHandle: boolean
|
||||
isConnected: boolean
|
||||
lastWrittenIndex: number
|
||||
messageCount: number
|
||||
}): boolean {
|
||||
if (!hasHandle || !isConnected) return true
|
||||
return hasPendingBridgeMessages(lastWrittenIndex, messageCount)
|
||||
}
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
makeResultMessage,
|
||||
isEligibleBridgeMessage,
|
||||
extractTitleText,
|
||||
shouldReportRunningForMessage,
|
||||
shouldReportRunningForMessages,
|
||||
BoundedUUIDSet,
|
||||
} from './bridgeMessaging.js'
|
||||
import { logBridgeSkip } from './debugUtils.js'
|
||||
@@ -72,6 +74,7 @@ import type {
|
||||
import type { StdoutMessage } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
||||
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
|
||||
|
||||
/**
|
||||
* StdoutMessage with optional session_id. The transport layer accepts
|
||||
@@ -321,6 +324,18 @@ export async function initEnvLessBridgeCore(
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror external metadata updates from the live REPL into the bridge's
|
||||
// CCR worker channel. Without this, proactive wait/sleep only changes local
|
||||
// UI state and the web session detail falls back to the generic working
|
||||
// spinner because automation_state never reaches remote-control.
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
if (tornDown) return
|
||||
transport.reportMetadata(metadata)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
// ── 5. JWT refresh scheduler ────────────────────────────────────────────
|
||||
// Schedule a callback 5min before expiry (per response.expires_in). On fire,
|
||||
// re-fetch /bridge with OAuth → rebuild transport with fresh credentials.
|
||||
@@ -625,7 +640,7 @@ export async function initEnvLessBridgeCore(
|
||||
...m,
|
||||
session_id: sessionId,
|
||||
})) as TransportMessage[]
|
||||
if (msgs.some(m => m.type === 'user')) {
|
||||
if (shouldReportRunningForMessages(msgs)) {
|
||||
transport.reportState('running')
|
||||
}
|
||||
logForDebugging(
|
||||
@@ -655,13 +670,13 @@ export async function initEnvLessBridgeCore(
|
||||
})) as TransportMessage[]
|
||||
if (events.length === 0) return
|
||||
// Mid-turn init: if Remote Control is enabled while a query is running,
|
||||
// the last eligible message is a user prompt or tool_result (both 'user'
|
||||
// type). Without this the init PUT's 'idle' sticks until the next user-
|
||||
// type message forwards via writeMessages — which for a pure-text turn
|
||||
// is never (only assistant chunks stream post-init). Check eligible (pre-
|
||||
// cap), not capped: the cap may truncate to a user message even when the
|
||||
// actual trailing message is assistant.
|
||||
if (eligible.at(-1)?.type === 'user') {
|
||||
// the last eligible message may be a real user prompt or tool_result.
|
||||
// Hidden slash-command scaffolding and pure reminder wrappers should not
|
||||
// resurrect a completed turn into "running". Check eligible (pre-cap),
|
||||
// not capped: the cap may truncate to a user message even when the actual
|
||||
// trailing message is assistant.
|
||||
const lastEligible = eligible.at(-1)
|
||||
if (lastEligible && shouldReportRunningForMessage(lastEligible)) {
|
||||
transport.reportState('running')
|
||||
}
|
||||
logForDebugging(`[remote-bridge] Flushing ${events.length} history events`)
|
||||
@@ -817,10 +832,11 @@ export async function initEnvLessBridgeCore(
|
||||
})) as TransportMessage[]
|
||||
// v2 does not derive worker_status from events server-side (unlike v1
|
||||
// session-ingress session_status_updater.go). Push it from here so the
|
||||
// CCR web session list shows Running instead of stuck on Idle. A user
|
||||
// message in the batch marks turn start. CCRClient.reportState dedupes
|
||||
// consecutive same-state pushes.
|
||||
if (filtered.some(m => m.type === 'user')) {
|
||||
// CCR web session list shows Running instead of stuck on Idle. Only
|
||||
// work-starting user messages mark turn start; hidden local-command
|
||||
// scaffolding and pure reminders should not re-open a completed turn.
|
||||
// CCRClient.reportState dedupes consecutive same-state pushes.
|
||||
if (shouldReportRunningForMessages(filtered)) {
|
||||
transport.reportState('running')
|
||||
}
|
||||
logForDebugging(`[remote-bridge] Sending ${filtered.length} message(s)`)
|
||||
|
||||
13
src/bridge/remoteInterruptHandling.ts
Normal file
13
src/bridge/remoteInterruptHandling.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
export function handleRemoteInterrupt(
|
||||
abortController: AbortController | null,
|
||||
): void {
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||||
const { pauseProactive } =
|
||||
require('../proactive/index.js') as typeof import('../proactive/index.js')
|
||||
pauseProactive()
|
||||
}
|
||||
|
||||
abortController?.abort()
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
createV2ReplTransport,
|
||||
} from './replBridgeTransport.js'
|
||||
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
|
||||
import { setSessionMetadataChangedListener } from '../utils/sessionState.js'
|
||||
import { isEnvTruthy, isInProtectedNamespace } from '../utils/envUtils.js'
|
||||
import { validateBridgeId } from './bridgeApi.js'
|
||||
import {
|
||||
@@ -87,6 +88,7 @@ export type ReplBridgeHandle = {
|
||||
sessionIngressUrl: string
|
||||
writeMessages(messages: Message[]): void
|
||||
writeSdkMessages(messages: SDKMessage[]): void
|
||||
markTranscriptReset?(): void
|
||||
sendControlRequest(request: SDKControlRequest): void
|
||||
sendControlResponse(response: SDKControlResponse): void
|
||||
sendControlCancelRequest(requestId: string): void
|
||||
@@ -555,6 +557,17 @@ export async function initBridgeCore(
|
||||
// server-driven via secret.use_code_sessions, with CLAUDE_BRIDGE_USE_CCR_V2
|
||||
// as an ant-dev override.
|
||||
let transport: ReplBridgeTransport | null = null
|
||||
// Mirror external metadata updates from the active REPL into whichever
|
||||
// transport currently owns the remote-control session. v1 ignores the call;
|
||||
// v2 forwards it to CCR /worker external_metadata so standby/sleeping and
|
||||
// other session metadata survive on web/mobile.
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
if (pollController.signal.aborted) return
|
||||
transport?.reportMetadata(metadata)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
// Bumped on every onWorkReceived. Captured in createV2ReplTransport's .then()
|
||||
// closure to detect stale resolutions: if two calls race while transport is
|
||||
// null, both registerWorker() (bumping server epoch), and whichever resolves
|
||||
@@ -1869,6 +1882,7 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
transport.reportState('idle')
|
||||
const resultMsg = {
|
||||
...makeResultMessage(currentSessionId),
|
||||
session_id: currentSessionId,
|
||||
|
||||
@@ -162,9 +162,12 @@ export class RemoteIO extends StructuredIO {
|
||||
setSessionStateChangedListener((state, details) => {
|
||||
this.ccrClient?.reportState(state, details)
|
||||
})
|
||||
setSessionMetadataChangedListener(metadata => {
|
||||
this.ccrClient?.reportMetadata(metadata)
|
||||
})
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
this.ccrClient?.reportMetadata(metadata)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
}
|
||||
|
||||
// Start connection only after all callbacks are wired (setOnData above,
|
||||
|
||||
@@ -487,6 +487,7 @@ export class CCRClient {
|
||||
external_metadata: {
|
||||
pending_action: null,
|
||||
task_summary: null,
|
||||
automation_state: null,
|
||||
},
|
||||
},
|
||||
'PUT worker (init)',
|
||||
|
||||
@@ -687,6 +687,7 @@ export const REMOTE_SAFE_COMMANDS: Set<Command> = new Set([
|
||||
btw, // Quick note
|
||||
feedback, // Send feedback
|
||||
plan, // Plan mode toggle
|
||||
proactive, // Toggle proactive mode
|
||||
keybindings, // Keybinding management
|
||||
statusline, // Status line toggle
|
||||
stickers, // Stickers
|
||||
@@ -727,9 +728,18 @@ export const BRIDGE_SAFE_COMMANDS: Set<Command> = new Set(
|
||||
* BRIDGE_SAFE_COMMANDS; 'local-jsx' commands render Ink UI and stay blocked.
|
||||
*/
|
||||
export function isBridgeSafeCommand(cmd: Command): boolean {
|
||||
if (cmd.type === 'local-jsx') return false
|
||||
if (cmd.type === 'local-jsx') return cmd.bridgeSafe === true
|
||||
if (cmd.type === 'prompt') return true
|
||||
return BRIDGE_SAFE_COMMANDS.has(cmd)
|
||||
return cmd.bridgeSafe === true || BRIDGE_SAFE_COMMANDS.has(cmd)
|
||||
}
|
||||
|
||||
export function getBridgeCommandSafety(
|
||||
cmd: Command,
|
||||
args: string,
|
||||
): { ok: true } | { ok: false; reason?: string } {
|
||||
if (!isBridgeSafeCommand(cmd)) return { ok: false }
|
||||
const reason = cmd.getBridgeInvocationError?.(args)
|
||||
return reason ? { ok: false, reason } : { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import { randomUUID, type UUID } from 'crypto'
|
||||
import { getReplBridgeHandle } from '../../bridge/replBridgeHandle.js'
|
||||
import {
|
||||
getLastMainRequestId,
|
||||
getOriginalCwd,
|
||||
getSessionId,
|
||||
regenerateSessionId,
|
||||
} from '../../bootstrap/state.js'
|
||||
import type { SDKStatusMessage } from '../../entrypoints/sdk/coreTypes.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
@@ -46,6 +48,21 @@ import {
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
|
||||
import { clearSessionCaches } from './caches.js'
|
||||
|
||||
function notifyRemoteConversationCleared(): void {
|
||||
const handle = getReplBridgeHandle()
|
||||
if (!handle) return
|
||||
handle.markTranscriptReset?.()
|
||||
|
||||
const message: SDKStatusMessage = {
|
||||
type: 'status',
|
||||
subtype: 'status',
|
||||
status: 'conversation_cleared',
|
||||
message: 'conversation_cleared',
|
||||
uuid: randomUUID(),
|
||||
}
|
||||
handle.writeSdkMessages([message])
|
||||
}
|
||||
|
||||
export async function clearConversation({
|
||||
setMessages,
|
||||
readFileState,
|
||||
@@ -107,6 +124,7 @@ export async function clearConversation({
|
||||
}
|
||||
|
||||
setMessages(() => [])
|
||||
notifyRemoteConversationCleared()
|
||||
|
||||
// Clear context-blocked flag so proactive ticks resume after /clear
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||||
|
||||
16
src/commands/plan/index.test.ts
Normal file
16
src/commands/plan/index.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import plan from './index.js'
|
||||
|
||||
describe('plan bridge invocation safety', () => {
|
||||
test('allows headless plan mode operations over Remote Control', () => {
|
||||
expect(plan.getBridgeInvocationError?.('')).toBeUndefined()
|
||||
expect(plan.getBridgeInvocationError?.('write a migration plan')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('blocks /plan open over Remote Control', () => {
|
||||
expect(plan.getBridgeInvocationError?.('open')).toBe(
|
||||
"Opening the local editor via /plan open isn't available over Remote Control.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const plan = {
|
||||
bridgeSafe: true,
|
||||
getBridgeInvocationError(args: string) {
|
||||
const subcommand = args.trim().split(/\s+/)[0]
|
||||
if (subcommand === 'open') {
|
||||
return "Opening the local editor via /plan open isn't available over Remote Control."
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
type: 'local-jsx',
|
||||
name: 'plan',
|
||||
description: 'Enable plan mode or view the current session plan',
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '../types/command.js'
|
||||
|
||||
const proactive = {
|
||||
bridgeSafe: true,
|
||||
type: 'local-jsx',
|
||||
name: 'proactive',
|
||||
description: 'Toggle proactive (autonomous) mode',
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { type FSWatcher, watch } from 'fs'
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { setMainLoopModelOverride } from '../bootstrap/state.js'
|
||||
import {
|
||||
type BridgePermissionCallbacks,
|
||||
type BridgePermissionResponse,
|
||||
isBridgePermissionResponse,
|
||||
parseBridgePermissionResponse,
|
||||
} from '../bridge/bridgePermissionCallbacks.js'
|
||||
import { handleRemoteInterrupt } from '../bridge/remoteInterruptHandling.js'
|
||||
import {
|
||||
isTranscriptResetResultReady,
|
||||
shouldDeferBridgeResult,
|
||||
} from '../bridge/bridgeResultScheduling.js'
|
||||
import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js'
|
||||
import { extractInboundMessageFields } from '../bridge/inboundMessages.js'
|
||||
import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js'
|
||||
@@ -36,6 +42,10 @@ import {
|
||||
createBridgeStatusMessage,
|
||||
createSystemMessage,
|
||||
} from '../utils/messages.js'
|
||||
import {
|
||||
buildTaskStateMessage,
|
||||
getTaskStateSnapshotKey,
|
||||
} from '../utils/taskStateMessage.js'
|
||||
import {
|
||||
getAutoModeUnavailableNotification,
|
||||
getAutoModeUnavailableReason,
|
||||
@@ -44,8 +54,17 @@ import {
|
||||
transitionPermissionMode,
|
||||
} from '../utils/permissions/permissionSetup.js'
|
||||
import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'
|
||||
import {
|
||||
getTaskListId,
|
||||
getTasksDir,
|
||||
listTasks,
|
||||
onTasksUpdated,
|
||||
} from '../utils/tasks.js'
|
||||
import { ContentBlockParam } from '@anthropic-ai/sdk/resources'
|
||||
|
||||
const TASK_STATE_DEBOUNCE_MS = 50
|
||||
const TASK_STATE_POLL_MS = 5000
|
||||
|
||||
/** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */
|
||||
export const BRIDGE_FAILURE_DISMISS_MS = 10_000
|
||||
|
||||
@@ -81,6 +100,8 @@ export function useReplBridge(
|
||||
const handleRef = useRef<ReplBridgeHandle | null>(null)
|
||||
const teardownPromiseRef = useRef<Promise<void> | undefined>(undefined)
|
||||
const lastWrittenIndexRef = useRef(0)
|
||||
const pendingResultAfterFlushRef = useRef(false)
|
||||
const transcriptResetPendingRef = useRef(false)
|
||||
// Tracks UUIDs already flushed as initial messages. Persists across
|
||||
// bridge reconnections so Bridge #2+ only sends new messages — sending
|
||||
// duplicate UUIDs causes the server to kill the WebSocket.
|
||||
@@ -109,6 +130,10 @@ export function useReplBridge(
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAppState(s => s.replBridgeConnected)
|
||||
: false
|
||||
const replBridgeSessionActive = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAppState(s => s.replBridgeSessionActive)
|
||||
: false
|
||||
const replBridgeOutboundOnly = feature('BRIDGE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAppState(s => s.replBridgeOutboundOnly)
|
||||
@@ -454,25 +479,24 @@ export function useReplBridge(
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingPermissionHandlers.delete(requestId)
|
||||
// Extract the permission decision from the control_response payload
|
||||
const inner = msg.response
|
||||
if (
|
||||
inner.subtype === 'success' &&
|
||||
inner.response &&
|
||||
isBridgePermissionResponse(inner.response)
|
||||
) {
|
||||
handler(inner.response)
|
||||
const parsed = parseBridgePermissionResponse(msg)
|
||||
if (!parsed) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Ignoring unrecognized control_response request_id=${requestId}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingPermissionHandlers.delete(requestId)
|
||||
handler(parsed)
|
||||
}
|
||||
|
||||
const handle = await initReplBridge({
|
||||
const rawHandle = await initReplBridge({
|
||||
outboundOnly,
|
||||
tags: outboundOnly ? ['ccr-mirror'] : undefined,
|
||||
onInboundMessage: handleInboundMessage,
|
||||
onPermissionResponse: handlePermissionResponse,
|
||||
onInterrupt() {
|
||||
abortControllerRef.current?.abort()
|
||||
handleRemoteInterrupt(abortControllerRef.current)
|
||||
},
|
||||
onSetModel(model) {
|
||||
const resolved = model === 'default' ? null : (model ?? null)
|
||||
@@ -565,6 +589,16 @@ export function useReplBridge(
|
||||
initialName: replBridgeInitialName,
|
||||
perpetual,
|
||||
})
|
||||
const handle = rawHandle
|
||||
? {
|
||||
...rawHandle,
|
||||
markTranscriptReset() {
|
||||
transcriptResetPendingRef.current = true
|
||||
pendingResultAfterFlushRef.current = false
|
||||
lastWrittenIndexRef.current = 0
|
||||
},
|
||||
}
|
||||
: null
|
||||
if (cancelled) {
|
||||
// Effect was cancelled while initReplBridge was in flight.
|
||||
// Tear down the handle to avoid leaking resources (poll loop,
|
||||
@@ -816,6 +850,8 @@ export function useReplBridge(
|
||||
}
|
||||
})
|
||||
lastWrittenIndexRef.current = 0
|
||||
pendingResultAfterFlushRef.current = false
|
||||
transcriptResetPendingRef.current = false
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -864,15 +900,152 @@ export function useReplBridge(
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
handle.writeMessages(newMessages)
|
||||
transcriptResetPendingRef.current = false
|
||||
}
|
||||
|
||||
if (
|
||||
pendingResultAfterFlushRef.current &&
|
||||
isTranscriptResetResultReady(
|
||||
transcriptResetPendingRef.current,
|
||||
messages.length,
|
||||
)
|
||||
) {
|
||||
transcriptResetPendingRef.current = false
|
||||
pendingResultAfterFlushRef.current = false
|
||||
handle.sendResult()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
pendingResultAfterFlushRef.current &&
|
||||
!transcriptResetPendingRef.current
|
||||
) {
|
||||
pendingResultAfterFlushRef.current = false
|
||||
handle.sendResult()
|
||||
}
|
||||
}
|
||||
}, [messages, replBridgeConnected])
|
||||
|
||||
useEffect(() => {
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
if (!replBridgeSessionActive || replBridgeOutboundOnly) return
|
||||
|
||||
let cancelled = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||
let watcher: FSWatcher | null = null
|
||||
let watchedDir: string | null = null
|
||||
let lastPublishedSnapshotKey: string | null = null
|
||||
let lastPublishedHandle: ReplBridgeHandle | null = null
|
||||
|
||||
const rewatch = (dir: string): void => {
|
||||
if (dir === watchedDir && watcher !== null) return
|
||||
watcher?.close()
|
||||
watcher = null
|
||||
watchedDir = dir
|
||||
try {
|
||||
watcher = watch(dir, schedulePublish)
|
||||
watcher.unref()
|
||||
} catch {
|
||||
// Writers ensure the directory exists; if it does not yet, the
|
||||
// poll timer and in-process task signal still converge the snapshot.
|
||||
}
|
||||
}
|
||||
|
||||
const publishTaskState = async (): Promise<void> => {
|
||||
const handle = handleRef.current
|
||||
if (!handle) return
|
||||
|
||||
const taskListId = getTaskListId()
|
||||
rewatch(getTasksDir(taskListId))
|
||||
|
||||
try {
|
||||
const tasks = await listTasks(taskListId)
|
||||
if (cancelled || handleRef.current !== handle) return
|
||||
const snapshotKey = getTaskStateSnapshotKey(taskListId, tasks)
|
||||
if (
|
||||
snapshotKey === lastPublishedSnapshotKey &&
|
||||
handle === lastPublishedHandle
|
||||
) {
|
||||
return
|
||||
}
|
||||
handle.writeSdkMessages([buildTaskStateMessage(taskListId, tasks)])
|
||||
lastPublishedSnapshotKey = snapshotKey
|
||||
lastPublishedHandle = handle
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[bridge:repl] Failed to publish task_state: ${errorMessage(err)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePublish = (): void => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = undefined
|
||||
void publishTaskState()
|
||||
}, TASK_STATE_DEBOUNCE_MS)
|
||||
debounceTimer.unref?.()
|
||||
}
|
||||
|
||||
void publishTaskState()
|
||||
const unsubscribe = onTasksUpdated(schedulePublish)
|
||||
pollTimer = setInterval(() => {
|
||||
void publishTaskState()
|
||||
}, TASK_STATE_POLL_MS)
|
||||
pollTimer.unref?.()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubscribe()
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
watcher?.close()
|
||||
}
|
||||
}
|
||||
}, [replBridgeSessionActive, replBridgeOutboundOnly])
|
||||
|
||||
const sendBridgeResult = useCallback(() => {
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
handleRef.current?.sendResult()
|
||||
const handle = handleRef.current
|
||||
if (!handle) {
|
||||
pendingResultAfterFlushRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isTranscriptResetResultReady(
|
||||
transcriptResetPendingRef.current,
|
||||
messagesRef.current.length,
|
||||
)
|
||||
) {
|
||||
transcriptResetPendingRef.current = false
|
||||
pendingResultAfterFlushRef.current = false
|
||||
handle.sendResult()
|
||||
return
|
||||
}
|
||||
|
||||
// Message mirroring happens in a separate effect. When the turn completes
|
||||
// before that effect flushes the latest transcript rows, hold the result
|
||||
// so remote state transitions after the final mirrored messages instead
|
||||
// of bouncing back to "running" on local slash commands like /clear.
|
||||
if (
|
||||
transcriptResetPendingRef.current ||
|
||||
shouldDeferBridgeResult({
|
||||
hasHandle: true,
|
||||
isConnected: replBridgeConnected,
|
||||
lastWrittenIndex: lastWrittenIndexRef.current,
|
||||
messageCount: messagesRef.current.length,
|
||||
})
|
||||
) {
|
||||
pendingResultAfterFlushRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
handle.sendResult()
|
||||
}
|
||||
}, [])
|
||||
}, [replBridgeConnected])
|
||||
|
||||
return { sendBridgeResult }
|
||||
}
|
||||
|
||||
@@ -269,6 +269,11 @@ export function convertSDKMessage(
|
||||
logForDebugging('[sdkMessageAdapter] Ignoring rate_limit_event message')
|
||||
return { type: 'ignored' }
|
||||
|
||||
case 'task_state':
|
||||
// Bridge-only task snapshots are consumed by the web panel, not REPL UIs.
|
||||
logForDebugging('[sdkMessageAdapter] Ignoring task_state message')
|
||||
return { type: 'ignored' }
|
||||
|
||||
default: {
|
||||
// Gracefully ignore unknown message types. The backend may send new
|
||||
// types before the client is updated; logging helps with debugging
|
||||
|
||||
@@ -207,6 +207,7 @@ const getCoordinatorUserContext: (
|
||||
/* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
|
||||
import useCanUseTool from '../hooks/useCanUseTool.js';
|
||||
import type { ToolPermissionContext, Tool } from '../Tool.js';
|
||||
import { notifyAutomationStateChanged } from '../utils/sessionState.js';
|
||||
import {
|
||||
applyPermissionUpdate,
|
||||
applyPermissionUpdates,
|
||||
@@ -341,6 +342,7 @@ import { useInboxPoller } from '../hooks/useInboxPoller.js';
|
||||
const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null;
|
||||
const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {};
|
||||
const PROACTIVE_FALSE = () => false;
|
||||
const PROACTIVE_NULL = (): number | null => null;
|
||||
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
|
||||
const useProactive =
|
||||
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
|
||||
@@ -928,6 +930,10 @@ export function REPL({
|
||||
proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE,
|
||||
proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE,
|
||||
);
|
||||
const proactiveNextTickAt = React.useSyncExternalStore<number | null>(
|
||||
proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE,
|
||||
proactiveModule?.getNextTickAt ?? PROACTIVE_NULL,
|
||||
);
|
||||
|
||||
// BriefTool.isEnabled() reads getUserMsgOptIn() from bootstrap state, which
|
||||
// /brief flips mid-session alongside isBriefOnly. The memo below needs a
|
||||
@@ -4944,6 +4950,48 @@ export function REPL({
|
||||
onQueueTick: (command: QueuedCommand) => enqueue(command),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!proactiveActive) {
|
||||
notifyAutomationStateChanged(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
proactiveNextTickAt !== null &&
|
||||
queuedCommands.length === 0 &&
|
||||
!isShowingLocalJSXCommand &&
|
||||
toolPermissionContext.mode !== 'plan' &&
|
||||
initialMessage === null
|
||||
) {
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: proactiveNextTickAt,
|
||||
sleep_until: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
}, [
|
||||
initialMessage,
|
||||
isLoading,
|
||||
isShowingLocalJSXCommand,
|
||||
proactiveActive,
|
||||
proactiveNextTickAt,
|
||||
queuedCommands.length,
|
||||
toolPermissionContext.mode,
|
||||
]);
|
||||
|
||||
// Abort the current operation when a 'now' priority message arrives
|
||||
// (e.g. from a chat UI client via UDS).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -174,6 +174,18 @@ export type CommandAvailability =
|
||||
|
||||
export type CommandBase = {
|
||||
availability?: CommandAvailability[]
|
||||
/**
|
||||
* Allows a local/local-jsx command to execute when it arrives over the
|
||||
* Remote Control bridge. Only use for commands that do not require local
|
||||
* interactive Ink UI and can safely complete headlessly.
|
||||
*/
|
||||
bridgeSafe?: boolean
|
||||
/**
|
||||
* Optional per-invocation validation for bridge-delivered slash commands.
|
||||
* Return a user-facing rejection reason when specific arguments are unsafe
|
||||
* to run headlessly over Remote Control.
|
||||
*/
|
||||
getBridgeInvocationError?: (args: string) => string | undefined
|
||||
description: string
|
||||
hasUserSpecifiedDescription?: boolean
|
||||
/** Defaults to true. Only set when the command has conditional enablement (feature flags, env checks, etc). */
|
||||
|
||||
30
src/utils/__tests__/messageQueueManager.test.ts
Normal file
30
src/utils/__tests__/messageQueueManager.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { isSlashCommand } from '../messageQueueManager.js'
|
||||
|
||||
describe('messageQueueManager.isSlashCommand', () => {
|
||||
test('treats normal slash commands as slash commands', () => {
|
||||
expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
bridgeOrigin: true,
|
||||
} as any),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
} as any),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
174
src/utils/__tests__/sessionState.test.ts
Normal file
174
src/utils/__tests__/sessionState.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
notifyAutomationStateChanged,
|
||||
notifySessionStateChanged,
|
||||
notifySessionMetadataChanged,
|
||||
resetSessionStateForTests,
|
||||
setSessionMetadataChangedListener,
|
||||
} from '../sessionState'
|
||||
|
||||
describe('sessionState metadata replay', () => {
|
||||
beforeEach(() => {
|
||||
resetSessionStateForTests()
|
||||
})
|
||||
|
||||
test('replays cached automation state to listeners that attach later', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
})
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('dedupes identical automation states after replay but forwards changes', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
})
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
})
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
})
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 123,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replays merged metadata snapshots instead of only the latest delta', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifySessionMetadataChanged({ model: 'claude-sonnet-4-6' })
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
})
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
model: 'claude-sonnet-4-6',
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: 456,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replays pending_action metadata cached through session-state transitions', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifySessionStateChanged('requires_action', {
|
||||
tool_name: 'Edit',
|
||||
action_description: 'Edit src/utils/sessionState.ts',
|
||||
tool_use_id: 'toolu_123',
|
||||
request_id: 'req_123',
|
||||
input: { path: 'src/utils/sessionState.ts' },
|
||||
})
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
pending_action: {
|
||||
tool_name: 'Edit',
|
||||
action_description: 'Edit src/utils/sessionState.ts',
|
||||
tool_use_id: 'toolu_123',
|
||||
request_id: 'req_123',
|
||||
input: { path: 'src/utils/sessionState.ts' },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('replays cleared task_summary metadata after returning to idle', () => {
|
||||
const seen: Array<Record<string, unknown>> = []
|
||||
|
||||
notifySessionMetadataChanged({ task_summary: 'Running regression suite' })
|
||||
notifySessionStateChanged('idle')
|
||||
|
||||
setSessionMetadataChangedListener(
|
||||
metadata => {
|
||||
seen.push(metadata as Record<string, unknown>)
|
||||
},
|
||||
{ replayCurrent: true },
|
||||
)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
task_summary: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
84
src/utils/__tests__/taskStateMessage.test.ts
Normal file
84
src/utils/__tests__/taskStateMessage.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildTaskStateMessage,
|
||||
getTaskStateSnapshotKey,
|
||||
} from "../taskStateMessage";
|
||||
|
||||
describe("buildTaskStateMessage", () => {
|
||||
test("filters internal tasks and preserves public task fields", () => {
|
||||
const message = buildTaskStateMessage("tasklist", [
|
||||
{
|
||||
id: "1",
|
||||
subject: "Visible task",
|
||||
description: "Shown in web UI",
|
||||
activeForm: "Doing visible task",
|
||||
status: "in_progress",
|
||||
owner: "agent-1",
|
||||
blocks: ["2"],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
subject: "Internal task",
|
||||
description: "Hidden from web UI",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: { _internal: true },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(message.type).toBe("task_state");
|
||||
expect(message.task_list_id).toBe("tasklist");
|
||||
expect(message.uuid).toEqual(expect.any(String));
|
||||
expect(message.tasks).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
subject: "Visible task",
|
||||
description: "Shown in web UI",
|
||||
activeForm: "Doing visible task",
|
||||
status: "in_progress",
|
||||
owner: "agent-1",
|
||||
blocks: ["2"],
|
||||
blockedBy: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds a stable snapshot key for equivalent public tasks", () => {
|
||||
const tasks = [
|
||||
{
|
||||
id: "2",
|
||||
subject: "Second",
|
||||
description: "Second task",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
subject: "First",
|
||||
description: "First task",
|
||||
status: "in_progress",
|
||||
blocks: ["2"],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "internal",
|
||||
subject: "Internal task",
|
||||
description: "Hidden",
|
||||
status: "pending",
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: { _internal: true },
|
||||
},
|
||||
];
|
||||
|
||||
const firstKey = getTaskStateSnapshotKey("tasklist", tasks as any);
|
||||
const secondKey = getTaskStateSnapshotKey("tasklist", [...tasks].reverse() as any);
|
||||
const message = buildTaskStateMessage("tasklist", tasks as any);
|
||||
|
||||
expect(firstKey).toBe(secondKey);
|
||||
expect(message.tasks.map(task => task.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,8 @@ export type HandlePromptSubmitParams = BaseExecutionParams & {
|
||||
* trigger local slash commands or skills.
|
||||
*/
|
||||
skipSlashCommands?: boolean
|
||||
/** Preserves that the input originated from Remote Control when queued. */
|
||||
bridgeOrigin?: boolean
|
||||
}
|
||||
|
||||
export async function handlePromptSubmit(
|
||||
@@ -147,6 +149,7 @@ export async function handlePromptSubmit(
|
||||
queuedCommands,
|
||||
uuid,
|
||||
skipSlashCommands,
|
||||
bridgeOrigin,
|
||||
} = params
|
||||
|
||||
const { setCursorOffset, clearBuffer, resetHistory } = helpers
|
||||
@@ -345,6 +348,7 @@ export async function handlePromptSubmit(
|
||||
mode,
|
||||
pastedContents: hasImages ? pastedContents : undefined,
|
||||
skipSlashCommands,
|
||||
bridgeOrigin,
|
||||
uuid,
|
||||
})
|
||||
|
||||
@@ -368,6 +372,7 @@ export async function handlePromptSubmit(
|
||||
mode,
|
||||
pastedContents: hasImages ? pastedContents : undefined,
|
||||
skipSlashCommands,
|
||||
bridgeOrigin,
|
||||
uuid,
|
||||
}
|
||||
|
||||
|
||||
@@ -535,13 +535,14 @@ export function getCommandsByMaxPriority(
|
||||
* Returns true if the command is a slash command that should be routed through
|
||||
* processSlashCommand rather than sent to the model as text.
|
||||
*
|
||||
* Commands with `skipSlashCommands` (e.g. bridge/CCR messages) are NOT treated
|
||||
* as slash commands — their text is meant for the model.
|
||||
* Commands with `skipSlashCommands` are usually treated as plain text, except
|
||||
* Remote Control bridge messages (`bridgeOrigin`) that are re-validated later
|
||||
* through isBridgeSafeCommand().
|
||||
*/
|
||||
export function isSlashCommand(cmd: QueuedCommand): boolean {
|
||||
return (
|
||||
typeof cmd.value === 'string' &&
|
||||
cmd.value.trim().startsWith('/') &&
|
||||
!cmd.skipSlashCommands
|
||||
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { getContentText } from 'src/utils/messages.js'
|
||||
import {
|
||||
findCommand,
|
||||
getBridgeCommandSafety,
|
||||
getCommandName,
|
||||
isBridgeSafeCommand,
|
||||
type LocalJSXCommandContext,
|
||||
} from '../../commands.js'
|
||||
import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
|
||||
@@ -432,10 +432,13 @@ async function processUserInputBase(
|
||||
? findCommand(parsed.commandName, context.options.commands)
|
||||
: undefined
|
||||
if (cmd) {
|
||||
if (isBridgeSafeCommand(cmd)) {
|
||||
const safety = getBridgeCommandSafety(cmd, parsed?.args ?? '')
|
||||
if (safety.ok) {
|
||||
effectiveSkipSlash = false
|
||||
} else {
|
||||
const msg = `/${getCommandName(cmd)} isn't available over Remote Control.`
|
||||
const msg =
|
||||
safety.reason ??
|
||||
`/${getCommandName(cmd)} isn't available over Remote Control.`
|
||||
return {
|
||||
messages: [
|
||||
createUserMessage({ content: inputString, uuid }),
|
||||
|
||||
@@ -19,12 +19,15 @@ type ProcessQueueResult = {
|
||||
*/
|
||||
function isSlashCommand(cmd: QueuedCommand): boolean {
|
||||
if (typeof cmd.value === 'string') {
|
||||
return cmd.value.trim().startsWith('/')
|
||||
return cmd.value.trim().startsWith('/') && (!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
|
||||
}
|
||||
// For ContentBlockParam[], check the first text block
|
||||
for (const block of cmd.value) {
|
||||
if (block.type === 'text') {
|
||||
return block.text.trim().startsWith('/')
|
||||
return (
|
||||
block.text.trim().startsWith('/') &&
|
||||
(!cmd.skipSlashCommands || cmd.bridgeOrigin === true)
|
||||
)
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type SessionState = 'idle' | 'running' | 'requires_action'
|
||||
|
||||
import { isProactiveActive } from '../proactive/index.js'
|
||||
|
||||
/**
|
||||
* Context carried with requires_action transitions so downstream
|
||||
* surfaces (CCR sidebar, push notifications) can show what the
|
||||
@@ -23,6 +25,15 @@ export type RequiresActionDetails = {
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type AutomationStatePhase = 'standby' | 'sleeping'
|
||||
|
||||
export type AutomationStateMetadata = {
|
||||
enabled: boolean
|
||||
phase: AutomationStatePhase | null
|
||||
next_tick_at: number | null
|
||||
sleep_until: number | null
|
||||
}
|
||||
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import type { PermissionMode } from './permissions/PermissionMode.js'
|
||||
import { enqueueSdkEvent } from './sdkEventQueue.js'
|
||||
@@ -34,6 +45,7 @@ export type SessionExternalMetadata = {
|
||||
is_ultraplan_mode?: boolean | null
|
||||
model?: string | null
|
||||
pending_action?: RequiresActionDetails | null
|
||||
automation_state?: AutomationStateMetadata | null
|
||||
// Opaque — typed at the emit site. Importing PostTurnSummaryOutput here
|
||||
// would leak the import path string into sdk.d.ts via agentSdkBridge's
|
||||
// re-export of SessionState.
|
||||
@@ -52,6 +64,9 @@ type SessionMetadataChangedListener = (
|
||||
metadata: SessionExternalMetadata,
|
||||
) => void
|
||||
type PermissionModeChangedListener = (mode: PermissionMode) => void
|
||||
type SessionMetadataListenerOptions = {
|
||||
replayCurrent?: boolean
|
||||
}
|
||||
|
||||
let stateListener: SessionStateChangedListener | null = null
|
||||
let metadataListener: SessionMetadataChangedListener | null = null
|
||||
@@ -65,8 +80,19 @@ export function setSessionStateChangedListener(
|
||||
|
||||
export function setSessionMetadataChangedListener(
|
||||
cb: SessionMetadataChangedListener | null,
|
||||
options?: SessionMetadataListenerOptions,
|
||||
): void {
|
||||
metadataListener = cb
|
||||
if (!cb || !options?.replayCurrent) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = getSessionMetadataSnapshot()
|
||||
if (Object.keys(snapshot).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
cb(snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +110,61 @@ export function setPermissionModeChangedListener(
|
||||
|
||||
let hasPendingAction = false
|
||||
let currentState: SessionState = 'idle'
|
||||
let currentAutomationState: AutomationStateMetadata | null = null
|
||||
let currentMetadata: SessionExternalMetadata = {}
|
||||
|
||||
function normalizeAutomationState(
|
||||
state: AutomationStateMetadata | null | undefined,
|
||||
): AutomationStateMetadata | null {
|
||||
if (!state || state.enabled !== true) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
phase:
|
||||
state.phase === 'standby' || state.phase === 'sleeping'
|
||||
? state.phase
|
||||
: null,
|
||||
next_tick_at:
|
||||
typeof state.next_tick_at === 'number' ? state.next_tick_at : null,
|
||||
sleep_until:
|
||||
typeof state.sleep_until === 'number' ? state.sleep_until : null,
|
||||
}
|
||||
}
|
||||
|
||||
function automationStateKey(
|
||||
state: AutomationStateMetadata | null,
|
||||
): string {
|
||||
return JSON.stringify(state)
|
||||
}
|
||||
|
||||
function applyMetadataUpdate(
|
||||
metadata: SessionExternalMetadata,
|
||||
): void {
|
||||
const nextMetadata = { ...currentMetadata }
|
||||
for (const key of Object.keys(metadata) as Array<
|
||||
keyof SessionExternalMetadata
|
||||
>) {
|
||||
const value = metadata[key]
|
||||
if (value === undefined) {
|
||||
delete nextMetadata[key]
|
||||
continue
|
||||
}
|
||||
;(nextMetadata as Record<string, unknown>)[key] = value
|
||||
}
|
||||
currentMetadata = nextMetadata
|
||||
}
|
||||
|
||||
export function getSessionMetadataSnapshot(): SessionExternalMetadata {
|
||||
const snapshot: SessionExternalMetadata = { ...currentMetadata }
|
||||
if (currentAutomationState) {
|
||||
snapshot.automation_state = { ...currentAutomationState }
|
||||
} else if ('automation_state' in currentMetadata) {
|
||||
snapshot.automation_state = currentMetadata.automation_state ?? null
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function getSessionState(): SessionState {
|
||||
return currentState
|
||||
@@ -101,18 +182,31 @@ export function notifySessionStateChanged(
|
||||
// null on the next non-blocked transition.
|
||||
if (state === 'requires_action' && details) {
|
||||
hasPendingAction = true
|
||||
metadataListener?.({
|
||||
notifySessionMetadataChanged({
|
||||
pending_action: details,
|
||||
})
|
||||
} else if (hasPendingAction) {
|
||||
hasPendingAction = false
|
||||
metadataListener?.({ pending_action: null })
|
||||
notifySessionMetadataChanged({ pending_action: null })
|
||||
}
|
||||
|
||||
// task_summary is written mid-turn by the forked summarizer; clear it at
|
||||
// idle so the next turn doesn't briefly show the previous turn's progress.
|
||||
if (state === 'idle') {
|
||||
metadataListener?.({ task_summary: null })
|
||||
notifySessionMetadataChanged({ task_summary: null })
|
||||
}
|
||||
|
||||
if (state !== 'idle') {
|
||||
notifyAutomationStateChanged(
|
||||
isProactiveActive()
|
||||
? {
|
||||
enabled: true,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
// Mirror to the SDK event stream so non-CCR consumers (scmuxd, VS Code)
|
||||
@@ -136,9 +230,25 @@ export function notifySessionStateChanged(
|
||||
export function notifySessionMetadataChanged(
|
||||
metadata: SessionExternalMetadata,
|
||||
): void {
|
||||
applyMetadataUpdate(metadata)
|
||||
metadataListener?.(metadata)
|
||||
}
|
||||
|
||||
export function notifyAutomationStateChanged(
|
||||
state: AutomationStateMetadata | null | undefined,
|
||||
): void {
|
||||
const nextState = normalizeAutomationState(state)
|
||||
if (
|
||||
automationStateKey(nextState) === automationStateKey(currentAutomationState)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
currentAutomationState = nextState
|
||||
applyMetadataUpdate({ automation_state: nextState })
|
||||
metadataListener?.({ automation_state: nextState })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired by onChangeAppState when toolPermissionContext.mode changes.
|
||||
* Downstream listeners (CCR external_metadata PUT, SDK status stream) are
|
||||
@@ -148,3 +258,13 @@ export function notifySessionMetadataChanged(
|
||||
export function notifyPermissionModeChanged(mode: PermissionMode): void {
|
||||
permissionModeListener?.(mode)
|
||||
}
|
||||
|
||||
export function resetSessionStateForTests(): void {
|
||||
stateListener = null
|
||||
metadataListener = null
|
||||
permissionModeListener = null
|
||||
hasPendingAction = false
|
||||
currentState = 'idle'
|
||||
currentAutomationState = null
|
||||
currentMetadata = {}
|
||||
}
|
||||
|
||||
76
src/utils/taskStateMessage.ts
Normal file
76
src/utils/taskStateMessage.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'
|
||||
import type { Task } from './tasks.js'
|
||||
|
||||
export type TaskStateItem = Pick<
|
||||
Task,
|
||||
| 'id'
|
||||
| 'subject'
|
||||
| 'description'
|
||||
| 'activeForm'
|
||||
| 'status'
|
||||
| 'owner'
|
||||
| 'blocks'
|
||||
| 'blockedBy'
|
||||
>
|
||||
|
||||
export type TaskStateMessage = SDKMessage & {
|
||||
type: 'task_state'
|
||||
uuid: string
|
||||
task_list_id: string
|
||||
tasks: TaskStateItem[]
|
||||
}
|
||||
|
||||
export type TaskStateSnapshot = Pick<
|
||||
TaskStateMessage,
|
||||
'task_list_id' | 'tasks'
|
||||
>
|
||||
|
||||
function toTaskStateItem(task: Task): TaskStateItem {
|
||||
return {
|
||||
id: task.id,
|
||||
subject: task.subject,
|
||||
description: task.description,
|
||||
activeForm: task.activeForm,
|
||||
status: task.status,
|
||||
owner: task.owner,
|
||||
blocks: [...task.blocks],
|
||||
blockedBy: [...task.blockedBy],
|
||||
}
|
||||
}
|
||||
|
||||
function compareTaskStateItems(a: TaskStateItem, b: TaskStateItem): number {
|
||||
return a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
export function buildTaskStateSnapshot(
|
||||
taskListId: string,
|
||||
tasks: Task[],
|
||||
): TaskStateSnapshot {
|
||||
return {
|
||||
task_list_id: taskListId,
|
||||
tasks: tasks
|
||||
.filter(task => !task.metadata?._internal)
|
||||
.map(toTaskStateItem)
|
||||
.sort(compareTaskStateItems),
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskStateSnapshotKey(
|
||||
taskListId: string,
|
||||
tasks: Task[],
|
||||
): string {
|
||||
return JSON.stringify(buildTaskStateSnapshot(taskListId, tasks))
|
||||
}
|
||||
|
||||
export function buildTaskStateMessage(
|
||||
taskListId: string,
|
||||
tasks: Task[],
|
||||
): TaskStateMessage {
|
||||
const snapshot = buildTaskStateSnapshot(taskListId, tasks)
|
||||
return {
|
||||
type: 'task_state',
|
||||
uuid: randomUUID(),
|
||||
...snapshot,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user