opencode-superagent 0.0.0-stage → 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +333 -2
  3. package/agent/superagent-router.md +7 -0
  4. package/agent/superagent-worker.md +10 -0
  5. package/dist/server.d.ts +3 -0
  6. package/dist/server.js +115 -0
  7. package/dist/src/abort.d.ts +31 -0
  8. package/dist/src/abort.js +176 -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 +20 -0
  12. package/dist/src/context.js +98 -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 +214 -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 +36 -0
  23. package/dist/src/protocol.d.ts +210 -0
  24. package/dist/src/protocol.js +88 -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 +72 -0
  28. package/dist/src/report.js +147 -0
  29. package/dist/src/router.d.ts +35 -0
  30. package/dist/src/router.js +209 -0
  31. package/dist/src/session-meta.d.ts +42 -0
  32. package/dist/src/session-meta.js +91 -0
  33. package/dist/src/state.d.ts +124 -0
  34. package/dist/src/state.js +431 -0
  35. package/dist/src/ticket-tools.d.ts +45 -0
  36. package/dist/src/ticket-tools.js +148 -0
  37. package/dist/src/ticket-view.d.ts +62 -0
  38. package/dist/src/ticket-view.js +135 -0
  39. package/dist/src/ticket.d.ts +246 -0
  40. package/dist/src/ticket.js +700 -0
  41. package/dist/src/tools.d.ts +85 -0
  42. package/dist/src/tools.js +376 -0
  43. package/dist/src/watchdog.d.ts +16 -0
  44. package/dist/src/watchdog.js +77 -0
  45. package/dist/src/watcher.d.ts +11 -0
  46. package/dist/src/watcher.js +181 -0
  47. package/dist/tui.d.ts +3 -0
  48. package/dist/tui.js +1027 -0
  49. package/package.json +79 -4
@@ -0,0 +1,176 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { DateTime, Effect, Option, Schema } from "effect";
3
+ import { requireRouter } from "./guard.js";
4
+ import { encodeThread, saveWorker, store, threadKey, toSessionID } from "./state.js";
5
+ import { closeTicketsForWorker } from "./ticket.js";
6
+ import { specFor, toolPermission } from "./tools.js";
7
+ /** How long a terminating call waits for an interrupted worker to settle before reporting a timeout. */
8
+ export const ABORT_TIMEOUT_MS = 10_000;
9
+ export const AbortInput = Schema.Struct({
10
+ sessionID: Schema.optionalKey(Schema.String),
11
+ reason: Schema.optionalKey(Schema.String),
12
+ all: Schema.optionalKey(Schema.Boolean),
13
+ stale: Schema.optionalKey(Schema.Boolean),
14
+ });
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
+ function abortNote(reason, detail, previous) {
25
+ const head = `aborted${reason === undefined ? "" : ` (${reason})`} [${detail}]`;
26
+ return previous === undefined ? head : `${head} | ${previous}`;
27
+ }
28
+ /**
29
+ * Drop the session from every thread affinity list it subscribed to, so later
30
+ * messages on those threads no longer target a terminated worker. An empty
31
+ * thread falls back to the router on the next message. The worker record keeps
32
+ * its `threads` for tracing.
33
+ */
34
+ const detachThreads = Effect.fn("superagent.detachThreads")(function* (ctx, sessionID) {
35
+ const worker = store.workers.get(sessionID);
36
+ if (worker === undefined)
37
+ return [];
38
+ const detached = [];
39
+ for (const threadID of worker.threads) {
40
+ const current = store.threads.get(threadID);
41
+ if (current === undefined || !current.includes(sessionID))
42
+ continue;
43
+ const next = current.filter((id) => id !== sessionID);
44
+ if (next.length === 0) {
45
+ store.threads.delete(threadID);
46
+ yield* ctx.storage.remove(threadKey(threadID)).pipe(Effect.catch(() => Effect.void));
47
+ }
48
+ else {
49
+ store.threads.set(threadID, next);
50
+ yield* ctx.storage
51
+ .set(threadKey(threadID), encodeThread({ threadID, workers: next, updatedAt: Date.now() }))
52
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
53
+ }
54
+ detached.push(threadID);
55
+ }
56
+ return detached;
57
+ });
58
+ /**
59
+ * Terminate one tracked worker. The record is marked `aborted` before the
60
+ * interrupt lands, so the watcher cannot resurrect it to `idle`/`done` when
61
+ * the interruption settles; the record itself stays in KV for tracing.
62
+ */
63
+ const abortWorker = Effect.fn("superagent.abortWorker")(function* (ctx, sessionID, reason) {
64
+ const worker = store.workers.get(sessionID);
65
+ if (worker === undefined)
66
+ return undefined;
67
+ const previous = worker.lastReport;
68
+ const info = yield* ctx.session.get({ sessionID: toSessionID(sessionID) }).pipe(Effect.option);
69
+ const running = Option.isSome(info) && isRunning(info.value);
70
+ worker.state = "aborted";
71
+ yield* saveWorker(ctx, worker);
72
+ // Terminating a worker aborts its ticket: the work did not finish, and a
73
+ // failed ticket is what the Router needs to decide on a re-dispatch.
74
+ yield* closeTicketsForWorker(ctx, {
75
+ sessionID,
76
+ outcome: "failed",
77
+ summary: `aborted${reason === undefined ? "" : `: ${reason}`}`,
78
+ note: "aborted",
79
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to abort ticket for worker", { sessionID, cause })));
80
+ const threads = yield* detachThreads(ctx, sessionID);
81
+ if (Option.isNone(info)) {
82
+ worker.lastReport = abortNote(reason, "session missing", previous);
83
+ yield* saveWorker(ctx, worker);
84
+ return { sessionID, status: "missing", interrupted: false, timedOut: false, threads };
85
+ }
86
+ if (!running) {
87
+ worker.lastReport = abortNote(reason, "already ended", previous);
88
+ yield* saveWorker(ctx, worker);
89
+ return { sessionID, status: "already-ended", interrupted: false, timedOut: false, threads };
90
+ }
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
+ 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
+ const timedOut = Option.isNone(settled);
94
+ const detail = timedOut ? "interrupted, settle timeout" : interrupted ? "interrupted" : "no active execution";
95
+ worker.lastReport = abortNote(reason, detail, previous);
96
+ yield* saveWorker(ctx, worker);
97
+ return { sessionID, status: "aborted", interrupted, timedOut, threads };
98
+ });
99
+ function render(results) {
100
+ if (results.length === 0) {
101
+ const text = "no tracked worker matched; nothing to abort";
102
+ return { output: { text }, content: text };
103
+ }
104
+ const counts = { aborted: 0, "already-ended": 0, missing: 0 };
105
+ const lines = [];
106
+ for (const result of results) {
107
+ counts[result.status] += 1;
108
+ const title = store.workers.get(result.sessionID)?.title ?? "-";
109
+ const detail = result.status === "aborted"
110
+ ? `interrupted=${result.interrupted}${result.timedOut ? " settle-timeout" : ""}`
111
+ : result.status;
112
+ const threads = result.threads.length === 0 ? "" : ` detached=${result.threads.join(",")}`;
113
+ lines.push(`- ${result.sessionID} ${title}: ${detail}${threads}`);
114
+ }
115
+ const summary = `aborted ${counts.aborted}, already ended ${counts["already-ended"]}, session missing ${counts.missing} (${results.length} worker(s))`;
116
+ const text = [summary, ...lines].join("\n");
117
+ return { output: { text }, content: text };
118
+ }
119
+ /**
120
+ * Terminate tracked workers. `caller` is set by the router tool (which then
121
+ * enforces router-only access); the TUI RPC passes nothing because the user
122
+ * drives it directly. Only sessions the plugin created can be touched: the
123
+ * router, user sessions and unknown ids are always refused.
124
+ */
125
+ export const runAbort = Effect.fn("superagent.abort")(function* (ctx, input, caller) {
126
+ if (caller !== undefined)
127
+ yield* requireRouter(caller, "abort_session");
128
+ const batch = input.all === true || input.stale === true;
129
+ if (batch && input.sessionID !== undefined) {
130
+ return yield* new Tool.Error({ message: "pass either sessionID or all/stale, not both" });
131
+ }
132
+ if (!batch && input.sessionID === undefined) {
133
+ return yield* new Tool.Error({
134
+ message: "abort_session needs a sessionID, or all: true / stale: true for batch cleanup",
135
+ });
136
+ }
137
+ if (input.sessionID !== undefined) {
138
+ if (input.sessionID === caller) {
139
+ return yield* new Tool.Error({ message: "cannot abort the calling session" });
140
+ }
141
+ if (store.routerID !== undefined && input.sessionID === store.routerID) {
142
+ return yield* new Tool.Error({ message: "cannot abort the router session" });
143
+ }
144
+ if (!store.workers.has(input.sessionID)) {
145
+ return yield* new Tool.Error({
146
+ message: `${input.sessionID} is not tracked by the superagent router; refusing to abort a session the plugin does not own`,
147
+ });
148
+ }
149
+ const result = yield* abortWorker(ctx, input.sessionID, input.reason);
150
+ return render(result === undefined ? [] : [result]);
151
+ }
152
+ // Batch mode never includes the router: it is not a worker record. `stale`
153
+ // only cleans records with no live work; `all` also interrupts running ones.
154
+ const targets = [...store.workers.values()]
155
+ .filter((worker) => input.all === true || worker.state !== "running")
156
+ .map((worker) => worker.sessionID);
157
+ const results = [];
158
+ for (const sessionID of targets) {
159
+ const result = yield* abortWorker(ctx, sessionID, input.reason);
160
+ if (result !== undefined)
161
+ results.push(result);
162
+ }
163
+ return render(results);
164
+ });
165
+ export const registerAbort = Effect.fn("superagent.registerAbort")(function* (ctx) {
166
+ yield* ctx.tool.transform((editor) => {
167
+ editor.add({
168
+ name: "abort_session",
169
+ description: specFor("abort_session").description,
170
+ input: AbortInput,
171
+ output: AbortOutput,
172
+ options: { permission: toolPermission(specFor("abort_session")) },
173
+ execute: (input, context) => runAbort(ctx, input, context.sessionID),
174
+ });
175
+ });
176
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Programmatic agent definitions for the Router and the Worker.
3
+ *
4
+ * Shipping the definitions as code is what makes the plugin installable as a
5
+ * standalone package: a fresh install no longer needs the user to copy
6
+ * `.opencode/agent/superagent-*.md` by hand. `agent/*.md` stays in the package
7
+ * as templates for customization and as the documented fallback for hosts
8
+ * whose kernel cannot create agents from a plugin transform.
9
+ *
10
+ * Precedence is "user files first". External plugins register before the
11
+ * built-in config agent plugin, so this transform replays *before*
12
+ * `.opencode/agent/*.md` documents are applied; a file therefore updates the
13
+ * same entry afterwards and keeps its own description, system, mode and
14
+ * hidden. The `get` guard below covers hosts that replay config first (and
15
+ * keeps repeated replays from re-applying the default).
16
+ */
17
+ import type { Plugin } from "@opencode/plugin/effect";
18
+ import type { AgentEditor } from "@opencode/plugin/effect/agent";
19
+ import { Effect } from "effect";
20
+ export declare const ROUTER_AGENT = "superagent-router";
21
+ export declare const WORKER_AGENT = "superagent-worker";
22
+ export interface AgentDefinition {
23
+ readonly id: string;
24
+ readonly description: string;
25
+ readonly mode: "primary" | "subagent";
26
+ readonly hidden: boolean;
27
+ readonly system: string;
28
+ }
29
+ export declare const AGENT_DEFINITIONS: readonly [{
30
+ readonly id: "superagent-router";
31
+ readonly description: "Superagent router. Talks to the human, never executes work itself.";
32
+ readonly mode: "primary";
33
+ readonly hidden: true;
34
+ readonly system: "你是 Super Agent Router 的会话身份;行为规则由运行时注入的 Router 提示提供。";
35
+ }, {
36
+ readonly id: "superagent-worker";
37
+ readonly description: "Superagent worker. Executes one task in its own session.";
38
+ readonly mode: "subagent";
39
+ readonly hidden: false;
40
+ readonly system: "你是一个独立的 Worker,在自己的会话中完成被分派的单一任务。\n\n- 专注任务本身,不主动联系用户。\n- 结束时用简短结论说明改了什么、验证了什么、还有什么风险;这段文字会作为 report 交给 Router。\n- 不要创建子任务给其他 worker。";
41
+ }];
42
+ /**
43
+ * Registers the defaults on an agent editor, mutating it in place.
44
+ *
45
+ * Returns the ids the kernel refused to create. `Agent.Info.default` materializes
46
+ * an unknown id, which is what newer kernels do inside `editor.update`; older
47
+ * kernels silently ignore an unknown id, and there the returned ids are the
48
+ * signal to fall back to the markdown templates.
49
+ */
50
+ export declare function applyAgentDefinitions(editor: AgentEditor): string[];
51
+ export declare const registerAgents: (ctx: Pick<Plugin.Context, "agent">) => Effect.Effect<void, never, import("effect/Scope").Scope>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Programmatic agent definitions for the Router and the Worker.
3
+ *
4
+ * Shipping the definitions as code is what makes the plugin installable as a
5
+ * standalone package: a fresh install no longer needs the user to copy
6
+ * `.opencode/agent/superagent-*.md` by hand. `agent/*.md` stays in the package
7
+ * as templates for customization and as the documented fallback for hosts
8
+ * whose kernel cannot create agents from a plugin transform.
9
+ *
10
+ * Precedence is "user files first". External plugins register before the
11
+ * built-in config agent plugin, so this transform replays *before*
12
+ * `.opencode/agent/*.md` documents are applied; a file therefore updates the
13
+ * same entry afterwards and keeps its own description, system, mode and
14
+ * hidden. The `get` guard below covers hosts that replay config first (and
15
+ * keeps repeated replays from re-applying the default).
16
+ */
17
+ import { Effect } from "effect";
18
+ export const ROUTER_AGENT = "superagent-router";
19
+ export const WORKER_AGENT = "superagent-worker";
20
+ const ROUTER_SYSTEM = "你是 Super Agent Router 的会话身份;行为规则由运行时注入的 Router 提示提供。";
21
+ const WORKER_SYSTEM = `你是一个独立的 Worker,在自己的会话中完成被分派的单一任务。
22
+
23
+ - 专注任务本身,不主动联系用户。
24
+ - 结束时用简短结论说明改了什么、验证了什么、还有什么风险;这段文字会作为 report 交给 Router。
25
+ - 不要创建子任务给其他 worker。`;
26
+ export const AGENT_DEFINITIONS = [
27
+ {
28
+ id: ROUTER_AGENT,
29
+ description: "Superagent router. Talks to the human, never executes work itself.",
30
+ mode: "primary",
31
+ hidden: true,
32
+ system: ROUTER_SYSTEM,
33
+ },
34
+ {
35
+ id: WORKER_AGENT,
36
+ description: "Superagent worker. Executes one task in its own session.",
37
+ mode: "subagent",
38
+ hidden: false,
39
+ system: WORKER_SYSTEM,
40
+ },
41
+ ];
42
+ /**
43
+ * Registers the defaults on an agent editor, mutating it in place.
44
+ *
45
+ * Returns the ids the kernel refused to create. `Agent.Info.default` materializes
46
+ * an unknown id, which is what newer kernels do inside `editor.update`; older
47
+ * kernels silently ignore an unknown id, and there the returned ids are the
48
+ * signal to fall back to the markdown templates.
49
+ */
50
+ export function applyAgentDefinitions(editor) {
51
+ return AGENT_DEFINITIONS.filter((definition) => !applyDefinition(editor, definition)).map((definition) => definition.id);
52
+ }
53
+ function applyDefinition(editor, definition) {
54
+ try {
55
+ if (editor.get(definition.id) !== undefined)
56
+ return true;
57
+ editor.update(definition.id, (agent) => {
58
+ agent.description = definition.description;
59
+ agent.mode = definition.mode;
60
+ agent.hidden = definition.hidden;
61
+ agent.system = definition.system;
62
+ });
63
+ return editor.get(definition.id) !== undefined;
64
+ }
65
+ catch {
66
+ // A transform that throws breaks every agent read for the whole process, so a
67
+ // kernel that rejects the edit degrades to "not registered" instead.
68
+ return false;
69
+ }
70
+ }
71
+ export const registerAgents = Effect.fn("superagent.registerAgents")(function* (ctx) {
72
+ const unregistered = new Set();
73
+ yield* ctx.agent.transform((editor) => {
74
+ for (const id of applyAgentDefinitions(editor))
75
+ unregistered.add(id);
76
+ });
77
+ // Force one replay so an unsupported kernel is detected during setup rather than
78
+ // on the first agent read.
79
+ yield* ctx.agent.list().pipe(Effect.ignore);
80
+ if (unregistered.size === 0)
81
+ return;
82
+ yield* Effect.logWarning("superagent could not register its agents on this kernel; copy agent/*.md into .opencode/agent instead", { agents: [...unregistered] });
83
+ });
@@ -0,0 +1,20 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Effect } from "effect";
3
+ /**
4
+ * Chain-recall block for the current thread: active tickets plus the newest
5
+ * workers, each as id + state + one-line summary + report handle. Only IDs and
6
+ * summaries are injected; `worker_detail`, `report_read`, `thread_detail` and
7
+ * `ticket_list` fetch the bodies on demand. The whole block is capped so a
8
+ * long summary cannot crowd out the router prompt.
9
+ */
10
+ export declare function buildRecallInjection(threadID: string | undefined): string;
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.
15
+ *
16
+ * The status line carries the ticket counters and the current thread's
17
+ * in-progress ticket ids/titles, so a follow-up can be linked back to an
18
+ * existing ticket without a tool call.
19
+ */
20
+ export declare const registerContext: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
@@ -0,0 +1,98 @@
1
+ import { Effect } from "effect";
2
+ import { directorySummary } from "./dirs.js";
3
+ import { ROUTER_PROMPT } from "./prompts.js";
4
+ import { registerRecall } from "./recall.js";
5
+ import { store } from "./state.js";
6
+ import { activeTicketSummary, currentTrigger, ticketStats } from "./ticket.js";
7
+ import { displayWidth, ROUTER_TOOLS } from "./tools.js";
8
+ /** How many recent workers the recall block names; their full text stays behind tools. */
9
+ const RECALL_WORKER_LIMIT = 3;
10
+ /** A recall line must stay readable in a narrow terminal. */
11
+ const RECALL_LINE_MAX = 100;
12
+ /** Whole recall block budget: ids and one-line summaries only, never report text. */
13
+ const RECALL_BUDGET = 800;
14
+ function flatten(text) {
15
+ return text.replace(/\s+/g, " ").trim();
16
+ }
17
+ function clip(text, max) {
18
+ if (max <= 0)
19
+ return "";
20
+ return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`;
21
+ }
22
+ /** One worker line inside 100 cells; the session id stays whole so it can be copied. */
23
+ function workerLine(worker) {
24
+ const head = `- ${worker.sessionID} ${worker.state}`;
25
+ const tail = " | report_read";
26
+ const room = RECALL_LINE_MAX - displayWidth(head) - displayWidth(tail) - 3;
27
+ const summary = flatten(worker.lastReport ?? worker.title ?? "");
28
+ return `${head} | ${clip(summary, Math.max(8, room))}${tail}`;
29
+ }
30
+ /**
31
+ * Chain-recall block for the current thread: active tickets plus the newest
32
+ * workers, each as id + state + one-line summary + report handle. Only IDs and
33
+ * summaries are injected; `worker_detail`, `report_read`, `thread_detail` and
34
+ * `ticket_list` fetch the bodies on demand. The whole block is capped so a
35
+ * long summary cannot crowd out the router prompt.
36
+ */
37
+ export function buildRecallInjection(threadID) {
38
+ if (threadID === undefined)
39
+ return "";
40
+ const active = activeTicketSummary(threadID, 3);
41
+ const lines = ["## 链式召回(只给 ID 与摘要,正文按需取)"];
42
+ if (active === "") {
43
+ lines.push("进行中工单(当前 thread):无");
44
+ }
45
+ else {
46
+ lines.push("进行中工单(当前 thread):");
47
+ for (const entry of active.split("; "))
48
+ lines.push(clip(`- ${entry}`, RECALL_LINE_MAX));
49
+ }
50
+ const workers = [...store.workers.values()]
51
+ .filter((worker) => worker.threadID === threadID || worker.threads.includes(threadID))
52
+ .sort((left, right) => right.createdAt - left.createdAt)
53
+ .slice(0, RECALL_WORKER_LIMIT);
54
+ lines.push(`最近 Worker(当前 thread,最多 ${RECALL_WORKER_LIMIT}):`);
55
+ if (workers.length === 0)
56
+ lines.push("无");
57
+ for (const worker of workers)
58
+ lines.push(workerLine(worker));
59
+ lines.push("取正文:worker_detail / report_read / thread_detail / ticket_list");
60
+ const text = lines.join("\n");
61
+ if (text.length <= RECALL_BUDGET)
62
+ return text;
63
+ // Pathological titles/summaries: shrink every line instead of dropping whole
64
+ // lines, so each worker/ticket ID stays visible while the block fits budget.
65
+ const perLine = Math.max(24, Math.floor(RECALL_BUDGET / lines.length) - 1);
66
+ return clip(lines.map((line) => clip(line, perLine)).join("\n"), RECALL_BUDGET);
67
+ }
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.
72
+ *
73
+ * The status line carries the ticket counters and the current thread's
74
+ * in-progress ticket ids/titles, so a follow-up can be linked back to an
75
+ * existing ticket without a tool call.
76
+ */
77
+ export const registerContext = Effect.fn("superagent.registerContext")(function* (ctx) {
78
+ yield* ctx.session.hook("context", (event) => Effect.sync(() => {
79
+ if (store.routerID === undefined || event.sessionID !== store.routerID)
80
+ return;
81
+ for (const name of Object.keys(event.tools)) {
82
+ if (!ROUTER_TOOLS.has(name))
83
+ delete event.tools[name];
84
+ }
85
+ const stats = ticketStats();
86
+ const threadID = currentTrigger()?.threadID;
87
+ const active = activeTicketSummary(threadID);
88
+ const tickets = `tickets=open${stats.open}/in_progress${stats.inProgress}/blocked${stats.blocked}/closed${stats.closed}`;
89
+ const focus = active === "" ? "" : `\n进行中工单(当前 thread):${active}`;
90
+ const recall = buildRecallInjection(threadID);
91
+ const chain = recall === "" ? "" : `\n\n${recall}`;
92
+ event.system.push({
93
+ type: "text",
94
+ text: `${ROUTER_PROMPT}\n\nStatus: workers=${store.workers.size} pending=${store.pending.length} triggers=${store.triggerTotal} ${tickets}. ${directorySummary()}.${focus}${chain}`,
95
+ });
96
+ }));
97
+ yield* registerRecall(ctx);
98
+ });
@@ -0,0 +1,45 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Effect } from "effect";
3
+ /**
4
+ * Router 自管目录记忆:单网关进程服务多目录,Router 从自然语言推断
5
+ * 项目指向并经 `dispatch({ directory })` 落到目标目录。
6
+ *
7
+ * - `dir:thread:<threadID>` -> 绝对路径:该聊天/话题上次使用的目录,命中则复用。
8
+ * - `dir:alias:<alias小写>` -> 绝对路径:别名(如 `ai-gw`)到目录的映射,basename 自动登记。
9
+ *
10
+ * 网关进程本身的目录(`ctx.location.directory`)只是缺省值,不重要。
11
+ */
12
+ export declare const DIR_THREAD_PREFIX = "dir:thread:";
13
+ export declare const DIR_ALIAS_PREFIX = "dir:alias:";
14
+ export declare const threadDirKey: (threadID: string) => string;
15
+ export declare const aliasDirKey: (alias: string) => string;
16
+ /** threadID -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
17
+ export declare const threadDirs: Map<string, string>;
18
+ /** 小写别名 -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
19
+ export declare const aliasDirs: Map<string, string>;
20
+ export declare function isValidDirectory(dir: string): boolean;
21
+ export declare function normalizeDirectory(dir: string): string | undefined;
22
+ /** 目录 basename 小写即默认别名(`/opt/ai-gw` -> `ai-gw`)。 */
23
+ export declare function basenameAlias(dir: string): string;
24
+ /** 启动时把 KV 中的目录映射读回内存;坏条目直接清理。 */
25
+ export declare const hydrateDirs: (ctx: Plugin.Context) => Effect.Effect<void, never, never>;
26
+ export interface ResolveDirectoryInput {
27
+ readonly threadID?: string;
28
+ readonly task?: string;
29
+ readonly requested?: string;
30
+ }
31
+ /**
32
+ * 目录解析优先级:显式 directory > thread 绑定 > 任务文本中的已知别名 >
33
+ * 调用方缺省(网关目录)。返回 `undefined` 时调用方用网关默认目录。
34
+ */
35
+ export declare function resolveDirectory(input: ResolveDirectoryInput): string | undefined;
36
+ /** 状态行/Spill 用的紧凑摘要(别名全列,thread 只记数量避免刷屏)。 */
37
+ export declare function directorySummary(): string;
38
+ /** 已知 thread 绑定的单行提示,拼到 flush 给 Router 的 trigger 文本后面。 */
39
+ export declare function threadDirHint(threadID: string): string | undefined;
40
+ export declare const rememberDirectory: (ctx: Plugin.Context, input: {
41
+ readonly threadID?: string;
42
+ readonly directory: string;
43
+ }) => Effect.Effect<void, never, never>;
44
+ /** `/dir` 手动切 Router 目录时同步记住 basename 别名,不改 thread 绑定。 */
45
+ export declare const rememberAlias: (ctx: Plugin.Context, directory: string) => Effect.Effect<void, never, never>;
@@ -0,0 +1,129 @@
1
+ import { Effect } from "effect";
2
+ /**
3
+ * Router 自管目录记忆:单网关进程服务多目录,Router 从自然语言推断
4
+ * 项目指向并经 `dispatch({ directory })` 落到目标目录。
5
+ *
6
+ * - `dir:thread:<threadID>` -> 绝对路径:该聊天/话题上次使用的目录,命中则复用。
7
+ * - `dir:alias:<alias小写>` -> 绝对路径:别名(如 `ai-gw`)到目录的映射,basename 自动登记。
8
+ *
9
+ * 网关进程本身的目录(`ctx.location.directory`)只是缺省值,不重要。
10
+ */
11
+ export const DIR_THREAD_PREFIX = "dir:thread:";
12
+ export const DIR_ALIAS_PREFIX = "dir:alias:";
13
+ export const threadDirKey = (threadID) => `${DIR_THREAD_PREFIX}${threadID}`;
14
+ export const aliasDirKey = (alias) => `${DIR_ALIAS_PREFIX}${alias.toLowerCase()}`;
15
+ /** threadID -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
16
+ export const threadDirs = new Map();
17
+ /** 小写别名 -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
18
+ export const aliasDirs = new Map();
19
+ export function isValidDirectory(dir) {
20
+ return dir.startsWith("/") && dir.length >= 1 && dir.length <= 500;
21
+ }
22
+ export function normalizeDirectory(dir) {
23
+ const trimmed = dir.trim();
24
+ if (!isValidDirectory(trimmed))
25
+ return undefined;
26
+ if (trimmed.length > 1 && trimmed.endsWith("/"))
27
+ return trimmed.slice(0, -1);
28
+ return trimmed;
29
+ }
30
+ /** 目录 basename 小写即默认别名(`/opt/ai-gw` -> `ai-gw`)。 */
31
+ export function basenameAlias(dir) {
32
+ const clean = dir.length > 1 && dir.endsWith("/") ? dir.slice(0, -1) : dir;
33
+ const parts = clean.split("/").filter((part) => part !== "");
34
+ return (parts[parts.length - 1] ?? "").toLowerCase();
35
+ }
36
+ function asString(value) {
37
+ return typeof value === "string" ? value : undefined;
38
+ }
39
+ /** 启动时把 KV 中的目录映射读回内存;坏条目直接清理。 */
40
+ export const hydrateDirs = Effect.fn("superagent.hydrateDirs")(function* (ctx) {
41
+ for (const prefix of [DIR_THREAD_PREFIX, DIR_ALIAS_PREFIX]) {
42
+ let after;
43
+ do {
44
+ const page = yield* ctx.storage
45
+ .scan({ prefix, after, limit: 100 })
46
+ .pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
47
+ for (const entry of page.entries) {
48
+ const dir = asString(entry.value);
49
+ if (dir === undefined || !isValidDirectory(dir)) {
50
+ yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
51
+ continue;
52
+ }
53
+ if (prefix === DIR_THREAD_PREFIX)
54
+ threadDirs.set(entry.key.slice(prefix.length), dir);
55
+ else
56
+ aliasDirs.set(entry.key.slice(prefix.length).toLowerCase(), dir);
57
+ }
58
+ after = page.next;
59
+ } while (after !== undefined);
60
+ }
61
+ });
62
+ /**
63
+ * 目录解析优先级:显式 directory > thread 绑定 > 任务文本中的已知别名 >
64
+ * 调用方缺省(网关目录)。返回 `undefined` 时调用方用网关默认目录。
65
+ */
66
+ export function resolveDirectory(input) {
67
+ if (input.requested !== undefined) {
68
+ const normalized = normalizeDirectory(input.requested);
69
+ if (normalized !== undefined)
70
+ return normalized;
71
+ }
72
+ if (input.threadID !== undefined) {
73
+ const bound = threadDirs.get(input.threadID);
74
+ if (bound !== undefined)
75
+ return bound;
76
+ }
77
+ const task = input.task?.toLowerCase() ?? "";
78
+ if (task !== "") {
79
+ const aliases = [...aliasDirs.entries()].sort((left, right) => right[0].length - left[0].length);
80
+ for (const [alias, dir] of aliases) {
81
+ if (alias.length >= 2 && task.includes(alias))
82
+ return dir;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ /** 状态行/Spill 用的紧凑摘要(别名全列,thread 只记数量避免刷屏)。 */
88
+ export function directorySummary() {
89
+ const aliases = [...aliasDirs.entries()].map(([alias, dir]) => `${alias}=${dir}`).join(",");
90
+ return `dirs: threads=${threadDirs.size} aliases=${aliases === "" ? "-" : aliases}`;
91
+ }
92
+ /** 已知 thread 绑定的单行提示,拼到 flush 给 Router 的 trigger 文本后面。 */
93
+ export function threadDirHint(threadID) {
94
+ const bound = threadDirs.get(threadID);
95
+ if (bound === undefined)
96
+ return undefined;
97
+ return `已知目录:${bound}(该 thread 上次使用,命中则复用 dispatch directory=${bound};换目录需用户确认)`;
98
+ }
99
+ export const rememberDirectory = Effect.fn("superagent.rememberDirectory")(function* (ctx, input) {
100
+ const normalized = normalizeDirectory(input.directory);
101
+ if (normalized === undefined)
102
+ return;
103
+ if (input.threadID !== undefined) {
104
+ threadDirs.set(input.threadID, normalized);
105
+ yield* ctx.storage
106
+ .set(threadDirKey(input.threadID), normalized)
107
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread directory", { cause })));
108
+ }
109
+ const alias = basenameAlias(normalized);
110
+ if (alias !== "") {
111
+ aliasDirs.set(alias, normalized);
112
+ yield* ctx.storage
113
+ .set(aliasDirKey(alias), normalized)
114
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist alias directory", { cause })));
115
+ }
116
+ });
117
+ /** `/dir` 手动切 Router 目录时同步记住 basename 别名,不改 thread 绑定。 */
118
+ export const rememberAlias = Effect.fn("superagent.rememberAlias")(function* (ctx, directory) {
119
+ const normalized = normalizeDirectory(directory);
120
+ if (normalized === undefined)
121
+ return;
122
+ const alias = basenameAlias(normalized);
123
+ if (alias === "")
124
+ return;
125
+ aliasDirs.set(alias, normalized);
126
+ yield* ctx.storage
127
+ .set(aliasDirKey(alias), normalized)
128
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist alias directory", { cause })));
129
+ });
@@ -0,0 +1,38 @@
1
+ import { type Plugin } from "@opencode/plugin/effect";
2
+ import { Tool } from "@opencode/schema/tool";
3
+ import { Effect, Schema } from "effect";
4
+ export declare const DispatchInput: Schema.Struct<{
5
+ readonly task: Schema.String;
6
+ readonly title: Schema.optionalKey<Schema.String>;
7
+ readonly threadID: Schema.optionalKey<Schema.String>;
8
+ readonly agent: Schema.optionalKey<Schema.String>;
9
+ readonly directory: Schema.optionalKey<Schema.String>;
10
+ readonly ticketID: Schema.String;
11
+ }>;
12
+ interface DispatchInput {
13
+ readonly task: string;
14
+ readonly title?: string;
15
+ readonly threadID?: string;
16
+ readonly agent?: string;
17
+ readonly directory?: string;
18
+ readonly ticketID: string;
19
+ }
20
+ /**
21
+ * The one dispatch implementation, shared by the `dispatch` tool and the
22
+ * `dispatch` RPC. Both paths pass the same ticket gate: `ticketID` is required
23
+ * by the schema and re-checked here, so a direct RPC call cannot skip the
24
+ * work order either.
25
+ *
26
+ * There is deliberately no `internal` bypass: no watchdog/resume path calls
27
+ * dispatch today (the watchdog reports to the Router and resume prompts an
28
+ * existing worker session), so every caller of this function is a real task
29
+ * dispatch and must be backed by a ticket.
30
+ */
31
+ export declare const runDispatch: (ctx: Plugin.Context, input: DispatchInput, caller: string) => Effect.Effect<{
32
+ output: {
33
+ sessionID: string & import("effect/Brand").Brand<"SessionID">;
34
+ };
35
+ content: string;
36
+ }, Tool.Error, never>;
37
+ export declare const registerTools: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
38
+ export {};