opencode-superagent 0.2.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 会话并跳转过去。
@@ -156,7 +164,7 @@ Router 的 system prompt 里只有短摘要(Inline):worker 数量、pendin
156
164
  - 去重:`dedupeKey` 缺省为 `sha1(source + threadID + 归一化 objective)`,建单前在同 thread 找 `open/in_progress` 的同键单,命中则返回原单并标 `reused=true`。
157
165
  - authorization:自动取 Router 最近一次被投递的 trigger(`triggerID` + 原文摘要 ≤300 字),所以每张单都能追溯「用户当时怎么说的」。
158
166
  - 存储:`ticket:<id>` 存全量记录;`ticketidx:status:<state>`、`ticketidx:thread:<threadID>`、`ticketidx:all` 是按 createdAt 倒序的派生索引(各截断 200 项)。hydrate 扫 `ticket:` 前缀重建内存与索引,索引丢失不会丢单;历史 Worker 不回填工单。
159
- - 强制:`dispatch` 的 `ticketID` 必填(工具与 RPC 同一实现)。缺失、单据不存在、单据已关闭(非 `open/in_progress`)、`threadID` 与单据不符、或单据已绑定运行中的 Worker,一律拒绝并给出可操作提示;RPC 直达同样被拦。
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,都会给出可操作提示。
160
168
  - 联动:派发成功即原子绑定——写入 `workerSessionID`、`attempts` 追加一条、`state → in_progress`;绑定失败会回滚新建的 Worker 会话(不会出现「Worker 起来了但单没绑上」)。Worker prompt 头部附「工单 + objective + acceptance」;Worker `done/failed` 由 watcher 结单,`abort_session` 关闭为 `failed`;watchdog 只告警,不改状态也不结单。
161
169
 
162
170
  ## 侧栏工单面板
@@ -264,6 +272,7 @@ context.ui.slot({ append: "sidebar.content", render: () => <SidebarTickets conte
264
272
  . # 仓库根即本包
265
273
  ├── tui.tsx # TUI 入口:keymap、slash 命令、RPC 调用、侧栏工单面板
266
274
  ├── src/protocol.ts # 服务端 RPC 契约(由服务端实现提供)
275
+ ├── src/version.ts # 从 package.json 读插件版本(带 fallback),生成启动/status 的版本提示
267
276
  ├── src/ticket-view.ts # 侧栏面板的纯视图模型:解析 tickets 文本、排序、按 cell 截断
268
277
  ├── src/agents.ts # Router/Worker agent 的程序化注册(插件 setup 调用)
269
278
  ├── src/ # 服务端 Router/Worker/批处理实现
@@ -278,13 +287,15 @@ context.ui.slot({ append: "sidebar.content", render: () => <SidebarTickets conte
278
287
  └── README.md
279
288
  ```
280
289
 
281
- 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 实际调用六个方法:
282
291
 
283
292
  - `router({}) -> { sessionID }`:创建或返回 Router 会话。
284
- - `status({}) -> { router?, pending, workers }`:查看 Router 与各 Worker 的 thread 归属,演示命令用它等待 `T-1` 的 Worker 就绪。
293
+ - `status({}) -> { version?, router?, pending, workers }`:`version` 是当前网关加载的插件版本(启动日志里也有),Router 尚未创建时不带 `router` 字段;演示命令用它等待 `T-1` 的 Worker 就绪。
285
294
  - `trigger({ source, threadID, author?, text, title?, createdAt?, id? }) -> { route, sessionID? }`:注入一条事件,`route` 为 `router` / `worker` / `dropped`。
286
295
  - `abort({ sessionID?, reason?, all?, stale? }) -> { text }`:终止/清理 Worker,被拒绝时抛 `abort.rejected`(带可读 message)。TUI 的 `/abort`(或命令面板 “Superagent abort worker”)会列出 Worker 供选择,首项是“清理全部已结束 worker”。
287
- - `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` 由宿主提供,是唯一被信任的身份来源。
288
299
 
289
300
  ## 已知限制与下一步
290
301
 
@@ -334,4 +345,4 @@ npm pack --dry-run # 预览 tarball(prepack/prepare 会各跑一次 build
334
345
  - **只告警不自动杀**:停滞后向 Router 注入一条 delivery `queue` 的提示(`Worker <sid>(<title>)已停滞 <N> 分钟,建议:stop 后重派或人工介入`)。不修改 Worker 的 `state`/`lastReport`,不结单,也不 interrupt;同一繁忙窗口只告警一次,避免误杀长任务和重复刷屏。
335
346
  - **单轮错误隔离**:单个 Worker 检查失败只 `logWarning` 并继续下一个;整轮失败也只告警,循环永不退出。
336
347
  - **pending 队列崩溃恢复**:入队时同步写 KV(键前缀 `pending:`,纯 JSON),flush 成功后删除该键;setup 时从 KV 读回未 flush 的 trigger 重新入内存队列(不自动建 Router,等下一次 `router` RPC 或新 trigger 时再 flush)。同一 id 重复入队会被去重。
337
- - **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,28 +1,28 @@
1
1
  import { Plugin } from "@opencode/plugin/effect";
2
2
  import type { RpcHandlers } from "@opencode/plugin/effect/rpc";
3
- import { Schema } from "effect";
4
3
  /**
5
- * The RPC surface this server registers. `stop` gains its rejection error here
6
- * rather than in the shared TUI contract: only the server refuses router-only
7
- * calls, and the TUI never calls `stop`.
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
8
  */
9
9
  export declare const ServerDefinition: {
10
10
  readonly id: "opencode.superagent";
11
- readonly events: {};
12
11
  readonly methods: {
13
- readonly router: {
14
- input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
12
+ router: {
13
+ input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
15
14
  output: import("effect/StandardSchema").StandardSchemaV1<{
16
15
  readonly sessionID: string;
17
16
  }, {
18
17
  readonly sessionID: string;
19
- }> & Schema.Struct<{
20
- readonly sessionID: Schema.String;
18
+ }> & import("effect/Schema").Struct<{
19
+ readonly sessionID: import("effect/Schema").String;
21
20
  }>;
22
21
  };
23
- readonly status: {
24
- input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
22
+ status: {
23
+ input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
25
24
  output: import("effect/StandardSchema").StandardSchemaV1<{
25
+ readonly version?: string | undefined;
26
26
  readonly router?: string | undefined;
27
27
  readonly pending: number;
28
28
  readonly workers: readonly {
@@ -32,6 +32,7 @@ export declare const ServerDefinition: {
32
32
  readonly state: "aborted" | "done" | "failed" | "idle" | "running";
33
33
  }[];
34
34
  }, {
35
+ readonly version?: string | undefined;
35
36
  readonly router?: string | undefined;
36
37
  readonly pending: number;
37
38
  readonly workers: readonly {
@@ -40,18 +41,19 @@ export declare const ServerDefinition: {
40
41
  readonly threads: readonly string[];
41
42
  readonly state: "aborted" | "done" | "failed" | "idle" | "running";
42
43
  }[];
43
- }> & Schema.Struct<{
44
- readonly router: Schema.optionalKey<Schema.String>;
45
- readonly pending: Schema.Number;
46
- readonly workers: Schema.$Array<Schema.Struct<{
47
- readonly sessionID: Schema.String;
48
- readonly title: Schema.optionalKey<Schema.String>;
49
- readonly threads: Schema.$Array<Schema.String>;
50
- readonly state: Schema.Literals<readonly ["running", "idle", "failed", "done", "aborted"]>;
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"]>;
51
53
  }>>;
52
54
  }>;
53
55
  };
54
- readonly tickets: {
56
+ tickets: {
55
57
  input: import("effect/StandardSchema").StandardSchemaV1<{
56
58
  readonly threadID?: string | undefined;
57
59
  readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
@@ -60,45 +62,45 @@ export declare const ServerDefinition: {
60
62
  readonly threadID?: string | undefined;
61
63
  readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
62
64
  readonly limit?: number | undefined;
63
- }> & Schema.Struct<{
64
- readonly threadID: Schema.optionalKey<Schema.String>;
65
- readonly status: Schema.optionalKey<Schema.Literals<readonly ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"]>>;
66
- readonly limit: Schema.optionalKey<Schema.Number>;
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>;
67
69
  }>;
68
70
  output: import("effect/StandardSchema").StandardSchemaV1<{
69
71
  readonly text: string;
70
72
  }, {
71
73
  readonly text: string;
72
- }> & Schema.Struct<{
73
- readonly text: Schema.String;
74
+ }> & import("effect/Schema").Struct<{
75
+ readonly text: import("effect/Schema").String;
74
76
  }>;
75
77
  errors: {
76
78
  "tickets.invalid_status": import("effect/StandardSchema").StandardSchemaV1<{
77
79
  readonly status: string;
78
80
  }, {
79
81
  readonly status: string;
80
- }> & Schema.Struct<{
81
- readonly status: Schema.String;
82
+ }> & import("effect/Schema").Struct<{
83
+ readonly status: import("effect/Schema").String;
82
84
  }>;
83
85
  };
84
86
  };
85
- readonly ticket: {
87
+ ticket: {
86
88
  input: import("effect/StandardSchema").StandardSchemaV1<{
87
89
  readonly ticketID: string;
88
90
  }, {
89
91
  readonly ticketID: string;
90
- }> & Schema.Struct<{
91
- readonly ticketID: Schema.String;
92
+ }> & import("effect/Schema").Struct<{
93
+ readonly ticketID: import("effect/Schema").String;
92
94
  }>;
93
95
  output: import("effect/StandardSchema").StandardSchemaV1<{
94
96
  readonly text: string;
95
97
  }, {
96
98
  readonly text: string;
97
- }> & Schema.Struct<{
98
- readonly text: Schema.String;
99
+ }> & import("effect/Schema").Struct<{
100
+ readonly text: import("effect/Schema").String;
99
101
  }>;
100
102
  };
101
- readonly dispatch: {
103
+ dispatch: {
102
104
  input: import("effect/StandardSchema").StandardSchemaV1<{
103
105
  readonly task: string;
104
106
  readonly title?: string | undefined;
@@ -113,26 +115,26 @@ export declare const ServerDefinition: {
113
115
  readonly agent?: string | undefined;
114
116
  readonly directory?: string | undefined;
115
117
  readonly ticketID: string;
116
- }> & Schema.Struct<{
117
- readonly task: Schema.String;
118
- readonly title: Schema.optionalKey<Schema.String>;
119
- readonly threadID: Schema.optionalKey<Schema.String>;
120
- readonly agent: Schema.optionalKey<Schema.String>;
121
- readonly directory: Schema.optionalKey<Schema.String>;
122
- readonly ticketID: Schema.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;
123
125
  }>;
124
126
  output: import("effect/StandardSchema").StandardSchemaV1<{
125
127
  readonly sessionID: string;
126
128
  }, {
127
129
  readonly sessionID: string;
128
- }> & Schema.Struct<{
129
- readonly sessionID: Schema.String;
130
+ }> & import("effect/Schema").Struct<{
131
+ readonly sessionID: import("effect/Schema").String;
130
132
  }>;
131
133
  errors: {
132
- "dispatch.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
134
+ "dispatch.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
133
135
  };
134
136
  };
135
- readonly trigger: {
137
+ trigger: {
136
138
  input: import("effect/StandardSchema").StandardSchemaV1<{
137
139
  readonly id?: string | undefined;
138
140
  readonly source: string;
@@ -149,14 +151,14 @@ export declare const ServerDefinition: {
149
151
  readonly text: string;
150
152
  readonly title?: string | undefined;
151
153
  readonly createdAt?: number | undefined;
152
- }> & Schema.Struct<{
153
- readonly id: Schema.optionalKey<Schema.String>;
154
- readonly source: Schema.String;
155
- readonly threadID: Schema.String;
156
- readonly author: Schema.optionalKey<Schema.String>;
157
- readonly text: Schema.String;
158
- readonly title: Schema.optionalKey<Schema.String>;
159
- readonly createdAt: Schema.optionalKey<Schema.Number>;
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>;
160
162
  }>;
161
163
  output: import("effect/StandardSchema").StandardSchemaV1<{
162
164
  readonly route: "dropped" | "router" | "worker";
@@ -164,12 +166,31 @@ export declare const ServerDefinition: {
164
166
  }, {
165
167
  readonly route: "dropped" | "router" | "worker";
166
168
  readonly sessionID?: string | undefined;
167
- }> & Schema.Struct<{
168
- readonly route: Schema.Literals<readonly ["router", "worker", "dropped"]>;
169
- readonly sessionID: Schema.optionalKey<Schema.String>;
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>;
170
172
  }>;
171
173
  };
172
- readonly abort: {
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: {
173
194
  input: import("effect/StandardSchema").StandardSchemaV1<{
174
195
  readonly sessionID?: string | undefined;
175
196
  readonly reason?: string | undefined;
@@ -180,43 +201,25 @@ export declare const ServerDefinition: {
180
201
  readonly reason?: string | undefined;
181
202
  readonly all?: boolean | undefined;
182
203
  readonly stale?: boolean | undefined;
183
- }> & Schema.Struct<{
184
- readonly sessionID: Schema.optionalKey<Schema.String>;
185
- readonly reason: Schema.optionalKey<Schema.String>;
186
- readonly all: Schema.optionalKey<Schema.Boolean>;
187
- readonly stale: Schema.optionalKey<Schema.Boolean>;
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>;
188
209
  }>;
189
210
  output: import("effect/StandardSchema").StandardSchemaV1<{
190
211
  readonly text: string;
191
212
  }, {
192
213
  readonly text: string;
193
- }> & Schema.Struct<{
194
- readonly text: Schema.String;
214
+ }> & import("effect/Schema").Struct<{
215
+ readonly text: import("effect/Schema").String;
195
216
  }>;
196
217
  errors: {
197
- "abort.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
198
- };
199
- };
200
- readonly stop: {
201
- readonly input: import("effect/StandardSchema").StandardSchemaV1<{
202
- readonly sessionID: string;
203
- }, {
204
- readonly sessionID: string;
205
- }> & Schema.Struct<{
206
- readonly sessionID: Schema.String;
207
- }>;
208
- readonly output: import("effect/StandardSchema").StandardSchemaV1<{
209
- readonly interrupted: boolean;
210
- }, {
211
- readonly interrupted: boolean;
212
- }> & Schema.Struct<{
213
- readonly interrupted: Schema.Boolean;
214
- }>;
215
- readonly errors: {
216
- readonly "stop.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
218
+ "abort.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & import("effect/Schema").Struct<{}>;
217
219
  };
218
220
  };
219
221
  };
222
+ readonly events: {};
220
223
  };
221
224
  type ServerRpcHandlers = RpcHandlers<typeof ServerDefinition>;
222
225
  /**
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Plugin, Rpc } from "@opencode/plugin/effect";
2
- import { Effect, Schema } from "effect";
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";
@@ -16,6 +16,7 @@ import { hydrateTickets, TICKET_STATES } from "./src/ticket.js";
16
16
  import { registerTicketTools, renderTicketText, renderTicketsText } from "./src/ticket-tools.js";
17
17
  import { registerWatcher } from "./src/watcher.js";
18
18
  import { registerWatchdog } from "./src/watchdog.js";
19
+ import { pluginVersion, versionNotice } from "./src/version.js";
19
20
  const runTrigger = Effect.fn("superagent.runTrigger")(function* (ctx, input) {
20
21
  const sources = ctx.options.sources;
21
22
  if (Array.isArray(sources) && !sources.includes(input.source))
@@ -55,20 +56,12 @@ const runTrigger = Effect.fn("superagent.runTrigger")(function* (ctx, input) {
55
56
  return { route: "router", sessionID: routerID };
56
57
  });
57
58
  /**
58
- * The RPC surface this server registers. `stop` gains its rejection error here
59
- * rather than in the shared TUI contract: only the server refuses router-only
60
- * calls, and the TUI never calls `stop`.
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.
61
63
  */
62
- export const ServerDefinition = {
63
- ...Definition,
64
- methods: {
65
- ...Definition.methods,
66
- stop: {
67
- ...Definition.methods.stop,
68
- errors: { "stop.rejected": Schema.toStandardSchemaV1(Schema.Struct({})) },
69
- },
70
- },
71
- };
64
+ export const ServerDefinition = Definition;
72
65
  /**
73
66
  * Router-only RPC methods are rejected unconditionally.
74
67
  *
@@ -89,6 +82,9 @@ export const rejectedRouterRpc = {
89
82
  export default Plugin.define({
90
83
  id: "opencode.superagent",
91
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());
92
88
  // Tickets first: hydrating workers reconciles finished `running` records
93
89
  // and closes their tickets, so the ticket store must already be loaded.
94
90
  yield* hydrateTickets(ctx);
@@ -111,6 +107,9 @@ export default Plugin.define({
111
107
  .register(ServerDefinition, {
112
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")))))),
113
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(),
114
113
  // Omit the key entirely when no router exists: an explicit
115
114
  // `undefined` value fails output validation.
116
115
  ...(store.routerID === undefined ? {} : { router: store.routerID }),
@@ -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
@@ -8,6 +8,7 @@ import { superagentMetadata, workerTitle } from "./session-meta.js";
8
8
  import { encodeThread, linkTriggerToWorker, saveWorker, store, threadKey, toSessionID, workerKey } from "./state.js";
9
9
  import { activeTicketForWorker, activeTicketSummary, closeTicket, currentTrigger, linkTicketToWorker, requireDispatchTicket, ticketStats, } from "./ticket.js";
10
10
  import { specFor } from "./tools.js";
11
+ import { pluginVersion } from "./version.js";
11
12
  export const DispatchInput = Schema.Struct({
12
13
  task: Schema.String,
13
14
  title: Schema.optionalKey(Schema.String),
@@ -45,10 +46,16 @@ function workerPrompt(threadID, title, task, ticket) {
45
46
  const subject = title === undefined ? "" : `Title: ${title}\n`;
46
47
  return `${ticketHeader(ticket)}Thread: ${threadID}\n${subject}Task:\n${task}\n\nEnd with a short report: what changed, how it was verified, remaining risks.`;
47
48
  }
48
- function statusText() {
49
+ /**
50
+ * Compact status text for the `status` tool and its diagnostics readers.
51
+ * Starts with the plugin version so a stale gateway (code upgraded but not
52
+ * restarted) is visible from the first line instead of guessed from behavior.
53
+ */
54
+ export function statusText() {
49
55
  const stats = ticketStats();
50
56
  const active = activeTicketSummary(undefined, 5);
51
57
  const lines = [
58
+ `superagent ${pluginVersion()} — restart the gateway after upgrading (no hot reload)`,
52
59
  `router: ${store.routerID ?? "none"} pending: ${store.pending.length} workers: ${store.workers.size} tickets: open=${stats.open} in_progress=${stats.inProgress} blocked=${stats.blocked} closed=${stats.closed}`,
53
60
  ];
54
61
  if (active !== "")
@@ -1,11 +1,13 @@
1
1
  import { Schema } from "effect";
2
2
  /**
3
- * Server-side RPC contract for the Super-agent P0 prototype.
3
+ * The single RPC contract shared by the server and the TUI.
4
4
  *
5
5
  * The TUI (`tui.tsx`) calls these methods through `client.rpc(Definition)`,
6
- * so the id, method names and field names are fixed. Schemas are wrapped
7
- * with `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is
8
- * what makes the same definition usable from both the Effect server
6
+ * and `server.ts` registers the very same object (`ServerDefinition =
7
+ * Definition`), so method names, input/output schemas and declared errors
8
+ * cannot drift between the two halves. Schemas are wrapped with
9
+ * `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is what
10
+ * makes the same definition usable from both the Effect server
9
11
  * (`ctx.rpc.register`) and the Promise TUI client.
10
12
  */
11
13
  export declare const Definition: {
@@ -24,6 +26,7 @@ export declare const Definition: {
24
26
  status: {
25
27
  input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
26
28
  output: import("effect/StandardSchema").StandardSchemaV1<{
29
+ readonly version?: string | undefined;
27
30
  readonly router?: string | undefined;
28
31
  readonly pending: number;
29
32
  readonly workers: readonly {
@@ -33,6 +36,7 @@ export declare const Definition: {
33
36
  readonly state: "aborted" | "done" | "failed" | "idle" | "running";
34
37
  }[];
35
38
  }, {
39
+ readonly version?: string | undefined;
36
40
  readonly router?: string | undefined;
37
41
  readonly pending: number;
38
42
  readonly workers: readonly {
@@ -42,6 +46,7 @@ export declare const Definition: {
42
46
  readonly state: "aborted" | "done" | "failed" | "idle" | "running";
43
47
  }[];
44
48
  }> & Schema.Struct<{
49
+ readonly version: Schema.optionalKey<Schema.String>;
45
50
  readonly router: Schema.optionalKey<Schema.String>;
46
51
  readonly pending: Schema.Number;
47
52
  readonly workers: Schema.$Array<Schema.Struct<{
@@ -185,6 +190,9 @@ export declare const Definition: {
185
190
  }> & Schema.Struct<{
186
191
  readonly interrupted: Schema.Boolean;
187
192
  }>;
193
+ errors: {
194
+ "stop.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
195
+ };
188
196
  };
189
197
  abort: {
190
198
  input: import("effect/StandardSchema").StandardSchemaV1<{
@@ -5,12 +5,14 @@ import { Schema } from "effect";
5
5
  // `src/ticket.ts`, so reusing it here keeps the RPC enum from drifting.
6
6
  import { TICKET_STATES } from "./ticket-view.js";
7
7
  /**
8
- * Server-side RPC contract for the Super-agent P0 prototype.
8
+ * The single RPC contract shared by the server and the TUI.
9
9
  *
10
10
  * The TUI (`tui.tsx`) calls these methods through `client.rpc(Definition)`,
11
- * so the id, method names and field names are fixed. Schemas are wrapped
12
- * with `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is
13
- * what makes the same definition usable from both the Effect server
11
+ * and `server.ts` registers the very same object (`ServerDefinition =
12
+ * Definition`), so method names, input/output schemas and declared errors
13
+ * cannot drift between the two halves. Schemas are wrapped with
14
+ * `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is what
15
+ * makes the same definition usable from both the Effect server
14
16
  * (`ctx.rpc.register`) and the Promise TUI client.
15
17
  */
16
18
  export const Definition = Rpc.define({
@@ -23,6 +25,9 @@ export const Definition = Rpc.define({
23
25
  status: {
24
26
  input: Schema.toStandardSchemaV1(Schema.Struct({})),
25
27
  output: Schema.toStandardSchemaV1(Schema.Struct({
28
+ // Optional so an older server that predates the version notice still
29
+ // satisfies the contract; the current server always sends it.
30
+ version: Schema.optionalKey(Schema.String),
26
31
  router: Schema.optionalKey(Schema.String),
27
32
  pending: Schema.Number,
28
33
  workers: Schema.Array(Schema.Struct({
@@ -55,7 +60,9 @@ export const Definition = Rpc.define({
55
60
  threadID: Schema.optionalKey(Schema.String),
56
61
  agent: Schema.optionalKey(Schema.String),
57
62
  directory: Schema.optionalKey(Schema.String),
58
- // Required: the RPC path enforces the same work-order gate as the tool.
63
+ // Required by the shape, but the RPC handler never runs it: RPC
64
+ // cannot prove router identity, so `dispatch` fails closed with
65
+ // `dispatch.rejected` and the router uses its tool instead.
59
66
  ticketID: Schema.String,
60
67
  })),
61
68
  output: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
@@ -79,6 +86,10 @@ export const Definition = Rpc.define({
79
86
  stop: {
80
87
  input: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
81
88
  output: Schema.toStandardSchemaV1(Schema.Struct({ interrupted: Schema.Boolean })),
89
+ // Declared in the shared contract, not only on the server: the TUI and
90
+ // the server register the same `Definition`, so a client can tell that
91
+ // `stop` is router-only without importing server internals.
92
+ errors: { "stop.rejected": Schema.toStandardSchemaV1(Schema.Struct({})) },
82
93
  },
83
94
  abort: {
84
95
  input: Schema.toStandardSchemaV1(Schema.Struct({
@@ -1,27 +1,8 @@
1
1
  import type { Plugin } from "@opencode/plugin/effect";
2
2
  import type { Session } from "@opencode/schema/session";
3
3
  import { Effect, type Schema } from "effect";
4
- export type WorkerState = "running" | "idle" | "failed" | "done" | "aborted";
5
- export interface WorkerRecord {
6
- readonly sessionID: string;
7
- readonly title?: string;
8
- /**
9
- * Primary thread this worker was dispatched for. Legacy records decode to
10
- * `""` and `hydrate` backfills it from `thread.workers`, so every live record
11
- * carries a forward link to its thread without losing the multi-thread
12
- * `threads` list.
13
- */
14
- threadID: string;
15
- /** Trigger that authorized the dispatch, when the record knows it. */
16
- triggerID?: string;
17
- readonly threads: Array<string>;
18
- state: WorkerState;
19
- readonly createdAt: number;
20
- /** Last persisted write. Refreshed by `saveWorker`, so an aborted record keeps a traceable timestamp. */
21
- updatedAt?: number;
22
- lastReport?: string;
23
- directory?: string;
24
- }
4
+ import { type WorkerRecord } from "./worker-state.js";
5
+ export type { WorkerRecord, WorkerState } from "./worker-state.js";
25
6
  export interface ThreadRecord {
26
7
  readonly threadID: string;
27
8
  readonly workers: Array<string>;
package/dist/src/state.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { DateTime, Effect } from "effect";
2
2
  import { buildReport, lastAssistantText, reportSummary, saveReport } from "./report.js";
3
3
  import { closeTicketsForWorker } from "./ticket.js";
4
+ import { closeOutcomeForWorkerState } from "./ticket-state.js";
5
+ import { WORKER_STATES, workers } from "./worker-state.js";
4
6
  const ROUTES = ["router", "worker"];
5
7
  /** Host context for fire-and-forget KV writes from synchronous interception points. Set during hydrate. */
6
8
  let persistHost;
@@ -106,7 +108,7 @@ export function upsertDirectTrigger(input, sessionID) {
106
108
  export const store = {
107
109
  routerID: undefined,
108
110
  routerBusy: false,
109
- workers: new Map(),
111
+ workers,
110
112
  threads: new Map(),
111
113
  pending: new PendingQueue(),
112
114
  triggers: new Map(),
@@ -130,7 +132,6 @@ export function isRunning(info) {
130
132
  const idle = info.time.idle;
131
133
  return idle === undefined || DateTime.toEpochMillis(busy) > DateTime.toEpochMillis(idle);
132
134
  }
133
- const STATES = ["running", "idle", "failed", "done", "aborted"];
134
135
  function isRecord(value) {
135
136
  return typeof value === "object" && value !== null;
136
137
  }
@@ -154,7 +155,7 @@ export function decodeWorker(value) {
154
155
  return undefined;
155
156
  if (typeof value["sessionID"] !== "string")
156
157
  return undefined;
157
- if (typeof value["state"] !== "string" || !STATES.includes(value["state"]))
158
+ if (typeof value["state"] !== "string" || !WORKER_STATES.includes(value["state"]))
158
159
  return undefined;
159
160
  const threads = stringArray(value["threads"]);
160
161
  if (threads === undefined)
@@ -295,11 +296,12 @@ const reconcileRunningWorker = Effect.fn("superagent.reconcileRunningWorker")(fu
295
296
  record.lastReport = reportSummary(captured);
296
297
  }
297
298
  yield* saveWorker(ctx, record);
298
- if (state === "done" || state === "failed") {
299
+ const outcome = closeOutcomeForWorkerState(state);
300
+ if (outcome !== undefined) {
299
301
  yield* closeTicketsForWorker(ctx, {
300
302
  sessionID: record.sessionID,
301
- outcome: state,
302
- summary: record.lastReport ?? report ?? `${state} (worker ${record.sessionID})`,
303
+ outcome,
304
+ summary: record.lastReport ?? report ?? `${outcome} (worker ${record.sessionID})`,
303
305
  }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to close ticket for rehydrated worker", {
304
306
  sessionID: record.sessionID,
305
307
  cause,