opencode-superagent 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,14 @@ opencode plugin add opencode-superagent
85
85
 
86
86
  4. 重启 TUI,确认插件加载成功。
87
87
 
88
+ ### 升级与重启(不支持热加载)
89
+
90
+ 插件没有热加载:改完 `src/` / `server.ts` 或换掉 npm 包版本后,必须**重启 OpenCode 网关**(`opencode service restart`,本仓库可用 `sh restart-service.sh`)才会加载新代码;只重启 TUI 或重开窗口不会更新后台服务。
91
+
92
+ - 启动日志会打印一行 `superagent <version> loaded; restart the OpenCode gateway after upgrading (no hot reload)`,只含版本号,不含路径、配置、token 或会话信息。
93
+ - `status` 工具输出的首行、以及 `status` RPC 的 `version` 字段,都会给出当前**已加载**的插件版本;升级后这里若还是旧版本,说明网关没重启。
94
+ - 排查顺序:日志里的版本 → `status` 首行 → `opencode service status`。三者一致仍行为异常时,再怀疑代码本身。
95
+
88
96
  ## 使用
89
97
 
90
98
  - 输入 `/superagent`(或在命令面板执行 `Superagent`):通过 RPC 创建/获取 Router 会话并跳转过去。
@@ -130,6 +138,7 @@ Router 的 system prompt 里只有短摘要(Inline):worker 数量、pendin
130
138
  | 工具 | 用途 |
131
139
  | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
132
140
  | `dispatch` | 派活给后台 Worker,可并行多次调用 |
141
+ | `sleep({ seconds? })` | 暂停 Router 指定秒数(默认 30,范围 1..600 的整数),Goal 模式下 dispatch 后给 Worker 执行时间;真实定时器,不忙等 |
133
142
  | `ticket_create({ threadID, title, objective, acceptance, directory, priority?, parentTicketID?, dedupeKey? })` | 建工单:`objective`/`acceptance`(1-10 条)/`directory` 必填,同 thread 同 dedupeKey 自动复用(reused=true) |
134
143
  | `ticket_list({ threadID?, status?, since?, limit? })` | 按 thread/状态/更新时间列工单(默认 20 条,上限 100),含 id/state/title/priority/更新时间/worker |
135
144
  | `ticket_update({ ticketID, status?, note? })` | 按状态机推进工单(open→in_progress→blocked/done/failed/cancelled/superseded),非法迁移报错 |
@@ -140,7 +149,8 @@ Router 的 system prompt 里只有短摘要(Inline):worker 数量、pendin
140
149
  | `report_read({ sessionID, offset?, limit? })` | 按字符区间读取 Worker 结束报告的**完整全文**(默认 4000 字/页,上限 20000),返回总长度与 nextOffset |
141
150
  | `abort_session({ sessionID?, reason?, all?, stale? })` | 终止/清理 Worker:中断、等待终态、标记 `aborted`、解除 thread 亲和(见下) |
142
151
 
143
- - 只有 Router 能调这些工具(`ticket_*`、`thread_detail`/`worker_detail`/`report_read`/`abort_session` 复用 `status` 的权限位,Router 会话已放行;其他会话直调会被拒绝)。
152
+ - 只有 Router 能调这些工具(`dispatch`、`sleep`、`ticket_*`、`thread_detail`/`worker_detail`/`report_read`/`abort_session` 复用 `status` 的权限位,Router 会话已放行;其他会话直调会被拒绝)。
153
+ - `sleep` 是 Goal 模式的节流阀:dispatch 之后暂停 Router(默认 30 秒,1..600 的整数,非法值直接报错),用 `Effect.sleep` 真实定时器等待,期间其他任务照常推进,不会忙等。它是 Router-only 工具:Worker 会话的工具列表里没有它,直调也会被 `guard` 拒绝。
144
154
  - Router 会话的权限位由 `src/tools.ts` 的目录派生(`ROUTER_PERMISSIONS`):新增工具必须在 spec 里声明 `permission`,`Tool.snapshot` 会按该权限位过滤,漏声明会让工具从模型可见列表里消失。
145
155
  - 所有 Spill 输出都是紧凑文本,`thread_detail`/`worker_detail` 单次总长度控制在 6000 字内,超出截断并注明;`report_read` 按页返回(默认 4000 字/页)。
146
156
  - trigger 历史持久化在 KV(`trigger:<id>`,含全文、route、sessionID):排队事件入队即记(全文,内存同步+KV 异步),flush 后补 `route=router`;直投 Worker 的事件由 prompt 钩子补记(文本与 Worker 可见格式一致)。hydrate 只加载最近 200 条(按 createdAt 倒序),`triggerTotal` 记总数。
@@ -154,8 +164,8 @@ Router 的 system prompt 里只有短摘要(Inline):worker 数量、pendin
154
164
  - 去重:`dedupeKey` 缺省为 `sha1(source + threadID + 归一化 objective)`,建单前在同 thread 找 `open/in_progress` 的同键单,命中则返回原单并标 `reused=true`。
155
165
  - authorization:自动取 Router 最近一次被投递的 trigger(`triggerID` + 原文摘要 ≤300 字),所以每张单都能追溯「用户当时怎么说的」。
156
166
  - 存储:`ticket:<id>` 存全量记录;`ticketidx:status:<state>`、`ticketidx:thread:<threadID>`、`ticketidx:all` 是按 createdAt 倒序的派生索引(各截断 200 项)。hydrate 扫 `ticket:` 前缀重建内存与索引,索引丢失不会丢单;历史 Worker 不回填工单。
157
- - 强制:`dispatch` 的 `ticketID` 必填(工具与 RPC 同一实现)。缺失、单据不存在、单据已关闭(非 `open/in_progress`)、`threadID` 与单据不符、或单据已绑定运行中的 Worker,一律拒绝并给出可操作提示;RPC 直达同样被拦。
158
- - 联动:派发成功即原子绑定——写入 `workerSessionID`、`attempts` 追加一条、`state → in_progress`;绑定失败会回滚新建的 Worker 会话(不会出现「Worker 起来了但单没绑上」)。Worker prompt 头部附「工单 + objective + acceptance」;Worker `done/failed` 由 watcher 结单,`abort_session` 与 watchdog 置 `failed`(note=aborted / stalled)。
167
+ - 强制:`dispatch` 的 `ticketID` 必填,工单门禁只作用于 **Router 的工具调用**——工具拿得到宿主的 `context.sessionID`,能证明调用方就是 Router。RPC 的 `dispatch` / `stop` 属于 fail-closed:RPC 上下文里没有调用方身份,伪造 `caller`/`routerID` 也没用,所以两者一律以 `dispatch.rejected` / `stop.rejected` 拒绝(`dispatch` 不会建会话、`stop` 不会中断 Worker),见下面「RPC 接口」。工具路径上的拒绝规则:缺失单据、单据不存在、单据已关闭(非 `open/in_progress`)、`threadID` 与单据不符、或单据已绑定运行中的 Worker,都会给出可操作提示。
168
+ - 联动:派发成功即原子绑定——写入 `workerSessionID`、`attempts` 追加一条、`state → in_progress`;绑定失败会回滚新建的 Worker 会话(不会出现「Worker 起来了但单没绑上」)。Worker prompt 头部附「工单 + objective + acceptance」;Worker `done/failed` 由 watcher 结单,`abort_session` 关闭为 `failed`;watchdog 只告警,不改状态也不结单。
159
169
 
160
170
  ## 侧栏工单面板
161
171
 
@@ -231,8 +241,9 @@ context.ui.slot({ append: "sidebar.content", render: () => <SidebarTickets conte
231
241
  - `stale: true`:只清理 `done`/`failed`/`idle`/`aborted` 的记录,不中断运行中的 Worker。
232
242
  - `all: true`:全部 Worker 都终止,运行中的先中断。
233
243
  - 单个终止的校验顺序:调用者自身 → Router 会话 → 必须是 superagent 记录的 Worker;用户手动会话、未知 sessionID 一律拒绝(不会误删用户会话)。
234
- - 运行中的 Worker:先 `session.interrupt`(优雅中断,`resume: false`,不重放队列),再 `session.wait` 等终态,超时 `ABORT_TIMEOUT_MS = 10s`(超时也会标记 `aborted` 并在结果里注明 `settle-timeout`)。
235
- - 已结束的 Worker(`done`/`failed`/`idle`,或 `time.busy` 不新于 `time.idle`):不发中断信号,只做清理语义,返回 `already-ended`。
244
+ - 所有存在的 Worker:无条件先 `session.interrupt`(未运行视为无害 no-op,但会清掉排队中的 prompt,避免 abort 返回后它才开始)。
245
+ - 运行中的 Worker:再 `session.wait` 等终态,超时 `ABORT_TIMEOUT_MS = 10s`(超时也会标记 `aborted` 并在结果里注明 `settle-timeout`)。
246
+ - 已结束的 Worker(`done`/`failed`/`idle`,或 `time.busy` 不新于 `time.idle`):不等待,返回 `already-ended`;若 interrupt 清掉了排队 prompt,`lastReport` 记 `interrupted queued prompt`。
236
247
  - KV 变更(`worker:<sessionID>`):`state` 置为 `aborted`,`lastReport` 前缀 `aborted (<reason>) [<detail>]`(保留旧 report),`updatedAt` 刷新;**记录保留**,不删除,便于追溯。
237
248
  - thread 亲和:从 `thread:<threadID>` 的 `workers` 列表移除该会话(列表空则删除该键),后续消息不再投给它;该 thread 没有其它可用 Worker 时,下条消息回落 Router 重新 `dispatch`。Worker 记录里的 `threads` 保留用于追溯。
238
249
  - 竞态保护:`state` 在中断**之前**就置为 `aborted`,`watcher.ts` 的 `handleEnded`/`session.idle` 会跳过 `aborted`,避免中断落地时被改回 `idle`/`done` 或重复向 Router 报“已完成”。
@@ -261,6 +272,7 @@ context.ui.slot({ append: "sidebar.content", render: () => <SidebarTickets conte
261
272
  . # 仓库根即本包
262
273
  ├── tui.tsx # TUI 入口:keymap、slash 命令、RPC 调用、侧栏工单面板
263
274
  ├── src/protocol.ts # 服务端 RPC 契约(由服务端实现提供)
275
+ ├── src/version.ts # 从 package.json 读插件版本(带 fallback),生成启动/status 的版本提示
264
276
  ├── src/ticket-view.ts # 侧栏面板的纯视图模型:解析 tickets 文本、排序、按 cell 截断
265
277
  ├── src/agents.ts # Router/Worker agent 的程序化注册(插件 setup 调用)
266
278
  ├── src/ # 服务端 Router/Worker/批处理实现
@@ -275,13 +287,15 @@ context.ui.slot({ append: "sidebar.content", render: () => <SidebarTickets conte
275
287
  └── README.md
276
288
  ```
277
289
 
278
- TUI 侧只依赖 `src/protocol.ts` 导出的 `Definition`(`id: "opencode.superagent"`)与 `src/tools.ts` 的工具目录(`/tools` 直接渲染它,不发 RPC),当前使用六个方法:
290
+ TUI 侧只依赖 `src/protocol.ts` 导出的 `Definition`(`id: "opencode.superagent"`)与 `src/tools.ts` 的工具目录(`/tools` 直接渲染它,不发 RPC)。服务端注册的就是同一个 `Definition` 对象(`server.ts` 的 `ServerDefinition = Definition`),所以方法、入参/出参和声明错误两边不会漂移。TUI 实际调用六个方法:
279
291
 
280
292
  - `router({}) -> { sessionID }`:创建或返回 Router 会话。
281
- - `status({}) -> { router?, pending, workers }`:查看 Router 与各 Worker 的 thread 归属,演示命令用它等待 `T-1` 的 Worker 就绪。
293
+ - `status({}) -> { version?, router?, pending, workers }`:`version` 是当前网关加载的插件版本(启动日志里也有),Router 尚未创建时不带 `router` 字段;演示命令用它等待 `T-1` 的 Worker 就绪。
282
294
  - `trigger({ source, threadID, author?, text, title?, createdAt?, id? }) -> { route, sessionID? }`:注入一条事件,`route` 为 `router` / `worker` / `dropped`。
283
295
  - `abort({ sessionID?, reason?, all?, stale? }) -> { text }`:终止/清理 Worker,被拒绝时抛 `abort.rejected`(带可读 message)。TUI 的 `/abort`(或命令面板 “Superagent abort worker”)会列出 Worker 供选择,首项是“清理全部已结束 worker”。
284
- - `tickets({ threadID?, status?, limit? }) -> { text }` / `ticket({ ticketID }) -> { text }`:渲染工单列表(按状态分组,窄终端友好)与单张工单的详情;对应 `/tickets` 与 `/ticket <id>`。
296
+ - `tickets({ threadID?, status?, limit? }) -> { text }` / `ticket({ ticketID }) -> { text }`:渲染工单列表(按状态分组,窄终端友好)与单张工单的详情;对应 `/tickets` 与 `/ticket <id>`。`status` 是闭合枚举,传非法值(如 `DONE`、拼写错误)会直接被协议校验拒绝,不会退化成“不过滤的完整列表”;服务端另有 `tickets.invalid_status` 作为第二道防线。
297
+
298
+ 契约里还声明了 `dispatch` 与 `stop`:两者只给 Router 的工具使用,**RPC 调用一律 fail-closed**。RPC 传输层不提供调用方 session(`RpcCallContext` 只有 `error`),入参里的 `caller`/`routerID` 可伪造(router id 在 `status` 里公开),因此 `dispatch` 固定返回 `dispatch.rejected`、`stop` 固定返回 `stop.rejected`,既不会建 Worker 也不会中断 Worker。这两个错误与其他方法一样声明在共享 `Definition` 里,server 与 TUI 读同一份定义;真正的 Router 通过工具干活,工具的 `context.sessionID` 由宿主提供,是唯一被信任的身份来源。
285
299
 
286
300
  ## 已知限制与下一步
287
301
 
@@ -328,7 +342,7 @@ npm pack --dry-run # 预览 tarball(prepack/prepare 会各跑一次 build
328
342
 
329
343
  - **检查间隔**:插件 scope 内 `Stream.tick` 每 60 秒跑一次(`src/watchdog.ts` 的 `WATCHDOG_INTERVAL_MS`),可用插件选项 `watchdogIntervalMs`(毫秒)覆盖。
330
344
  - **停滞定义**:`state` 为 `running` 的 Worker,若其会话 `time.busy` 比当前早超过 30 分钟(`STALL_TIMEOUT_MS`,可用 `stallTimeoutMs` 覆盖),且 `time.idle` 没有新于 `busy` 的更新(执行期间 `idle` 只保留上一次执行的结束时间),视为停滞。
331
- - **只告警不自动杀**:停滞后向 Router 注入一条 delivery `queue` 的提示(`Worker <sid>(<title>)已停滞 <N> 分钟,建议:stop 后重派或人工介入`),并把该 Worker 记为 `failed`(`lastReport` 前缀 `watchdog: stalled <N>m`)。不 interrupt,避免误杀长任务。
345
+ - **只告警不自动杀**:停滞后向 Router 注入一条 delivery `queue` 的提示(`Worker <sid>(<title>)已停滞 <N> 分钟,建议:stop 后重派或人工介入`)。不修改 Worker 的 `state`/`lastReport`,不结单,也不 interrupt;同一繁忙窗口只告警一次,避免误杀长任务和重复刷屏。
332
346
  - **单轮错误隔离**:单个 Worker 检查失败只 `logWarning` 并继续下一个;整轮失败也只告警,循环永不退出。
333
347
  - **pending 队列崩溃恢复**:入队时同步写 KV(键前缀 `pending:`,纯 JSON),flush 成功后删除该键;setup 时从 KV 读回未 flush 的 trigger 重新入内存队列(不自动建 Router,等下一次 `router` RPC 或新 trigger 时再 flush)。同一 id 重复入队会被去重。
334
- - **status 无 Router**:Router 尚未创建时 `status` 返回 `{pending, workers}` 且不含 `router` 字段(显式 `undefined` 会导致输出校验失败,故直接省略该键)。
348
+ - **status 无 Router**:Router 尚未创建时 `status` 返回 `{version, pending, workers}` 且不含 `router` 字段(显式 `undefined` 会导致输出校验失败,故直接省略该键)。`version` 始终存在,是当前网关加载的插件版本。
package/dist/server.d.ts CHANGED
@@ -1,3 +1,240 @@
1
1
  import { Plugin } from "@opencode/plugin/effect";
2
+ import type { RpcHandlers } from "@opencode/plugin/effect/rpc";
3
+ /**
4
+ * The RPC surface this server registers. It is the shared `Definition` object
5
+ * itself, not a copy: `stop.rejected` and `dispatch.rejected` live in
6
+ * `src/protocol.ts`, which is also what the TUI imports, so the server and the
7
+ * TUI can never advertise different errors.
8
+ */
9
+ export declare const ServerDefinition: {
10
+ readonly id: "opencode.superagent";
11
+ readonly methods: {
12
+ router: {
13
+ input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
14
+ output: import("effect/StandardSchema").StandardSchemaV1<{
15
+ readonly sessionID: string;
16
+ }, {
17
+ readonly sessionID: string;
18
+ }> & import("effect/Schema").Struct<{
19
+ readonly sessionID: import("effect/Schema").String;
20
+ }>;
21
+ };
22
+ status: {
23
+ input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
24
+ output: import("effect/StandardSchema").StandardSchemaV1<{
25
+ readonly version?: string | undefined;
26
+ readonly router?: string | undefined;
27
+ readonly pending: number;
28
+ readonly workers: readonly {
29
+ readonly sessionID: string;
30
+ readonly title?: string | undefined;
31
+ readonly threads: readonly string[];
32
+ readonly state: "aborted" | "done" | "failed" | "idle" | "running";
33
+ }[];
34
+ }, {
35
+ readonly version?: string | undefined;
36
+ readonly router?: string | undefined;
37
+ readonly pending: number;
38
+ readonly workers: readonly {
39
+ readonly sessionID: string;
40
+ readonly title?: string | undefined;
41
+ readonly threads: readonly string[];
42
+ readonly state: "aborted" | "done" | "failed" | "idle" | "running";
43
+ }[];
44
+ }> & import("effect/Schema").Struct<{
45
+ readonly version: import("effect/Schema").optionalKey<import("effect/Schema").String>;
46
+ readonly router: import("effect/Schema").optionalKey<import("effect/Schema").String>;
47
+ readonly pending: import("effect/Schema").Number;
48
+ readonly workers: import("effect/Schema").$Array<import("effect/Schema").Struct<{
49
+ readonly sessionID: import("effect/Schema").String;
50
+ readonly title: import("effect/Schema").optionalKey<import("effect/Schema").String>;
51
+ readonly threads: import("effect/Schema").$Array<import("effect/Schema").String>;
52
+ readonly state: import("effect/Schema").Literals<readonly ["running", "idle", "failed", "done", "aborted"]>;
53
+ }>>;
54
+ }>;
55
+ };
56
+ tickets: {
57
+ input: import("effect/StandardSchema").StandardSchemaV1<{
58
+ readonly threadID?: string | undefined;
59
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
60
+ readonly limit?: number | undefined;
61
+ }, {
62
+ readonly threadID?: string | undefined;
63
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
64
+ readonly limit?: number | undefined;
65
+ }> & import("effect/Schema").Struct<{
66
+ readonly threadID: import("effect/Schema").optionalKey<import("effect/Schema").String>;
67
+ readonly status: import("effect/Schema").optionalKey<import("effect/Schema").Literals<readonly ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"]>>;
68
+ readonly limit: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
69
+ }>;
70
+ output: import("effect/StandardSchema").StandardSchemaV1<{
71
+ readonly text: string;
72
+ }, {
73
+ readonly text: string;
74
+ }> & import("effect/Schema").Struct<{
75
+ readonly text: import("effect/Schema").String;
76
+ }>;
77
+ errors: {
78
+ "tickets.invalid_status": import("effect/StandardSchema").StandardSchemaV1<{
79
+ readonly status: string;
80
+ }, {
81
+ readonly status: string;
82
+ }> & import("effect/Schema").Struct<{
83
+ readonly status: import("effect/Schema").String;
84
+ }>;
85
+ };
86
+ };
87
+ ticket: {
88
+ input: import("effect/StandardSchema").StandardSchemaV1<{
89
+ readonly ticketID: string;
90
+ }, {
91
+ readonly ticketID: string;
92
+ }> & import("effect/Schema").Struct<{
93
+ readonly ticketID: import("effect/Schema").String;
94
+ }>;
95
+ output: import("effect/StandardSchema").StandardSchemaV1<{
96
+ readonly text: string;
97
+ }, {
98
+ readonly text: string;
99
+ }> & import("effect/Schema").Struct<{
100
+ readonly text: import("effect/Schema").String;
101
+ }>;
102
+ };
103
+ dispatch: {
104
+ input: import("effect/StandardSchema").StandardSchemaV1<{
105
+ readonly task: string;
106
+ readonly title?: string | undefined;
107
+ readonly threadID?: string | undefined;
108
+ readonly agent?: string | undefined;
109
+ readonly directory?: string | undefined;
110
+ readonly ticketID: string;
111
+ }, {
112
+ readonly task: string;
113
+ readonly title?: string | undefined;
114
+ readonly threadID?: string | undefined;
115
+ readonly agent?: string | undefined;
116
+ readonly directory?: string | undefined;
117
+ readonly ticketID: string;
118
+ }> & import("effect/Schema").Struct<{
119
+ readonly task: import("effect/Schema").String;
120
+ readonly title: import("effect/Schema").optionalKey<import("effect/Schema").String>;
121
+ readonly threadID: import("effect/Schema").optionalKey<import("effect/Schema").String>;
122
+ readonly agent: import("effect/Schema").optionalKey<import("effect/Schema").String>;
123
+ readonly directory: import("effect/Schema").optionalKey<import("effect/Schema").String>;
124
+ readonly ticketID: import("effect/Schema").String;
125
+ }>;
126
+ output: import("effect/StandardSchema").StandardSchemaV1<{
127
+ readonly sessionID: string;
128
+ }, {
129
+ readonly sessionID: string;
130
+ }> & import("effect/Schema").Struct<{
131
+ readonly sessionID: import("effect/Schema").String;
132
+ }>;
133
+ errors: {
134
+ "dispatch.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
135
+ };
136
+ };
137
+ trigger: {
138
+ input: import("effect/StandardSchema").StandardSchemaV1<{
139
+ readonly id?: string | undefined;
140
+ readonly source: string;
141
+ readonly threadID: string;
142
+ readonly author?: string | undefined;
143
+ readonly text: string;
144
+ readonly title?: string | undefined;
145
+ readonly createdAt?: number | undefined;
146
+ }, {
147
+ readonly id?: string | undefined;
148
+ readonly source: string;
149
+ readonly threadID: string;
150
+ readonly author?: string | undefined;
151
+ readonly text: string;
152
+ readonly title?: string | undefined;
153
+ readonly createdAt?: number | undefined;
154
+ }> & import("effect/Schema").Struct<{
155
+ readonly id: import("effect/Schema").optionalKey<import("effect/Schema").String>;
156
+ readonly source: import("effect/Schema").String;
157
+ readonly threadID: import("effect/Schema").String;
158
+ readonly author: import("effect/Schema").optionalKey<import("effect/Schema").String>;
159
+ readonly text: import("effect/Schema").String;
160
+ readonly title: import("effect/Schema").optionalKey<import("effect/Schema").String>;
161
+ readonly createdAt: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
162
+ }>;
163
+ output: import("effect/StandardSchema").StandardSchemaV1<{
164
+ readonly route: "dropped" | "router" | "worker";
165
+ readonly sessionID?: string | undefined;
166
+ }, {
167
+ readonly route: "dropped" | "router" | "worker";
168
+ readonly sessionID?: string | undefined;
169
+ }> & import("effect/Schema").Struct<{
170
+ readonly route: import("effect/Schema").Literals<readonly ["router", "worker", "dropped"]>;
171
+ readonly sessionID: import("effect/Schema").optionalKey<import("effect/Schema").String>;
172
+ }>;
173
+ };
174
+ stop: {
175
+ input: import("effect/StandardSchema").StandardSchemaV1<{
176
+ readonly sessionID: string;
177
+ }, {
178
+ readonly sessionID: string;
179
+ }> & import("effect/Schema").Struct<{
180
+ readonly sessionID: import("effect/Schema").String;
181
+ }>;
182
+ output: import("effect/StandardSchema").StandardSchemaV1<{
183
+ readonly interrupted: boolean;
184
+ }, {
185
+ readonly interrupted: boolean;
186
+ }> & import("effect/Schema").Struct<{
187
+ readonly interrupted: import("effect/Schema").Boolean;
188
+ }>;
189
+ errors: {
190
+ "stop.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
191
+ };
192
+ };
193
+ abort: {
194
+ input: import("effect/StandardSchema").StandardSchemaV1<{
195
+ readonly sessionID?: string | undefined;
196
+ readonly reason?: string | undefined;
197
+ readonly all?: boolean | undefined;
198
+ readonly stale?: boolean | undefined;
199
+ }, {
200
+ readonly sessionID?: string | undefined;
201
+ readonly reason?: string | undefined;
202
+ readonly all?: boolean | undefined;
203
+ readonly stale?: boolean | undefined;
204
+ }> & import("effect/Schema").Struct<{
205
+ readonly sessionID: import("effect/Schema").optionalKey<import("effect/Schema").String>;
206
+ readonly reason: import("effect/Schema").optionalKey<import("effect/Schema").String>;
207
+ readonly all: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
208
+ readonly stale: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
209
+ }>;
210
+ output: import("effect/StandardSchema").StandardSchemaV1<{
211
+ readonly text: string;
212
+ }, {
213
+ readonly text: string;
214
+ }> & import("effect/Schema").Struct<{
215
+ readonly text: import("effect/Schema").String;
216
+ }>;
217
+ errors: {
218
+ "abort.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
219
+ };
220
+ };
221
+ };
222
+ readonly events: {};
223
+ };
224
+ type ServerRpcHandlers = RpcHandlers<typeof ServerDefinition>;
225
+ /**
226
+ * Router-only RPC methods are rejected unconditionally.
227
+ *
228
+ * The RPC transport does not expose a caller identity: `RpcCallContext` only
229
+ * carries `error`. The old handler therefore passed `store.routerID` as
230
+ * `runDispatch`'s caller, which let every RPC client act as the router. Input
231
+ * fields such as `caller` / `routerID` are just as forgeable (and the router
232
+ * id is public in `status`), so they are never used as proof of identity.
233
+ * Until the host exposes the calling session on the RPC context, the safe
234
+ * behavior is to keep router-only effects off the RPC surface. The real
235
+ * router still works through its tools, whose `context.sessionID` is
236
+ * authoritative and is checked by `runDispatch` / `runAbort`.
237
+ */
238
+ export declare const rejectedRouterRpc: Pick<ServerRpcHandlers, "dispatch" | "stop">;
2
239
  declare const _default: Plugin.Plugin<import("effect/Scope").Scope>;
3
240
  export default _default;
package/dist/server.js CHANGED
@@ -1,20 +1,22 @@
1
- import { Plugin } from "@opencode/plugin/effect";
1
+ import { Plugin, Rpc } from "@opencode/plugin/effect";
2
2
  import { Effect } from "effect";
3
3
  import { registerAbort, runAbort } from "./src/abort.js";
4
4
  import { registerAgents } from "./src/agents.js";
5
5
  import { registerContext } from "./src/context.js";
6
6
  import { hydrateDirs } from "./src/dirs.js";
7
- import { registerTools, runDispatch } from "./src/dispatch.js";
7
+ import { registerTools } from "./src/dispatch.js";
8
8
  import { formatTrigger } from "./src/format.js";
9
9
  import { Definition } from "./src/protocol.js";
10
10
  import { hydrateReports } from "./src/report.js";
11
11
  import { ensureRouter, enqueuePending, flushPending, hydratePending, registerRouterIdle } from "./src/router.js";
12
12
  import { hydrateSessionMeta } from "./src/session-meta.js";
13
+ import { registerSleep } from "./src/sleep.js";
13
14
  import { hydrate, store, toSessionID } from "./src/state.js";
14
15
  import { hydrateTickets, TICKET_STATES } from "./src/ticket.js";
15
16
  import { registerTicketTools, renderTicketText, renderTicketsText } from "./src/ticket-tools.js";
16
17
  import { registerWatcher } from "./src/watcher.js";
17
18
  import { registerWatchdog } from "./src/watchdog.js";
19
+ import { pluginVersion, versionNotice } from "./src/version.js";
18
20
  const runTrigger = Effect.fn("superagent.runTrigger")(function* (ctx, input) {
19
21
  const sources = ctx.options.sources;
20
22
  if (Array.isArray(sources) && !sources.includes(input.source))
@@ -53,26 +55,48 @@ const runTrigger = Effect.fn("superagent.runTrigger")(function* (ctx, input) {
53
55
  yield* flushPending(ctx);
54
56
  return { route: "router", sessionID: routerID };
55
57
  });
56
- const runStop = Effect.fn("superagent.runStop")(function* (ctx, sessionID) {
57
- if (!store.workers.has(sessionID))
58
- return { interrupted: false };
59
- const result = yield* ctx.session
60
- .interrupt({ sessionID: toSessionID(sessionID) })
61
- .pipe(Effect.catch(() => Effect.succeed({ interrupted: false })));
62
- return { interrupted: result.interrupted };
63
- });
58
+ /**
59
+ * The RPC surface this server registers. It is the shared `Definition` object
60
+ * itself, not a copy: `stop.rejected` and `dispatch.rejected` live in
61
+ * `src/protocol.ts`, which is also what the TUI imports, so the server and the
62
+ * TUI can never advertise different errors.
63
+ */
64
+ export const ServerDefinition = Definition;
65
+ /**
66
+ * Router-only RPC methods are rejected unconditionally.
67
+ *
68
+ * The RPC transport does not expose a caller identity: `RpcCallContext` only
69
+ * carries `error`. The old handler therefore passed `store.routerID` as
70
+ * `runDispatch`'s caller, which let every RPC client act as the router. Input
71
+ * fields such as `caller` / `routerID` are just as forgeable (and the router
72
+ * id is public in `status`), so they are never used as proof of identity.
73
+ * Until the host exposes the calling session on the RPC context, the safe
74
+ * behavior is to keep router-only effects off the RPC surface. The real
75
+ * router still works through its tools, whose `context.sessionID` is
76
+ * authoritative and is checked by `runDispatch` / `runAbort`.
77
+ */
78
+ export const rejectedRouterRpc = {
79
+ dispatch: (_input, context) => Effect.fail(context.error("dispatch.rejected", "dispatch is only available to the router session; RPC callers cannot prove that identity — use the router's dispatch tool", {})),
80
+ stop: (_input, context) => Effect.fail(context.error("stop.rejected", "stop is only available to the router session; RPC callers cannot prove that identity — use abort_session from the router", {})),
81
+ };
64
82
  export default Plugin.define({
65
83
  id: "opencode.superagent",
66
84
  effect: Effect.fn(function* (ctx) {
85
+ // Version only: no paths, options or tokens. Makes a stale gateway obvious
86
+ // in the log right after an upgrade (the plugin has no hot reload).
87
+ yield* Effect.logInfo(versionNotice());
88
+ // Tickets first: hydrating workers reconciles finished `running` records
89
+ // and closes their tickets, so the ticket store must already be loaded.
90
+ yield* hydrateTickets(ctx);
67
91
  yield* hydrate(ctx);
68
92
  // Before any session work: the Router and Worker sessions resolve their agent by id.
69
93
  yield* registerAgents(ctx);
70
94
  yield* hydratePending(ctx);
71
95
  yield* hydrateDirs(ctx);
72
96
  yield* hydrateSessionMeta(ctx);
73
- yield* hydrateTickets(ctx);
74
97
  yield* hydrateReports(ctx);
75
98
  yield* registerTools(ctx);
99
+ yield* registerSleep(ctx);
76
100
  yield* registerTicketTools(ctx);
77
101
  yield* registerAbort(ctx);
78
102
  yield* registerContext(ctx);
@@ -80,9 +104,12 @@ export default Plugin.define({
80
104
  yield* registerWatcher(ctx);
81
105
  yield* registerWatchdog(ctx);
82
106
  yield* ctx.rpc
83
- .register(Definition, {
107
+ .register(ServerDefinition, {
84
108
  router: () => ensureRouter(ctx).pipe(Effect.map((sessionID) => ({ sessionID })), Effect.catch((cause) => Effect.logWarning("superagent router handler failed", { cause }).pipe(Effect.andThen(Effect.die(new Error("superagent router is unavailable")))))),
85
109
  status: () => Effect.sync(() => ({
110
+ // Reported alongside the runtime state so the TUI/diagnostics can
111
+ // show which plugin build the gateway actually loaded.
112
+ version: pluginVersion(),
86
113
  // Omit the key entirely when no router exists: an explicit
87
114
  // `undefined` value fails output validation.
88
115
  ...(store.routerID === undefined ? {} : { router: store.routerID }),
@@ -94,20 +121,25 @@ export default Plugin.define({
94
121
  state: worker.state,
95
122
  })),
96
123
  })),
97
- tickets: (input) => Effect.sync(() => renderTicketsText({
98
- ...(input.threadID === undefined ? {} : { threadID: input.threadID }),
99
- ...(input.status === undefined || !TICKET_STATES.includes(input.status)
100
- ? {}
101
- : { status: input.status }),
102
- ...(input.limit === undefined ? {} : { limit: input.limit }),
103
- })).pipe(Effect.map((text) => ({ text }))),
124
+ tickets: (input, context) => {
125
+ // The protocol literal already rejects unknown states with
126
+ // `rpc.invalid_input`; keep an explicit RPC error as a second line of
127
+ // defence so a bypassed/older client can never turn an unknown status
128
+ // into an unfiltered list.
129
+ if (input.status !== undefined && !TICKET_STATES.includes(input.status)) {
130
+ return Effect.fail(context.error("tickets.invalid_status", `invalid ticket status: ${String(input.status)}`, {
131
+ status: String(input.status),
132
+ }));
133
+ }
134
+ return Effect.sync(() => renderTicketsText({
135
+ ...(input.threadID === undefined ? {} : { threadID: input.threadID }),
136
+ ...(input.status === undefined ? {} : { status: input.status }),
137
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
138
+ })).pipe(Effect.map((text) => ({ text })));
139
+ },
104
140
  ticket: (input) => Effect.sync(() => ({ text: renderTicketText(input.ticketID) })),
105
- // RPC dispatches go through the same implementation as the tool, so
106
- // the ticket gate (required ticketID, workable state, thread match, no
107
- // running worker) cannot be skipped by calling the RPC directly.
108
- dispatch: (input, context) => runDispatch(ctx, input, store.routerID ?? "").pipe(Effect.map((result) => result.output), Effect.catchTag("Tool.Error", (error) => Effect.fail(context.error("dispatch.rejected", error.message, {})))),
141
+ ...rejectedRouterRpc,
109
142
  trigger: (input) => runTrigger(ctx, input).pipe(Effect.catch((cause) => Effect.logWarning("superagent trigger handler failed", { cause }).pipe(Effect.as({ route: "dropped" })))),
110
- stop: (input) => runStop(ctx, input.sessionID).pipe(Effect.catch((cause) => Effect.logWarning("superagent stop handler failed", { cause }).pipe(Effect.as({ interrupted: false })))),
111
143
  abort: (input, context) => runAbort(ctx, input).pipe(Effect.map((result) => ({ text: result.output.text })), Effect.catchTag("Tool.Error", (error) => Effect.fail(context.error("abort.rejected", error.message, {})))),
112
144
  })
113
145
  .pipe(Effect.orDie);
package/dist/src/abort.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Tool } from "@opencode/schema/tool";
2
- import { DateTime, Effect, Option, Schema } from "effect";
2
+ import { Effect, Option, Schema } from "effect";
3
3
  import { requireRouter } from "./guard.js";
4
- import { encodeThread, saveWorker, store, threadKey, toSessionID } from "./state.js";
4
+ import { encodeThread, isRunning, saveWorker, store, threadKey, toSessionID } from "./state.js";
5
5
  import { closeTicketsForWorker } from "./ticket.js";
6
6
  import { specFor, toolPermission } from "./tools.js";
7
7
  /** How long a terminating call waits for an interrupted worker to settle before reporting a timeout. */
@@ -13,14 +13,6 @@ export const AbortInput = Schema.Struct({
13
13
  stale: Schema.optionalKey(Schema.Boolean),
14
14
  });
15
15
  const AbortOutput = Schema.Struct({ text: Schema.String });
16
- /** A Session is running while `time.busy` is newer than the last completed execution's `time.idle`. */
17
- function isRunning(info) {
18
- const busy = info.time.busy;
19
- if (busy === undefined)
20
- return false;
21
- const idle = info.time.idle;
22
- return idle === undefined || DateTime.toEpochMillis(busy) > DateTime.toEpochMillis(idle);
23
- }
24
16
  function abortNote(reason, detail, previous) {
25
17
  const head = `aborted${reason === undefined ? "" : ` (${reason})`} [${detail}]`;
26
18
  return previous === undefined ? head : `${head} | ${previous}`;
@@ -83,12 +75,15 @@ const abortWorker = Effect.fn("superagent.abortWorker")(function* (ctx, sessionI
83
75
  yield* saveWorker(ctx, worker);
84
76
  return { sessionID, status: "missing", interrupted: false, timedOut: false, threads };
85
77
  }
78
+ // Interrupt unconditionally: when the session is not executing this is a
79
+ // harmless no-op, but it still clears a prompt queued behind the previous
80
+ // execution so it cannot start after abort returned.
81
+ const interrupted = yield* ctx.session.interrupt({ sessionID: toSessionID(sessionID) }).pipe(Effect.map((result) => result.interrupted), Effect.catch((cause) => Effect.logWarning("superagent abort interrupt failed", { sessionID, cause }).pipe(Effect.as(false))));
86
82
  if (!running) {
87
- worker.lastReport = abortNote(reason, "already ended", previous);
83
+ worker.lastReport = abortNote(reason, interrupted ? "interrupted queued prompt" : "already ended", previous);
88
84
  yield* saveWorker(ctx, worker);
89
- return { sessionID, status: "already-ended", interrupted: false, timedOut: false, threads };
85
+ return { sessionID, status: "already-ended", interrupted, timedOut: false, threads };
90
86
  }
91
- const interrupted = yield* ctx.session.interrupt({ sessionID: toSessionID(sessionID) }).pipe(Effect.map((result) => result.interrupted), Effect.catch((cause) => Effect.logWarning("superagent abort interrupt failed", { sessionID, cause }).pipe(Effect.as(false))));
92
87
  const settled = yield* ctx.session.wait({ sessionID: toSessionID(sessionID) }).pipe(Effect.timeoutOption(ABORT_TIMEOUT_MS), Effect.catch((cause) => Effect.logWarning("superagent abort wait failed", { sessionID, cause }).pipe(Effect.as(Option.some(undefined)))));
93
88
  const timedOut = Option.isNone(settled);
94
89
  const detail = timedOut ? "interrupted, settle timeout" : interrupted ? "interrupted" : "no active execution";
@@ -9,9 +9,16 @@ import { Effect } from "effect";
9
9
  */
10
10
  export declare function buildRecallInjection(threadID: string | undefined): string;
11
11
  /**
12
- * Router-only context hook: strip every tool outside the router set
13
- * (derived from the tool catalog in `src/tools.ts`),
14
- * then append the router prompt plus a short live status line.
12
+ * Narrow one request's toolset by session role. The Router keeps only the
13
+ * catalog tools; a tracked Worker loses every router-only tool (dispatch,
14
+ * sleep, tickets, recall, abort_session), so those tools are not listed to
15
+ * the worker model at all. User sessions are left untouched.
16
+ */
17
+ export declare function filterSessionTools(sessionID: string, tools: Record<string, unknown>): void;
18
+ /**
19
+ * Context hook: strip each session's toolset to what that role may see
20
+ * (Router: catalog only; Worker: catalog tools removed), then append the
21
+ * router prompt plus a short live status line for the Router.
15
22
  *
16
23
  * The status line carries the ticket counters and the current thread's
17
24
  * in-progress ticket ids/titles, so a follow-up can be linked back to an
@@ -66,9 +66,25 @@ export function buildRecallInjection(threadID) {
66
66
  return clip(lines.map((line) => clip(line, perLine)).join("\n"), RECALL_BUDGET);
67
67
  }
68
68
  /**
69
- * Router-only context hook: strip every tool outside the router set
70
- * (derived from the tool catalog in `src/tools.ts`),
71
- * then append the router prompt plus a short live status line.
69
+ * Narrow one request's toolset by session role. The Router keeps only the
70
+ * catalog tools; a tracked Worker loses every router-only tool (dispatch,
71
+ * sleep, tickets, recall, abort_session), so those tools are not listed to
72
+ * the worker model at all. User sessions are left untouched.
73
+ */
74
+ export function filterSessionTools(sessionID, tools) {
75
+ const router = store.routerID !== undefined && sessionID === store.routerID;
76
+ const worker = store.workers.has(sessionID);
77
+ if (!router && !worker)
78
+ return;
79
+ for (const name of Object.keys(tools)) {
80
+ if (router ? !ROUTER_TOOLS.has(name) : ROUTER_TOOLS.has(name))
81
+ delete tools[name];
82
+ }
83
+ }
84
+ /**
85
+ * Context hook: strip each session's toolset to what that role may see
86
+ * (Router: catalog only; Worker: catalog tools removed), then append the
87
+ * router prompt plus a short live status line for the Router.
72
88
  *
73
89
  * The status line carries the ticket counters and the current thread's
74
90
  * in-progress ticket ids/titles, so a follow-up can be linked back to an
@@ -76,12 +92,12 @@ export function buildRecallInjection(threadID) {
76
92
  */
77
93
  export const registerContext = Effect.fn("superagent.registerContext")(function* (ctx) {
78
94
  yield* ctx.session.hook("context", (event) => Effect.sync(() => {
79
- if (store.routerID === undefined || event.sessionID !== store.routerID)
95
+ const router = store.routerID !== undefined && event.sessionID === store.routerID;
96
+ if (!router && !store.workers.has(event.sessionID))
97
+ return;
98
+ filterSessionTools(event.sessionID, event.tools);
99
+ if (!router)
80
100
  return;
81
- for (const name of Object.keys(event.tools)) {
82
- if (!ROUTER_TOOLS.has(name))
83
- delete event.tools[name];
84
- }
85
101
  const stats = ticketStats();
86
102
  const threadID = currentTrigger()?.threadID;
87
103
  const active = activeTicketSummary(threadID);
@@ -17,6 +17,12 @@ interface DispatchInput {
17
17
  readonly directory?: string;
18
18
  readonly ticketID: string;
19
19
  }
20
+ /**
21
+ * Compact status text for the `status` tool and its diagnostics readers.
22
+ * Starts with the plugin version so a stale gateway (code upgraded but not
23
+ * restarted) is visible from the first line instead of guessed from behavior.
24
+ */
25
+ export declare function statusText(): string;
20
26
  /**
21
27
  * The one dispatch implementation, shared by the `dispatch` tool and the
22
28
  * `dispatch` RPC. Both paths pass the same ticket gate: `ticketID` is required