feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)

问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。

新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。

密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
  1. 进程环境
  2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
     因此身份一致
  3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
     (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
      只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。

不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。

为什么不会重复注册(读 SDK 实现确认,并用探针实测):
  resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
  settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
  命令行 -e 传入的扩展。
  探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。

deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。

验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
This commit is contained in:
2026-09-11 11:28:27 +08:00
parent 1692c615c6
commit 19a3161ee4
22 changed files with 6889 additions and 5566 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,139 +1,139 @@
export interface User {
user_id: string;
username: string;
display_name: string;
role: 'admin' | 'user';
status: 'active' | 'disabled';
allowed_agents: string[];
allowed_paths: string[];
last_login?: string;
created_at?: string;
user_id: string;
username: string;
display_name: string;
role: "admin" | "user";
status: "active" | "disabled";
allowed_agents: string[];
allowed_paths: string[];
last_login?: string;
created_at?: string;
}
export interface Workspace {
name: string;
path: string;
name: string;
path: string;
}
export interface Agent {
agent_id?: string;
agent_name: string;
workspaces: Workspace[];
platform: string;
status: string;
/** 派给该 Agent 的新任务默认多少个来回0 = 不限) */
default_rounds?: number;
agent_id?: string;
agent_name: string;
workspaces: Workspace[];
platform: string;
status: string;
/** 派给该 Agent 的新任务默认多少个来回0 = 不限) */
default_rounds?: number;
}
export interface Address {
name: string;
path: string;
session: string;
raw: string;
name: string;
path: string;
session: string;
raw: string;
}
export interface Session {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count?: number;
/**
* 别名是谁定的:
* platform = Agent 平台自动同步来的,后续同步可以覆盖
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
*/
alias_source?: 'platform' | 'manual';
/** 用户驳回过的改名提议 */
rename_dismissed?: string;
/**
* 本任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
*
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
*/
max_rounds?: number;
used_rounds?: number;
/** 权限档位plan / workspace / full */
permission_mode?: string;
/** 档位实际强制力native / advisory */
permission_enforcement?: string;
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count?: number;
/**
* 别名是谁定的:
* platform = Agent 平台自动同步来的,后续同步可以覆盖
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
*/
alias_source?: "platform" | "manual";
/** 用户驳回过的改名提议 */
rename_dismissed?: string;
/**
* 本任务的往返预算0 = 本会话不限,仅受 Agent 全局配额约束)。
*
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
*/
max_rounds?: number;
used_rounds?: number;
/** 权限档位plan / workspace / full */
permission_mode?: string;
/** 档位实际强制力native / advisory */
permission_enforcement?: string;
}
/** 会话往返预算快照 */
export interface SessionBudget {
session_id: string;
max_rounds: number;
used_rounds: number;
/** 不限时为 -1 */
remaining: number;
unlimited: boolean;
session_id: string;
max_rounds: number;
used_rounds: number;
/** 不限时为 -1 */
remaining: number;
unlimited: boolean;
}
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
export interface Attachment {
attachment_id: string;
/** 为 null 表示已上传但尚未随邮件发出 */
mail_id: string | null;
uploader: string;
filename: string;
content_type: string;
size_bytes: number;
sha256: string;
created_at: string;
attachment_id: string;
/** 为 null 表示已上传但尚未随邮件发出 */
mail_id: string | null;
uploader: string;
filename: string;
content_type: string;
size_bytes: number;
sha256: string;
created_at: string;
}
export interface Mail {
mail_id: string;
session_id: string;
parent_mail_id: string | null;
from_name: string;
from_workspace: string;
to_name: string;
to_workspace: string;
cc_list: Address[];
subject: string;
body: string;
mail_type: 'normal' | 'permission_request';
permission_options: string[] | null;
permission_result: string | null;
/**
* 待办类型:`permission`(危险操作审批)/ `question`(模型主动提问)。
*
* 两者共用 permission_request 这个 mail_type但**该渲染什么完全不同**
* 前者是「批准 / 拒绝」,后者是「回答问题」(勾选 + 自由文本)。
* 混用一套 UI 会让人把「回答问题」当成「批准执行」。
*/
permission_kind?: string;
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select。 */
permission_multi_select?: boolean;
status: 'unread' | 'read' | 'archived';
created_at: string;
hop_limit?: number;
session_alias?: string;
/**
* **这条会话**的工作目录sessions.workspace
*
* 不能用 from_workspace / to_workspace 代替:
* - 人 → Agentto_workspace 是真路径from_workspace 为空(人没有工作目录)
* - Agent → 人to_workspace 为空,而 **from_workspace 存的是 Agent 名**
* 而不是路径(历史遗留)
*
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
*/
session_workspace?: string;
body_preview?: string;
attachments?: Attachment[];
/** 发件方是人类用户而不是 Agent服务端 EXISTS users 判的) */
from_human: boolean;
/** 收件方是人类用户而不是 Agent */
to_human: boolean;
permission_mode?: string;
permission_enforcement?: string;
mail_id: string;
session_id: string;
parent_mail_id: string | null;
from_name: string;
from_workspace: string;
to_name: string;
to_workspace: string;
cc_list: Address[];
subject: string;
body: string;
mail_type: "normal" | "permission_request";
permission_options: string[] | null;
permission_result: string | null;
/**
* 待办类型:`permission`(危险操作审批)/ `question`(模型主动提问)。
*
* 两者共用 permission_request 这个 mail_type但**该渲染什么完全不同**
* 前者是「批准 / 拒绝」,后者是「回答问题」(勾选 + 自由文本)。
* 混用一套 UI 会让人把「回答问题」当成「批准执行」。
*/
permission_kind?: string;
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select。 */
permission_multi_select?: boolean;
status: "unread" | "read" | "archived";
created_at: string;
hop_limit?: number;
session_alias?: string;
/**
* **这条会话**的工作目录sessions.workspace
*
* 不能用 from_workspace / to_workspace 代替:
* - 人 → Agentto_workspace 是真路径from_workspace 为空(人没有工作目录)
* - Agent → 人to_workspace 为空,而 **from_workspace 存的是 Agent 名**
* 而不是路径(历史遗留)
*
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
*/
session_workspace?: string;
body_preview?: string;
attachments?: Attachment[];
/** 发件方是人类用户而不是 Agent服务端 EXISTS users 判的) */
from_human: boolean;
/** 收件方是人类用户而不是 Agent */
to_human: boolean;
permission_mode?: string;
permission_enforcement?: string;
}
/**
@ -145,30 +145,30 @@ export interface Mail {
* depth 是**相对锚点**的层级0 = 锚点,负数 = 祖先,正数 = 子孙。
* 分块加载时根可能还没取到,所以不用「距根深度」。
*/
export interface ThreadNode extends Omit<Mail, 'body'> {
/** 距**线索根**的层级0 = 根1 = 它的直接回复 */
depth: number;
attachment_count: number;
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
detached?: boolean;
/** 父邮件确实存在但无权查看(区别于「尚未加载」) */
parent_hidden?: boolean;
body?: string;
export interface ThreadNode extends Omit<Mail, "body"> {
/** 距**线索根**的层级0 = 根1 = 它的直接回复 */
depth: number;
attachment_count: number;
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
detached?: boolean;
/** 父邮件确实存在但无权查看(区别于「尚未加载」) */
parent_hidden?: boolean;
body?: string;
}
export interface ThreadPage {
anchor_mail_id: string;
/** 线索根的 mail_id整棵树从它展开 */
root_mail_id: string;
/** 锚点距根的层数,用于高亮定位 */
anchor_depth: number;
nodes: ThreadNode[];
total: number;
/** 因权限被过滤掉的节点数 */
hidden: number;
has_more: boolean;
/** 下一页 offset原样回传即可 */
next_offset: number;
anchor_mail_id: string;
/** 线索根的 mail_id整棵树从它展开 */
root_mail_id: string;
/** 锚点距根的层数,用于高亮定位 */
anchor_depth: number;
nodes: ThreadNode[];
total: number;
/** 因权限被过滤掉的节点数 */
hidden: number;
has_more: boolean;
/** 下一页 offset原样回传即可 */
next_offset: number;
}
/**
@ -179,69 +179,69 @@ export interface ThreadPage {
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
export interface RenameProposal {
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
alias: string;
reason?: string;
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
alias: string;
reason?: string;
}
export interface Contact {
session_id: string;
agent_name: string;
path: string;
session_alias: string;
address: string;
status: string;
mail_count: number;
unread_count: number;
last_activity: string;
/** 会话主题(多由 Agent 平台的模型生成的摘要) */
subject: string;
/** 本任务的往返预算0 = 不限) */
max_rounds: number;
used_rounds: number;
permission_mode?: string;
permission_enforcement?: string;
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
last_from: string;
last_preview: string;
session_id: string;
agent_name: string;
path: string;
session_alias: string;
address: string;
status: string;
mail_count: number;
unread_count: number;
last_activity: string;
/** 会话主题(多由 Agent 平台的模型生成的摘要) */
subject: string;
/** 本任务的往返预算0 = 不限) */
max_rounds: number;
used_rounds: number;
permission_mode?: string;
permission_enforcement?: string;
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
last_from: string;
last_preview: string;
}
export interface PermissionRequest {
request_id: string;
mail_id: string;
session_id: string;
agent_name: string;
question: string;
options: string[];
context: string;
result: string | null;
decided_at: string | null;
created_at: string;
request_id: string;
mail_id: string;
session_id: string;
agent_name: string;
question: string;
options: string[];
context: string;
result: string | null;
decided_at: string | null;
created_at: string;
}
export interface SessionDetail {
session: Session;
mails: Mail[];
session: Session;
mails: Mail[];
}
export interface HumanSession {
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count: number;
unread_count: number;
/** 本任务的往返预算0 = 不限) */
max_rounds?: number;
used_rounds?: number;
permission_mode?: string;
permission_enforcement?: string;
session_id: string;
session_alias: string | null;
from_agent: string;
subject: string;
status: string;
created_at: string;
updated_at: string;
mail_count: number;
unread_count: number;
/** 本任务的往返预算0 = 不限) */
max_rounds?: number;
used_rounds?: number;
permission_mode?: string;
permission_enforcement?: string;
}
export type SuggestKind = 'name' | 'path' | 'session';
export type SuggestKind = "name" | "path" | "session";
/**
* 会话候选项的来源。
@ -249,38 +249,38 @@ export type SuggestKind = 'name' | 'path' | 'session';
* platform 平台侧会话镜像(人直接在 opencode/DSH 界面上开的)
* new 新建会话的哨兵项
*/
export type SessionCandidateSource = 'mail' | 'platform' | 'new';
export type SessionCandidateSource = "mail" | "platform" | "new";
export interface SessionCandidate {
/** 填进 session 位的值 */
alias: string;
/** 给人看,用来分辨两条别名相似的会话在谈什么 */
title?: string;
source: SessionCandidateSource;
/** 仅 mail 来源有意义 */
unread?: number;
/** 填进 session 位的值 */
alias: string;
/** 给人看,用来分辨两条别名相似的会话在谈什么 */
title?: string;
source: SessionCandidateSource;
/** 仅 mail 来源有意义 */
unread?: number;
}
export interface SuggestResult {
kind: SuggestKind;
suggestions: string[];
/**
* 带标题与来源的完整候选,与 suggestions 同序。
* 仅 kind === 'session' 时返回suggestions 保留纯字符串形式是为了
* 不打破已部署的前端与第三方客户端。
*/
candidates?: SessionCandidate[];
kind: SuggestKind;
suggestions: string[];
/**
* 带标题与来源的完整候选,与 suggestions 同序。
* 仅 kind === 'session' 时返回suggestions 保留纯字符串形式是为了
* 不打破已部署的前端与第三方客户端。
*/
candidates?: SessionCandidate[];
}
/** 系统初始化状态 */
export interface SetupStatus {
needs_setup: boolean;
needs_setup: boolean;
}
/** 管理员可授权范围候选 */
export interface AdminScopes {
agents: string[];
paths: string[];
agents: string[];
paths: string[];
}
/**
@ -291,58 +291,58 @@ export interface AdminScopes {
* 对 Agent 来说就是一封普通邮件,它不知道也不需要知道信来自日历。
*/
export interface CalendarEvent {
event_id: string;
title: string;
description: string;
/**
* 提醒邮件的正文。留空时后端按 title/time/description 生成默认模板。
* 支持 {title} {time} {description} 三个变量,触发时替换。
*/
reminder_text: string;
/**
* 收件 Agent 名 / 完整地址 —— **单收件人时代的字段**。
* 保留作兼容与兜底recipients 为空时才用它们。
* 新代码一律用 effectiveRecipients()。
*/
agent_name: string;
to_address: string;
/**
* 收件人列表,每项是完整三维地址串。
*
* 存原始串而不是结构化地址session 位的 new/别名三态该在**触发那一刻**
* 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化
* 而重复事件每次触发都该重新决定落到哪条会话。
*/
recipients: string[];
/**
* 多收件人的投递方式。
* separate = 各发一封、落各自会话、互相看不到
* together = 首个为主收件人,其余进抄送、共享同一条线索
*
* 两种都要而不是二选一:「三个 Agent 各自独立汇报」与「pi 主办、dsh 知情」
* 是完全不同的任务形态。用错 together 会让本该独立判断的 Agent 互相
* 看到回复而趋同,那种污染事后无法分离。
*/
delivery_mode: DeliveryMode;
/** ISO 8601 */
event_time: string;
/** 提前多少分钟提醒0 = 到点才提醒 */
remind_before: number;
recurrence: Recurrence;
/** 重复终止时间;越过它事件自动置为 cancelled */
recurrence_end?: string;
status: 'active' | 'paused' | 'cancelled';
/** 上次触发的墙上时钟 */
last_fired_at?: string;
/**
* 已触发的那个 occurrence值 = 当时的 event_time
* 去重靠它与 event_time 相等判断,不是拿 last_fired_at 比大小 ——
* 后端 DueEvents 有 60 秒 lookahead后者在窗口内恒为真会导致每 tick 重发。
*/
fired_for?: string;
created_at: string;
updated_at: string;
created_by: string;
event_id: string;
title: string;
description: string;
/**
* 提醒邮件的正文。留空时后端按 title/time/description 生成默认模板。
* 支持 {title} {time} {description} 三个变量,触发时替换。
*/
reminder_text: string;
/**
* 收件 Agent 名 / 完整地址 —— **单收件人时代的字段**。
* 保留作兼容与兜底recipients 为空时才用它们。
* 新代码一律用 effectiveRecipients()。
*/
agent_name: string;
to_address: string;
/**
* 收件人列表,每项是完整三维地址串。
*
* 存原始串而不是结构化地址session 位的 new/别名三态该在**触发那一刻**
* 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化
* 而重复事件每次触发都该重新决定落到哪条会话。
*/
recipients: string[];
/**
* 多收件人的投递方式。
* separate = 各发一封、落各自会话、互相看不到
* together = 首个为主收件人,其余进抄送、共享同一条线索
*
* 两种都要而不是二选一:「三个 Agent 各自独立汇报」与「pi 主办、dsh 知情」
* 是完全不同的任务形态。用错 together 会让本该独立判断的 Agent 互相
* 看到回复而趋同,那种污染事后无法分离。
*/
delivery_mode: DeliveryMode;
/** ISO 8601 */
event_time: string;
/** 提前多少分钟提醒0 = 到点才提醒 */
remind_before: number;
recurrence: Recurrence;
/** 重复终止时间;越过它事件自动置为 cancelled */
recurrence_end?: string;
status: "active" | "paused" | "cancelled";
/** 上次触发的墙上时钟 */
last_fired_at?: string;
/**
* 已触发的那个 occurrence值 = 当时的 event_time
* 去重靠它与 event_time 相等判断,不是拿 last_fired_at 比大小 ——
* 后端 DueEvents 有 60 秒 lookahead后者在窗口内恒为真会导致每 tick 重发。
*/
fired_for?: string;
created_at: string;
updated_at: string;
created_by: string;
}
/**
@ -353,38 +353,38 @@ export interface CalendarEvent {
* 也没有 lunar_weekly农历没有「周」这个单位
*/
export type Recurrence =
| 'none'
| 'daily'
| 'weekly'
| 'monthly'
| 'yearly'
| 'lunar_monthly'
| 'lunar_yearly';
| "none"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "lunar_monthly"
| "lunar_yearly";
export type DeliveryMode = 'separate' | 'together';
export type DeliveryMode = "separate" | "together";
/** 事件附件(随提醒邮件一起发出) */
export interface CalendarAttachment {
attachment_id: string;
event_id: string;
filename: string;
sha256: string;
size_bytes: number;
created_at: string;
attachment_id: string;
event_id: string;
filename: string;
sha256: string;
size_bytes: number;
created_at: string;
}
/** 新建/编辑事件的请求体。event_time 必填,其余可省。 */
export interface CalendarEventInput {
title: string;
description?: string;
reminder_text?: string;
agent_name?: string;
to_address?: string;
recipients?: string[];
delivery_mode?: DeliveryMode;
event_time: string;
remind_before?: number;
recurrence?: Recurrence;
recurrence_end?: string | null;
status?: 'active' | 'paused' | 'cancelled';
title: string;
description?: string;
reminder_text?: string;
agent_name?: string;
to_address?: string;
recipients?: string[];
delivery_mode?: DeliveryMode;
event_time: string;
remind_before?: number;
recurrence?: Recurrence;
recurrence_end?: string | null;
status?: "active" | "paused" | "cancelled";
}