Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`,分支 `worktree-<flatten(slug)>`;`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。
Expand Down
14 changes: 2 additions & 12 deletions assets/desktop_pet/agent_office_pet.html
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['###', '#..', '###', '..#', '###'],
Expand Down
21 changes: 17 additions & 4 deletions assets/desktop_pet/office_characters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
Expand All @@ -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_______']],
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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);
3 changes: 2 additions & 1 deletion docs/background-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ opencode(两者源码已核对),要点是「框架兑现叫醒」,而不是靠
- **完成判定**:登记表在通知交给 Sink 之前仍把任务算作活跃(`running_count`),
headless 与子代理完成判定据此不会在「任务结束」与「通知进邮箱、开回合」之间
的空隙里误判为空闲。
- **Web 呈现**:详情栏「后台任务」页签(每会话一个,后台命令与子代理合在一个列表)。摘要面板的
- **Web 呈现**:详情栏「后台任务」页签(每会话一个,后台命令与子代理合在一个列表:后台命令是
卡片,子代理是带头像的单行无边框行,见 [subagents.md](subagents.md) 第 4 节)。摘要面板的
「后台任务」分组常驻,列运行中的任务和耗时,末尾一行「N 已完成」,点它打开页签。子代理列表里
`created_at` 为空时(子会话刚建好还没写元数据)按第一次看到的时间计时
(`subagentTasks.js` 的 `mergeSubagentTaskList`)。
Expand Down
29 changes: 21 additions & 8 deletions docs/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 一拍的钟(三个点全场同步),有人订阅才开定时器、
最后一个退订就停;点头的位移取整到设备像素(头像是最近邻放大的像素画,挪半个设备像素脸会抖)。
等待授权 / 等待回答目前没有单独的图标(办公室里有),任务对象里没有这两种状态。

### 权限 / 提问冒泡

Expand Down Expand Up @@ -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 影响。
Expand Down Expand Up @@ -344,7 +357,7 @@ provider 上会被拒或合并)。

- Web 输入框:「蜂群模式(星型)」「蜂群模式(网状)」两个互斥菜单项;芯片 = 服务端模式(messages
快照 / `session_updated{swarm_mode}`)+ 未提交的本地选择,只有不同才随消息提交(`web/src/lib/swarmMode.js`)。
- 后台任务页签复用星型的任务卡片,卡片显示 `agent_path`;页签只由用户打开,派子代理不自动弹出。
- 后台任务页签复用星型的任务行,`agent_path` 放在行的悬停提示里;页签只由用户打开,派子代理不自动弹出。
- TUI:`/swarm`;侧栏运行中任务以路径为显示名。

### 有意未复刻 / 与 Codex 的差异
Expand Down
14 changes: 10 additions & 4 deletions web/scripts/test-expert-pixel-avatars.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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();}
Loading
Loading