diff --git a/CLAUDE.md b/CLAUDE.md index c9e161e81..6200c3ab0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ The Superpowers plugin is disabled for this repository. Do not invoke or follow - **回复中的三种发送**(对标 Claude 桌面版 next / later / now):Enter = 软插话 `/turn/steer`,先画在对话末尾(`SteeredMessages`,排队状态 `lane: 'steer'`),气泡上「立即发送」走 `/turn/steer/send-now`(服务端把这条未读插话提成优先回合并打断,**不要**改成再调 `/turn/interrupt` 重发,会发两遍)、撤回走 `/turn/steer/withdraw` 且**消息退回输入框不删**(已有内容时放前面换行,`steerWithdrawal.js`);回合结束仍未读的插话转回排队卡片;Ctrl+Enter = 排到回合结束(原排队卡片),Ctrl+Alt+Enter = `fork` 带 `whole: true` 复制整个会话后在新会话发送。见 [docs/daemon-api.md](docs/daemon-api.md)。 - **侧边对话只放只读工具**(`side_chat.cpp` 小工具循环):白名单 `file_read` / `grep` / `glob` / `lsp` 与会话策略取交集,MCP 一律不放,**不要**改成按 `is_read_only` 筛;凡要弹确认的一律拒绝;在 `MtimeTracker::DetachedReadScope` 里执行;Web 侧边多一个 `send_to_main_session`,用户确认之前绝不送达,送达走 `send_side_chat_message`(有可插话回合就 steer,否则普通 send)。归档:侧边对话只放只读工具。 - **后台任务**:会话级登记表(`tool/background_tasks`,AgentLoop 持有)管后台命令与后台子代理,结束时经邮箱通知会话(在跑就模型边界插入,空闲就开新回合);bash 前台超时**转后台不杀**、`run_in_background` / `monitor`、开头长 sleep 拒绝;用户插话 / 「转到后台」/ TUI Ctrl+B 让前台工作让路;通知落盘为 user + `metadata.background_task`,按内部上下文处理(不计回合、不当标题)。Web 对话里任务状态挂在发起它的工具行上:工具行照常折在「已处理」里,任务没结束时包着它的每层折叠条只带炫光(不转圈、文案不变),会话空闲时末尾**不画**「N 个后台任务正在运行」(那行转圈让用户以为回合没结束,别加回去)。全文 [docs/background-tasks.md](docs/background-tasks.md)。 -- **子代理 / 蜂群**:`spawn_subagent` / `wait_subagent` 双端注册,子会话是普通 SessionRegistry 会话(父 cwd + 权限模式),不能再派子代理;子会话从会话列表隐藏、始终跟随主会话(无单独归档 / 删除,主会话永久删除才级联);蜂群 `off | star | mesh` 两套工具互斥,邮件进 AgentLoop 邮箱,信封落盘为 user 角色 + `metadata.inter_agent`,子 agent 完成不唤醒空闲父 agent,Web 芯片只在与服务端模式不同时才随消息提交 `swarm_mode`。全文 [docs/subagents.md](docs/subagents.md)。 +- **子代理 / 蜂群**:`spawn_subagent` / `wait_subagent` 双端注册,子会话是普通 SessionRegistry 会话(父 cwd + 权限模式),不能再派子代理;子会话从会话列表隐藏、始终跟随主会话(无单独归档 / 删除,主会话永久删除才级联);蜂群 `off | star | mesh` 两套工具互斥,邮件进 AgentLoop 邮箱,信封落盘为 user 角色 + `metadata.inter_agent`,子 agent 完成不唤醒空闲父 agent,Web 芯片只在与服务端模式不同时才随消息提交 `swarm_mode`。Web 里每个子代理前面一个 16px 头像(绑定专家用专家的,否则按子会话 id 生成,对话 / 摘要面板 / 后台任务页签是同一张脸),右上角的状态图标与桌面像素办公室共用 `office_characters.js` 的 `miniSprite`(运行中三个点 + 点头,完成是星,中止是红色感叹号),只有运行中的头像订阅 `avatarStatus.js` 那只共用的钟;后台任务页签里子代理是单行无边框的行(标题、token 数、时间靠右,只有运行中的多一行状态,别的不显示),后台命令仍是卡片。全文 [docs/subagents.md](docs/subagents.md)。 - **写边界**:`AgentLoop::write_root()`(会话 worktree > LOOP 策略 > 继承)非空即有边界,Yolo 不免除,只有 dangerous 放行;子会话共享父会话 worktree 并继承写边界;bash 走 `shell_write_guard` 可证明写目标守卫(**守卫不是沙箱**,事后用 `workspace_touched` 快照兜底);附加文件夹不能打穿写边界。归档:写边界与子代理继承、多文件夹项目。 - **Worktree**:落在主仓根 `.acecode/worktrees/`,分支 `worktree-`;`EnterWorktree` / `ExitWorktree` 只在用户明确提到 worktree 时可调,会话存储位置不动;合回主干必须再调 `ExitWorktree`;`ExitWorktree remove` fail-closed;`--worktree [name]` 时 worktree 就是项目根。纯逻辑 [worktree_core](src/domain/worktree/worktree_core.hpp),git 层 [worktree_manager](src/domain/worktree/worktree_manager.hpp)。归档:Worktree 隔离。 - **Goal**:每会话至多一个,存项目级 `state.sqlite3`,只有 `active` 自动 continuation(Plan mode 不触发);unattended 下写工具自动放行、bash 仍取决策表沙盒;provider 终止错误把 goal 转 usage_limited(429)/ blocked,用户 abort 转 paused。归档:Thread Goals。 diff --git a/assets/desktop_pet/agent_office_pet.html b/assets/desktop_pet/agent_office_pet.html index b3c797e61..1228995f5 100644 --- a/assets/desktop_pet/agent_office_pet.html +++ b/assets/desktop_pet/agent_office_pet.html @@ -209,21 +209,11 @@ } /* ---------- 3. 小人:1 套模板 × 调色板 × 翻转 × 补丁 ---------- */ -const { characterSprite: charSprite, stableLook, HAIR_COLORS, SKIN_TONES, +const { characterSprite: charSprite, miniSprite: miniSpr, stableLook, HAIR_COLORS, SKIN_TONES, SHIRT_COLORS, ACC_COLORS, HAIR_STYLES, HEADWEAR, FACEWEAR, CHESTWEAR, TALL_HAIR } = globalThis.ACEOfficeCharacters; -/* ---------- 4. 其他像素模板(图标 / 数字 / 信封 / 植物) ---------- */ -const MINI = { - q: ['.yyy.', 'y...y', '...y.', '..y..', '.....', '..y..'], - ex: ['y', 'y', 'y', 'y', '.', 'y'], - star: ['..y..', '.yyy.', 'yyyyy', '.yyy.', 'y...y'], - z: ['yyyy', '..y.', '.y..', 'yyyy'], - arrow: ['yyyyy', '.yyy.', '..y..'], - dot: ['yy', 'yy'], - glass: ['yyyyy', '.y.y.', '..y..', '.y.y.', 'yyyyy'], -}; -const miniSpr = (n, c) => sprite(`mi|${n}|${c}`, () => ({ cv: pix(MINI[n], { y: c }) })); +/* ---------- 4. 其他像素模板(数字 / 信封 / 植物;头顶状态图标 miniSpr 在共用的 office_characters.js 里) ---------- */ const DIGITS = { 0: ['###', '#.#', '#.#', '#.#', '###'], 1: ['.#.', '##.', '.#.', '.#.', '###'], 2: ['###', '..#', '###', '#..', '###'], 3: ['###', '..#', '.##', '..#', '###'], 4: ['#.#', '#.#', '###', '..#', '..#'], 5: ['###', '#..', '###', '..#', '###'], diff --git a/assets/desktop_pet/office_characters.js b/assets/desktop_pet/office_characters.js index f4506ef29..2c2771a4b 100644 --- a/assets/desktop_pet/office_characters.js +++ b/assets/desktop_pet/office_characters.js @@ -116,7 +116,7 @@ const ACC = { // 配饰 / 发型也是补丁 braid: { front: [[8, '_h__________'], [9, '_hH_________'], [10, '__Hh________'], [11, '__hH________'], [12, '__Hh________'], [13, '___a________']], back: [[9, '_____hH_____'], [10, '_____Hh_____'], [11, '_____hH_____'], [12, '_____Hh_____'], [13, '_____aa_____']] }, - // ---- 扩充配饰(10 种):帽子 / 头饰 / 脸部 / 胸前,颜色走 a / A(accColor)或固定色 ---- + // ---- 扩充配饰(9 种):帽子 / 头饰 / 脸部 / 胸前,颜色走 a / A(accColor)或固定色 ---- beanie: { front: [[0, '....aawaa...'], [1, '..aaaaaaaaa.'], [2, '.aaaaaaaaaaa'], [3, '.AAAAAAAAAAA']], back: [[0, '...aawaaa...'], [1, '..aaaaaaaaa.'], [2, '.aaaaaaaaaaa'], [3, '.AAAAAAAAAAA']] }, headband: { front: [[3, '.aaaaaaaaaaa']], @@ -126,7 +126,6 @@ const ACC = { // 配饰 / 发型也是补丁 shades: { front: [[6, '____gkkgkkkk'], [7, '_____kk__kk_']], back: [[6, '__________g_']] }, mask: { front: [[7, '____M_______'], [8, '____Mmmmmmmm'], [9, '____MmmmmmM_']], back: [[7, '__________M_']] }, beard: { front: [[7, '____h_______'], [8, '____hhsssshh'], [9, '___HhhhhhhH_']], back: [[8, '__________h_']] }, - mustache: { front: [[8, '______HhhH__'], [9, '______H__H__']] }, bowtie: { front: [[11, '____aaAaa___'], [12, '____a___a___']] }, badge: { front: [[11, '____a___a___'], [12, '_____a_a____'], [13, '_____yYy____']], back: [[11, '____aaaa____']] }, scarf: { front: [[10, '____aAaAa___'], [11, '___aaaaaaa__'], [12, '____A_______'], [13, '____a_______']], @@ -173,7 +172,9 @@ const ACC_COLORS = ['#e8544a', '#3a8cf0', '#45b36a', // 四个槽位各抽一个,互不打架;空字符串的权重就是「不戴」的概率 const HAIR_STYLES = ['', '', 'long', 'ponytail', 'twintails', 'bun', 'spiky', 'bald', 'bangs', 'bob', 'afro', 'mohawk', 'braid']; const HEADWEAR = ['', '', '', 'cap', 'beanie', 'headband', 'bow', 'headset']; -const FACEWEAR = ['', '', '', 'glasses', 'shades', 'mask', 'beard', 'mustache']; +// 最后一格原来是小胡子:脸只有 7 像素宽,胡子和上面的眼睛连成一块,怎么画都难看,拿掉了。 +// 位置留空而不是删掉,其他人抽到的配饰不变。 +const FACEWEAR = ['', '', '', 'glasses', 'shades', 'mask', 'beard', '']; const CHESTWEAR = ['', '', 'tie', 'bowtie', 'badge', 'scarf']; const TALL_HAIR = new Set(['bun', 'spiky', 'afro', 'mohawk']); // 帽子会把它们压扁 function stableLook(seed) { @@ -183,6 +184,18 @@ function stableLook(seed) { return {hair:choose(HAIR_COLORS),skin:choose(SKIN_TONES),shirt:choose(SHIRT_COLORS),pants:'#3a3f52',acc:[style,head,choose(FACEWEAR),choose(CHESTWEAR)].filter(Boolean),accColor:choose(ACC_COLORS)}; } +// 头顶状态图标:办公室画在小人右上方,网页头像角上用的是同一套。 +const MINI = { + q: ['.yyy.', 'y...y', '...y.', '..y..', '.....', '..y..'], + ex: ['y', 'y', 'y', 'y', '.', 'y'], + star: ['..y..', '.yyy.', 'yyyyy', '.yyy.', 'y...y'], + z: ['yyyy', '..y.', '.y..', 'yyyy'], + arrow: ['yyyyy', '.yyy.', '..y..'], + dot: ['yy', 'yy'], + glass: ['yyyyy', '.y.y.', '..y..', '.y.y.', 'yyyyy'], +}; +const miniSprite = (name, color) => sprite(`mi|${name}|${color}`, () => ({ cv: pix(MINI[name], { y: color }) })); + function stableSeed(value) { let hash = 2166136261; for (const c of String(value)) hash = Math.imul(hash ^ c.codePointAt(0), 16777619); @@ -195,7 +208,7 @@ function characterSprite(look, pose = 'front', frame = 'stand', flip = false) { normalized.acc.join('+'), normalized.accColor || ''].join('|'); return charSprite(normalized, pose, frame, flip); } -root.ACEOfficeCharacters = Object.freeze({stableSeed, stableLook, characterSprite, +root.ACEOfficeCharacters = Object.freeze({stableSeed, stableLook, characterSprite, miniSprite, HAIR_COLORS, SKIN_TONES, SHIRT_COLORS, ACC_COLORS, HAIR_STYLES, HEADWEAR, FACEWEAR, CHESTWEAR, TALL_HAIR}); })(globalThis); diff --git a/docs/background-tasks.md b/docs/background-tasks.md index b413af714..c9512c51b 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -32,7 +32,8 @@ opencode(两者源码已核对),要点是「框架兑现叫醒」,而不是靠 - **完成判定**:登记表在通知交给 Sink 之前仍把任务算作活跃(`running_count`), headless 与子代理完成判定据此不会在「任务结束」与「通知进邮箱、开回合」之间 的空隙里误判为空闲。 -- **Web 呈现**:详情栏「后台任务」页签(每会话一个,后台命令与子代理合在一个列表)。摘要面板的 +- **Web 呈现**:详情栏「后台任务」页签(每会话一个,后台命令与子代理合在一个列表:后台命令是 + 卡片,子代理是带头像的单行无边框行,见 [subagents.md](subagents.md) 第 4 节)。摘要面板的 「后台任务」分组常驻,列运行中的任务和耗时,末尾一行「N 已完成」,点它打开页签。子代理列表里 `created_at` 为空时(子会话刚建好还没写元数据)按第一次看到的时间计时 (`subagentTasks.js` 的 `mergeSubagentTaskList`)。 diff --git a/docs/subagents.md b/docs/subagents.md index 8558f8a01..2aa7e57f0 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -137,24 +137,39 @@ GET /api/sessions?parent=<主会话id> ← 任务快照(标题/tokens/ 父会话 tool_end(spawn_subagent/wait_subagent) → refetch session_status 帧(未知 busy 会话) → refetch ← wait=true 期间父 turn 无 tool_end,靠它感知新子任务 -子会话 WS 事件(busy/usage/tool_start/tool_end/session_updated)→ 卡片增量 +子会话 WS 事件(busy/usage/session_updated)→ 任务行增量 ``` - `web/src/lib/subagentTasks.js` — 纯状态层(Node 单测):快照归一化、refetch 合并 - (保留实时聚合字段 toolCount/lastTool 与本地 aborted 标记)、事件增量、分组/徽标计数、 - 格式化(`34.0k tokens · 2 tools · bash`、耗时)。 + (保留本地 aborted 标记)、事件增量、分组/徽标计数、列表行文案(`34.0k tokens`、运行中的耗时、 + 结束后的「N 分钟前」)。子会话的工具事件不改任务列表(列表不显示工具统计,不为它重画整个对话)。 - `web/src/lib/useSubagentTasks.js` — hook:REST + WS 增量 + **对运行中任务 `connection.retainSession`(不依赖面板开关)** —— 这是子会话的 permission_request / question_request 能到达 App 全局监听并冒泡的前提。parent 切换有迟到响应守卫。 - `web/src/components/SubagentPanel.jsx` 的 `BackgroundTasksView` — 详情栏「后台任务」页签的内容 (每个会话一个页签,`previewTabs.js` 的 `openBackgroundTasksTab`,正在看的任务记在页签的 `taskId`, - 切页签回来仍停在原处):运行中 / 已完成分组卡片;运行中卡片右上停止按钮, - 已完成组没有归档 / 清除入口(子会话始终留在面板里);「查看会话」原地切到只读 + 切页签回来仍停在原处):运行中 / 已完成两组。子代理是单行无边框的行:头像、标题、token 数, + 时间靠右(运行中是已跑多久,结束后是多久之前结束);只有运行中的多一行状态(运行中 / 正在停止…), + 别的都不显示,中止按钮悬停才出现;后台命令保持原来的卡片。已完成组没有归档 / 清除入口 + (子会话始终留在面板里);点子代理那一行原地切到只读 transcript,历史请求带父会话的 `workspaceHash` —— Desktop 的 daemon 同时服务多个工作区,子会话 不在内存里(Desktop 重启后、网状 agent 被换出)时只能按它读盘,否则 404 SESSION_NOT_FOUND(复用主会话 Message/ToolBlock 紧凑渲染,`useSessionTranscript` live:'auto' 实时跟尾,AskUserQuestion 工具行过滤不显示)。 - 入口:会话三点菜单「后台任务」、对话里的智能体分组卡片、摘要面板的运行中任务行与「N 已完成」。 +- **头像**:每个子代理前面一个像素小人(`PixelAvatar.jsx` 的 `SubagentAvatar`):绑定了专家用专家的 + 头像,否则按子会话 id 生成(`officeCharacters.js` 的 `subagentAvatarIdentity`),所以同一个子代理在 + 三处是同一张脸——后台任务页签的行、摘要面板的运行中任务行、对话里的「调用了 N 个智能体」 + (行首排开这几个子代理的头像,最多 6 张;外面包着的工具汇总行同样,「已处理」和进行中的行不画; + 展开后的每个智能体行也带)。后台命令没有头像,保持终端图标。四处都是 16px。 +- **头像上的状态图标**:右上角按任务状态画一个小图标,画法照桌面像素办公室(同一份像素模板, + `assets/desktop_pet/office_characters.js` 的 `miniSprite`):运行中是三个点轮流跳,小人每 1.4 秒 + 左右点一下头;已完成是一颗星,后台任务页签的已完成行只显示普通头像;已中止是红色感叹号; + 任务列表里查不到这个子代理时不画 + (`subagentTasks.js` 的 `subagentAvatarStatus`)。「画什么、画在哪、何时点头」在 + `avatarStatus.js`:所有运行中的头像共用一只 280ms 一拍的钟(三个点全场同步),有人订阅才开定时器、 + 最后一个退订就停;点头的位移取整到设备像素(头像是最近邻放大的像素画,挪半个设备像素脸会抖)。 + 等待授权 / 等待回答目前没有单独的图标(办公室里有),任务对象里没有这两种状态。 ### 权限 / 提问冒泡 @@ -254,8 +269,6 @@ TuiState overlay,工具线程 wait ask_cv 天然带回结果)。只需两点:入 - **no_workspace 父会话**:子会话继承父 cwd 但按普通 workspace 会话建档;`?parent=` 的 磁盘扫描按请求 workspace 的 project_dir,所以 no_workspace 父的子任务 active 时可见、 daemon 重启后扫不到(v1 接受)。 -- **运行中卡片的工具计数**(Web)从订阅时刻累积:页面中途打开时已完成任务只显示 - tokens/耗时,undercount 是有意取舍。 - **远程权限请求无取消回收**(TUI):子会话被 abort 后已入队/已展示的请求悬空,用户回应 是 no-op(unknown request_id),无害但 overlay 需手动关。 - **purge 不清理 attention 记录**(session_read_state):量小,残留无 UI 影响。 @@ -344,7 +357,7 @@ provider 上会被拒或合并)。 - Web 输入框:「蜂群模式(星型)」「蜂群模式(网状)」两个互斥菜单项;芯片 = 服务端模式(messages 快照 / `session_updated{swarm_mode}`)+ 未提交的本地选择,只有不同才随消息提交(`web/src/lib/swarmMode.js`)。 -- 后台任务页签复用星型的任务卡片,卡片显示 `agent_path`;页签只由用户打开,派子代理不自动弹出。 +- 后台任务页签复用星型的任务行,`agent_path` 放在行的悬停提示里;页签只由用户打开,派子代理不自动弹出。 - TUI:`/swarm`;侧栏运行中任务以路径为显示名。 ### 有意未复刻 / 与 Codex 的差异 diff --git a/web/scripts/test-expert-pixel-avatars.mjs b/web/scripts/test-expert-pixel-avatars.mjs index 2329af903..e76230926 100644 --- a/web/scripts/test-expert-pixel-avatars.mjs +++ b/web/scripts/test-expert-pixel-avatars.mjs @@ -61,14 +61,20 @@ try { const page=await browser.newPage({viewport:{width:1100,height:850}}); page.on('pageerror',error=>errors.push(error.message)); await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/avatar-fixture`); - await page.waitForFunction(()=>document.querySelectorAll('[data-pixel-avatar]').length===11); + // 10 位专家 + 子代理分组行行首一张 + 展开后的智能体行一张。 + await page.waitForFunction(()=>document.querySelectorAll('[data-pixel-avatar]').length===12); await page.waitForFunction(()=>[...document.querySelectorAll('[data-pixel-avatar]')].every(canvas=> canvas.getContext('2d').getImageData(0,0,14,14).data.some((value,index)=>index%4===3&&value>0))); const identity=await page.evaluate(()=>window.avatarIdentity); const pair=page.locator(`[data-pixel-avatar="${identity}"]`); - assert.equal(await pair.count(),2); + assert.equal(await pair.count(),3); const pixels=await pair.evaluateAll(nodes=>nodes.map(c=>c.toDataURL())); - assert.equal(pixels[0],pixels[1], 'catalog and live child use identical portrait pixels'); + assert.ok(pixels.every(value=>value===pixels[0]), 'catalog and live child use identical portrait pixels'); + // 运行中的子代理头像右上角是办公室那套状态图标(三个点),分组行和智能体行各一个,而且真的画了东西。 + const marks=page.locator('canvas[data-avatar-status="running"]'); + assert.equal(await marks.count(),2); + assert.ok((await marks.evaluateAll(nodes=>nodes.map(c=>c.getContext('2d').getImageData(0,0,c.width,c.height) + .data.some((value,index)=>index%4===3&&value>0)))).every(Boolean), 'status marks are painted'); assert.equal(await page.locator('#custom img').getAttribute('src'),'/acecode-logo.png'); assert.equal(await page.locator('#custom canvas').count(),0); const frame=page.frames().find(f=>f!==page.mainFrame()); @@ -91,5 +97,5 @@ try { await page.screenshot({path:path.join(output,`pixel-avatars-${theme}.png`)}); } assert.deepEqual(errors,[]); - console.log(JSON.stringify({output,checks:9,errors})); + console.log(JSON.stringify({output,checks:11,errors})); } finally {await browser?.close();await server.close();} diff --git a/web/src/components/ActivityLine.jsx b/web/src/components/ActivityLine.jsx index 4ad461550..344e20afc 100644 --- a/web/src/components/ActivityLine.jsx +++ b/web/src/components/ActivityLine.jsx @@ -15,6 +15,9 @@ export function ActivityLine({ detailSeparator = null, onDetailOverflowChange, icon = null, + // 代替固定图标位的一组前导内容(子代理头像),宽度随内容;给了它就不画 icon / 转圈。 + // 头像右上角的状态图标会往右伸出几像素,所以头像之间、头像和文案之间比固定图标位多留一点。 + leading = null, running = false, spinnerStatic = false, preferIconWhileRunning = false, @@ -49,14 +52,20 @@ export function ActivityLine({ }; const content = ( <> -