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
package/dist/server.js ADDED
@@ -0,0 +1,148 @@
1
+ import { Plugin, Rpc } from "@opencode/plugin/effect";
2
+ import { Effect, Schema } from "effect";
3
+ import { registerAbort, runAbort } from "./src/abort.js";
4
+ import { registerAgents } from "./src/agents.js";
5
+ import { registerContext } from "./src/context.js";
6
+ import { hydrateDirs } from "./src/dirs.js";
7
+ import { registerTools } from "./src/dispatch.js";
8
+ import { formatTrigger } from "./src/format.js";
9
+ import { Definition } from "./src/protocol.js";
10
+ import { hydrateReports } from "./src/report.js";
11
+ import { ensureRouter, enqueuePending, flushPending, hydratePending, registerRouterIdle } from "./src/router.js";
12
+ import { hydrateSessionMeta } from "./src/session-meta.js";
13
+ import { registerSleep } from "./src/sleep.js";
14
+ import { hydrate, store, toSessionID } from "./src/state.js";
15
+ import { hydrateTickets, TICKET_STATES } from "./src/ticket.js";
16
+ import { registerTicketTools, renderTicketText, renderTicketsText } from "./src/ticket-tools.js";
17
+ import { registerWatcher } from "./src/watcher.js";
18
+ import { registerWatchdog } from "./src/watchdog.js";
19
+ const runTrigger = Effect.fn("superagent.runTrigger")(function* (ctx, input) {
20
+ const sources = ctx.options.sources;
21
+ if (Array.isArray(sources) && !sources.includes(input.source))
22
+ return { route: "dropped" };
23
+ if (input.text.trim() === "")
24
+ return { route: "dropped" };
25
+ const formatted = formatTrigger(input);
26
+ // Sticky thread affinity: follow-ups on a known thread go straight back to
27
+ // its worker instead of spawning a fresh session per message. Running
28
+ // workers get everything; otherwise the most recent idle/done worker is
29
+ // resumed (prompting a finished session starts a new execution in it, and
30
+ // the watcher flips it back to running). Failed workers are skipped so the
31
+ // next message gets a fresh dispatch through the Router. A genuinely new
32
+ // thread (no affinity record) starts with the Router.
33
+ const subscribed = store.threads.get(input.threadID) ?? [];
34
+ const running = subscribed.filter((sessionID) => store.workers.get(sessionID)?.state === "running");
35
+ const resumed = [...subscribed].reverse().find((sessionID) => {
36
+ const state = store.workers.get(sessionID)?.state;
37
+ return state === "idle" || state === "done";
38
+ });
39
+ const targets = running.length > 0 ? running : resumed === undefined ? [] : [resumed];
40
+ if (targets.length > 0) {
41
+ const sessions = ctx.session;
42
+ let delivered = 0;
43
+ for (const target of targets) {
44
+ const ok = yield* sessions.prompt({ sessionID: toSessionID(target), text: formatted, delivery: "queue" }).pipe(Effect.as(true), Effect.catch((cause) => Effect.logWarning("superagent direct-to-worker prompt failed", { worker: target, cause }).pipe(Effect.as(false))));
45
+ if (ok)
46
+ delivered += 1;
47
+ }
48
+ if (delivered > 0)
49
+ return { route: "worker", sessionID: targets[0] };
50
+ }
51
+ yield* enqueuePending(ctx, input);
52
+ const routerID = yield* ensureRouter(ctx);
53
+ if (!store.routerBusy)
54
+ yield* flushPending(ctx);
55
+ return { route: "router", sessionID: routerID };
56
+ });
57
+ /**
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`.
61
+ */
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
+ };
72
+ /**
73
+ * Router-only RPC methods are rejected unconditionally.
74
+ *
75
+ * The RPC transport does not expose a caller identity: `RpcCallContext` only
76
+ * carries `error`. The old handler therefore passed `store.routerID` as
77
+ * `runDispatch`'s caller, which let every RPC client act as the router. Input
78
+ * fields such as `caller` / `routerID` are just as forgeable (and the router
79
+ * id is public in `status`), so they are never used as proof of identity.
80
+ * Until the host exposes the calling session on the RPC context, the safe
81
+ * behavior is to keep router-only effects off the RPC surface. The real
82
+ * router still works through its tools, whose `context.sessionID` is
83
+ * authoritative and is checked by `runDispatch` / `runAbort`.
84
+ */
85
+ export const rejectedRouterRpc = {
86
+ dispatch: (_input, context) => Effect.fail(context.error("dispatch.rejected", "dispatch is only available to the router session; RPC callers cannot prove that identity — use the router's dispatch tool", {})),
87
+ stop: (_input, context) => Effect.fail(context.error("stop.rejected", "stop is only available to the router session; RPC callers cannot prove that identity — use abort_session from the router", {})),
88
+ };
89
+ export default Plugin.define({
90
+ id: "opencode.superagent",
91
+ effect: Effect.fn(function* (ctx) {
92
+ // Tickets first: hydrating workers reconciles finished `running` records
93
+ // and closes their tickets, so the ticket store must already be loaded.
94
+ yield* hydrateTickets(ctx);
95
+ yield* hydrate(ctx);
96
+ // Before any session work: the Router and Worker sessions resolve their agent by id.
97
+ yield* registerAgents(ctx);
98
+ yield* hydratePending(ctx);
99
+ yield* hydrateDirs(ctx);
100
+ yield* hydrateSessionMeta(ctx);
101
+ yield* hydrateReports(ctx);
102
+ yield* registerTools(ctx);
103
+ yield* registerSleep(ctx);
104
+ yield* registerTicketTools(ctx);
105
+ yield* registerAbort(ctx);
106
+ yield* registerContext(ctx);
107
+ yield* registerRouterIdle(ctx);
108
+ yield* registerWatcher(ctx);
109
+ yield* registerWatchdog(ctx);
110
+ yield* ctx.rpc
111
+ .register(ServerDefinition, {
112
+ 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
+ status: () => Effect.sync(() => ({
114
+ // Omit the key entirely when no router exists: an explicit
115
+ // `undefined` value fails output validation.
116
+ ...(store.routerID === undefined ? {} : { router: store.routerID }),
117
+ pending: store.pending.length,
118
+ workers: [...store.workers.values()].map((worker) => ({
119
+ sessionID: worker.sessionID,
120
+ ...(worker.title === undefined ? {} : { title: worker.title }),
121
+ threads: [...worker.threads],
122
+ state: worker.state,
123
+ })),
124
+ })),
125
+ tickets: (input, context) => {
126
+ // The protocol literal already rejects unknown states with
127
+ // `rpc.invalid_input`; keep an explicit RPC error as a second line of
128
+ // defence so a bypassed/older client can never turn an unknown status
129
+ // into an unfiltered list.
130
+ if (input.status !== undefined && !TICKET_STATES.includes(input.status)) {
131
+ return Effect.fail(context.error("tickets.invalid_status", `invalid ticket status: ${String(input.status)}`, {
132
+ status: String(input.status),
133
+ }));
134
+ }
135
+ return Effect.sync(() => renderTicketsText({
136
+ ...(input.threadID === undefined ? {} : { threadID: input.threadID }),
137
+ ...(input.status === undefined ? {} : { status: input.status }),
138
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
139
+ })).pipe(Effect.map((text) => ({ text })));
140
+ },
141
+ ticket: (input) => Effect.sync(() => ({ text: renderTicketText(input.ticketID) })),
142
+ ...rejectedRouterRpc,
143
+ trigger: (input) => runTrigger(ctx, input).pipe(Effect.catch((cause) => Effect.logWarning("superagent trigger handler failed", { cause }).pipe(Effect.as({ route: "dropped" })))),
144
+ abort: (input, context) => runAbort(ctx, input).pipe(Effect.map((result) => ({ text: result.output.text })), Effect.catchTag("Tool.Error", (error) => Effect.fail(context.error("abort.rejected", error.message, {})))),
145
+ })
146
+ .pipe(Effect.orDie);
147
+ }),
148
+ });
@@ -0,0 +1,31 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Tool } from "@opencode/schema/tool";
3
+ import { Effect, Schema } from "effect";
4
+ /** How long a terminating call waits for an interrupted worker to settle before reporting a timeout. */
5
+ export declare const ABORT_TIMEOUT_MS = 10000;
6
+ export declare const AbortInput: Schema.Struct<{
7
+ readonly sessionID: Schema.optionalKey<Schema.String>;
8
+ readonly reason: Schema.optionalKey<Schema.String>;
9
+ readonly all: Schema.optionalKey<Schema.Boolean>;
10
+ readonly stale: Schema.optionalKey<Schema.Boolean>;
11
+ }>;
12
+ interface AbortInput {
13
+ readonly sessionID?: string;
14
+ readonly reason?: string;
15
+ readonly all?: boolean;
16
+ readonly stale?: boolean;
17
+ }
18
+ /**
19
+ * Terminate tracked workers. `caller` is set by the router tool (which then
20
+ * enforces router-only access); the TUI RPC passes nothing because the user
21
+ * drives it directly. Only sessions the plugin created can be touched: the
22
+ * router, user sessions and unknown ids are always refused.
23
+ */
24
+ export declare const runAbort: (ctx: Plugin.Context, input: AbortInput, caller?: string | undefined) => Effect.Effect<{
25
+ output: {
26
+ text: string;
27
+ };
28
+ content: string;
29
+ }, Tool.Error, never>;
30
+ export declare const registerAbort: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
31
+ export {};
@@ -0,0 +1,171 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { Effect, Option, Schema } from "effect";
3
+ import { requireRouter } from "./guard.js";
4
+ import { encodeThread, isRunning, 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
+ function abortNote(reason, detail, previous) {
17
+ const head = `aborted${reason === undefined ? "" : ` (${reason})`} [${detail}]`;
18
+ return previous === undefined ? head : `${head} | ${previous}`;
19
+ }
20
+ /**
21
+ * Drop the session from every thread affinity list it subscribed to, so later
22
+ * messages on those threads no longer target a terminated worker. An empty
23
+ * thread falls back to the router on the next message. The worker record keeps
24
+ * its `threads` for tracing.
25
+ */
26
+ const detachThreads = Effect.fn("superagent.detachThreads")(function* (ctx, sessionID) {
27
+ const worker = store.workers.get(sessionID);
28
+ if (worker === undefined)
29
+ return [];
30
+ const detached = [];
31
+ for (const threadID of worker.threads) {
32
+ const current = store.threads.get(threadID);
33
+ if (current === undefined || !current.includes(sessionID))
34
+ continue;
35
+ const next = current.filter((id) => id !== sessionID);
36
+ if (next.length === 0) {
37
+ store.threads.delete(threadID);
38
+ yield* ctx.storage.remove(threadKey(threadID)).pipe(Effect.catch(() => Effect.void));
39
+ }
40
+ else {
41
+ store.threads.set(threadID, next);
42
+ yield* ctx.storage
43
+ .set(threadKey(threadID), encodeThread({ threadID, workers: next, updatedAt: Date.now() }))
44
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
45
+ }
46
+ detached.push(threadID);
47
+ }
48
+ return detached;
49
+ });
50
+ /**
51
+ * Terminate one tracked worker. The record is marked `aborted` before the
52
+ * interrupt lands, so the watcher cannot resurrect it to `idle`/`done` when
53
+ * the interruption settles; the record itself stays in KV for tracing.
54
+ */
55
+ const abortWorker = Effect.fn("superagent.abortWorker")(function* (ctx, sessionID, reason) {
56
+ const worker = store.workers.get(sessionID);
57
+ if (worker === undefined)
58
+ return undefined;
59
+ const previous = worker.lastReport;
60
+ const info = yield* ctx.session.get({ sessionID: toSessionID(sessionID) }).pipe(Effect.option);
61
+ const running = Option.isSome(info) && isRunning(info.value);
62
+ worker.state = "aborted";
63
+ yield* saveWorker(ctx, worker);
64
+ // Terminating a worker aborts its ticket: the work did not finish, and a
65
+ // failed ticket is what the Router needs to decide on a re-dispatch.
66
+ yield* closeTicketsForWorker(ctx, {
67
+ sessionID,
68
+ outcome: "failed",
69
+ summary: `aborted${reason === undefined ? "" : `: ${reason}`}`,
70
+ note: "aborted",
71
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to abort ticket for worker", { sessionID, cause })));
72
+ const threads = yield* detachThreads(ctx, sessionID);
73
+ if (Option.isNone(info)) {
74
+ worker.lastReport = abortNote(reason, "session missing", previous);
75
+ yield* saveWorker(ctx, worker);
76
+ return { sessionID, status: "missing", interrupted: false, timedOut: false, threads };
77
+ }
78
+ // Interrupt unconditionally: when the session is not executing this is a
79
+ // harmless no-op, but it still clears a prompt queued behind the previous
80
+ // execution so it cannot start after abort returned.
81
+ const interrupted = yield* ctx.session.interrupt({ sessionID: toSessionID(sessionID) }).pipe(Effect.map((result) => result.interrupted), Effect.catch((cause) => Effect.logWarning("superagent abort interrupt failed", { sessionID, cause }).pipe(Effect.as(false))));
82
+ if (!running) {
83
+ worker.lastReport = abortNote(reason, interrupted ? "interrupted queued prompt" : "already ended", previous);
84
+ yield* saveWorker(ctx, worker);
85
+ return { sessionID, status: "already-ended", interrupted, timedOut: false, threads };
86
+ }
87
+ const settled = yield* ctx.session.wait({ sessionID: toSessionID(sessionID) }).pipe(Effect.timeoutOption(ABORT_TIMEOUT_MS), Effect.catch((cause) => Effect.logWarning("superagent abort wait failed", { sessionID, cause }).pipe(Effect.as(Option.some(undefined)))));
88
+ const timedOut = Option.isNone(settled);
89
+ const detail = timedOut ? "interrupted, settle timeout" : interrupted ? "interrupted" : "no active execution";
90
+ worker.lastReport = abortNote(reason, detail, previous);
91
+ yield* saveWorker(ctx, worker);
92
+ return { sessionID, status: "aborted", interrupted, timedOut, threads };
93
+ });
94
+ function render(results) {
95
+ if (results.length === 0) {
96
+ const text = "no tracked worker matched; nothing to abort";
97
+ return { output: { text }, content: text };
98
+ }
99
+ const counts = { aborted: 0, "already-ended": 0, missing: 0 };
100
+ const lines = [];
101
+ for (const result of results) {
102
+ counts[result.status] += 1;
103
+ const title = store.workers.get(result.sessionID)?.title ?? "-";
104
+ const detail = result.status === "aborted"
105
+ ? `interrupted=${result.interrupted}${result.timedOut ? " settle-timeout" : ""}`
106
+ : result.status;
107
+ const threads = result.threads.length === 0 ? "" : ` detached=${result.threads.join(",")}`;
108
+ lines.push(`- ${result.sessionID} ${title}: ${detail}${threads}`);
109
+ }
110
+ const summary = `aborted ${counts.aborted}, already ended ${counts["already-ended"]}, session missing ${counts.missing} (${results.length} worker(s))`;
111
+ const text = [summary, ...lines].join("\n");
112
+ return { output: { text }, content: text };
113
+ }
114
+ /**
115
+ * Terminate tracked workers. `caller` is set by the router tool (which then
116
+ * enforces router-only access); the TUI RPC passes nothing because the user
117
+ * drives it directly. Only sessions the plugin created can be touched: the
118
+ * router, user sessions and unknown ids are always refused.
119
+ */
120
+ export const runAbort = Effect.fn("superagent.abort")(function* (ctx, input, caller) {
121
+ if (caller !== undefined)
122
+ yield* requireRouter(caller, "abort_session");
123
+ const batch = input.all === true || input.stale === true;
124
+ if (batch && input.sessionID !== undefined) {
125
+ return yield* new Tool.Error({ message: "pass either sessionID or all/stale, not both" });
126
+ }
127
+ if (!batch && input.sessionID === undefined) {
128
+ return yield* new Tool.Error({
129
+ message: "abort_session needs a sessionID, or all: true / stale: true for batch cleanup",
130
+ });
131
+ }
132
+ if (input.sessionID !== undefined) {
133
+ if (input.sessionID === caller) {
134
+ return yield* new Tool.Error({ message: "cannot abort the calling session" });
135
+ }
136
+ if (store.routerID !== undefined && input.sessionID === store.routerID) {
137
+ return yield* new Tool.Error({ message: "cannot abort the router session" });
138
+ }
139
+ if (!store.workers.has(input.sessionID)) {
140
+ return yield* new Tool.Error({
141
+ message: `${input.sessionID} is not tracked by the superagent router; refusing to abort a session the plugin does not own`,
142
+ });
143
+ }
144
+ const result = yield* abortWorker(ctx, input.sessionID, input.reason);
145
+ return render(result === undefined ? [] : [result]);
146
+ }
147
+ // Batch mode never includes the router: it is not a worker record. `stale`
148
+ // only cleans records with no live work; `all` also interrupts running ones.
149
+ const targets = [...store.workers.values()]
150
+ .filter((worker) => input.all === true || worker.state !== "running")
151
+ .map((worker) => worker.sessionID);
152
+ const results = [];
153
+ for (const sessionID of targets) {
154
+ const result = yield* abortWorker(ctx, sessionID, input.reason);
155
+ if (result !== undefined)
156
+ results.push(result);
157
+ }
158
+ return render(results);
159
+ });
160
+ export const registerAbort = Effect.fn("superagent.registerAbort")(function* (ctx) {
161
+ yield* ctx.tool.transform((editor) => {
162
+ editor.add({
163
+ name: "abort_session",
164
+ description: specFor("abort_session").description,
165
+ input: AbortInput,
166
+ output: AbortOutput,
167
+ options: { permission: toolPermission(specFor("abort_session")) },
168
+ execute: (input, context) => runAbort(ctx, input, context.sessionID),
169
+ });
170
+ });
171
+ });
@@ -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,27 @@
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
+ * Narrow one request's toolset by session role. The Router keeps only the
13
+ * catalog tools; a tracked Worker loses every router-only tool (dispatch,
14
+ * sleep, tickets, recall, abort_session), so those tools are not listed to
15
+ * the worker model at all. User sessions are left untouched.
16
+ */
17
+ export declare function filterSessionTools(sessionID: string, tools: Record<string, unknown>): void;
18
+ /**
19
+ * Context hook: strip each session's toolset to what that role may see
20
+ * (Router: catalog only; Worker: catalog tools removed), then append the
21
+ * router prompt plus a short live status line for the Router.
22
+ *
23
+ * The status line carries the ticket counters and the current thread's
24
+ * in-progress ticket ids/titles, so a follow-up can be linked back to an
25
+ * existing ticket without a tool call.
26
+ */
27
+ export declare const registerContext: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
@@ -0,0 +1,114 @@
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
+ * Narrow one request's toolset by session role. The Router keeps only the
70
+ * catalog tools; a tracked Worker loses every router-only tool (dispatch,
71
+ * sleep, tickets, recall, abort_session), so those tools are not listed to
72
+ * the worker model at all. User sessions are left untouched.
73
+ */
74
+ export function filterSessionTools(sessionID, tools) {
75
+ const router = store.routerID !== undefined && sessionID === store.routerID;
76
+ const worker = store.workers.has(sessionID);
77
+ if (!router && !worker)
78
+ return;
79
+ for (const name of Object.keys(tools)) {
80
+ if (router ? !ROUTER_TOOLS.has(name) : ROUTER_TOOLS.has(name))
81
+ delete tools[name];
82
+ }
83
+ }
84
+ /**
85
+ * Context hook: strip each session's toolset to what that role may see
86
+ * (Router: catalog only; Worker: catalog tools removed), then append the
87
+ * router prompt plus a short live status line for the Router.
88
+ *
89
+ * The status line carries the ticket counters and the current thread's
90
+ * in-progress ticket ids/titles, so a follow-up can be linked back to an
91
+ * existing ticket without a tool call.
92
+ */
93
+ export const registerContext = Effect.fn("superagent.registerContext")(function* (ctx) {
94
+ yield* ctx.session.hook("context", (event) => Effect.sync(() => {
95
+ const router = store.routerID !== undefined && event.sessionID === store.routerID;
96
+ if (!router && !store.workers.has(event.sessionID))
97
+ return;
98
+ filterSessionTools(event.sessionID, event.tools);
99
+ if (!router)
100
+ return;
101
+ const stats = ticketStats();
102
+ const threadID = currentTrigger()?.threadID;
103
+ const active = activeTicketSummary(threadID);
104
+ const tickets = `tickets=open${stats.open}/in_progress${stats.inProgress}/blocked${stats.blocked}/closed${stats.closed}`;
105
+ const focus = active === "" ? "" : `\n进行中工单(当前 thread):${active}`;
106
+ const recall = buildRecallInjection(threadID);
107
+ const chain = recall === "" ? "" : `\n\n${recall}`;
108
+ event.system.push({
109
+ type: "text",
110
+ text: `${ROUTER_PROMPT}\n\nStatus: workers=${store.workers.size} pending=${store.pending.length} triggers=${store.triggerTotal} ${tickets}. ${directorySummary()}.${focus}${chain}`,
111
+ });
112
+ }));
113
+ yield* registerRecall(ctx);
114
+ });