e8583ecd4101e16b03a0070dbe35b7a8d55e090a
6 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| e8583ecd41 |
fix: 事故全链路修复 — 调度器合流 + 接管保护 + 补全去重 + 权限显示 + homeagent SSE 振荡
## 事故现场 用户选中补全里的「项目定位」→ 邮件投进另一条会话,界面显示的名字也不是 自己选的那个。授权页只显示 Agent 名,看不出哪个目录哪条线索。 ## 四处因果链 **① 调度器自己的 new_mail payload(起点)。** `notifyRecipients`(handler) 与 `SendCalendarMail`(scheduler)是两份代码。加 `platform_session_id` 时只改了 handler 那份 → 日历提醒投进接管会话时插件不知道是接管 → 另开一条新会话 → 命名同步冲掉接管会话的别名。 修法:抽出 `internal/notify` 包,唯一入口 `notify.Recipients`。 handler / scheduler / permission.go 都走它。新增字段时不存在「另一处忘了改」。 **② SyncSessionAlias 覆盖接管别名。** 别名是人从补全里选中的平台 slug, 任何平台命名同步都不该动它。加守卫 `platform_id <> ''` → 有绑定就返回当前值。 **③ SuggestSessionCandidates 按别名字符串去重。** 别名一被冲掉,同一条会话 出现两次(一次被冲的名字、一次镜像 slug),而另一条真实会话被吃掉。 改按 `platform_id` 去重。 mail 侧查 `s.platform_id`,镜像侧查 `platform_id`。 **④ FindOrCreateDefaultSession 不排除接管会话。** 日历提醒省略 session 位 → FindOrCreateDefaultSession 挑中人显式指定的接管会话。加 `platform_id = ''` 条件。 ## 权限页 **CreatePermissionMail 不写 from_workspace。** `from_workspace` 存空串 → 前端 `g.path && ...` 不渲染 → 人只看到光秃的 Agent 名,不知道哪个目录 哪条线索在请求权限。修法:INSERT 时从 sessions.workspace 取。 **SSE payload 缺 session_alias。** permission.go 的 SSE 不走 notify 包(决策人 不是地址解析出的参与方),但 payload 也要带 `session_alias` → 前端拼出 `pi@/home/program/agentmail.别名`,而不是光秃的 `pi`。 **mailGroups.ts:path ← session_workspace。** `from_workspace` 对 Agent 存的是 Agent 名(历史遗留),不能当路径用。PermissionList 显示完整三段地址 `agent@path.alias`。 ## NarrowStack z-index 窄屏日历的星期表头(`sticky top-0 z-10`)穿透到二级页面之上。覆盖层 auto z-index 输给 z-10 → 底层组件的层叠穿透到覆盖层。 修法:底层容器加 `isolate`(isolation: isolate),自成层叠上下文; 覆盖层加 `z-10`。只给覆盖层加 z-index 只能治当前一处,底层再写更大的 z-index 又会复现。 ## homeagent SSE 自激振荡 根因:五处缺陷叠加,SSE 每 60 秒断一次 → Gateway 全量重放 → 再断 → 再重放。 1. `p.client`(60s Timeout)跑 SSE 长连接 → 新增 `sseClient`(无超时) 2. `InjectInputSync` 在读循环里同步调用 → 改为 `go p.handleNewMail(evt)` 3. `lastEventID` 无条件赋值,Gateway 重放时发旧 ID → 单调递增 `sseMaxID` 4. 无邮件级去重 → 补 `deliveredMails map[string]bool` 5. 手动 `[]byte` 管理:每次 `buf[lineStart:]` 缩小 cap → 最终 len==cap → Read 零长切片 → 满速空转。改 `bufio.Reader`。 ## 清库 保留 jianf + 4 个 Agent 密钥 + 模型范围配置。清掉 mails/sessions/ calendar_events/attachments/agent_platform_sessions/relayed_mails/ permission_requests/rate_limits。测试数据已全部清零。 ## 测试 - gateway 7 包全过;repo + 7 例(adopt_alias_test.go) - web 182 例(mailGroups 新增 session_workspace 断言) - 前端构建通过 |
|||
| 55b3f9bc4e |
fix(web): 地址显示按「人 / Agent」分维度 —— 别名跟 Agent 走,人只显示名字
## 症状
单封邮件的元信息三行都不对(生产实测那封 12:12:12):
发件 jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
收件 pi@/home/program/agentmail
抄送 pi@/home/program/agentmail.new
人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。
## 三处错
**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。
**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。
**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。
## 修法
`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:
Agent → pi@/home/program/agentmail.日程提醒:… 三段齐全
人 → jianf 裸名字
`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。
## 顺带修掉 `dsh@dsh`
改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。
会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。
## formatAddress 与后端对齐
第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:
jianf@.任务 → name=jianf path="" session=任务 ✓
jianf.任务 → name="jianf.任务" ✗
现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。
## 取舍
列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。
## homeagent 日程工具的两个修复(同批)
**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。
**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。
## 验收
- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
|
|||
| e6fd2fafdc |
feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
|
|||
| ca64d12057 |
feat: 工作区归属修复 + 平台会话同步 + 对话树整树展开 + DSH 插件
四个各自独立的生产缺陷,共同的根源都是「本该属于会话的属性没有存在会话上」。 ## 1. dsh 指定工作目录完全失效(所有会话落进「未分组」) 插件建会话时用的 cwd 是自己拼的 `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。DSH 与 opencode 都按 cwd 给会话分组,于是所有 邮件会话既不属于任何项目、彼此也不同组。 而 Gateway 从来没把地址里的 path 位发给插件:`notifyRecipients` 的 payload 只有 mail_id/session_id/from_name/subject,`to_workspace` 虽然入库了却不在 SSE 事件里,插件即使想用也拿不到。 - SSE `new_mail` 事件加 `to_workspace`。**每个收件方拿到自己那个地址的 path**, 不是主收件人的 —— 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区 - 两个插件的 cwd 都改为取寻址的 path 位;不存在的目录**不创建**而是回退到 兜底目录(一个笔误不该在磁盘上落下真目录,Agent 会在里面一无所获地干活) - 拒绝相对路径:cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/` ## 2. 会话别名列不出工作区下的历史会话(无法选择) workspace 只存在于 `mails.to_workspace` 上,「这个工作区下有哪些会话」必须 JOIN mails 再从收发双方的 workspace 里猜。而 Agent 回信时 from_workspace 填的是 **Agent 名**而不是路径,旧条件 `to_workspace = $p OR from_workspace = $p` 在只剩 Agent 回信可匹配时两边都对不上。 - `sessions.workspace` 新列,`CreateSession` 从地址的 path 位带入 - `SuggestSessionCandidates` 取代 `SuggestSessionsFor`:以会话自己的 workspace 为权威,历史会话(该列为空)回退到 mails 反推 —— 升级后老会话不该消失 - `FindOrCreateDefaultSession` 同步改用会话的 workspace ## 3. 平台侧会话在补全里根本不存在 人直接在 opencode/DSH 界面上开的会话,Gateway 一无所知。 新增 `agent_platform_sessions` 镜像表,插件在心跳里上报快照。 **上报而非 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥主动连 Gateway, Gateway 从不外呼),反向拉取需要它保存各平台的地址与凭证,那是另一套信任模型。 - 与 sessions 表分开存:镜像里是别人家的会话,id 属于平台的 id 空间,没有 本侧的 owner/预算/邮件。混进 sessions 会让每一处「按会话鉴权」都要先判断 这条到底是不是真的本侧会话 - **整表替换而非增量合并**:平台侧删掉的会话必须从候选里消失 —— session 位是 三态语义,指向不存在的会话直接 404 - **nil 与空数组语义不同**:插件拉不到列表时省略该字段(保留镜像), 而不是传空数组把镜像抹掉 - **subagent 子会话不上报**:实测 DSH 的 list 里混着 49 条子会话,标题就是 派活的提示词前缀(九条都叫 "You are auditing ONE file"),slug 全撞名; 它们是父 agent 内部的工作单元,人往里发邮件毫无意义 - **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里 出现几个点哪个都不确定的候选 - DSH 插件此前**完全没有心跳** —— Gateway 靠 last_seen 判在线,一直靠注册撑着 补全候选带标题与来源:`suggestions` 保留纯字符串数组(不打破已部署的前端与 第三方客户端),新增同序的 `candidates`。过滤时标题也参与匹配 —— 人记得的是 「缓存选型」而不是 brisk-harbor 这种随机短名。 ## 4. 对话树看不见抄送与转发产生的分支 旧实现从锚点分「祖先链 + 子树」两路展开,而**兄弟节点既不是锚点的祖先也不是 它的子孙**:一封抄送给两个 Agent 的邮件收到两个回复,从其中一个看树永远看不到 另一个;挂在原件上的转发分支同理。 改为先 `ThreadRootOf` 上溯到线索根,再从根整树 BFS。只剩一个加载方向, 因此不再需要滚动位置补偿。前端补上抄送人列表与转发标记 —— 树上两个兄弟节点 为什么并列,唯一的解释就是父邮件抄送给了两个人。 ## 5. DSH 插件(Phase 7.7) 卡了一下午的 `Cannot read properties of undefined (reading 'kind')` 根因是 `followup()` 的参数形状:DSH 要完整的 UserMessage(content + source), 而我照抄了 opencode 的 parts 数组。错误抛在 agent-loop 内部,不指向调用点。 - `agent/status` → idle 时自动转发最后一条 assistant 消息(对应 opencode 的 session.idle),复用 relay-dedup 让位于模型的主动回信,走免配额通道 - `approval/request` 权限询问转邮件问人。与 opencode 的差异:那边的 permission.ask 是同步钩子只能立即返回 ask,DSH 这边是异步 waterfall, 可以真的等人 —— 拆插件时未决询问一律 fail closed,否则 await 永不返回 - 会话别名由模型标题派生(保留中文,去掉 `.` `@` `/` 等寻址分隔符 —— 留在别名里会让它自己被解析器切开) - 逻辑放 lib/ 下的纯函数并加测试:三类约定都是「错了不当场报错、只在深处 炸一个无关错误」 ## 其他 - `deploy/reset-demo.sh`:清空演示邮件数据,保留账号与密钥。备份用 `.backup` 而非 cp(WAL 下 cp 拿到的是缺尾巴的库);手工按依赖顺序删(SQLite 的 foreign_keys 默认关,声明了 REFERENCES 也不级联);只在目标是默认库时才碰 systemd(演练时误停过一次生产服务) - 插件 dist/ 不进版本库,install.sh 负责构建 - `permission_decision` 事件补 session_id:插件重启丢了待决映射时要靠它定位会话 |
|||
| 07e6b789b2 |
feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图
## 配额重构:废除 Agent 终身额度
原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。
改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
人类不受限(agentLimiterKey 返回空串即不计量)
## 窄屏适配(用户反馈「窄屏基本不可用」)
原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。
第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)
## 工作列表卡片视图(Phase 7.1 最后一项)
中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。
- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库
## 修掉的缺陷
- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
(决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
(改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏
## 回复/转发栏
- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
速率限制都走这条路,之前点发送毫无反应
## 测试
- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
|
|||
| 0e754617a4 |
feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结) |