opencode-superagent 0.0.0-stage → 0.2.0

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +336 -2
  3. package/agent/superagent-router.md +7 -0
  4. package/agent/superagent-worker.md +10 -0
  5. package/dist/server.d.ts +237 -0
  6. package/dist/server.js +148 -0
  7. package/dist/src/abort.d.ts +31 -0
  8. package/dist/src/abort.js +171 -0
  9. package/dist/src/agents.d.ts +51 -0
  10. package/dist/src/agents.js +83 -0
  11. package/dist/src/context.d.ts +27 -0
  12. package/dist/src/context.js +114 -0
  13. package/dist/src/dirs.d.ts +45 -0
  14. package/dist/src/dirs.js +129 -0
  15. package/dist/src/dispatch.d.ts +38 -0
  16. package/dist/src/dispatch.js +296 -0
  17. package/dist/src/format.d.ts +3 -0
  18. package/dist/src/format.js +14 -0
  19. package/dist/src/guard.d.ts +4 -0
  20. package/dist/src/guard.js +9 -0
  21. package/dist/src/prompts.d.ts +1 -0
  22. package/dist/src/prompts.js +37 -0
  23. package/dist/src/protocol.d.ts +219 -0
  24. package/dist/src/protocol.js +95 -0
  25. package/dist/src/recall.d.ts +58 -0
  26. package/dist/src/recall.js +309 -0
  27. package/dist/src/report.d.ts +78 -0
  28. package/dist/src/report.js +176 -0
  29. package/dist/src/router.d.ts +34 -0
  30. package/dist/src/router.js +234 -0
  31. package/dist/src/session-meta.d.ts +42 -0
  32. package/dist/src/session-meta.js +91 -0
  33. package/dist/src/sleep.d.ts +35 -0
  34. package/dist/src/sleep.js +58 -0
  35. package/dist/src/state.d.ts +126 -0
  36. package/dist/src/state.js +480 -0
  37. package/dist/src/ticket-tools.d.ts +45 -0
  38. package/dist/src/ticket-tools.js +148 -0
  39. package/dist/src/ticket-view.d.ts +62 -0
  40. package/dist/src/ticket-view.js +135 -0
  41. package/dist/src/ticket.d.ts +253 -0
  42. package/dist/src/ticket.js +717 -0
  43. package/dist/src/tools.d.ts +85 -0
  44. package/dist/src/tools.js +392 -0
  45. package/dist/src/watchdog.d.ts +23 -0
  46. package/dist/src/watchdog.js +94 -0
  47. package/dist/src/watcher.d.ts +17 -0
  48. package/dist/src/watcher.js +165 -0
  49. package/dist/tui.d.ts +3 -0
  50. package/dist/tui.js +1057 -0
  51. package/package.json +80 -4
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The Router tool catalog: one entry per tool the Router session may call.
3
+ *
4
+ * It is the single source of truth for four consumers:
5
+ * - `server.ts` registrations take their description and permission action
6
+ * from here (`specFor`), which throws when a tool has no entry, so a
7
+ * registration cannot ship undocumented or invisible;
8
+ * - `context.ts` filters the Router's toolset by `ROUTER_TOOLS`, derived below;
9
+ * - the Router session's permissions come from `ROUTER_PERMISSIONS`, also
10
+ * derived below, because `Tool.snapshot` hides a tool whose permission
11
+ * action is wholly denied;
12
+ * - the TUI `/tools` command renders the same entries, so a new tool shows up
13
+ * without touching the command.
14
+ *
15
+ * Parameters are declared for display and checked against the registered input
16
+ * schema in `test/tools.test.ts`, which fails when the two drift apart.
17
+ */
18
+ export type ToolRisk = "read" | "write" | "terminate";
19
+ export type ToolGroup = "dispatch" | "query" | "control";
20
+ export interface ToolParam {
21
+ readonly name: string;
22
+ readonly required: boolean;
23
+ readonly type: "string" | "number" | "boolean" | "string[]";
24
+ /** Accepted values when only a fixed set is meaningful. */
25
+ readonly values?: ReadonlyArray<string>;
26
+ /** Short note; keep it under one display line (see `describeTool`). */
27
+ readonly note?: string;
28
+ }
29
+ export interface ToolSpec {
30
+ readonly name: string;
31
+ readonly group: ToolGroup;
32
+ /** Registered verbatim as the description the Router model sees. */
33
+ readonly description: string;
34
+ /** One sentence for the `/tools` browser. */
35
+ readonly summary: string;
36
+ readonly params: ReadonlyArray<ToolParam>;
37
+ readonly risk: ToolRisk;
38
+ /**
39
+ * Permission action registered with the tool, which decides catalog
40
+ * visibility. Defaults to the tool name. `Tool.snapshot` drops a tool whose
41
+ * action is wholly denied, so the Router session must allow every action the
42
+ * catalog declares (`ROUTER_PERMISSIONS`), or the tool simply never reaches
43
+ * the model.
44
+ */
45
+ readonly permission?: string;
46
+ /** How a call is gated: automatic, or through a permission action. */
47
+ readonly confirmation: string;
48
+ /** Extra usage notes for the detail view. */
49
+ readonly usage?: ReadonlyArray<string>;
50
+ }
51
+ export declare const GROUP_LABELS: Record<ToolGroup, string>;
52
+ export declare const RISK_LABELS: Record<ToolRisk, string>;
53
+ export declare const ROUTER_TOOL_SPECS: ReadonlyArray<ToolSpec>;
54
+ /** Tools the Router may see; `context.ts` strips everything else from its toolset. */
55
+ export declare const ROUTER_TOOLS: ReadonlySet<string>;
56
+ /** Catalog entry for a registered tool. Throws so a registration can never ship undocumented. */
57
+ export declare function specFor(name: string): ToolSpec;
58
+ /** The permission action a tool registers with; absent means its own name. */
59
+ export declare const toolPermission: (spec: ToolSpec) => string;
60
+ /** Visibility actions the catalog needs; sorted so the ruleset stays stable across restarts. */
61
+ export declare const ROUTER_PERMISSION_ACTIONS: ReadonlyArray<string>;
62
+ /**
63
+ * The Router session's permission ruleset. Deny everything first, then allow
64
+ * exactly the actions its catalog tools register with: `Tool.snapshot` drops a
65
+ * tool whose action is wholly denied, so a missing allow here hides the tool
66
+ * from the model even though it is registered and documented.
67
+ */
68
+ export declare const ROUTER_PERMISSIONS: ReadonlyArray<{
69
+ readonly action: string;
70
+ readonly resource: string;
71
+ readonly effect: "allow" | "deny";
72
+ }>;
73
+ /** What the Router cannot do, independent of which tools it currently has. */
74
+ export declare const ROUTER_LIMITS: ReadonlyArray<string>;
75
+ /** Terminal cells a string occupies: CJK and fullwidth forms take two. */
76
+ export declare function displayWidth(text: string): number;
77
+ /**
78
+ * Wrap by cell width: Latin words stay whole, CJK text has no spaces so it
79
+ * breaks between characters, and a word wider than the line is split.
80
+ */
81
+ export declare function wrapText(text: string, width: number): Array<string>;
82
+ /** Full description of one tool: summary, parameter table, risk and usage notes. */
83
+ export declare function describeTool(spec: ToolSpec, width?: number): string;
84
+ /** The capability-boundary section shared by `/tools`. */
85
+ export declare function describeLimits(width?: number): string;
@@ -0,0 +1,392 @@
1
+ /**
2
+ * The Router tool catalog: one entry per tool the Router session may call.
3
+ *
4
+ * It is the single source of truth for four consumers:
5
+ * - `server.ts` registrations take their description and permission action
6
+ * from here (`specFor`), which throws when a tool has no entry, so a
7
+ * registration cannot ship undocumented or invisible;
8
+ * - `context.ts` filters the Router's toolset by `ROUTER_TOOLS`, derived below;
9
+ * - the Router session's permissions come from `ROUTER_PERMISSIONS`, also
10
+ * derived below, because `Tool.snapshot` hides a tool whose permission
11
+ * action is wholly denied;
12
+ * - the TUI `/tools` command renders the same entries, so a new tool shows up
13
+ * without touching the command.
14
+ *
15
+ * Parameters are declared for display and checked against the registered input
16
+ * schema in `test/tools.test.ts`, which fails when the two drift apart.
17
+ */
18
+ export const GROUP_LABELS = {
19
+ dispatch: "调度类",
20
+ query: "查询类",
21
+ control: "控制类",
22
+ };
23
+ export const RISK_LABELS = {
24
+ read: "只读",
25
+ write: "可写",
26
+ terminate: "终止",
27
+ };
28
+ export const ROUTER_TOOL_SPECS = [
29
+ {
30
+ name: "dispatch",
31
+ group: "dispatch",
32
+ description: "Dispatch one independent task to a background worker session in the given directory (absolute path). ticketID is required and must be an open/in_progress ticket for this thread with no running worker: create it with ticket_create first. Only the router may call it.",
33
+ summary: "把独立任务派给后台 Worker 会话,不阻塞 Router。",
34
+ params: [
35
+ { name: "task", required: true, type: "string", note: "任务内容" },
36
+ { name: "title", required: false, type: "string", note: "会话标题" },
37
+ { name: "threadID", required: false, type: "string", note: "thread 绑定" },
38
+ { name: "agent", required: false, type: "string", note: "覆盖默认 agent" },
39
+ { name: "directory", required: false, type: "string", note: "绝对路径" },
40
+ { name: "ticketID", required: true, type: "string", note: "必填,先 ticket_create" },
41
+ ],
42
+ risk: "write",
43
+ confirmation: "自动执行,不逐次确认:先校验工单,再创建 Worker 会话并以队列 prompt 投递任务。",
44
+ usage: [
45
+ "强制:必须先 ticket_create 建单,再 dispatch({ ticketID });缺 ticketID 直接报错。",
46
+ "工单须为 open/in_progress、属于同一 thread、且没有运行中的 Worker;被拒时按报错补救(补建单 / 等原 Worker 结束或 abort_session 后建新单)再派。",
47
+ "每次派发都要带 directory(绝对路径);Router 会复用 thread 绑定与目录别名。",
48
+ "同一 thread 的后续消息直投该 Worker,不要重复 dispatch。",
49
+ "派发成功即绑定工单:workerSessionID 写入、attempts+1、state → in_progress;Worker 结束自动结单。",
50
+ ],
51
+ },
52
+ {
53
+ name: "sleep",
54
+ group: "control",
55
+ description: 'Pause the router for a bounded number of seconds (default 30, range 1..600) with a real timer, never a busy loop, and return "slept Ns". In Goal mode, call it after dispatch to pause and give the Worker execution time before you check in again. Only the router may call it; tracked worker sessions neither see nor may call it.',
56
+ summary: "暂停指定秒数(默认 30,范围 1..600),给 Worker 预留执行时间。",
57
+ params: [{ name: "seconds", required: false, type: "number", note: "默认 30,1..600 的整数" }],
58
+ risk: "read",
59
+ permission: "status",
60
+ confirmation: "只读等待,自动执行(permission: status);非法秒数直接报错。",
61
+ usage: [
62
+ "Goal 模式:dispatch 后先 sleep,再回来汇总,不要空转轮询 Worker。",
63
+ "seconds 缺省 30;必须是 1..600 的整数,0/负数/超界/非数字都报错。",
64
+ "真实定时器(Effect.sleep):等待期间 Router 与其他任务照常推进。",
65
+ "仅 Router 可见:Worker 会话的工具列表里没有它,直调也会被拒绝。",
66
+ ],
67
+ },
68
+ {
69
+ name: "ticket_create",
70
+ group: "dispatch",
71
+ description: "Create a work ticket before dispatching: title, objective, acceptance criteria (1-10) and an absolute directory are required. Returns the ticket id and reuses an open ticket with the same dedupe key in the same thread. Only the router may call it.",
72
+ summary: "建工单:写清目标、验收标准与目录,重复任务自动复用。",
73
+ params: [
74
+ { name: "threadID", required: true, type: "string", note: "thread 标识" },
75
+ { name: "title", required: true, type: "string", note: `≤80 字` },
76
+ { name: "objective", required: true, type: "string", note: "要做什么" },
77
+ { name: "acceptance", required: true, type: "string[]", note: "1-10 条验收标准" },
78
+ { name: "directory", required: true, type: "string", note: "绝对路径" },
79
+ { name: "priority", required: false, type: "string", values: ["p0", "p1", "p2"], note: "默认 p1" },
80
+ { name: "parentTicketID", required: false, type: "string", note: "拆单时指向父单" },
81
+ { name: "dedupeKey", required: false, type: "string", note: "默认按来源+thread+目标生成" },
82
+ ],
83
+ risk: "write",
84
+ permission: "status",
85
+ confirmation: "自动执行:仅建单,不派活;authorization 自动取当前触发器的 id 与原话摘要。",
86
+ usage: [
87
+ "acceptance 为空直接拒绝建单:想不出验收标准就先问用户。",
88
+ "同 thread 已有 open/in_progress 同 dedupeKey 的工单会直接复用(reused=true)。",
89
+ ],
90
+ },
91
+ {
92
+ name: "ticket_list",
93
+ group: "query",
94
+ description: "List tickets, optionally filtered by thread, state, update time and limit. Returns id/state/title/priority/worker per ticket. Only the router may call it.",
95
+ summary: "按 thread/状态/时间列出工单(默认 20 条)。",
96
+ params: [
97
+ { name: "threadID", required: false, type: "string", note: "只看该 thread" },
98
+ {
99
+ name: "status",
100
+ required: false,
101
+ type: "string",
102
+ values: ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"],
103
+ note: "状态过滤",
104
+ },
105
+ { name: "since", required: false, type: "number", note: "updatedAt 起始毫秒" },
106
+ { name: "limit", required: false, type: "number", note: "默认 20,上限 100" },
107
+ ],
108
+ risk: "read",
109
+ permission: "status",
110
+ confirmation: "只读,自动执行(permission: status)。",
111
+ usage: ["按 updatedAt 倒序;需要某张单的完整验收标准用 /ticket <id>。"],
112
+ },
113
+ {
114
+ name: "ticket_update",
115
+ group: "control",
116
+ description: "Move a ticket along its state machine (open → in_progress → blocked/done/failed/cancelled/superseded) and optionally append a note. Illegal transitions are rejected. Only the router may call it.",
117
+ summary: "推进工单状态或追加备注,非法迁移直接报错。",
118
+ params: [
119
+ { name: "ticketID", required: true, type: "string", note: "工单 ID" },
120
+ {
121
+ name: "status",
122
+ required: false,
123
+ type: "string",
124
+ values: ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"],
125
+ note: "目标状态",
126
+ },
127
+ { name: "note", required: false, type: "string", note: "备注,写入 history" },
128
+ ],
129
+ risk: "write",
130
+ permission: "status",
131
+ confirmation: "自动执行:状态迁移按状态机校验。",
132
+ usage: ["结束任务优先用 ticket_close(带 outcome 与 summary),不要手动跳到 done。"],
133
+ },
134
+ {
135
+ name: "ticket_close",
136
+ group: "control",
137
+ description: "Close a ticket with an outcome (done, failed or cancelled) and a summary; records closedAt, closeReason and a history entry. Only the router may call it.",
138
+ summary: "结单:写入结论、关闭时间与历史。",
139
+ params: [
140
+ { name: "ticketID", required: true, type: "string", note: "工单 ID" },
141
+ { name: "outcome", required: true, type: "string", values: ["done", "failed", "cancelled"], note: "结果" },
142
+ { name: "summary", required: true, type: "string", note: "结论,≤500 字" },
143
+ ],
144
+ risk: "write",
145
+ permission: "status",
146
+ confirmation: "自动执行:已结单的工单保持原状态,改结论需新建工单。",
147
+ usage: ["Worker 结束会自动结单;只有在 Router 主动放弃/改判时才手动调用。"],
148
+ },
149
+ {
150
+ name: "status",
151
+ group: "query",
152
+ description: "Show a compact summary of the router, pending triggers and worker sessions.",
153
+ summary: "查看 Router、pending 触发器数量与全部 Worker 的 thread/状态/最近报告。",
154
+ params: [],
155
+ risk: "read",
156
+ confirmation: "只读,自动执行(permission: status)。",
157
+ usage: ["system 状态行只给计数,细节用 status / thread_detail。"],
158
+ },
159
+ {
160
+ name: "thread_detail",
161
+ group: "query",
162
+ description: "Show a thread's trigger history plus its subscribed workers. Only the router may call it.",
163
+ summary: "查看某个 thread 的触发器历史(谁在何时说了什么)与订阅它的 Worker。",
164
+ params: [
165
+ { name: "threadID", required: true, type: "string", note: "thread 标识" },
166
+ { name: "limit", required: false, type: "number", note: "默认 20 条,上限 200" },
167
+ { name: "offset", required: false, type: "number", note: "从最新往前跳过 N 条,默认 0" },
168
+ ],
169
+ risk: "read",
170
+ permission: "status",
171
+ confirmation: "只读,自动执行(permission: status)。",
172
+ usage: [
173
+ "详细信息不在 system 里:需要原文时用它,不要猜。",
174
+ "内存只留最近 200 条;offset/limit 超出时按需从 KV 回源,每次最多补 20 条。",
175
+ ],
176
+ },
177
+ {
178
+ name: "worker_detail",
179
+ group: "query",
180
+ description: "Show a worker's state plus its recent assistant messages. Only the router may call it.",
181
+ summary: "查看某个 Worker 的状态与最近几条 assistant 文本。",
182
+ params: [
183
+ { name: "sessionID", required: true, type: "string", note: "Worker 会话 ID" },
184
+ { name: "messages", required: false, type: "number", note: "默认 5,上限 10" },
185
+ ],
186
+ risk: "read",
187
+ permission: "status",
188
+ confirmation: "只读,自动执行(permission: status)。",
189
+ usage: ["用于验收 Worker 的中间结论,不必等它结束。", "单条消息默认截断 1500 字;要看完整报告用 report_read。"],
190
+ },
191
+ {
192
+ name: "report_read",
193
+ group: "query",
194
+ description: "Read the full report text a worker produced when it finished, by character range (offset, limit). Returns the slice plus the total length, truncation state and the next offset. Only the router may call it.",
195
+ summary: "分页读取 Worker 结束报告的完整全文(默认 4000 字/页)。",
196
+ params: [
197
+ { name: "sessionID", required: true, type: "string", note: "Worker 会话 ID" },
198
+ { name: "offset", required: false, type: "number", note: "起始字符偏移,默认 0" },
199
+ { name: "limit", required: false, type: "number", note: "默认 4000,上限 20000" },
200
+ ],
201
+ risk: "read",
202
+ permission: "status",
203
+ confirmation: "只读,自动执行(permission: status)。",
204
+ usage: [
205
+ "只存 Worker 结束时捕获的全文;没有全文记录会直接报错,此时用 worker_detail。",
206
+ "按返回的 nextOffset 翻页,到末尾时提示「已到末尾」。",
207
+ ],
208
+ },
209
+ {
210
+ name: "abort_session",
211
+ group: "control",
212
+ description: "Terminate a tracked worker session: interrupt it, wait for it to settle, mark it aborted and detach it from its thread so later messages fall back to the router. Pass sessionID, or all/stale for batch cleanup. Only the router may call it.",
213
+ summary: "终止/清理 Worker:中断执行、等待收敛、标记 aborted 并从 thread 摘除。",
214
+ params: [
215
+ { name: "sessionID", required: false, type: "string", note: "单个 Worker" },
216
+ { name: "reason", required: false, type: "string", note: "终止原因" },
217
+ { name: "all", required: false, type: "boolean", values: ["true"], note: "终止全部" },
218
+ { name: "stale", required: false, type: "boolean", values: ["true"], note: "只清理已结束" },
219
+ ],
220
+ risk: "terminate",
221
+ permission: "status",
222
+ confirmation: "自动执行(permission: status):只接受插件跟踪的 Worker,Router 自身与用户会话一律拒绝。",
223
+ usage: [
224
+ "sessionID 与 all/stale 二选一;stale 不会中断运行中的 Worker。",
225
+ "终止后该 thread 的下一条消息回落到 Router 重新 dispatch。",
226
+ ],
227
+ },
228
+ ];
229
+ const specsByName = new Map(ROUTER_TOOL_SPECS.map((spec) => [spec.name, spec]));
230
+ /** Tools the Router may see; `context.ts` strips everything else from its toolset. */
231
+ export const ROUTER_TOOLS = new Set(specsByName.keys());
232
+ /** Catalog entry for a registered tool. Throws so a registration can never ship undocumented. */
233
+ export function specFor(name) {
234
+ const spec = specsByName.get(name);
235
+ if (spec === undefined)
236
+ throw new Error(`superagent tool ${name} has no entry in src/tools.ts`);
237
+ return spec;
238
+ }
239
+ /** The permission action a tool registers with; absent means its own name. */
240
+ export const toolPermission = (spec) => spec.permission ?? spec.name;
241
+ /** Visibility actions the catalog needs; sorted so the ruleset stays stable across restarts. */
242
+ export const ROUTER_PERMISSION_ACTIONS = [
243
+ ...new Set(ROUTER_TOOL_SPECS.map(toolPermission)),
244
+ ].sort();
245
+ /**
246
+ * The Router session's permission ruleset. Deny everything first, then allow
247
+ * exactly the actions its catalog tools register with: `Tool.snapshot` drops a
248
+ * tool whose action is wholly denied, so a missing allow here hides the tool
249
+ * from the model even though it is registered and documented.
250
+ */
251
+ export const ROUTER_PERMISSIONS = [
252
+ { action: "*", resource: "*", effect: "deny" },
253
+ ...ROUTER_PERMISSION_ACTIONS.map((action) => ({ action, resource: "*", effect: "allow" })),
254
+ ];
255
+ /** What the Router cannot do, independent of which tools it currently has. */
256
+ export const ROUTER_LIMITS = [
257
+ "不能读写文件或执行命令:没有 Read/Write/Edit/Bash,只能派 Worker 去做。",
258
+ "不能操作非 superagent 会话:abort_session 只接受插件自己创建的 Worker,用户会话、Router 自身和未知 sessionID 一律拒绝。",
259
+ "不能修改自身:改不了自己的 prompt、工具集或配置,也不能终止自己所在的 Router 会话。",
260
+ "不能直接和 Worker 的模型对话:只能 dispatch 任务,或读它的状态、最近 assistant 消息与报告全文(report_read)。",
261
+ "不能跨网关或跨进程:状态存在网关进程内存与 KV,重启后按 KV 恢复,不跨机器同步。",
262
+ ];
263
+ /** Detail views render inside the medium dialog, so keep lines inside its text column. */
264
+ const DETAIL_WIDTH = 52;
265
+ /** Terminal cells a string occupies: CJK and fullwidth forms take two. */
266
+ export function displayWidth(text) {
267
+ let width = 0;
268
+ for (const char of text)
269
+ width += fullWidth(char.codePointAt(0) ?? 0) ? 2 : 1;
270
+ return width;
271
+ }
272
+ function fullWidth(code) {
273
+ return ((code >= 0x1100 && code <= 0x115f) ||
274
+ (code >= 0x2e80 && code <= 0xa4cf) ||
275
+ (code >= 0xac00 && code <= 0xd7a3) ||
276
+ (code >= 0xf900 && code <= 0xfaff) ||
277
+ (code >= 0xfe30 && code <= 0xfe6f) ||
278
+ (code >= 0xff00 && code <= 0xff60) ||
279
+ (code >= 0xffe0 && code <= 0xffe6) ||
280
+ (code >= 0x20000 && code <= 0x3fffd));
281
+ }
282
+ /** CJK punctuation that must not start a line, so it stays attached to the previous unit. */
283
+ const NO_BREAK_BEFORE = ",。、;:!?)》」』】…";
284
+ /** Atomic wrap units: Latin words stay whole, CJK breaks between characters. */
285
+ function tokens(text) {
286
+ const result = [];
287
+ let word = "";
288
+ for (const char of text) {
289
+ if (char === " " || fullWidth(char.codePointAt(0) ?? 0)) {
290
+ if (word !== "")
291
+ result.push(word);
292
+ word = "";
293
+ const previous = result.at(-1);
294
+ if (NO_BREAK_BEFORE.includes(char) && previous !== undefined) {
295
+ result[result.length - 1] = `${previous}${char}`;
296
+ continue;
297
+ }
298
+ result.push(char);
299
+ continue;
300
+ }
301
+ word += char;
302
+ }
303
+ if (word !== "")
304
+ result.push(word);
305
+ return result;
306
+ }
307
+ /**
308
+ * Wrap by cell width: Latin words stay whole, CJK text has no spaces so it
309
+ * breaks between characters, and a word wider than the line is split.
310
+ */
311
+ export function wrapText(text, width) {
312
+ const lines = [];
313
+ let line = "";
314
+ let cells = 0;
315
+ const push = (unit) => {
316
+ const unitWidth = displayWidth(unit);
317
+ if (cells + unitWidth > width && line !== "") {
318
+ lines.push(line.trimEnd());
319
+ line = "";
320
+ cells = 0;
321
+ }
322
+ line += unit;
323
+ cells += unitWidth;
324
+ };
325
+ for (const token of tokens(text)) {
326
+ if (token === " ") {
327
+ if (line !== "")
328
+ push(token);
329
+ continue;
330
+ }
331
+ if (displayWidth(token) > width)
332
+ for (const char of token)
333
+ push(char);
334
+ else
335
+ push(token);
336
+ }
337
+ if (line !== "")
338
+ lines.push(line.trimEnd());
339
+ return lines;
340
+ }
341
+ /** One bulleted item, with continuation lines indented under the text. */
342
+ function bullet(text, width) {
343
+ return wrapText(`· ${text}`, width).map((line, index) => (index === 0 ? line : ` ${line}`));
344
+ }
345
+ /** A labelled paragraph whose continuation lines line up under the text. */
346
+ function labelled(label, text, width) {
347
+ const indent = " ".repeat(displayWidth(label));
348
+ return wrapText(`${label}${text}`, width).map((line, index) => (index === 0 ? line : `${indent}${line}`));
349
+ }
350
+ function pad(text, width) {
351
+ return `${text}${" ".repeat(Math.max(0, width - displayWidth(text)))}`;
352
+ }
353
+ function paramLine(param, nameWidth) {
354
+ const required = param.required ? "必填" : "可选";
355
+ const type = param.values === undefined ? param.type : `${param.type} ${param.values.join("|")}`;
356
+ const note = param.note === undefined ? "" : ` ${param.note}`;
357
+ return `${pad(param.name, nameWidth)}${required} ${type}${note}`;
358
+ }
359
+ /** Full description of one tool: summary, parameter table, risk and usage notes. */
360
+ export function describeTool(spec, width = DETAIL_WIDTH) {
361
+ const lines = [`${spec.name} · ${GROUP_LABELS[spec.group]} · ${RISK_LABELS[spec.risk]}`];
362
+ lines.push(...wrapText(spec.summary, width));
363
+ if (spec.params.length === 0) {
364
+ lines.push("", "参数:无");
365
+ }
366
+ else {
367
+ const nameWidth = Math.max(...spec.params.map((param) => displayWidth(param.name))) + 2;
368
+ const indent = " ".repeat(nameWidth + 7);
369
+ lines.push("", "参数");
370
+ for (const param of spec.params) {
371
+ const [head, ...rest] = wrapText(paramLine(param, nameWidth), width - 2);
372
+ if (head !== undefined)
373
+ lines.push(` ${head}`);
374
+ // Continuation lines sit under the type column, so re-wrap them at the
375
+ // remaining width or a long token would overflow the dialog.
376
+ for (const line of rest.length === 0 ? [] : wrapText(rest.join(" "), width - indent.length)) {
377
+ lines.push(`${indent}${line}`);
378
+ }
379
+ }
380
+ }
381
+ lines.push("", ...labelled("确认:", spec.confirmation, width));
382
+ for (const note of spec.usage ?? [])
383
+ lines.push(...bullet(note, width));
384
+ return lines.join("\n");
385
+ }
386
+ /** The capability-boundary section shared by `/tools`. */
387
+ export function describeLimits(width = DETAIL_WIDTH) {
388
+ const lines = ["Router 只能调度,不能自己动手。以下是明确的边界:", ""];
389
+ for (const limit of ROUTER_LIMITS)
390
+ lines.push(...bullet(limit, width));
391
+ return lines.join("\n");
392
+ }
@@ -0,0 +1,23 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Effect } from "effect";
3
+ /** Default interval between stall checks. Overridable with the `watchdogIntervalMs` plugin option. */
4
+ export declare const WATCHDOG_INTERVAL_MS = 60000;
5
+ /** Default stall threshold. Overridable with the `stallTimeoutMs` plugin option. */
6
+ export declare const STALL_TIMEOUT_MS: number;
7
+ /**
8
+ * Older records may still carry the watchdog marker in `lastReport`; keep
9
+ * honouring it so a restart cannot re-alert the same legacy stall.
10
+ */
11
+ export declare const STALLED_PREFIX = "watchdog: stalled";
12
+ /** Test/hydrate hook: forget every in-memory stall alert. */
13
+ export declare function resetWatchdogAlerts(): void;
14
+ export declare const checkWorker: (ctx: Plugin.Context, sessionID: string, now: number, stallTimeoutMs: number) => Effect.Effect<void, never, never>;
15
+ /**
16
+ * Periodic stall detector. Every interval it flags `running` workers whose
17
+ * session has been busy longer than the stall timeout without an `idle`
18
+ * update, and reports them to the router. It is alert-only: the worker record
19
+ * keeps its `running` state, its ticket stays open and the worker is never
20
+ * interrupted, so the Router stays in control. Alerts are deduped per busy
21
+ * window. All errors are swallowed with a warning so the loop never dies.
22
+ */
23
+ export declare const registerWatchdog: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
@@ -0,0 +1,94 @@
1
+ import { DateTime, Effect, Option, Stream } from "effect";
2
+ import { ensureRouter } from "./router.js";
3
+ import { store, toSessionID } from "./state.js";
4
+ /** Default interval between stall checks. Overridable with the `watchdogIntervalMs` plugin option. */
5
+ export const WATCHDOG_INTERVAL_MS = 60_000;
6
+ /** Default stall threshold. Overridable with the `stallTimeoutMs` plugin option. */
7
+ export const STALL_TIMEOUT_MS = 30 * 60_000;
8
+ /**
9
+ * Older records may still carry the watchdog marker in `lastReport`; keep
10
+ * honouring it so a restart cannot re-alert the same legacy stall.
11
+ */
12
+ export const STALLED_PREFIX = "watchdog: stalled";
13
+ /**
14
+ * Sessions already reported for their current busy window. Alert-only mode
15
+ * never mutates the worker record, so dedupe lives in memory. Keying by
16
+ * `time.busy` lets a resumed worker alert once more if it stalls again.
17
+ */
18
+ const alerted = new Map();
19
+ /** Test/hydrate hook: forget every in-memory stall alert. */
20
+ export function resetWatchdogAlerts() {
21
+ alerted.clear();
22
+ }
23
+ function optionMs(ctx, key, fallback) {
24
+ const raw = ctx.options[key];
25
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0)
26
+ return fallback;
27
+ return raw;
28
+ }
29
+ export const checkWorker = Effect.fn("superagent.watchdogCheck")(function* (ctx, sessionID, now, stallTimeoutMs) {
30
+ const worker = store.workers.get(sessionID);
31
+ if (worker === undefined || worker.state !== "running") {
32
+ alerted.delete(sessionID);
33
+ return;
34
+ }
35
+ if (worker.lastReport !== undefined && worker.lastReport.startsWith(STALLED_PREFIX))
36
+ return;
37
+ const info = yield* ctx.session.get({ sessionID: toSessionID(sessionID) }).pipe(Effect.option);
38
+ if (Option.isNone(info)) {
39
+ yield* Effect.logWarning("superagent watchdog: worker session not found", { sessionID });
40
+ return;
41
+ }
42
+ const busy = info.value.time.busy;
43
+ if (busy === undefined) {
44
+ alerted.delete(sessionID);
45
+ return;
46
+ }
47
+ const busyMs = DateTime.toEpochMillis(busy);
48
+ const idle = info.value.time.idle;
49
+ // While a session runs, `idle` still holds the previous execution's end.
50
+ // Only an `idle` newer than `busy` proves progress since this execution started.
51
+ if (idle !== undefined && DateTime.toEpochMillis(idle) > busyMs) {
52
+ alerted.delete(sessionID);
53
+ return;
54
+ }
55
+ // One alert per busy window: duplicate checks must not spam the Router.
56
+ if (alerted.get(sessionID) === busyMs)
57
+ return;
58
+ const stalledMs = now - busyMs;
59
+ if (stalledMs <= stallTimeoutMs)
60
+ return;
61
+ const minutes = Math.floor(stalledMs / 60_000);
62
+ const title = worker.title ?? "-";
63
+ const text = `Worker ${sessionID}(${title})已停滞 ${minutes} 分钟,建议:stop 后重派或人工介入`;
64
+ const routerID = yield* ensureRouter(ctx).pipe(Effect.catch((cause) => Effect.logWarning("superagent watchdog cannot report without a router", { cause }).pipe(Effect.as(undefined))));
65
+ if (routerID !== undefined) {
66
+ yield* ctx.session
67
+ .synthetic({ sessionID: toSessionID(routerID), text, description: "superagent watchdog", delivery: "queue" })
68
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent watchdog failed to report to router", { cause })));
69
+ }
70
+ alerted.set(sessionID, busyMs);
71
+ // Alert only: never change worker.state, never close its ticket and never
72
+ // interrupt automatically, so a long task is not killed by mistake. The
73
+ // Router decides whether to stop/re-dispatch.
74
+ yield* Effect.logWarning("superagent watchdog: worker stalled", { sessionID, minutes });
75
+ });
76
+ const checkAll = Effect.fn("superagent.watchdogCheckAll")(function* (ctx, stallTimeoutMs) {
77
+ const now = Date.now();
78
+ for (const sessionID of [...store.workers.keys()]) {
79
+ yield* checkWorker(ctx, sessionID, now, stallTimeoutMs).pipe(Effect.catch((cause) => Effect.logWarning("superagent watchdog check failed", { sessionID, cause })));
80
+ }
81
+ });
82
+ /**
83
+ * Periodic stall detector. Every interval it flags `running` workers whose
84
+ * session has been busy longer than the stall timeout without an `idle`
85
+ * update, and reports them to the router. It is alert-only: the worker record
86
+ * keeps its `running` state, its ticket stays open and the worker is never
87
+ * interrupted, so the Router stays in control. Alerts are deduped per busy
88
+ * window. All errors are swallowed with a warning so the loop never dies.
89
+ */
90
+ export const registerWatchdog = Effect.fn("superagent.registerWatchdog")(function* (ctx) {
91
+ const intervalMs = optionMs(ctx, "watchdogIntervalMs", WATCHDOG_INTERVAL_MS);
92
+ const stallTimeoutMs = optionMs(ctx, "stallTimeoutMs", STALL_TIMEOUT_MS);
93
+ yield* Stream.tick(intervalMs).pipe(Stream.runForEach(() => checkAll(ctx, stallTimeoutMs).pipe(Effect.catch((cause) => Effect.logWarning("superagent watchdog run failed", { cause })))), Effect.forkScoped);
94
+ });
@@ -0,0 +1,17 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Effect } from "effect";
3
+ import { type WorkerRecord } from "./state.js";
4
+ /**
5
+ * Capture one finished report: the complete text goes to `report:<sessionID>`
6
+ * first, then the worker record keeps a short summary for status lines and the
7
+ * router injection. The full text is written before anything else so a crash
8
+ * between the two writes can only lose the summary, never the evidence.
9
+ */
10
+ export declare const captureReport: (ctx: Plugin.Context, worker: WorkerRecord, text: string) => Effect.Effect<import("./report.js").ReportRecord, never, never>;
11
+ /**
12
+ * Apply a `session.idle` notification. Only a running worker may become idle:
13
+ * `done`, `failed` and `aborted` are terminal for the lifecycle, and an idle
14
+ * event must never resurrect or overwrite them.
15
+ */
16
+ export declare const handleIdle: (ctx: Plugin.Context, sessionID: string) => Effect.Effect<void, never, never>;
17
+ export declare const registerWatcher: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;