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.
- package/LICENSE +21 -0
- package/README.md +336 -2
- package/agent/superagent-router.md +7 -0
- package/agent/superagent-worker.md +10 -0
- package/dist/server.d.ts +237 -0
- package/dist/server.js +148 -0
- package/dist/src/abort.d.ts +31 -0
- package/dist/src/abort.js +171 -0
- package/dist/src/agents.d.ts +51 -0
- package/dist/src/agents.js +83 -0
- package/dist/src/context.d.ts +27 -0
- package/dist/src/context.js +114 -0
- package/dist/src/dirs.d.ts +45 -0
- package/dist/src/dirs.js +129 -0
- package/dist/src/dispatch.d.ts +38 -0
- package/dist/src/dispatch.js +296 -0
- package/dist/src/format.d.ts +3 -0
- package/dist/src/format.js +14 -0
- package/dist/src/guard.d.ts +4 -0
- package/dist/src/guard.js +9 -0
- package/dist/src/prompts.d.ts +1 -0
- package/dist/src/prompts.js +37 -0
- package/dist/src/protocol.d.ts +219 -0
- package/dist/src/protocol.js +95 -0
- package/dist/src/recall.d.ts +58 -0
- package/dist/src/recall.js +309 -0
- package/dist/src/report.d.ts +78 -0
- package/dist/src/report.js +176 -0
- package/dist/src/router.d.ts +34 -0
- package/dist/src/router.js +234 -0
- package/dist/src/session-meta.d.ts +42 -0
- package/dist/src/session-meta.js +91 -0
- package/dist/src/sleep.d.ts +35 -0
- package/dist/src/sleep.js +58 -0
- package/dist/src/state.d.ts +126 -0
- package/dist/src/state.js +480 -0
- package/dist/src/ticket-tools.d.ts +45 -0
- package/dist/src/ticket-tools.js +148 -0
- package/dist/src/ticket-view.d.ts +62 -0
- package/dist/src/ticket-view.js +135 -0
- package/dist/src/ticket.d.ts +253 -0
- package/dist/src/ticket.js +717 -0
- package/dist/src/tools.d.ts +85 -0
- package/dist/src/tools.js +392 -0
- package/dist/src/watchdog.d.ts +23 -0
- package/dist/src/watchdog.js +94 -0
- package/dist/src/watcher.d.ts +17 -0
- package/dist/src/watcher.js +165 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1057 -0
- 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
|
+
});
|