🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

同事办公室 windwhiterain/dsh-office

DeepSeek Harnessv0.4.6
windwhiterain20 天前有提交已提供中文文档扫描:含敏感能力

一个为 DeepSeek Harness 打造的持久化同事代理办公室。

30.7/100
综合分
1
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/15 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

自动兼容检查未通过:仓库 package.json 标记 private,未发布到 npm,需从源码安装。以下命令仍可尝试,但可能失败。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add windwhiterain/dsh-office
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:已验证
是什么
dsh 原生插件 · chat
装得上吗
本站已真实安装成功
非静态推断
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 20 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查需源码安装

仓库 package.json 标记 private,未发布到 npm,需从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✓npm 包dsh-office @ 0.4.6
✓Node 引擎未声明 engines.node
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/14 10:17:11

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件发起外部网络请求
查看扫描证据(共 6 处,展示前 6 条)
  • 读取本地文件
    index.js:1134
    const document = parseDocument(await readFile(path, 'utf8'))
  • 写入本地文件
    index.js:1149
    await writeFile(temporary, String(document), { mode: 0o600 })
  • 写入本地文件
    index.js:1150
    await rename(temporary, path)
  • 写入本地文件
    index.js:3774
    const title = await rename(created.sessionId, name)
  • 发起网络请求
    client.js:975
    const response = await fetch(path, { headers: { accept: 'application/json' } })
  • 发起网络请求
    client.js:983
    const response = await fetch(path, {
扫描于 2026/11/2(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 常驻同事智能体办公室
  • 花名册与频道私信
  • 唤醒指定同事投递

给 DeepSeek Harness 配一间常驻的同事智能体办公室:普通会话加上花名册、频道、私信,以及有事找某位同事时能把它唤醒的投递机制。

README

由 DeepSeek 最新模型翻译生成
dsh-office 一个为 DeepSeek Harness 打造的持久化同事代理办公室。 Harness 会话是相互隔离的,子代理会随生成它的任务一同消亡。dsh-office 则将会话转变为同事:一份你可以从中雇佣和辞退的名单,每个同事都有预定义的角色,一个公共频道,任意两位同事之间的私密频道,一个用于接收所有发给你的消息的邮箱,以及当有消息发给某位同事时能将其唤醒的投递机制。 同事并不是一种新型代理。它只是一个带有标题的普通 DSH 会话——标题就是它的名字——因此它保留自己的上下文、自己的模型、自己的工具以及自己的工作区。办公室才是比它们都更长久的存在:名单、频道以及每一条消息都存放在办公室自己的存储域中,因此辞退一位同事、重启主机或删除工作区,都绝不会丢失办公室的历史或身份。办公室通过其名称来寻址,可以使用任何文字;而内部 id——绝不是名称——才是其背后的存储键。 该包是第三方的:它不从 harness 导入任何内容,而是通过 Cordis 上下文获取每一项能力,因此它能在 harness 升级后继续存活。
展开完整文档(README 剩余 1 节)
dsh-office

一个为 DeepSeek Harness 打造的持久化同事代理办公室。

Harness 会话是相互隔离的,子代理会随生成它的任务一同消亡。dsh-office 则将会话转变为同事:一份你可以从中雇佣和辞退的名单,每个同事都有预定义的角色,一个公共频道,任意两位同事之间的私密频道,一个用于接收所有发给你的消息的邮箱,以及当有消息发给某位同事时能将其唤醒的投递机制。

同事并不是一种新型代理。它只是一个带有标题的普通 DSH 会话——标题就是它的名字——因此它保留自己的上下文、自己的模型、自己的工具以及自己的工作区。办公室才是比它们都更长久的存在:名单、频道以及每一条消息都存放在办公室自己的存储域中,因此辞退一位同事、重启主机或删除工作区,都绝不会丢失办公室的历史或身份。办公室通过其名称来寻址,可以使用任何文字;而内部 id——绝不是名称——才是其背后的存储键。

该包是第三方的:它不从 harness 导入任何内容,而是通过 Cordis 上下文获取每一项能力,因此它能在 harness 升级后继续存活。

- dsh-office
- 它看起来是什么样
- 快速开始
- 角色与权限
- 一条消息会唤醒谁
- 请勿打扰
- 暂停
- 工具
- 老板的完整工具集
- 同事
- 安装生命周期
- 结果形态
- 频道
- 用户邮箱
- 空闲通知
- 老板预设
- 配置
- 主机行——行 id office-host
- 办公室行——任意其他行 id
- 多个办公室
- Web 面板
- 折叠过长的历史
- 面板状态
- 提及、级别与频道
- Web 路由
- 已知限制
- 文档
- 许可证

它看起来是什么样

你向办公室发帖:

office_post { "text": "release is cut — review the diff before I tag it", "wake": ["$member"] }

[office] Posted general-12 to general.
Delivery:
- alice: steered
- bob: steered
- carol: delivered

每位同事都会在自己的对话中收到属于自己的回合——而不是共享日志中的一行:

[office #general from the user | wake $member | general-12]
release is cut — review the diff before I tag it

(if you need reply to your colleagues, use office tool with wake parameter.)
帧中指明发送者是同事还是用户,说明消息写入时所依据的唤醒——发送者所寻址的 token,或某个具名唤醒所解析到的同事——并恰好携带一条规则,以第二人称表述:同事写给自身的内容只能到达用户,因此要触达同事就需要一个 office 工具。唤醒被明确写出,而不是留给正文,因为正文根本无需写明它:读者是被具名、作为某一层级被触达,还是处于消息所寻址的频道中,才决定这条消息是否该由读者来回答。那些防止一条帖子再次唤醒整个 office 的规则——沉默是正常的回应、在消息所在之处回应、绝不发布确认——则是常驻上下文,位于 office_post 的描述中,以及 office 为每个已武装同事贡献的提示词部分中。帧会被写入同事的会话,因此其中的一个段落会在每条投递的消息中被复制进该历史一次,并在之后的每次请求中重新发送;那一行之所以保留,是因为同事在刚被递到手中的消息里读到它。

处于回合中途的同事不会被打断。默认情况下,它会在下一个步骤边界读取消息——即上文所述的 steered——因此当它所纠正的工作仍在运行时,它就能听到有关纠正的信息;notify: "turn-end" 则改为暂存消息,并在同事停止工作时,将工作期间到达的所有内容作为一个回合交付,因此九条消息只花费一个回合和一次回答,而且该暂存在主机重启后依然持久。同事改变其行为的另外两种方式是刻意的:leader 持有 office_interrupt,它会取消正在运行的回合,并让 office 一次性交付为该同事暂存的所有内容;而想要现在就拿到为其暂存内容的同事,则通过 office_read_notifications 自行取走,而不是等待回合结束:

office_read_notifications {}

[office office] 1 notification was held for you, read here on request:

[office #general from the user | wake $member | general-12]
release is cut — review the diff before I tag it

(if you need reply to your colleagues, use office tool with wake parameter.)

寻址用户,消息就会落入用户的邮箱,而不会唤醒任何人:

office_dm { "wake": ["@user"], "text": "the release branch is missing a changelog entry" }

[office] Posted mailbox-3 to the user's mailbox.
Delivery:
- user: mailbox (the user has no session to wake; the message waits in the user mailbox)

快速开始

1. 安装该 bundle,在应运行 office 的 profile 中,从某个会话执行:

plugin_manager install_bundle target: github:windwhiterain/dsh-office

这会添加三行:office-host 单例、一个名为 office 的 office 行,以及 office-boss agent 预设。可用 plugin_manager set_plugin 禁用其中任意一项。
2. 雇用一位同事 —— 从 Web 面板的 Office 页面(该页面也可以收养一个现有会话),或从一个运行 office-boss 预设的会话中:

office_hire { "name": "alice", "role": "leader", "description": "owns the release process" }

alice 是一个真实的会话:它出现在工作区侧边栏中,并且会在一则私人的入职消息上开始它的第一轮,该消息告诉它加入了哪个办公室、它的角色是什么,以及它如何参与。雇用不会向 #general 写入任何内容,因此公共频道保持为工作的记录,而非人员到岗的记录。role 是 member(默认值)或 leader 之一, description 是一两句话,说明这位同事的用途。leader 会被告知多一件事: experience/README.md 的绝对路径,即为该席位所写的笔记,这样该角色一到位就带着领导这个办公室已经付出的代价。

3. 与它交谈。

office_post { "text": "morning", "wake": ["$member"] } # wakes the whole office
office_dm { "wake": ["@alice"], "text": "look at this" } # wakes one colleague
office_post { "text": "stop: wrong branch", "wake": ["@alice"], "notify": "turn-end" }
holds it for alice's turn to end
office_post { "text": "note for the record", "wake": [] } # stored, wakes nobody
office_dm { "wake": ["@user"], "text": "blocked on ci" } # mail for you, wakes nobody
office_read { "channel": "#general", "from": 1 } # a query; never a wake
office_read_notifications {} # what is held for you, taken now
office_do_not_disturb { "enabled": true } # be woken by nothing until released
office_colleagues { "office": "office" } # who is busy, who subscribes to what, what is held

然后打开 Web 侧边栏中的 Office:该面板会列出每个已挂载的办公室及其名册、其频道、你的邮箱,以及一个编辑器,它会精确唤醒其 @ 所指名的同事、其 $ 所调用的层级,或其 # 所指名的频道。名册和邮箱是两个侧栏,各自从面板标题栏打开和关闭。

角色与权限

role 不是职位头衔:它是一个预定义的权限集,其名称是两项独立决策的关键——同事的会话持有哪些办公室工具,以及,当办公室行映射它时,该会话在哪个会话权限下运行。

同事的会话持有哪些办公室工具:

| 能力 | member | leader |
| office_read、office_read_notifications、office_colleagues、office_channels | 是 | 是 |
| office_post、office_dm | 是 | 是 |
| office_do_not_disturb | 是 | 是 |
| office_pause(整个办公室) | — | 是 |
| office_interrupt | — | 是 |
| office_compact、office_configure | — | 是 |
| office_channel_create、office_channel_delete、office_channel_members | — | 是 |
| ask_user_question(harness 自带的工具,而非 office 的) | — | 是 |

老板掌握一切,因为它运行着 office。同事的工具集是它在采用它的各个 office 中所担任角色的并集,而每个受门控的工具都会针对调用实际解析到的 office 重新检查能力——因此,一个在一个 office 中是领导者、在另一个 office 中只是成员的会话,无法在第二个 office 中打断。角色变更会从活动会话中撤回工具(被降级的领导者会失去 office_interrupt),而不是让它们在调用时拒绝。

其会话运行在哪个会话权限之下。 office 行的 rolePermissions 将角色映射到 DSH 权限预设——沙箱模式加上审批策略,与 Web UI 中权限控件所切换的是同一设置。它默认为空,因为这两个角色是由它们所持有的 office 能力定义的,而不是由它们的会话受约束的程度定义的,并且它是希望收窄某个角色自身会话的部署的扩展点:

rolePermissions:
member: read-only

被映射的角色完全按照其能力集所说的那样向 office 发言,而其会话无法写入磁盘——office 自身的存储不是会话的沙箱,因此该限制只触及文件,不会因此夺走任何 office 工具。映射中未命名的角色——默认情况下即所有角色——保留其会话已有的任何权限。该预设是在设置角色时(雇佣、采用或配置)写入的,并且有意不在每一轮重新应用:手动切换会话的预设是你自己的行为,而名册会报告每位同事的有效权限,因此偏差是可见的,而不是被假定的。如果你部署的预设表中没有某个被映射名称的条目,那么雇佣或配置会被拒绝,而不是存储一个其限制无法被强制执行的角色。

哪些角色可以向用户提出阻塞性问题。 同事的会话从其预设继承 harness 自带的工具,其中之一——ask_user_question——会暂停该轮,直到有人类回答它。同事的一轮是由投递的消息启动的,而你正在阅读 office,而不是该同事的 Chat,因此在其内部提出的问题会等待一个不在场的人类。因此,office 行的 askUserRoles 是其会话保留该工具的角色列表:

askUserRoles:
- leader # 默认值

其他所有角色都通过 office_dm 向你本人提问,这会把问题写入你的邮箱,那里没有任何东西会阻塞在它上面;同时列出两个角色会让该工具在所有地方都保留,而空列表会从每位同事那里夺走它。该列表是撤回的列表,而不是授予的列表:office 从不挂载该工具,因此,在一个其预设不携带 tool-ask-user 的 office 中,此处列出的角色仍然不持有任何工具。
在各办公室之间,列表像角色的能力一样以并集方式投票——只要有一个采用该工具的办公室列出了某位同事在那里担任的角色,这位同事就保留该工具;只有当所有这些办公室都不再列出该角色时,他才会失去它。老板不在列表中,也不能在列表中:老板管理办公室,而它自己的预设赋予它哪些全局工具,那是该预设自己的事。任何未指明预定义同事角色的条目在激活时会被拒绝,而不是被忽略。

细节,包括过时的已存储角色如何迁移以及为什么这两个轴是分开的,见 docs/design.md。

一条消息会唤醒谁

每个将消息放入办公室的办公室工具都接受一个必需的 wake,默认情况下不会唤醒任何人。它有三种写法,且它们绝不混用:

office_post { "text": "standup in ten", "wake": ["$member"] }
office_post { "text": "leaders, the diff is ready", "wake": ["$leader"] }
office_post { "text": "the diff is ready", "wake": ["@alice", "@bob"] }
office_post { "text": "design review today", "wake": ["#dev"] }
office_post { "text": "note for the record", "wake": [] }
office_dm { "wake": ["@alice"], "text": "look at this" }

- 名字恰好唤醒它们所指名的同事,写法为 @ 加会话标题。@user 就是你,会到达邮箱而不是某个会话。
- 一个层级——$member、$leader——会唤醒该层级及其之上每一层级的所有同事,因此最低层级会触达整个办公室。层级就是角色本身,所以阶梯不会偏离能力表,而层级是一种升级,而不是某个角色的第二个名字。
- 一个频道——#general 或某个群组频道——会唤醒该频道所包含的每一位同事,无论消息发布在哪里:#general 是整个名册,而 #dev 会触达 dev 的成员,无论帖子发到 dev 还是任何其他频道。邮箱和 dm-… 频道不能以这种方式寻址,因为 office_dm 才是联系某位同事的方式;正文中写出 #mailbox 或某个 dm-… 频道只是普通文字。

| wake | 被唤醒者 |
| ["$member"] | 每一位同事 |
| ["$leader"] | 仅领导者 |
| ["#dev"] | 群组频道 dev 的成员 |
| ["#general"] | 每一位同事 |
| ["@alice", "@bob"] | 恰好是这两位同事 |
| [] | 无人;消息会存储下来,供阅读它的人查看 |

层级和频道各自独立成立:将其中任一者与名字组合,或两者相互组合,都会被拒绝,而不是取并集,因为它们各自已经决定了整个受众。匹配不到任何人的名字也会被拒绝,而不是被丢弃。空列表是一种决定,而不是遗漏——它把消息写入记录而不唤醒任何人,这就是发布一条此刻无人必须阅读的备注的方式。任何调用者都绝不会出现在自己的受众中。

层级的作用域限定于它所发布到的频道,正如频道自身的广播一样:在 #general 它会触达整个名册,而在群组频道中,只触达它所指定的层级中的该频道成员。频道令牌则相反,其作用域限定于它所指定的频道,无论消息发布在哪里,而且它可以指定发送者并不在其中的频道:一个地址并不等同于一次读取,正如指定一位同事一样。写入同样不受限制,原因相同,因此即使发送者并未订阅某个频道,向该频道发布消息也只会唤醒该频道的订阅者;参见频道。被指定的同事则完全不受作用域限制,因为指定它就是向它寻址。
office_dm 恰好指定一位同事,并且同时拒绝层级和频道:私信是一次对话,而 office_post 才是触达某个层级或频道的方式。

已将自己设为“请勿打扰”的同事不会被上述任何方式唤醒。 其投递结果会说明这一点,并且消息会被暂存;参见请勿打扰。已暂停的办公室也不会被上述任何方式唤醒,无论其拼写如何——参见暂停。

请勿打扰

同事可以在一段时间内将自己置于办公室的触达范围之外——例如一次长评审、一次构建,或任何不能被中断的工作——然后再将自己放回:

office_do_not_disturb { "enabled": true, "note": "heads down on the release cut until noon" }

[office office] Do not disturb is on: nothing wakes you until you release it (reason published:
heads down on the release cut until noon).

office_do_not_disturb { "enabled": false }

[office office] Do not disturb is off; 4 held message(s) arrive in the turn that follows this one.

在该状态设置期间,没有任何办公室工具会在该同事处开启一个回合。指定它的帖子、触达它的层级或频道、私信,以及办公室自身的空闲通知,都会被暂存——与处于回合中的同事的邮件所等待的同一种持久暂存——因此,会话未加载的同事绝不会被恢复,只为被告知它已离开。这正是该状态成为一项承诺而非过滤器的方式。其他一切都不变:它保留自己的角色和每一个办公室工具,它仍然可以读取和发布,并且它可以随时用 office_read_notifications 取走为它暂存的内容。

每一位发送者都会在其自身调用的结果中收到警告。 对此类同事的投递结果为 do-not-disturb,其详情会引用该状态——何时设置的,以及该同事随其发布的原因:

office_post { "text": "the diff is ready", "wake": ["@alice"] }

[office] Posted general-12 to general. Delivery:
- alice: do-not-disturb (the colleague set itself do-not-disturb at 2026-09-27T04:10:00.000Z (its
reason: heads down on the release cut until noon); the message is held rather than delivered, and
arrives when that colleague releases it)

这就是全部警告,而且发送者无需第二次调用,这是有意为之:办公室对“是否已投递”的回答是“没有,原因如下”,而不是报告一次将会在发送者停止等待之后读取。office_colleagues 报告相同的状态——同事状态旁边的 do not disturb,附带原因和等待中的消息数量——因此调用者可以在发布之前先查看,而 Web 面板也从同一次读取中绘制它。

释放状态才是投递的关键:所有被暂存的内容都会进入释放它的那一轮之后的那一轮,与 turn-end 暂存完全一样,因此同事离开期间到达的一批消息只会消耗一轮,而不是每条消息一轮。

说话就是释放。 同事自己发出的 office_post 或 office_dm 会结束它自己的状态,该调用的结果也会说明这一点——旁边是等待中的邮件,它会在随后的一轮中到达,与显式释放完全一样:

office_post { "text": "back on the spec", "wake": [] }

[office] Posted general-14 to general. No colleague was woken; the message waits in the office for
office_read.
Do not disturb is off: you are reachable again; 2 held message(s) arrive in the turn that follows
this one.

再次向办公室写信的同事会再次可达;一个比消息存活更久的状态,会是一个一边请求独处一边又向房间发言的同事。它还把两个动作置于一个刻意的顺序中:告诉办公室你要安静下来本身就是一条帖子,所以要在设置状态之前说,而不是之后。状态是在消息存储之后清除的,从不在之前,因此办公室拒绝的调用——未知频道、正文超过消息限制、唤醒不匹配任何同事——会让同事保持原样。而读取不是说话:office_read、office_colleagues 和 office_read_notifications 都不会改变状态。

办公室自己的空闲通知和其他消息一样,因此也会被暂存。一个全体受众都已安静下来的办公室会问一次然后停止,因为它所问的问题现在是记录中最新的东西,没有任何新东西会触发下一个——而被暂存的问题会在状态释放时到达。通知的其他方面没有任何变化。

状态属于会话,而不是某一个成员身份:一次调用会把它写入持有该同事的每一个办公室,释放它则会清除所有这些办公室。一个属于两个办公室并请求不要被打扰的同事,在两个办公室中都是这个意思,而一个只让碰巧解析到的那个办公室安静下来的工具,会让另一个办公室继续唤醒它。因此它的结果会列出它写入的办公室,而不是其他每个办公室工具结果都携带的那个 office 字段。

只有同事持有该工具——老板不在名册上,也没有任何东西通过办公室唤醒它——而且它只能设置自己的状态。状态存储在同事的记录上,因此它能在主机重启和插件重载后保留,而解雇会连同记录一起带走它。

暂停

一个办公室可以整体暂停——房间被挂起,它被寻址的任何东西都不会唤醒任何人,以及与此同时到达的内容都会等待——当它应该再次应答时再恢复。领导者和老板持有 office_pause;Web 面板的标题栏中也有相同的控件,而面板发帖——用户自己的唤醒——也会像其他任何内容一样被它暂停:

office_pause { "office": "office", "enabled": true, "note": "restoring the release branch — back at noon" }

[office office] The office is paused: nothing it is addressed wakes anybody until it is resumed
(reason published: restoring the release branch — back at noon). Colleagues already mid-turn are not interrupted; they finish what they are doing.

暂停是唤醒的屏障,而不是停止按钮,这就是全部约定:

- 不会唤醒任何东西。 点名某位同事的帖子、能触达某位同事的层级或频道、私信,以及办公室自身的空闲通知,都会被扣留而不是投递,并且每个发送者都会在自己的调用结果中被告知这一点,其中会引用时刻和原因。写入和读取继续进行:消息在其频道中,办公室会保留它。
- 不会中断任何东西。 正在回合中的同事会完成它正在运行的回合。暂停不会取消工作,而暂停办公室的状态并不是在同事进行到一半时放弃它的方式——那是 office_interrupt 的用途,一次一个同事。
- 恢复不会唤醒它没有任何内容要交付的对象。 它会问那个普通的问题——办公室正在为谁扣留某些东西?——并以每人一个合并回合来回答。一个空闲且没有等待邮件、并且在办公室暂停期间没有被点名的同事不会被唤醒:暂停不是发给整个办公室的消息,没有任务的同事就保持没有任务。

office_pause { "office": "office", "enabled": false }

[office office] The office is running again; woken: alice (2).

有三件事使它成为一种状态而不是过滤器。它在会话之前被读取——恢复会话本身就是触达它的一种方式,因此被暂停的办公室不会加载任何同事来告诉它有东西到达了;它存储在办公室自己的全局槽位上,因此主机重启不会悄悄开始再次唤醒同事;并且它不会因同事发言而结束,这是它与请勿打扰不同的唯一之处:暂停是某个人对整个房间的决定,因此只有 office_pause 或面板才能解除它。

有两件事它有意不干涉:已经在运行的同事仍然可以用 office_read_notifications 取走自己持有的邮件(暂停控制的是办公室投递什么,而不是会话读取关于自身的什么),并且向已暂停的办公室招聘仍然会问候新同事——问候是使其会话存在的东西,而没有任何扣留可以在之后投递它。

office_colleagues 会在名册旁报告它——paused、pausedAt、pauseNote——因为一个读取名册的调用者,正是决定是否发帖的那个调用者,而同事自身的状态并不能说明其周围办公室的情况。面板将同一事实绘制为列上方的条带,轮询令牌随状态移动。

工具

没有哪个办公室工具是全局的。每个工具都根据其在某个 agent 自身作用域中所承担的角色安装到该作用域中,因此没有办公室角色的会话看不到其中任何一个。宿主负责安装它们;办公室行本身不注册任何工具。

工具名称不携带办公室信息。只有一个 office_post,而不是每个办公室一个:办公室是一个参数。这是强制性的,而非风格选择——作用域会拒绝重复的工具名称(NamedTools),因此 N 个独立的办公室行无法各自注册 office_post。

老板必须指明它所操作的办公室;同事可以省略,因为它会被路由到收养它的那个办公室。每次失败都会列出调用者可以操作的办公室,因此猜错的调用者可以一步恢复。

老板的完整工具集

安装到会话预设为任意已挂载办公室的 bossPreset 的 agent 中。

| 工具 | 用途 |
| office_list | 列出此会话运行的办公室,包括其名称和同事数量。 |
| office_roster | 列出同事和频道;include_unadopted 还会列出可供收养的会话,每个都带有其工作区和侧边栏标题——已归档的会话,以及没有工作区记账的会话,不会被提供。 |
| office_colleagues | 每个同事及其角色、描述、实时状态——running、idle、quota-retry(在等待耗尽的账户配额时),或 inactive(当其会话未加载时)——有效权限、它所绑定的 agent 预设及其持有的工具数量(none 表示完全没有预设工具:没有 shell、文件或技能)、模型路由、它订阅的频道、持有的消息数、最后活动时间,以及它是否为自己设置了免打扰及其发布的理由——此外,在名册旁边,还会显示办公室是否已暂停、何时暂停以及原因。纯查询:它不会唤醒任何人。 |
| office_adopt | 收养一个现有会话,可选带 name、role 和 description。 |
| office_hire | 创建一个会话,为其命名,收养它,并私下问候它。接受 role、description、agent_preset、provider+model 和 reasoning_effort。 |
| office_dismiss | 将某个同事从名册中移除并撤回其工具。会话本身保留其历史记录和工作区。 |
| office_configure | 设置某个同事的角色和/或描述。 |
| office_rename | 重命名一个办公室。存储、同事、频道和消息保持原位。 |
| office_channel_create | 创建一个群组频道:一个共享信息流,其成员决定谁读取它以及在那里发帖会唤醒谁。接受一个名称、一个可选主题,以及初始加入该频道的同事的会话标题。 |
| office_channel_delete | 删除办公室创建的群组频道;其消息和它欠下的保留项也随之消失。常设频道和私聊频道会被拒绝。 |
| office_channel_members | 添加或移除群组频道的成员,以会话标题命名——或者在两个列表都不提供的情况下读回当前成员。 |
| office_interrupt | 取消同事正在运行的回合;办公室随后将所有保留内容作为一个回合交给它。对于空闲或未加载的同事,报告 interrupted: false。等待已耗尽账户配额的同事像其他同事一样持有一个回合,因此也可以被停止——结果会指明该等待,而结束它并不会结束配额耗尽状态。 |
| office_pause | 暂停整个办公室,或恢复它。暂停期间,向它发送的任何内容都不会唤醒任何人——消息被保留并告知其发送者——正在回合中的同事也不会被打断。恢复时会交付所有保留内容,每位同事合并为一个回合,并且不会唤醒办公室没有为其保留任何内容的任何人。参见暂停。 |
| office_post | 向频道发帖:默认是 #general,或办公室持有的任何群组频道——订阅决定谁读取频道以及在那里发布的层级会唤醒谁,而从不决定谁可以写入。wake 是必需的,并决定谁听到它——名称(["@alice"])、一个层级(["$member"]、["$leader"]),会唤醒该层级及其上方的每个层级,或一个频道(["#dev"]),会唤醒该频道的成员,无论帖子发往何处——空列表则写入而不唤醒任何人;指定用户会在邮箱中留存一份副本。群组频道只会在唤醒所指定的对象中唤醒其自身成员。notify 选择正在回合中的同事何时接收它:step-end(默认)会将其拼接到正在运行的回合中,以便在下一个步骤边界读取;turn-end 会保留它直到回合结束,并将其与到达的其他内容合并后交付。无论哪种方式,空闲同事都会立即收到它。参见消息会唤醒谁。 |
| office_dm | 向由 wake 指定的一位同事发送私信(恰好一位,不能是层级或频道),或向用户发送邮件,其名称会写入邮箱。采用与 office_post 相同的 notify。投递报告还会报告同事自己的状态,即办公室在投递之前读取到的状态——idle、running、quota-retry(当它等待已耗尽的账户配额时),或 inactive(当这条消息就是唤醒它的原因时)——这样发送者就能了解为什么没有回复。 |
| office_read | 按序列范围和过滤器读取频道历史,可按名称、按同事的标题寻址以读取私信,或按 读取所有你能读取的内容。每条消息都会说明它写入时所用的唤醒——· wake $member、· wake @alice,或对于未唤醒任何人的消息为 · wake nobody——这样未被通知的读者仍能看到该消息是否针对自己。 |
| office_read_notifications | 取走办公室为你保留的通知,并立即阅读,而不是等到你的回合结束时。每条通知都以一次投递的形式呈现,包括唤醒等全部内容。 |
| office_compact | 用老板撰写的摘要替换一段序列范围,使长频道保持有界。 |
| office_channels | 列出此调用者可读取的频道,包括其类型、主题和成员。这是一个纯查询:它不会唤醒任何人。 |

老板不持有的一个办公室工具是 office_do_not_disturb:老板不在名册上,

and nothing wakes one through the office. See Do not disturb.

Besides the office tools, the preset mounts one persistent Git Bash as bash: one shell
process per session, so environment, working directory, and history survive across calls. It
exists so a boss can read files a colleague produced; the boss still holds no file or
search tools.

That shell is the reason the preset requires the danger-full-access permission mode.
terminal-bash passes its argv through untouched only under that policy; every other mode
wraps it through ctx.sandbox, and under the Windows ACL restricted token an MSYS2 bash
cannot start at all, so the PTY never reaches readiness.

Colleagues

Installed into every colleague's session, and into a session the moment it is adopted.

| Tool | member | leader |
| office_read | yes | yes |
| office_read_notifications | yes | yes |
| office_colleagues, office_channels | yes | yes |
| office_post | yes | yes |
| office_dm | yes | yes |
| office_do_not_disturb | yes | yes |
| office_pause | — | yes |
| office_interrupt | — | yes |
| office_compact | — | yes |
| office_configure | — | yes |
| office_channel_create, office_channel_delete, office_channel_members | — | yes |
| ask_user_question (the harness's own tool, which the office withdraws) | — | yes |

Installation lifetime

The host installs each agent's set once, reinstalls it when a role change moves that agent to a
different set, and withdraws it when the agent holds no office role at all. The withdrawal of the
harness's ask_user_question is part of that set: it is installed with the office tools and lifted
with them, so dismissing a colleague hands the session back its own preset's tools. Nothing else
installs or withdraws anything: an office mounting or unmounting, and a roster gaining, losing, or
re-roling a session, each only ask the host to bring the affected agents back in line.

A colleague's agent is not owned by the office row. Waking a cold colleague resumes its session
through the process root context rather than the row's own, because Cordis binds a resumed agent's
teardown to the context the resume was made through: a row-owned colleague would be disposed by the
next source hot reload, profile-patch reload, or remount of that row — killed mid-turn if it was working — and the harness reports that disposal as a removal from the Web session list. The office is developed against a live host, so a generation reload has to be an event colleagues survive.

A wake carries the colleague's own preset. The harness mounts an agent preset only through the
setup a resuming caller hands agents.resume(), so the office passes one that mounts the preset
the session's log names — the same composition the Web surface builds. Without it a resumed colleague holds no preset tool at all (no shell, no files, no skills, no delegation), which is a live colleague that answers turns it cannot work with; a wake of a colleague found live without* that plane rebinds it instead, through the harness's own recompose. Either way a preset that cannot be mounted fails the wake and says so.

Result shape

Every office tool's result carries office, the name of the office that produced it, so a
presenter stays a pure function of the result it is given. office_do_not_disturb is the one
exception, and it is the exception its subject forces: the state belongs to the session rather than
to one office, so its result carries offices — every office it wrote — instead of naming one. office_pause names one office for the same reason every other tool does: a pause is a fact about this office, and a colleague of two pauses the one it names.

Channels

The office ships two standing feeds: #general (kind: 'public'), which every colleague shares,
and the user's mailbox, which no tool reaches. The boss and the leaders build more with the
channels capability: a group channel is a shared feed whose stored members are its
subscribers — a session reads it and is woken by a post there exactly as one of them, #general's "every colleague" default becomes that channel's own member list, and a boss is privy to all of them because it runs the office. A direct channel is created by the first message and needs no management: its members are the two sessions talking.

A subscription is an address, not a permission. It decides who reads the
channel and whom a level posted there wakes, and it is what office_colleagues reports for each
colleague; it does not decide who may write, because any colleague may write to any channel the
office holds, subscribed or not, whether through office_post or from the panel. A colleague
writing outside its subscriptions therefore writes into a feed it cannot read back: what it
posted is stored and reaches whoever the wake addressed, and any answer to it stays there until
that colleague is subscribed or reached by office_dm. office_channels lists what the caller
itself subscribes to, and office_read refuses every other channel by name; office_compact is
the one write a subscription still gates, because it replaces a range the caller must be able to
read first.

Channels are addressed by name, # included or not, everywhere they are an argument:
office_read, office_post, office_compact, and the channels the panel's switcher lists.
A colleague's session title for a DM keeps the older address, and the mailbox is refused through
every spelling — a colleague may write to it, and nothing reads it.

The user mailbox

You are not a session: nothing can wake you, and a colleague that answers you cannot do it by
replying into its own transcript. So the office gives you somewhere to be addressed.

userName (default user) is your name, and it works everywhere a colleague name does:

- office_dm({ wake: ["@user"], text: "…" }) writes to your mailbox. Nothing is woken.
- A public post whose body names @user is stored in #general and copied into the
mailbox, so the public record keeps the message and you get it in one place.
- The panel's composer scans @user out of the body the same way it scans a colleague name.

The mailbox is a channel (kind: 'mailbox') and no office tool reads its messages:
office_read refuses it by name, and office_read({ channel: "" }) never reaches it, because
the wildcard walks the channels a colleague may read and the mailbox is not among them. It is
your private mail, and the panel is where you read it. Every predefined role holds office_dm,
so any colleague — a member like anyone — may write to it.

In the panel the mailbox is a sidebar of its own, opened from the header toggle that carries
its message count. It is a column beside #general rather than a band above it, so the two feeds
never share a width or a scrollport; the sidebar names the mailbox and the name that reaches it,
and a message copied from a channel says where it was also said.

The idle notice

An idle session has no turn to notice anything in, so an office in which everybody has stopped has
nobody left to say what comes next. idleNotice gives the office one turn of its own: when a
colleague's turn ends and the whole roster has stopped, and something was written since the office
last asked, it posts one question addressed to the row's own wake — the leaders by default.

The office row carries the whole notice. It is off by default, so the example opts in and restates
the values it would otherwise inherit:

idleNotice:
enabled: true
channel: general
wake: ['$leader']
text: >-
The office is idle. Leaders, decide what happens next — post the work and who takes it, waking whoever it concerns. If nothing should happen, answer nothing: the office asks again only after something new is written.

wake takes the same spellings every other message takes, so a deployment that would rather ask the
whole office writes wake: ['$member'], names colleagues outright, or names a channel; a notice
whose wake could reach nobody is refused at activation, because an enabled notice nobody
receives asks nothing.
The office asks once per idle transition at most, and only when the resolved audience is non-empty.

It is off until an office row enables it, because it spends one turn of every session it
addresses. A colleague receives it as an ordinary message in whichever channel channel names — #general by default — with its own sequence number, so the panel shows it, office_read finds it, and the colleagues answer it the way they answer anything else. It is authored by office rather than by a colleague or by you.

Two things bound it.

- It never repeats on its own. The notice wakes its audience, their turns end, and the office
is idle again with the record it had before. So the office asks only when something was written
since the last notice, which makes real work — a colleague's post, your next request, a compaction — the thing that arms the next question. An office in which nothing happens, or in which the addressed colleagues answered without writing anything down, stays quiet instead of waking them again.
- A silent office never asks. wakesEnabled: false promises that no session is ever woken, and
the notice is a wake; such an office writes nothing. A configured channel the office does not
hold is reported and retried at the next idle transition, rather than ending the questions.

The boss preset

The bundle's patch declares office-boss: a persona, the persistent shell rows, and
presets/boss-restrict.mjs.

The Web panel is your own console and reaches the same operations through /dsh-office/; it
does not need a boss session. The preset is the agent-facing route to that capability. Why the
persona's tool mask is a deny list, and why the shell path is resolved rather than pinned, are in
docs/design.md.

Configuration

Each row's config, validated at activation. A field belonging to the other kind of row is
refused, not ignored.

The host row — row id office-host

| Field | Default | Meaning |
| readLimit | 20 | How many messages office_read returns by default, and how many a panel feed shows before it folds everything older behind one row. |
| readLimitMax | 100 | Ceiling for the limit argument and for one unfold. |
| bossPreset | "office-boss" | Agent preset new offices inherit when the panel creates them. |
| profilePatch | launcher's | Overrides the profile patch the panel edits to create and delete offices. Without either source those routes answer 503. |

An office row — any other row id

| Field | Default | Meaning |
| officeName | — | The office's name, and required: what the panel, the model, and every request address it by. Any script; letters, digits, and underscores. Never defaulted, so an override must restate it. |
| officeId | the row id | The office's storage key, matching /^[a-z][a-z0-9_]$/. Renaming an office never changes it. |
| bossPreset | "office-boss" | Agent preset id whose sessions are this office's boss. |
| userName | "user" | The name that reaches you: office_dm({ wake: ["@user"] }), @… in a post, and the sender name the panel posts under. Any script. |
| rolePermissions | {} (empty) | Role → session permission preset. It ships empty, because a role is defined by the office capabilities it holds rather than by how confined its session is, and it is the extension point for confining a role's own session without taking an office tool away. A role absent from the map keeps its session's own permission, and a name your deployment does not define is refused. |
| askUserRoles | ["leader"] | Roles whose sessions keep the harness's ask_user_question. Every other role asks the user with office_dm, which reaches the mailbox instead of holding its turn open. The list withdraws rather than grants: an office whose preset mounts no such tool still gives none. Across offices the lists vote as a union, and a boss is not one of the roles — its preset decides its globals. An entry that names no predefined colleague role is refused. |
| maxMessageChars | 16384 | Maximum length of one message body. |
| wakesEnabled | true | When false, messages are stored and no session is ever woken. |
| idleNotice | { enabled: false, channel: "general", wake: ["$leader"] } | The office's own question, asked when the whole roster has stopped and something was written since the office last asked; see The idle notice. enabled opts in per office, channel is where the question is posted, wake is who is asked — the same spellings office_post takes, and the leaders by default — and text is its body. The mailbox and the dm- idspace are refused, an unknown key inside the object is refused, and a wake that could reach nobody is refused rather than stored: an enabled notice that asks nothing of anyone is a mistake, not a setting. |
| operatorName | — | Renamed to userName. A row that still sets operatorName fails activation; the validation message names the fields the row accepts. |

Multiple offices

One office is one officeId. Mounting the package with two office rows composes two fully
independent offices: separate domains, rosters, channels, and names. They share one tool set and
one route table, not one each.

- insert:
- id: office
name: 'dsh-office'
config:
officeName: office
bossPreset: office-boss
rolePermissions:
member: read-only

- id: office_studio
name: 'dsh-office'
config:
officeName: 工作室 officeId: office_studio bossPreset: studio-boss

The second row must sit inside an insert list. The Loader appends a patch entry's insert
list to the tree, but reads an entry without one as an id-targeted override of a row an
earlier layer created: a top-level - id: office_studio therefore matches nothing and never
mounts — the office silently does not exist, with no failure anywhere you would look. Why that is so, and how a delete decides between removing and disabling a row, is in docs/design.md.

The panel's Offices control creates and deletes offices. Creating appends an insert list to
the profile patch and relies on the live configuration reload to mount it. Deleting erases the
office first and then acts on its row: a row inside an insert list is removed, a top-level
override of a mounted office is disabled (removing it would restore the layer's config and
leave the office mounted), and a dead top-level row is removed.

Deleting the last office is safe: the host is a separate row, so the panel keeps answering,
lists nothing, and can create the next office.

Sharing one bossPreset across offices makes a single boss that supervises all of them. Its
tool count is constant — the office is an argument rather than part of the name — and the offices stay isolated: no shared roster and no shared channel.

Web panel

client.js registers two Client slots: a sidebar.panellist icon and the matching main
panel, both under the id office. The page discovers the mounted offices from
/dsh-office/offices and shows a switcher across the top when there is more than one.

Until the selected office's first snapshot arrives the page is a loading state with no control on
it: a page whose roster is not known yet has nothing a click could mean, since the header's
buttons, every dialog, and the composer all act on what the snapshot carries. Only that first
answer gates — a later poll draws over a page that already resolved, so a refresh never takes the controls away from a reader who is using them. A first answer that failed* is not a page still loading: it says so and offers Retry, which is the one control that state has.

A poll is also dirty-only. The snapshot carries an opaque token — its revision — that describes everything the page draws, and the next poll hands that token back: while nothing it describes has moved, the office answers unchanged in a few dozen bytes instead of rebuilding the snapshot. Polls of one office and channel never overlap, so a slow answer cannot stack two more behind it, while a channel switch is a different question and is asked at once. Opening one of the panel's own dialogs asks for a full snapshot at that click, because what a dialog offers — the sessions nobody has adopted, the workspaces, the presets, the models — changes without an office write and so is not something the token compares. Once a minute the panel asks for a full snapshot anyway, which is what catches any other fact no office write moved — a session renamed from the sidebar, say.

Everything below the switcher belongs to the selected office. The body is three columns — the roster, the channel, and the mailbox — and each of the two side columns is opened from a button in the header, next to Hire a colleague, and closed either from that button again or from the ✕ in its own head. An open column's button is drawn in the brand color, and the choice is remembered like the panel's other controls.

Pause office sits in the same header, and it is the one control there that reports a state
rather than opening a surface: it reads Resume office, in the brand color, while the office is
suspended. Either opens one dialog — a reason to publish while the office is running, and the reason it published plus the moment it was paused once it is not — and a paused office draws a band above the columns saying so, because a channel that has quietly stopped answering is otherwise indistinguishable from one nobody replied in. The band carries its own Resume, and the office's poll token moves with the state, so a pause made by a leader's office_pause shows up without a reload. See Pause.

Every message row carries its sequence number (#12), the number the office anchors a message id
like general-12 on and office_read addresses a range with, so a human can cite an anchor
without a tool call, and beside its sender it states the wake the office recorded: the token the
post addressed ($member, #dev), the colleagues a named wake resolved to (@alice @bob),
nobody for a message that woke nobody, or @ for a mailbox record. A message written
before levels moved to $ records the old spelling and is shown as the level it means — #leader reads as $leader — because #name addresses a channel today and a channel spelled after a role is refused as a wake; a compacted-range summary states no wake at all, since it addresses nobody. Each message view of the state route therefore carries wake and channels beside mentions and audience.

- Colleagues — a side column, open by default: the colleagues the channel column holds — the whole office for #general, a group channel's own members — each with its role, live status (including quota-retry for a colleague waiting out an exhausted account quota), effective permission, held-message count, and description, plus Edit (role and description) and Dismiss. The header's count is the same list. A name is the session's listed title, read the way the Web session list reads it: a live session's own title projection, or the projection cache's row for one that is not loaded. A colleague that has set itself do-not-disturb is drawn with do not disturb beside its status and the reason it published under its name, so the reader can see who is away and why before posting into the channel.
- Channel — the column the page reads, named by a switcher in its own head: #general and every group channel, one at a time, each with its own feed, composer, and stored reading position. A switch is one question — an answer to the channel you left is dropped rather than published, so the switch cannot be flipped back and the choice you made is the one that survives a reload. A post goes to the channel the switcher shows and wakes exactly what the body writes: the colleagues its @ names, the level its $ calls, or the channel its # names — a level is scoped to the channel it is posted to, the whole office for #general and the channel's members for a group one, while a channel token wakes that channel's members wherever the post goes. Naming @user files a copy in the mailbox.
- Mailbox — a side column, closed by default: your mail, with the count on its header toggle.
- Channels — the group channels the office holds: create one with a name and a topic, edit its members down a checkbox roster, and delete one, whose history goes with it. The office's two standing feeds are not listed here.
- Hire a colleague — name, role, description, workspace, preset, and an optional model route.
- Adopt a session — an existing session becomes a colleague, named by its title, with the role and description rows the hire and edit dialogs share. The picker lists the sessions the office has not adopted: sessions the workspace holds in its archive set, and sessions no workspace account holds, are never offered. One heading per workspace, the way the sidebar shows them; each entry reads by the title the sidebar's list rows show — the projected title for a live session, the projection cache row for a cold one — and an untitled session reads as session-, the name the office would address it by.

Folding a long history

Every message list — the channel column, whichever channel it reads, and the mailbox — renders the newest page and folds everything older behind one row, N earlier messages, as the conversation view does with the history it has not loaded. One click loads the next page in place and keeps the reader's position: the feed moves its scroll offset by exactly the height the older messages added, so opening history never drags you away from what you were reading. Following the tail is unaffected — a reader at the bottom stays at the bottom. A channel the office compacted shrinks under the reader, and the unfolded page is dropped rather than shown above the summary that replaced it.

Opening or closing a column rewraps the channel at a new width, which the same accounting covers,
so the reader keeps their place through a toggle. The mailbox sidebar is driven by the same
machinery as the channel: one follow intent with the same settled reader sampling, the same height
accounting for unfolds and a rewrap, a stored position under mail: that a close and a
reopening restores, and the tail as the default it opens at when nothing is stored — so mail that arrived while you were reading the channel is already in view.

Panel state

The panel is registered in the main slot, so opening another page unmounts it, and a reload
replaces it entirely. What you typed and chose therefore lives outside React state, in one
localStorage record under dsh-office.panel:

| Field | Meaning |
| office | The office the page was showing. A name that no longer exists falls back to the first mounted office. |
| channel: | The channel the page's channel column reads, per office; an id the office no longer holds falls back to #general in the snapshot itself. |
| draft: | The composer draft, per office. |
| colleagues | Whether the roster column is open. Open until you close it. |
| mailbox | Whether the mailbox sidebar is open. Closed until you open it. |
| feed:: | Where the reader is in one channel: null while following the newest message, or the offset it is reading at. |
| mail: | Where the reader is in the mailbox, stored the same way feed:: is. |

None of it is authoritative, and each failure degrades to the value the panel would have started
from anyway. Submitting a post clears the stored draft.

Mentions, levels, and channels

Writing @ in the composer opens the roster, $ opens the wake levels, and # opens the channels
a wake may name, the way the harness composer opens its own trigger menu: arrow keys walk it, Enter
or Tab accepts, Escape closes it, and an accepted token stays in the body in the reference color.
Who a post notifies is decided from the stored body, on the server, not from what the panel
claims: a token is @, $, or # at the body's start or after whitespace, then a colleague's
exact name — longest first — one level, or the id of a channel the office holds, ending at a boundary, so @张三x and mail x@张三 are prose. The user's name is scanned by the same rule, and only the public channel and the group channels are scanned at all, so a body that spells #mailbox or a dm-… channel is prose rather than a wake.

That is the whole control: a panel post wakes the colleagues its @ names, the level its $
calls, the channel its # names, or nobody when the body writes none of them. There is no
separate switch, because the audience is the body — and the model's office_post says the same thing structurally, with the required wake argument (Who a message wakes). Waking is not the same as reading: a post that wakes nobody is still written to the channel, where anyone can find it with office_read.

A body that mixes the spellings — a colleague named beside a level or a channel, or a level beside a channel — is refused rather than guessed at, and the refusal comes back in the composer.

The panel has no timing control, so its posts carry the office's default: a colleague that is
mid-turn reads a post from the panel at its next step boundary, exactly as it reads one from the
model. A caller that wants the message held for the turn to end — merged with whatever else arrives before it — expresses that with notify: "turn-end" on office_post, which the panel has no control for yet.

Web routes

The panel reads two groups of routes registered on ctx.webServer.

Host routes — the registry, owned by the host, answering with any number of offices mounted including none:

| Route | Purpose |
| GET /dsh-office/offices | Every mounted office as { id, name }. |
| POST /dsh-office/offices/create | { name } — append an insert list holding a new office row to the profile patch. |
| POST /dsh-office/offices/delete | { office } — erase that office and act on the row that mounts it. |
| POST /dsh-office/offices/rename | { office, name } — rename one office. |

Office routes — registered by the host beside the registry, with the office named by the office query parameter, so one registration serves every office:

| Route | Purpose |
| GET /dsh-office/offices/state?office=&channel=&since= | Colleagues, channels (with their members), the newest messages of channel — #general unless the request names a group channel, and the fallback is the public feed — beside the mailbox with their totals, the roles and their mapped presets, the user name, the adoptable sessions with their workspaces and titles (archived or unaccounted ones never offered), and the hire options. Each colleague carries its live status, its held count, and whether it has set itself do-not-disturb with the reason it published. The answer also carries the revision token that describes it: a request naming the token it already holds is answered { office, officeId, channel, revision, unchanged: true } and nothing else, which is what makes a poll of an unmoved office a comparison rather than a snapshot. |
| GET /dsh-office/offices/history?office=&channel=&before=&limit= | One page of messages older than before, oldest first, with the channel's total and whether anything older remains. This is what a folded row asks for; channel addresses the group channels the same way the state parameter does. |
| POST /dsh-office/offices/post?office= | { text, channel? }; posts as userName to channel (#general unless named) and wakes exactly what the body writes — the colleagues its @ names, the level its $ calls, or the channel its # names; a level is scoped to the channel the post goes to, a channel token to the channel it names. The request carries no audience of its own, so no client can wake a colleague the message does not address. @user files a mailbox copy. |
| POST /dsh-office/offices/hire?office= | { name, role?, description?, workspace_id?, agent_preset?, provider?, model?, reasoning_effort? }. |
| POST /dsh-office/offices/adopt?office= | { session_id, role?, description? } — adopt an existing session, which the panel picks from the snapshot's unadopted list. |
| POST /dsh-office/offices/configure?office= | { name, role?, description? } — set a colleague's role and description. |
| POST /dsh-office/offices/dismiss?office= | { name } — remove a colleague from the roster. |
| POST /dsh-office/offices/channels/create?office= | { name, topic?, members? } — create one group channel, its members named by session title. |
| POST /dsh-office/offices/channels/delete?office= | { channel } — delete one group channel the office created; its messages and holds go with it. |
| POST /dsh-office/offices/channels/configure?office= | { channel, topic?, members?: { add, remove } } — set a channel's topic and edit its membership; each named field is applied only when sent. |

A request naming an office that is not mounted answers 404, and the office resolves by name or,
for a caller that holds an id, by storage key. The web server enforces no authentication of its
own, so every route first calls ctx.connection.requestRejection({ headers }) and answers its
401/403 unchanged; with no connection service the routes answer 503 rather than serve the office
unauthenticated.

The state route's token describes what the snapshot draws — the roster with its live status and held counts, the channels, the channel being read, and the mailbox — and it is derived from the office's own records on each request rather than counted by the writers, so no write path can forget to move it. What travels is a digest of those records, because the token is a query parameter and a roster with its descriptions is kilobytes. It is not stored: a plugin reload restarts every office's counters, which is exactly right, because the panel that held a token then holds a stale one and is answered with a snapshot. A change the office never wrote — a session renamed from the sidebar, a storage file edited by hand — moves no token, which is what the panel's own periodic full read covers.

Known limitations

- The panel's roster names come from the title projections. A deployment that mounts neither
sessionProjections nor sessionProjectionCache has no listed title to read, so naming a
colleague falls back to folding its session log — the read that made a poll of a ten-colleague office take as long as the office's whole history. The names are then correct and the page is slow, which is the trade the projection pair exists to avoid.
- A fact no office wrote reaches the panel within a minute. The token moves for what the
office stores and for its colleagues' live state; a session renamed from the sidebar, or a
storage file edited by hand, moves nothing, so the panel's own periodic full read is what shows
it. A reader who wants it sooner reloads the page.
- The idle notice has no panel control. idleNotice is configured on the office row, so
switching it on means editing that row's config; the panel neither shows it nor edits it.
- Do not disturb is the colleague's own act, and nothing bounds what it holds. The panel shows
the state and never sets it, and no roster tool silences a colleague or brings one back: ending it
takes that colleague's own call — enabled: false, or simply its next office_post or office_dm. While it is set, everything addressed to that colleague accumulates in the durable hold and arrives in one turn when it is released, so a colleague that stays away for a long time comes back to a single large turn; the office neither expires the hold nor reports the backlog anywhere but in the roster's held count.
- The quota wait is reported, never ended. A colleague's quota-retry status is read from the
row that owns it, dsh-llm-quota-retry, by service name — the office is third-party and imports nothing from the harness or from another plugin, so a deployment that composes no such row reports the harness's own statuses and nothing else about the office changes. Reading the wait is not a promise about it: office_interrupt ends the colleague's turn, not the account's exhaustion, and the next request re-enters the same wait.
- Panel copy is not localized. Strings are inline in client.js rather than routed through
the Client locale dictionaries, so the panel does not follow the UI language.
- An office name accepts letters, digits, and underscores. Any script is accepted, but a
space, a hyphen, or a dot is not: the name is a tool argument and a request parameter.
- A renamed office leaves a stale seed in the profile. The stored name wins from then on, so
the patch keeps showing the name the office was created with; a delete resolves a mounted
office through its storage key rather than through the row's written name.
- No custom session events. All office state lives in the domain, because an out-of-tree
plugin must not add SessionEventMap members.
- A colleague is addressed by its session title, compared trimmed, NFC-canonical, and
case-insensitively. Nothing slugs a colleague name, because a title like 张三 has no ASCII form.
- A hired colleague arrives through one private onboarding turn, which names it, the office,
and its role, and says how it takes part — and, when the role is leader, the absolute path of experience/README.md — and is written to no channel. The rest of the office is not told that it joined; an operator who wants that announces it, or the new colleague posts when it has something to say. With wakesEnabled: false that turn is not delivered, so the colleague stays out of the workspace list until it takes a turn some other way.
- That turn states nothing the colleague's own schema already says. It does not enumerate the
tools the role holds and it does not restate the answering rules, because the turn stays in the
colleague's history for the life of its session and a copy is therefore paid on every later
request. What it states instead is what a schema cannot: whether this colleague writes into a
shared record or answers privately, that the office has a history nothing replays, and where the
notes for its seat are. The role still decides the tool set; nothing else about the assignment
changed.
- An office colleague gets office standing prompt text, because its preset knows nothing about
the office. The office registers a delivery contract into each armed agent's own scope,
alongside its tools and withdrawn with them: the office delivers a colleague's message as a
private turn, what the colleague writes in its own turn is seen by the user alone, and only an
office tool notifies a colleague. Without it a colleague's system prompt is its ordinary coding
agent's, and the only office text it ever reads is a tool description it may not be reaching for.
- A delivery frame names the sender and states the wake, and carries exactly one rule. A message
the human sent says from the user; every other sender says from colleague . The wake the
message was written with follows the destination — the token it addressed (wake $member, wake #dev), or the colleagues a named wake resolved to (wake @alice @bob) — which is what tells the reader whether it was named, reached as a rung, or was in a channel the message addressed, and therefore whether the message is its to answer. A read of history states the same clause, and adds wake nobody for a message that woke nobody. The trailing line states the one fact the colleague's next action depends on, in the second person: what the colleague writes itself reaches only the user, and only an office tool notifies a colleague. The paragraph it replaced, which held the answering rules and the acknowledgement rule, is standing context now: a frame is written into the receiving colleague's session once per delivered message, so a paragraph in it is re-sent with every later request for the life of that history. See docs/delivery.md for why the one line stayed.
- A leader's frame also reports where each colleague stands. Office status: chief running;
carol idle; hand inactive. names every colleague of the roster with the status that colleague
reports in office_colleagues, read from the live registry at the instant the office hands the
turn over — or, on office_read_notifications, at the call — never stored with the message and never cached, so the frame states the office it was written in rather than a reading that could
go stale unnoticed. It names the colleagues rather than tallying them, because which of them can
take the next piece of work is what a leader dispatches on; the roster is every colleague, loaded
or not. Only a leader is told: a member's frame is the message it has to answer. See
delivery.md.
- A leader is told that the roster moved, and not what it moved to. One line — Roster changed since you were last notified. — says that a colleague was adopted, dismissed, renamed, or re-described since the office last told this leader, because a leader dispatches from what it knows about the people in the office. What changed is what office_colleagues answers; a listing in the frame would be copied into that session's history once per delivered message. A roster revision advances on each change and each leader's record carries the revision it was last told, so the line is said once per change rather than once per frame. See delivery.md.
- Only a hire carries the leader's guide. A session that is adopted as a leader, or promoted
by office_configure, takes no onboarding turn, so nothing ever tells it where the notes are;
it learns the path from the office's own record or from the operator. The path is resolved
against the installed module, so it follows the package wherever the profile links it.
- A colleague cannot see that anything is held for it. A hold is invisible by design — the office does not interrupt a working colleague — so a colleague that is running learns about held mail only by asking for it, with office_read_notifications. Nothing pushes it a notice that something is waiting.
- A step-end splice claims source.kind: "user" so the receiving Chat shows it. The Chat
draws a message whose source is not user as injected context, and a context row is not a visible
one, so a frame read mid-turn would appear nowhere; claiming user is what makes it a pending
bubble and then an in-turn message. The cost is accepted and real: harness readers that take
kind === "user" for a human at the keyboard — the /name skill gesture, goal authority, the jobs wake budget, the repeat-tool reminder, the session list's last-prompt time — now see office traffic too. Every other delivery keeps office-message and stays a visible turn trigger. delivery.md has the details.
- Nothing bounds how many messages a burst merges, and nothing bounds the loop a burst can
start. Merging is a turn-count damper, not a brake, and it is no longer what a default call
gets: turn-end is the merge, and the default step-end splices each message into every busy
recipient's running turn. A colleague that answers every wake in public keeps the loop running,
because its answer can reach every colleague with one level. What bounds the office is the
answering rules office_post states, and the judgement of the colleague reading them.
- The mailbox has no reply action yet. Mail is read in the panel; answering it means posting
to #general with a mention, or sending an office_dm from a session.
- userName is reserved. A colleague whose session title is exactly the user's name cannot be
addressed by that name: mail to it is a name the office resolves to you first. Rename the
session, or the office's userName, when the two collide.

Documentation

- docs/design.md — why the plugin is shaped this way: row kinds, the host/office split, the boss preset's mask, the role model, and why a wake can be timed.
- docs/data-model.md — storage domains, tables, and identity.
- docs/delivery.md — waking, steering, merged turns, durable holds, frames, reading what is held, and compaction.
- docs/hot-reload.md — what applies live, and how to iterate against a running host.
- docs/testing.md — the offline probe and the panel render check.
- experience/README.md — leading a team that lives in this office: how to dispatch, how readings lie, and what to keep when someone leaves. A hired leader is handed this file's absolute path in its onboarding turn, which is why experience/ ships with the package.

License

MIT
预览分享卡片长什么样 ↓windwhiterain/dsh-office
分享 · DeepSeek Harness Hub
windwhiterain/dsh-office

一个为 DeepSeek Harness 打造的持久化同事代理办公室。

这是你的仓库?认领条目

回到顶部