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),未手工拼写
This commit is contained in:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -0,0 +1,141 @@
/**
* 三维寻址的构造与判读 —— 所有平台插件共用。
*
* 为什么这些函数必须共用、且必须是纯函数:
*
* 地址拼错不会报错。`name@path.session` 的每一段都可以省略,任何组合都能被
* `ParseAddress` 解析出**某个**结果,于是拼错的代价不是失败而是**投到别处**。
* 生产上真实发生过两次:
*
* 1. 插件把 `.new` 原样当作回信地址 —— `.new` 是一次性动作,回过去只会
* 再建一条平行会话,双方从此各说各话。
* 2. path 为空时朴素拼接得到 `admin.silent-harbor` —— 没有 `@`
* 整串被当成名字session 位静默丢失。
*
* 两次都是「拼字符串」造成的,所以拼地址这件事收进这里,各平台不再自己拼。
*/
/**
* 拼一个可寻址的 `name@path.session`。
*
* **空 path 也必须留下 `@` 与 `.`**`admin@.silent-harbor` 才解析成
* name=admin path="" session=silent-harbor。省掉 `@` 得到的
* `admin.silent-harbor` 会被整串当作名字。
*
* session 省略时不写那一位(默认会话语义)。
*
* @param {string} name 收件方名Agent 名或人类用户名)
* @param {string} [path] 工作目录,可为空
* @param {string} [session] 会话别名;空则省略该位
* @returns {string} 地址name 为空时返回空串
*/
export function formatAddress(name, path, session) {
const n = String(name ?? '').trim();
const p = String(path ?? '').trim();
const s = String(session ?? '').trim();
if (!n) return '';
if (!s) return p ? `${n}@${p}` : n;
return `${n}@${p}.${s}`;
}
/**
* 判断自己在这封邮件里是收件人还是抄送方。
*
* 为什么需要它:被抄送方与主收件人的**职责不同**。线上那封联调邮件里,
* admin 主发 dsh、抄送 opencode分工是「dsh 提供源码解读、opencode 提供部署
* 现状、最后由 dsh 汇报」。收件箱若不区分身份,两方都会以为自己是负责人,
* 或者都以为自己只是旁观者。
*
* @param {any} mail `/mail/inbox` 返回的一封邮件
* @param {string} selfName 自己的 Agent 名
* @returns {'to'|'cc'|'unknown'}
*/
export function roleOf(mail, selfName) {
const self = String(selfName ?? '').trim();
if (!self) return 'unknown';
if (mail?.to_name === self) return 'to';
if (Array.isArray(mail?.cc_list) && mail.cc_list.some(c => c?.name === self)) {
return 'cc';
}
return 'unknown';
}
/**
* 给出「把回信发回这条会话」的地址。
*
* 发件人一侧**不带 path**Agent 回信时 `from_workspace` 存的是 Agent 名而不是
* 路径(历史遗留),拿它拼会得到 `dsh@dsh.alias` 这种投不出去的东西。
* 人类发件人本来就没有工作目录。
*
* 别名为空时退回 `name`(默认会话)而不是编一个 —— 但注意这与「投回同一条会话」
* 不等价,默认会话是该 name 当前最活跃的那条。调用方要区分时看返回值有没有 `.`。
*
* @param {any} mail 一封邮件
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function replyAddressFor(mail, alias) {
const a = alias ?? mail?.session_alias ?? '';
return formatAddress(mail?.from_name, '', a);
}
/**
* 给出自己在这条会话里的地址,供转发说明或向第三方引用时使用。
*
* 用 `to_workspace`(自己那个地址的 path 位)而不是发件人的:
* 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区。
*
* @param {any} mail 一封邮件
* @param {string} selfName 自己的 Agent 名
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {string}
*/
export function selfAddressFor(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
// 抄送方拿到的 to_workspace 是主收件人的,自己的 path 在 cc_list 里。
// 不取对的那个会让「我是谁」这句话指向别人的工作目录。
let path = mail?.to_workspace ?? '';
if (mail?.to_name !== selfName && Array.isArray(mail?.cc_list)) {
const mine = mail.cc_list.find(c => c?.name === selfName);
if (mine) path = mine.path ?? '';
}
return formatAddress(selfName, path, a);
}
/**
* 列出这封邮件的全部参与方及各自可投递的地址。
*
* 这是「回给抄收方」缺的那块信息:知道有谁,**以及用什么地址找到他**。
* 抄送方的 path 取它自己那个地址的 path 位。
*
* 自己会被标 `is_self`,而不是从列表里剔掉 —— 剔掉的话模型无法确认
* 「这封信是不是也发给了我」,也就无法判断自己是不是该回。
*
* @param {any} mail 一封邮件
* @param {string} [selfName] 自己的名字,用于标记 is_self
* @param {string} [alias] 会话别名,缺省取 mail.session_alias
* @returns {{role: string, name: string, path: string, address: string, is_self: boolean}[]}
*/
export function participantsOfMail(mail, selfName, alias) {
const a = alias ?? mail?.session_alias ?? '';
const self = String(selfName ?? '').trim();
const out = [];
const add = (role, name, path) => {
const n = String(name ?? '').trim();
if (!n) return;
out.push({
role,
name: n,
path: String(path ?? ''),
address: formatAddress(n, path, a),
is_self: !!self && n === self,
});
};
// 发件人一侧 path 留空,理由同 replyAddressFor
add('from', mail?.from_name, '');
add('to', mail?.to_name, mail?.to_workspace);
if (Array.isArray(mail?.cc_list)) {
for (const c of mail.cc_list) add('cc', c?.name, c?.path);
}
return out;
}

View File

@ -0,0 +1,74 @@
/**
* 启动补拉:把插件离线期间到的邮件变成与 SSE 事件同形的投递任务。
*
* 为什么需要它:**SSE 只推连上之后的事件**。插件重启前发来的邮件不会再推一次,
* 心跳响应的 `pending_mails` 是唯一线索。不补拉的后果是那封邮件永远躺在
* 收件箱里,而发件人以为 Agent 收到了 —— 这比明确的失败更难排查。
*
* 两个平台共用必须逐字节相同deploy/check-shared-libs.sh 校验)。
*/
/**
* 一次补拉最多处理几封。
*
* 上限存在的理由:每封都要起一轮模型。攒了 80 封的时候一次性全放出去,
* 等于对上游打 80 个并发请求,且最后那几封要等前面全部跑完。
* 超出的部分留在收件箱里,下次重启或人工触发时再处理。
*/
export const MAX_CATCHUP = 5;
/**
* 把收件箱里的一封邮件转成 SSE `new_mail` 那个形状。
*
* 补拉与 SSE 走同一条投递路径deliverMail因此形状必须一致 ——
* 两条路径各写一遍投递逻辑的话,某一条上的修复会漏掉另一条。
*
* @param {any} mail `/mail/inbox` 返回的一行
* @returns {{mail_id: string, session_id: string, from_name: string,
* subject: string, mail_type: string, role: string,
* to_workspace: string, catchup: true}}
*/
export function mailToEvent(mail) {
return {
mail_id: mail?.mail_id || '',
session_id: mail?.session_id || '',
from_name: mail?.from_name || '',
subject: mail?.subject || '',
mail_type: mail?.mail_type || 'normal',
role: 'to',
to_workspace: mail?.to_workspace || '',
// 标记来源,投递侧可据此决定是否在提示词里说明「这是积压的邮件」
catchup: true,
};
}
/**
* 从收件箱挑出该补投的邮件。
*
* @param {any[]} mails `/mail/inbox?status=unread` 的结果
* @param {Set<string>} seen 已经通过 SSE 投过的 mail_id避免重复投递
* @param {number} [max] 上限,默认 MAX_CATCHUP
* @returns {any[]} 与 SSE 事件同形的投递任务,按时间正序(老的先处理)
*/
export function selectCatchup(mails, seen, max = MAX_CATCHUP) {
if (!Array.isArray(mails) || mails.length === 0) return [];
const picked = [];
for (const m of mails) {
const id = m?.mail_id;
if (!id) continue;
// 心跳与 SSE 建连之间有个窗口:那期间到的邮件既在 pending_mails 里、
// 也会被 SSE 推一次。不去重就会投两遍,模型回两封信。
if (seen && seen.has(id)) continue;
// permission 类邮件不补投它是给人看的询问Agent 侧没有可恢复的上下文
// (原来的工具调用早随进程一起没了),投过去只会让模型困惑。
if (m?.mail_type && m.mail_type !== 'normal') continue;
picked.push(m);
}
// 收件箱按时间倒序返回,补投要按正序 —— 先来的先处理,
// 否则同一会话里的多封邮件会被倒着塞进去,上下文顺序是乱的。
picked.reverse();
return picked.slice(0, Math.max(0, max)).map(mailToEvent);
}

View File

@ -0,0 +1,237 @@
/**
* 寻址发现工具 —— 所有平台插件共用的**纯逻辑**部分。
*
* 三个 Agent 侧只读端点(`/agent/contacts`、`/agent/contacts/suggest`、
* `/agent/sessions/{id}/participants`)的返回值怎么渲染给模型看,与平台 SDK 无关,
* 所以收进这里。各平台只负责把自己的工具定义壳套上去。
*
* # 这一组端点解决的问题
*
* 在它们存在之前,`send_mail` 的 `to` 是一个**只能靠记忆拼写的自由文本字段**。
* 人类侧从来不是这样三段式输入框逐段查候选name / path / session 每一段都从
* 活数据里选。Agent 只能猜,而猜错不会报错 —— 生产上 dsh 猜了
* `opencode@/home`,地址解析通过、投递成功,但那不是 opencode 的工作目录,
* 那个错误路径静默变成了新会话的 workspace。
*
* # 渲染的取舍
*
* 一律输出**可直接粘进 `to` 的完整地址**,而不是把三段分开列。模型看到
* `opencode@/home.silent-harbor` 会整串复制;看到 `name=opencode path=/home
* session=silent-harbor` 则要自己拼,而自己拼就是问题的来源。
*/
/**
* 渲染候选收件人清单(`kind: "name"`)。
*
* 只给名字,不给地址:此时还不知道 path 与 session硬拼出来的
* 裸名字地址会投到「默认会话」—— 那不一定是调用方想要的那条。
* 明确提示下一步该查什么,模型才会继续往下走而不是就地拼一个。
*
* @param {string[]} names
* @returns {string}
*/
export function renderNameSuggestions(names) {
const list = Array.isArray(names) ? names.filter(Boolean) : [];
if (list.length === 0) return '当前没有可投递的收件人。';
return [
`可投递的收件人(${list.length} 个):`,
list.map(n => `- ${n}`).join('\n'),
'',
'下一步:用 suggest_address 带上 name 查它可用的工作目录path 位)。',
].join('\n');
}
/**
* 渲染工作目录候选(`kind: "path"`)。
*
* 空列表要说清「这不代表不能发」path 位允许为空(人类用户没有工作目录),
* 不解释的话模型会卡在这一步,或者编一个路径出来。
*
* @param {string[]} paths
* @param {string} name 正在查的收件人名,用于拼下一步的提示
* @returns {string}
*/
export function renderPathSuggestions(paths, name) {
const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
if (list.length === 0) {
return [
`${name} 没有记录在案的工作目录。`,
'这不代表不能给它发信 —— path 位可以留空(人类用户就没有工作目录)。',
`直接用 suggest_address(name="${name}", path="") 查它的会话,或直接发给 ${name}`,
].join('\n');
}
return [
`${name} 用过的工作目录(按最近使用排序):`,
list.map(p => `- ${p}`).join('\n'),
'',
`下一步:用 suggest_address(name="${name}", path="<上面某一个>") 查该目录下可续谈的会话。`,
].join('\n');
}
/**
* 渲染会话候选(`kind: "session"`)。
*
* **`addresses` 与 `suggestions` 同序**,服务端保证。这里优先用 `addresses`
* 那是服务端拼好的完整地址,插件不必自己拼(自己拼过一次,拼错了)。
*
* `new` 永远在最后且带一句警告:它不是一条已存在的会话。排在前面会让模型
* 在想续谈时顺手开出一条新线索 —— 生产上已经发生过。
*
* @param {object} data `/agent/contacts/suggest` 的返回体
* @param {string} name
* @param {string} path
* @returns {string}
*/
export function renderSessionSuggestions(data, name, path) {
const aliases = Array.isArray(data?.suggestions) ? data.suggestions : [];
const addresses = Array.isArray(data?.addresses) ? data.addresses : [];
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
// 只有 new 一项 = 这个 name@path 下还没有任何可续谈的会话
const existing = aliases.filter(a => a !== 'new');
if (existing.length === 0) {
return [
`${name}${path ? '@' + path : ''} 下还没有可续谈的会话。`,
`要开一条新线索用 ${addressAt(addresses, aliases, 'new') || `${name}@${path}.new`}`,
'并在 send_mail 里传 session_alias 给它命名,之后就能按名字续谈。',
].join('\n');
}
const lines = [`${name}${path ? '@' + path : ''} 下可续谈的会话:`];
for (let i = 0; i < aliases.length; i++) {
const alias = aliases[i];
const addr = addresses[i] || '';
const c = candidates[i] || {};
if (alias === 'new') continue; // new 单独放最后
const bits = [];
if (c.title) bits.push(c.title);
if (typeof c.unread === 'number' && c.unread > 0) bits.push(`${c.unread} 封未读`);
if (c.source === 'platform') bits.push('平台侧会话');
lines.push(`- ${addr || alias}${bits.length ? ` ${bits.join('')}` : ''}`);
}
lines.push('');
lines.push('把上面某个地址原样填进 send_mail 的 to 即可投进那条会话。');
const newAddr = addressAt(addresses, aliases, 'new');
if (newAddr) {
lines.push(`若确实要开一条**新**线索(而不是接着上面某条谈)才用 ${newAddr}`);
}
return lines.join('\n');
}
/** 按别名在同序的 addresses 里取地址。 */
function addressAt(addresses, aliases, alias) {
const i = aliases.indexOf(alias);
return i >= 0 ? addresses[i] || '' : '';
}
/**
* 渲染会话参与方清单。
*
* 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、**用什么地址找到他**、
* 以及谁还没开口。`mail_count` 为 0 的那个就是还没回应的人 —— 服务端只数
* 「作为发件人」的邮件,正是为了让这个判断成立。
*
* @param {object} data `/agent/sessions/{id}/participants` 的返回体
* @returns {string}
*/
export function renderParticipants(data) {
const parts = Array.isArray(data?.participants) ? data.participants : [];
if (parts.length === 0) return '该会话还没有参与方(可能是一条刚建立的空会话)。';
const alias = data?.session_alias || '';
const lines = [`会话 #${alias || '未命名'} 的参与方:`];
for (const p of parts) {
const tags = [];
if (p.is_self) tags.push('就是你');
if (Array.isArray(p.roles) && p.roles.length) {
tags.push(p.roles.map(roleLabel).join('/'));
}
if (p.mail_count === 0 && !p.is_self) tags.push('尚未回应');
const addr = p.address ? p.address : '(无可投递地址:该会话尚未命名)';
lines.push(`- ${p.name} ${addr}${tags.length ? ` [${tags.join('')}]` : ''}`);
}
lines.push('');
lines.push('要联系其中某一方,把它的地址原样填进 send_mail 的 to。');
return lines.join('\n');
}
/**
* 渲染联系人清单(本 Agent 参与过的全部会话)。
*
* 按未读优先、其次最近活跃排序:模型问「我还有什么没处理」时,
* 有未读的那些才是答案。
*
* @param {object} data `/agent/contacts` 的返回体
* @param {number} limit 最多列出多少条
* @returns {string}
*/
export function renderContacts(data, limit = 20) {
const list = Array.isArray(data?.contacts) ? data.contacts.slice() : [];
if (list.length === 0) return '还没有任何往来会话。';
list.sort((a, b) => {
const ua = a?.unread_count || 0;
const ub = b?.unread_count || 0;
if (ua !== ub) return ub - ua;
return String(b?.last_activity || '').localeCompare(String(a?.last_activity || ''));
});
const shown = list.slice(0, limit);
const lines = [`往来会话(共 ${list.length}${list.length > shown.length ? `,列出前 ${shown.length}` : ''}`];
for (const c of shown) {
const bits = [];
if (c.unread_count > 0) bits.push(`${c.unread_count} 封未读`);
if (c.subject) bits.push(c.subject);
if (c.max_rounds > 0) {
const left = Math.max(0, c.max_rounds - (c.used_rounds || 0));
bits.push(`${left}/${c.max_rounds} 个来回`);
}
const addr = c.address || '(未命名会话,只能用 reply_to 续谈)';
lines.push(`- ${addr}${bits.length ? ` ${bits.join('')}` : ''}`);
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return String(role);
}
}
/**
* 渲染对话树,回答「谁已经回了、谁还没回」。
*
* 缩进表示层级。**detached 必须标出来**:那表示父邮件不在本次结果里
* (无权查看或尚未加载),不标的话模型会以为这是一条独立线索。
*
* @param {object} data `/agent/mail/{id}/thread` 的返回体
* @param {string} [selfName] 自己的名字,用于标出哪几封是自己发的
* @returns {string}
*/
export function renderThread(data, selfName = '') {
const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
if (nodes.length === 0) return '这条线索上没有可见的邮件。';
const lines = [`线索共 ${data?.total ?? nodes.length}${data?.hidden ? `(另有 ${data.hidden} 封无权查看)` : ''}`];
for (const n of nodes) {
const depth = typeof n?.depth === 'number' ? Math.max(0, n.depth) : 0;
const indent = ' '.repeat(Math.min(depth, 8));
const marks = [];
if (selfName && n?.from_name === selfName) marks.push('你发的');
if (n?.mail_id === data?.anchor_mail_id) marks.push('当前这封');
if (n?.detached) marks.push(n.parent_hidden ? '父邮件无权查看' : '父邮件尚未加载');
lines.push(
`${indent}- ${n?.from_name ?? '?'}${n?.to_name ?? '?'}: ${n?.subject ?? '(无主题)'}` +
` [${n?.mail_id ?? '?'}]${marks.length ? ` (${marks.join('')})` : ''}`
);
}
if (data?.has_more) {
lines.push('');
lines.push(`还有更多,用 offset=${data.next_offset} 继续取。`);
}
return lines.join('\n');
}

View File

@ -0,0 +1,144 @@
/**
* 收件箱渲染与已读策略 —— 所有平台插件共用。
*
* 提到 lib/ 是因为这几条规则每一条都对应过一次真实的错误行为,而它们与
* 平台 SDK 无关:无论 opencode 的 zod 工具还是 DSH 的 defineTool
* 渲染出的文本与标记已读的时机都该一致。新接一个平台时直接复用这里。
*/
import { roleOf, replyAddressFor, participantsOfMail } from './addressing.js';
/** 人类可读的字节数,用于附件清单展示。 */
export function formatSize(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 把一封邮件渲染成模型可读的文本块。
*
* @param {any} m `/mail/inbox` 返回的一封邮件
* @param {number} bodyLimit 正文截断长度
* @param {string} [selfName] 自己的 Agent 名。给了就能判定「我是收件人还是抄送方」
* 并给出参与方地址;不给则退化成旧行为(兼容未传该参数的调用方)。
* @returns {string}
*/
export function renderMail(m, bodyLimit = 200, selfName = '') {
const alias = m?.session_alias || '';
const lines = [
`[${m?.status ?? 'unknown'}] ${m?.from_name ?? 'unknown'}: ${m?.subject ?? '(无主题)'}`,
`邮件 ID: ${m?.mail_id ?? 'unknown'}`,
`会话: #${alias || '未命名'}`,
];
// 收件人必须显示。不显示的后果:被抄送方既不知道主收件人是谁,
// 也无法向对方转达或汇报 —— 线上那封联调邮件要求「由收件人汇报」,
// 抄送方却看不到收件人叫什么。
if (m?.to_name) {
let toLine = `收件人: ${m.to_name}`;
if (m?.to_workspace) toLine += `@${m.to_workspace}`;
lines.push(toLine);
}
// 抄送要显示:一封邮件为什么同时到了几个人手上,只有抄送能解释。
// 不显示的话模型会以为这是私下发给它一个人的,回信时漏掉其他参与方。
if (Array.isArray(m?.cc_list) && m.cc_list.length > 0) {
lines.push('抄送: ' + m.cc_list.map(c => c?.raw || c?.name || '?').join('、'));
}
// 自己的身份。抄送方与主收件人的职责不同,不区分的话两方都会
// 以为自己是负责人,或者都以为自己只是旁观者。
if (selfName) {
const role = roleOf(m, selfName);
if (role === 'to') lines.push('你的身份: 收件人(主办)');
else if (role === 'cc') lines.push('你的身份: 抄送方(配合)');
}
// **必须给出 attachment_id**:只说「有附件」模型就无从下载。
if (Array.isArray(m?.attachments) && m.attachments.length > 0) {
lines.push(
'附件: ' +
m.attachments
.map(a => `${a?.filename ?? '?'}${formatSize(a?.size_bytes)}, id=${a?.attachment_id ?? '?'}`)
.join('、')
);
lines.push('下载附件请用 download_attachment 工具。');
}
// 列表接口只给 body_preview省带宽单封接口才有 body。两者都兜住。
const body = m?.body_preview || m?.body || '';
lines.push(`内容: ${String(body).slice(0, bodyLimit)}`);
// 可投递地址放在最后,紧贴正文 —— 模型读完内容紧接着就要决定发给谁。
//
// 这一段是「精准发信」的关键:之前模型只能从抄送行里拄一个
// `opencode@/home.new` 拄过去,而 `.new` 是一次性的,回过去只会再建一条
// 平行会话。这里给的地址全部已经把 session 位换成真实别名。
if (selfName && alias) {
const parts = participantsOfMail(m, selfName, alias);
const others = parts.filter(p => !p.is_self && p.address);
if (others.length > 0) {
lines.push(
'可投递地址: ' +
others.map(p => `${p.address}${roleLabel(p.role)}`).join('、')
);
lines.push(`直接回信给发件人用 ${replyAddressFor(m, alias)},或传 reply_to=${m?.mail_id ?? ''}`);
}
}
return lines.join('\n');
}
/** 角色的中文标签。模型读到「抄送方」比读到 cc 更容易判对分工。 */
function roleLabel(role) {
switch (role) {
case 'from': return '发件人';
case 'to': return '收件人';
case 'cc': return '抄送方';
default: return role;
}
}
/**
* 渲染整个收件箱。
* @param {any[]} mails
* @param {number} bodyLimit
* @param {string} [selfName] 自己的 Agent 名,透传给 renderMail
* @returns {string}
*/
export function renderInbox(mails, bodyLimit = 200, selfName = '') {
const list = Array.isArray(mails) ? mails : [];
if (list.length === 0) return '收件箱为空。';
return list.map(m => renderMail(m, bodyLimit, selfName)).join('\n\n');
}
/**
* 判断本次读取该标记哪些邮件为已读。
*
* 两条规则:
*
* 1. **只标本次真正列出来的**,不是全部未读。`limit` 之外的还没看过,
* 一并标掉等于让它们凭空消失。
* 2. **`status=all` 时不标**。那是「回顾历史」的读法,把历史邮件标成已读
* 会让下一轮真正的新邮件混在里面认不出来。
*
* 不标的后果是每次拉收件箱都重复捞同一批,处理过的和新来的混在一起,
* 模型分不清哪封该回。
*
* @param {string|undefined} status 本次查询用的过滤条件
* @param {any[]} mails 本次返回的邮件
* @returns {string[]} 待标记的 mail_id空数组表示不需要标记
*/
export function idsToMarkRead(status, mails) {
if (status === 'all') return [];
const list = Array.isArray(mails) ? mails : [];
return list.map(m => m?.mail_id).filter(id => typeof id === 'string' && id);
}
/** 收件箱默认过滤条件。默认只看未读 —— 默认 all 会让模型每轮重读旧邮件。 */
export const DEFAULT_INBOX_STATUS = 'unread';
/** 收件箱默认返回条数。 */
export const DEFAULT_INBOX_LIMIT = 5;

View File

@ -0,0 +1,169 @@
/**
* 平台模型目录的整理与降级选择 —— 所有平台插件共用。
*
* 两个职责:
* 1. 把各平台的 provider/model 结构整理成统一的上报格式(随心跳发给 Gateway
* 2. 按管理员划定的范围决定「先试哪个、再试哪个」
*
* 为什么随心跳上报而不是只在注册时报一次:模型清单会在运行中变(换 provider
* 配置、上游上下线、换 API key。只在注册时报的话目录会静静变陈而管理员
* 在配置页上看到的是上次重启时的快照 —— 选中一个平台已经调不到的模型,
* 失败要到真发邮件时才暴露。
*/
/** 单次上报的模型数上限。与服务端的 maxCatalogModels 一致。 */
export const MAX_CATALOG = 300;
/**
* 把 opencode 的 `/config/providers` 响应整理成上报格式。
*
* @param {any} config `client.config.providers()` 的结果
* @returns {object[]} `[{ provider, model, display_name }]`
*/
export function snapshotOpencodeModels(config) {
const providers = Array.isArray(config?.providers) ? config.providers : [];
const out = [];
for (const p of providers) {
const provider = typeof p?.id === 'string' ? p.id : '';
if (!provider) continue;
// models 是对象而非数组:键是 model id值是元数据
const models = p?.models && typeof p.models === 'object' ? p.models : {};
for (const [id, meta] of Object.entries(models)) {
if (!id) continue;
out.push({
provider,
model: id,
display_name: typeof meta?.name === 'string' ? meta.name : '',
});
}
}
return dedupeAndCap(out);
}
/**
* 把 DSH 的 provider/model 列表整理成上报格式。
*
* DSH 侧要先 `ctx.llm.listProviders()` 再对每个 provider `listModels()`
* 因此这里收的是已经拍平的结果。
*
* @param {any[]} entries `[{ provider, id, name }]`
* @returns {object[]}
*/
export function snapshotDshModels(entries) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 把 pi 的模型列表整理成上报格式。
*
* pi 侧的取法是 `await modelRuntime.getAvailable()` —— **不是** `getModels()`。
* 两者差别很大:本机实测目录里有 1221 个模型,而带凭证、真能调起来的只有 1 个。
* 上报 `getModels()` 的结果会让管理员在配置页选中一个注定失败的路由,
* 而失败要到真发邮件时才暴露(模型目录上报的全部意义就是避免这件事)。
*
* pi 的 Model 对象上provider 在 `provider` 字段、模型 id 在 `id` 字段,
* 展示名在 `name`。形状与 DSH 侧一致,但语义来源不同,因此单独一个函数
* ——照抄 snapshotDshModels 会让「必须用 getAvailable」这条约束无处记录。
*
* @param {any[]} models `await modelRuntime.getAvailable()` 的结果
* @returns {object[]}
*/
export function snapshotPiModels(models) {
const list = Array.isArray(models) ? models : [];
const out = [];
for (const m of list) {
const provider = typeof m?.provider === 'string' ? m.provider : '';
const model = typeof m?.id === 'string' ? m.id : '';
if (!provider || !model) continue;
out.push({
provider,
model,
display_name: typeof m?.name === 'string' ? m.name : '',
});
}
return dedupeAndCap(out);
}
/**
* 决定这一轮按什么顺序尝试模型。
*
* 三种情形:
*
* 1. **管理员划定了范围** → 按 rank 顺序(服务端已排好),逐个降级
* 2. **没划定范围**`allowed` 为空)→ 返回 `[undefined]`
* 表示「用平台自己的默认模型试一次」。**不是**空数组:
* 空数组会让调用方一次都不试,等于让 Agent 彻底哑掉,
* 而「管理员没配」的正确含义是不限定。
* 3. **插件配了 `AGENTMAIL_REPLY_PROVIDER`/`MODEL`** → 那是部署方的显式指定,
* 优先于「平台默认」,但**不优先于管理员划定的范围**
* 范围是运行时可改的策略,环境变量是部署时的兜底。
*
* @param {readonly {provider: string, model: string}[]} allowed 管理员划定的范围(按 rank
* @param {{provider?: string, model?: string}|undefined} envDefault 环境变量指定的模型
* @returns {(({provider: string, model: string})|undefined)[]} 依次尝试的候选;
* `undefined` 表示这一次不指定模型、交给平台
*/
export function modelAttemptOrder(allowed, envDefault) {
const list = Array.isArray(allowed) ? allowed.filter(m => m?.provider && m?.model) : [];
if (list.length > 0) return list.map(m => ({ provider: m.provider, model: m.model }));
if (envDefault?.provider && envDefault?.model) {
return [{ provider: envDefault.provider, model: envDefault.model }];
}
return [undefined];
}
/**
* 把多次尝试的失败原因整理成一封邮件正文。
*
* 全部失败时必须发这封信:模型一次都没跑起来,会话里没有任何 assistant 消息,
* 自动转发因此什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
*
* @param {{provider?: string, model?: string, error: string}[]} failures 每次尝试的失败
* @param {string} subject 原邮件主题
* @returns {string} Markdown 正文
*/
export function renderFailureReport(failures, subject) {
const list = Array.isArray(failures) ? failures : [];
const lines = [
`本次未能处理「${subject || '(无主题)'}」:划定范围内的模型全部调用失败。`,
'',
`已尝试 ${list.length} 个:`,
'',
];
list.forEach((f, i) => {
const route = f?.provider && f?.model ? `${f.provider}/${f.model}` : '(平台默认模型)';
lines.push(`${i + 1}. **${route}**`);
// 缩进四格让报错原文成为代码块,避免其中的 Markdown 字符影响排版
lines.push(` ${String(f?.error ?? '未知错误').replace(/\n/g, '\n ')}`);
});
lines.push('');
lines.push('可能的原因模型已下线、API key 失效、上游限流,或该 provider 未在平台侧配置。');
lines.push('调整可用模型范围:配置页 → Agent 模型范围。');
return lines.join('\n');
}
/** 去重provider/model 组合)并截断。 */
function dedupeAndCap(list) {
const seen = new Set();
const out = [];
for (const m of list) {
const key = `${m.provider}/${m.model}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(m);
if (out.length >= MAX_CATALOG) break;
}
return out;
}

View File

@ -0,0 +1,58 @@
// 自动转发去重的纯逻辑。
//
// 单独一个文件而不是放在 index.js 里导出:**opencode 会把插件入口模块的
// 每一个导出都当成插件工厂**`Object.values(mod)` 逐个检查是不是函数),
// 多导出一个 Map 就会让整个插件加载失败:
// ERROR message="failed to load plugin" error="Plugin export is not a function"
// 实测踩过 —— 插件静默不加载,邮件全都投不进去。
// 因此入口文件只能 `export default`,其余东西一律搁在这里。
/** 取三维地址的名字段admin@root.alias -> admin */
export function addrName(addr) {
return String(addr || "").split("@")[0].trim();
}
/**
* 本轮内模型**自己调 send_mail** 发出去的信(按 opencode 会话)。
*
* session.idle 的自动转发要据此让位:模型已经亲手回过这条线索了,
* 再把它最后那段话转一遍,收件箱里就是两封内容几乎一样的邮件。
* 生产实测过这个后果 —— 同一轮里 311 字节和 342 字节各一封,
* 说的是同一件事,其中带附件的那封才是模型真正想发的。
*
* 为什么不靠 relay_key 幂等:那个键是 assistant message id
* 保证的是「同一条消息不被转两次」,管不了「模型已经自己发过了」。
*
* 窗口是「一轮」deliverMail 投递新邮件时清空(新一轮开始),
* relaySummary 用完即清。
*/
export const explicitSends = new Map(); // opencode session id -> { names:Set, replyTos:Set }
/** 记下模型这一轮主动发了信,给谁、回的哪封。 */
export function noteExplicitSend(sessionID, to, replyTo) {
if (!sessionID) return;
let rec = explicitSends.get(sessionID);
if (!rec) {
rec = { names: new Set(), replyTos: new Set() };
explicitSends.set(sessionID, rec);
}
const name = addrName(to);
if (name) rec.names.add(name);
if (replyTo) rec.replyTos.add(String(replyTo));
}
/**
* 本轮是否该跳过自动转发。
*
* @param sent 该会话本轮的主动发信记录 { names:Set, replyTos:Set },可为空
* @param replyTo 自动转发本来要发给谁(三维地址或纯名字)
* @param mailID 自动转发本来要 reply_to 的邮件 id
*/
export function shouldSkipAutoRelay(sent, replyTo, mailID) {
if (!sent) return false;
// 收件人同名:模型已经跟这个人说过了
if (sent.names.has(addrName(replyTo))) return true;
// 同一封信已被回过:即使收件人写法不同(别名/路径不同)也算回过
if (mailID && sent.replyTos.has(String(mailID))) return true;
return false;
}

View File

@ -0,0 +1,234 @@
/**
* 平台会话快照:把 harness 自己的会话列表整理成 Gateway 的上报格式。
*
* 为什么需要它:写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。
* Gateway 只看得见邮件驱动的那部分 —— 人直接在 opencode/DSH 界面上开的会话
* 它一无所知,于是那些会话的别名在补全里根本不出现,无法选择。
*
* 为什么是插件上报而不是 Gateway 拉取当前架构是单向的Agent 持密钥主动连
* GatewayGateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与凭证,
* 那是另一套信任模型。
*/
/** 单次上报的会话数上限。与服务端的 maxPlatformSessions 一致。 */
export const MAX_REPORTED = 200;
/**
* 把 opencode 的 session 列表整理成上报格式。
*
* @param {any[]} sessions client.session.list() 的结果
* @param {(id: string) => boolean} isMailDriven 该平台会话是否由邮件驱动
* @returns {object[]} 按最近活跃排序、截断到 MAX_REPORTED 的上报项
*/
export function snapshotOpencodeSessions(sessions, isMailDriven = () => false) {
const list = Array.isArray(sessions) ? sessions : [];
const out = [];
for (const s of list) {
const id = typeof s?.id === 'string' ? s.id : '';
if (!id) continue;
// 没有 slug 的会话不报slug 是填进 session 位的值,
// 没有它这一项在补全里点下去只能得到一个空的 session 段。
const slug = typeof s?.slug === 'string' ? s.slug : '';
if (!slug) continue;
out.push({
platform_id: id,
// opencode 的工作目录在 directory 上path 是项目内的子路径,不是 cwd
workspace: typeof s?.directory === 'string' ? s.directory : '',
slug,
title: typeof s?.title === 'string' ? s.title : '',
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(s?.time?.updated ?? s?.time?.created),
});
}
return sortAndCap(out);
}
/**
* 把 DSH 的 agent 列表整理成上报格式。
*
* DSH 没有 opencode 那样的 slug别名由**模型生成的会话标题**派生
* (与「别名复用平台命名」的既定决策一致)。占位标题不派生别名:
* DSH 在模型生成真标题前会先落一个 fallback 标题,内容是用户第一句话的截断,
* 而那句话是插件自己拼的提示词。
*
* @param {any[]} entries [{ id, cwd, title, updatedAt }]
* @param {(id: string) => boolean} isMailDriven
* @returns {object[]}
*/
export function snapshotDshSessions(entries, isMailDriven = () => false) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const e of list) {
const id = typeof e?.id === 'string' ? e.id : '';
if (!id) continue;
// subagent 子会话不上报:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。
// 而且它们的标题就是派活时的提示词前缀(实测九条会话都叫
// "You are auditing ONE file"),派生出的 slug 全都撞名、毫无区分度。
if (isSubagent(e)) continue;
const title = typeof e?.title === 'string' ? e.title : '';
const slug = slugFromTitle(title);
if (!slug) continue;
out.push({
platform_id: id,
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
slug,
title,
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(e?.updatedAt),
});
}
// slug 撞名的只留最近那条:别名是**寻址**用的,
// 同一个 slug 对应多条会话时服务端只能取其中一条updated_at DESC LIMIT 1
// 上报一堆同名项只会让人在补全列表里看到几个一模一样、点哪个都不确定的候选。
return dedupeBySlug(sortAndCap(out));
}
/**
* 把 pi 的 `SessionManager.list()/listAll()` 结果整理成上报格式。
*
* pi 的会话名字来自会话文件里最后一条 `session_info` 条目:
* - pi-web 在一条会话的首次 prompt 时用模型生成一个 2-6 词的标题
* - TUI 的 `/name`、启动参数 `--name`、`/resume` 里的改名也写同一处
* - **pi 内核SDK自己不生成**:桥用 createAgentSession 起的会话没有名字,
* 要由桥按「Gateway 定稿的别名」回写(见 index 的 syncNaming
*
* 与另两个平台的差异pi 的 SessionInfo 里**没有 subagent 标记**。
* pi-subagents 把子会话写在自定义 sessionDirrun 根目录)下,默认会话目录
* 列不到它们,因此这里不需要 S-2 那样的显式过滤。
*
* @param {any[]} entries SessionInfo 列表 `[{ id, cwd, name, modified }]`
* @param {(id: string) => boolean} isMailDriven
* @returns {object[]}
*/
export function snapshotPiSessions(entries, isMailDriven = () => false) {
const list = Array.isArray(entries) ? entries : [];
const out = [];
for (const e of list) {
const id = typeof e?.id === 'string' ? e.id : '';
if (!id) continue;
const name = typeof e?.name === 'string' ? e.name : '';
// 没有名字的会话不报S-1pi 的列表在无名时显示首条消息,
// 而首条消息对邮件驱动的会话就是桥自己拼的提示词 —— 拿它当别名毫无区分度。
if (!name) continue;
// 模型把思维链当标题写进来的那些不报(见 isUnusableName
if (isUnusableName(name)) continue;
const slug = slugFromTitle(name);
if (!slug) continue;
out.push({
platform_id: id,
// 老会话的 cwd 是空串pi 的 SessionInfo 注释里写明了),照实上报,
// 服务端按空 workspace 处理,不要拿桥自己的 cwd 冒充。
workspace: typeof e?.cwd === 'string' ? e.cwd : '',
slug,
title: name,
mail_driven: Boolean(isMailDriven(id)),
updated_at: toISO(e?.modified ?? e?.created),
});
}
return dedupeBySlug(sortAndCap(out));
}
/**
* 判断一个平台侧名字是否不适合当别名。
*
* 这条判废是 pi 特有的pi-web 的标题生成器(`sessionNameGenerator`)只做了
* 「取首行 + 去引号 + 截 60 字符」,没有防思维链泄漏。本机 81 条会话里实测捞到:
*
* "The user is asking me to generate a title for a coding-agent"
* "我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短…"
*
* 这类字符串派生出的别名又长又没有指代作用,填进三维地址里更是灾难。
* 判废后调用方回退到「不上报」或「用邮件主题派生」,都比它强。
*
* 宁可漏判也不误判:错杀一个好名字会让那条会话失去可寻址的别名,
* 而漏掉一个坏名字只是别名难看。
*
* @param {string} name
* @returns {boolean}
*/
export function isUnusableName(name) {
const s = String(name ?? '').trim();
if (!s) return true;
// 自指标题生成任务 = 模型把系统提示词复述了出来
if (/生成标题|标题应|拟一个标题|generate a (short |concise )?title|session title|as a title/i.test(s)) {
return true;
}
// 以第三人称叙述用户意图开头 = 思维链的典型开场
if (/^(the user\b|用户(想|要|在|希望)|我们只需要|我需要先|首先(|,))/i.test(s)) return true;
// 又长又分句 = 一段话而不是一个标题pi-web 截断上限是 60
if (s.length >= 48 && /[。;;]|\.\s/.test(s)) return true;
return false;
}
/** 判断一条会话是否为 subagent 子会话。两个字段任一成立即算。 */
function isSubagent(e) {
if (e?.origin === 'subagent') return true;
const depth = e?.delegationDepth;
return typeof depth === 'number' && depth > 0;
}
/** 同 slug 只保留第一条(调用前已按最近活跃排序)。 */
function dedupeBySlug(list) {
const seen = new Set();
const out = [];
for (const item of list) {
if (seen.has(item.slug)) continue;
seen.add(item.slug);
out.push(item);
}
return out;
}
/**
* 把模型生成的会话标题转成可寻址的 slug。
*
* 保留中文而不转拼音:标题「缓存层选型评估」转成 huancunceng-xuanxing 之后
* 既不好读也不好打,而 AgentMail 的别名校验本来就允许中文(三维地址按最后一个
* `.` 切分,中文不影响解析)。
*
* 处理:空白 → `-`,去掉会干扰寻址的字符(`.` 是 session 位的分隔符,
* `@` 是 path 位的分隔符,`/` 会被当成路径),压缩连续 `-`,截断到 48 字符。
*
* @param {string} title
* @returns {string} slug无法派生时为空串
*/
export function slugFromTitle(title) {
const raw = String(title ?? '').trim();
if (!raw) return '';
const slug = raw
.replace(/[\s\u3000]+/g, '-')
// 寻址相关的分隔符必须去掉,否则别名本身会被解析器切开
.replace(/[.@/\\:,;'"`?#[\]{}()<>|*!$&=+%^~]/g, '')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
// 截断可能又切出尾部的 -
.replace(/-+$/g, '');
// 纯符号标题清干净后会剩空串
return slug;
}
/** 毫秒时间戳、ISO 串或 Date → ISO 串;无法解析时返回 undefined。 */
function toISO(v) {
if (typeof v === 'number' && Number.isFinite(v)) {
return new Date(v).toISOString();
}
// pi 的 SessionInfo 给的是 Date 实例created/modified不是时间戳。
// 少了这一支会让整份快照的 updated_at 全是 undefined于是服务端只能按
// 上报时间排序 —— 补全列表里「最近在谈的那条」不再排在前面。
if (v instanceof Date) {
return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
}
if (typeof v === 'string' && v) {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
return undefined;
}
/** 按最近活跃降序排列并截断。上千条会话对补全列表毫无用处。 */
function sortAndCap(list) {
return list
.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')))
.slice(0, MAX_REPORTED);
}

View File

@ -0,0 +1,77 @@
/**
* 邮件寻址里的工作目录(三维地址 name@path.session 的 path 位)。
*
* 这个模块存在的理由是一次真实故障:插件建会话时用的 cwd 是自己拼的
* `~/.dsh/mail-sessions/mail-<uuid>` —— 每封邮件一个全新的空目录。
* DSH 与 opencode 都按 cwd 给会话分组,于是所有邮件会话既不属于任何项目、
* 彼此也不同组,界面上全落进「未分组」。
*
* path 位本来就是「希望它在哪儿干活」,插件只需照用。
*/
import { existsSync, mkdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';
/**
* 校验寻址里的工作目录,不可用时返回调用方给的兜底。
*
* 决策顺序:
* 1. path 位是一个已存在的目录 → 直接用它(同 path 的多封邮件天然同组)
* 2. path 位非空但目录不存在 → **不创建**,返回兜底
* 3. path 位为空(地址写成 `dsh` 而不带 `@/path`)→ 兜底
*
* 为什么不给不存在的 path 建目录:那等于让一个笔误(`/home/porgram/x`
* 在磁盘上落下一个真目录,而 Agent 会在里面一无所获地干活 ——
* 用户看到会话建起来了却什么都做不了,比明确落到兜底目录更难排查。
*
* 为什么拒绝相对路径cwd 的相对基准是 harness 进程的启动目录,
* 那是个与邮件语义无关的量systemd 下通常是 `/`)。
*
* 兜底由调用方给因为各平台的兜底不同opencode 有插件启动时的 directory
* 可用DSH 没有、只能落到 `~/.dsh/mail-sessions/<会话>`(见 mailSessionFallback
*
* @param {string} workspace 事件里的 to_workspace
* @param {string} fallback 不可用时的兜底目录(可为空串 = 交给平台自己决定)
* @returns {{cwd: string, grouped: boolean}} grouped 为真表示落在了寻址指定的目录里
*/
export function resolveWorkspaceCwd(workspace, fallback) {
const raw = typeof workspace === 'string' ? workspace.trim() : '';
const fb = typeof fallback === 'string' ? fallback : '';
if (!raw || !isAbsolute(raw)) return { cwd: fb, grouped: false };
const abs = resolve(raw);
try {
if (existsSync(abs) && statSync(abs).isDirectory()) {
return { cwd: abs, grouped: true };
}
} catch {
// 权限不足等:当作不可用
}
return { cwd: fb, grouped: false };
}
/**
* 没有天然兜底的平台DSH用这个`~/.dsh/mail-sessions/<会话 id>`。
* @param {string} sessionKey 会话标识
* @returns {string}
*/
export function mailSessionFallback(sessionKey) {
return join(homedir(), '.dsh', 'mail-sessions', String(sessionKey || 'default'));
}
/**
* 确保兜底目录存在。寻址指定的目录本来就存在(否则不会被选中),
* 只有兜底目录需要现建。
* @param {string} cwd resolveWorkspaceCwd 的结果
* @param {boolean} grouped 是否落在寻址指定的目录里
*/
export function ensureCwd(cwd, grouped) {
if (grouped || !cwd) return;
try {
mkdirSync(cwd, { recursive: true });
} catch {
// 建不出来就让 harness 自己报错,这里不该吞掉真实原因
}
}