opencode-superagent 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +336 -2
  3. package/agent/superagent-router.md +7 -0
  4. package/agent/superagent-worker.md +10 -0
  5. package/dist/server.d.ts +237 -0
  6. package/dist/server.js +148 -0
  7. package/dist/src/abort.d.ts +31 -0
  8. package/dist/src/abort.js +171 -0
  9. package/dist/src/agents.d.ts +51 -0
  10. package/dist/src/agents.js +83 -0
  11. package/dist/src/context.d.ts +27 -0
  12. package/dist/src/context.js +114 -0
  13. package/dist/src/dirs.d.ts +45 -0
  14. package/dist/src/dirs.js +129 -0
  15. package/dist/src/dispatch.d.ts +38 -0
  16. package/dist/src/dispatch.js +296 -0
  17. package/dist/src/format.d.ts +3 -0
  18. package/dist/src/format.js +14 -0
  19. package/dist/src/guard.d.ts +4 -0
  20. package/dist/src/guard.js +9 -0
  21. package/dist/src/prompts.d.ts +1 -0
  22. package/dist/src/prompts.js +37 -0
  23. package/dist/src/protocol.d.ts +219 -0
  24. package/dist/src/protocol.js +95 -0
  25. package/dist/src/recall.d.ts +58 -0
  26. package/dist/src/recall.js +309 -0
  27. package/dist/src/report.d.ts +78 -0
  28. package/dist/src/report.js +176 -0
  29. package/dist/src/router.d.ts +34 -0
  30. package/dist/src/router.js +234 -0
  31. package/dist/src/session-meta.d.ts +42 -0
  32. package/dist/src/session-meta.js +91 -0
  33. package/dist/src/sleep.d.ts +35 -0
  34. package/dist/src/sleep.js +58 -0
  35. package/dist/src/state.d.ts +126 -0
  36. package/dist/src/state.js +480 -0
  37. package/dist/src/ticket-tools.d.ts +45 -0
  38. package/dist/src/ticket-tools.js +148 -0
  39. package/dist/src/ticket-view.d.ts +62 -0
  40. package/dist/src/ticket-view.js +135 -0
  41. package/dist/src/ticket.d.ts +253 -0
  42. package/dist/src/ticket.js +717 -0
  43. package/dist/src/tools.d.ts +85 -0
  44. package/dist/src/tools.js +392 -0
  45. package/dist/src/watchdog.d.ts +23 -0
  46. package/dist/src/watchdog.js +94 -0
  47. package/dist/src/watcher.d.ts +17 -0
  48. package/dist/src/watcher.js +165 -0
  49. package/dist/tui.d.ts +3 -0
  50. package/dist/tui.js +1057 -0
  51. package/package.json +80 -4
@@ -0,0 +1,219 @@
1
+ import { Schema } from "effect";
2
+ /**
3
+ * Server-side RPC contract for the Super-agent P0 prototype.
4
+ *
5
+ * The TUI (`tui.tsx`) calls these methods through `client.rpc(Definition)`,
6
+ * so the id, method names and field names are fixed. Schemas are wrapped
7
+ * with `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is
8
+ * what makes the same definition usable from both the Effect server
9
+ * (`ctx.rpc.register`) and the Promise TUI client.
10
+ */
11
+ export declare const Definition: {
12
+ readonly id: "opencode.superagent";
13
+ readonly methods: {
14
+ router: {
15
+ input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
16
+ output: import("effect/StandardSchema").StandardSchemaV1<{
17
+ readonly sessionID: string;
18
+ }, {
19
+ readonly sessionID: string;
20
+ }> & Schema.Struct<{
21
+ readonly sessionID: Schema.String;
22
+ }>;
23
+ };
24
+ status: {
25
+ input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
26
+ output: import("effect/StandardSchema").StandardSchemaV1<{
27
+ readonly router?: string | undefined;
28
+ readonly pending: number;
29
+ readonly workers: readonly {
30
+ readonly sessionID: string;
31
+ readonly title?: string | undefined;
32
+ readonly threads: readonly string[];
33
+ readonly state: "aborted" | "done" | "failed" | "idle" | "running";
34
+ }[];
35
+ }, {
36
+ readonly router?: string | undefined;
37
+ readonly pending: number;
38
+ readonly workers: readonly {
39
+ readonly sessionID: string;
40
+ readonly title?: string | undefined;
41
+ readonly threads: readonly string[];
42
+ readonly state: "aborted" | "done" | "failed" | "idle" | "running";
43
+ }[];
44
+ }> & Schema.Struct<{
45
+ readonly router: Schema.optionalKey<Schema.String>;
46
+ readonly pending: Schema.Number;
47
+ readonly workers: Schema.$Array<Schema.Struct<{
48
+ readonly sessionID: Schema.String;
49
+ readonly title: Schema.optionalKey<Schema.String>;
50
+ readonly threads: Schema.$Array<Schema.String>;
51
+ readonly state: Schema.Literals<readonly ["running", "idle", "failed", "done", "aborted"]>;
52
+ }>>;
53
+ }>;
54
+ };
55
+ tickets: {
56
+ input: import("effect/StandardSchema").StandardSchemaV1<{
57
+ readonly threadID?: string | undefined;
58
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
59
+ readonly limit?: number | undefined;
60
+ }, {
61
+ readonly threadID?: string | undefined;
62
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
63
+ readonly limit?: number | undefined;
64
+ }> & Schema.Struct<{
65
+ readonly threadID: Schema.optionalKey<Schema.String>;
66
+ readonly status: Schema.optionalKey<Schema.Literals<readonly ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"]>>;
67
+ readonly limit: Schema.optionalKey<Schema.Number>;
68
+ }>;
69
+ output: import("effect/StandardSchema").StandardSchemaV1<{
70
+ readonly text: string;
71
+ }, {
72
+ readonly text: string;
73
+ }> & Schema.Struct<{
74
+ readonly text: Schema.String;
75
+ }>;
76
+ errors: {
77
+ "tickets.invalid_status": import("effect/StandardSchema").StandardSchemaV1<{
78
+ readonly status: string;
79
+ }, {
80
+ readonly status: string;
81
+ }> & Schema.Struct<{
82
+ readonly status: Schema.String;
83
+ }>;
84
+ };
85
+ };
86
+ ticket: {
87
+ input: import("effect/StandardSchema").StandardSchemaV1<{
88
+ readonly ticketID: string;
89
+ }, {
90
+ readonly ticketID: string;
91
+ }> & Schema.Struct<{
92
+ readonly ticketID: Schema.String;
93
+ }>;
94
+ output: import("effect/StandardSchema").StandardSchemaV1<{
95
+ readonly text: string;
96
+ }, {
97
+ readonly text: string;
98
+ }> & Schema.Struct<{
99
+ readonly text: Schema.String;
100
+ }>;
101
+ };
102
+ dispatch: {
103
+ input: import("effect/StandardSchema").StandardSchemaV1<{
104
+ readonly task: string;
105
+ readonly title?: string | undefined;
106
+ readonly threadID?: string | undefined;
107
+ readonly agent?: string | undefined;
108
+ readonly directory?: string | undefined;
109
+ readonly ticketID: string;
110
+ }, {
111
+ readonly task: string;
112
+ readonly title?: string | undefined;
113
+ readonly threadID?: string | undefined;
114
+ readonly agent?: string | undefined;
115
+ readonly directory?: string | undefined;
116
+ readonly ticketID: string;
117
+ }> & Schema.Struct<{
118
+ readonly task: Schema.String;
119
+ readonly title: Schema.optionalKey<Schema.String>;
120
+ readonly threadID: Schema.optionalKey<Schema.String>;
121
+ readonly agent: Schema.optionalKey<Schema.String>;
122
+ readonly directory: Schema.optionalKey<Schema.String>;
123
+ readonly ticketID: Schema.String;
124
+ }>;
125
+ output: import("effect/StandardSchema").StandardSchemaV1<{
126
+ readonly sessionID: string;
127
+ }, {
128
+ readonly sessionID: string;
129
+ }> & Schema.Struct<{
130
+ readonly sessionID: Schema.String;
131
+ }>;
132
+ errors: {
133
+ "dispatch.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
134
+ };
135
+ };
136
+ trigger: {
137
+ input: import("effect/StandardSchema").StandardSchemaV1<{
138
+ readonly id?: string | undefined;
139
+ readonly source: string;
140
+ readonly threadID: string;
141
+ readonly author?: string | undefined;
142
+ readonly text: string;
143
+ readonly title?: string | undefined;
144
+ readonly createdAt?: number | undefined;
145
+ }, {
146
+ readonly id?: string | undefined;
147
+ readonly source: string;
148
+ readonly threadID: string;
149
+ readonly author?: string | undefined;
150
+ readonly text: string;
151
+ readonly title?: string | undefined;
152
+ readonly createdAt?: number | undefined;
153
+ }> & Schema.Struct<{
154
+ readonly id: Schema.optionalKey<Schema.String>;
155
+ readonly source: Schema.String;
156
+ readonly threadID: Schema.String;
157
+ readonly author: Schema.optionalKey<Schema.String>;
158
+ readonly text: Schema.String;
159
+ readonly title: Schema.optionalKey<Schema.String>;
160
+ readonly createdAt: Schema.optionalKey<Schema.Number>;
161
+ }>;
162
+ output: import("effect/StandardSchema").StandardSchemaV1<{
163
+ readonly route: "dropped" | "router" | "worker";
164
+ readonly sessionID?: string | undefined;
165
+ }, {
166
+ readonly route: "dropped" | "router" | "worker";
167
+ readonly sessionID?: string | undefined;
168
+ }> & Schema.Struct<{
169
+ readonly route: Schema.Literals<readonly ["router", "worker", "dropped"]>;
170
+ readonly sessionID: Schema.optionalKey<Schema.String>;
171
+ }>;
172
+ };
173
+ stop: {
174
+ input: import("effect/StandardSchema").StandardSchemaV1<{
175
+ readonly sessionID: string;
176
+ }, {
177
+ readonly sessionID: string;
178
+ }> & Schema.Struct<{
179
+ readonly sessionID: Schema.String;
180
+ }>;
181
+ output: import("effect/StandardSchema").StandardSchemaV1<{
182
+ readonly interrupted: boolean;
183
+ }, {
184
+ readonly interrupted: boolean;
185
+ }> & Schema.Struct<{
186
+ readonly interrupted: Schema.Boolean;
187
+ }>;
188
+ };
189
+ abort: {
190
+ input: import("effect/StandardSchema").StandardSchemaV1<{
191
+ readonly sessionID?: string | undefined;
192
+ readonly reason?: string | undefined;
193
+ readonly all?: boolean | undefined;
194
+ readonly stale?: boolean | undefined;
195
+ }, {
196
+ readonly sessionID?: string | undefined;
197
+ readonly reason?: string | undefined;
198
+ readonly all?: boolean | undefined;
199
+ readonly stale?: boolean | undefined;
200
+ }> & Schema.Struct<{
201
+ readonly sessionID: Schema.optionalKey<Schema.String>;
202
+ readonly reason: Schema.optionalKey<Schema.String>;
203
+ readonly all: Schema.optionalKey<Schema.Boolean>;
204
+ readonly stale: Schema.optionalKey<Schema.Boolean>;
205
+ }>;
206
+ output: import("effect/StandardSchema").StandardSchemaV1<{
207
+ readonly text: string;
208
+ }, {
209
+ readonly text: string;
210
+ }> & Schema.Struct<{
211
+ readonly text: Schema.String;
212
+ }>;
213
+ errors: {
214
+ "abort.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
215
+ };
216
+ };
217
+ };
218
+ readonly events: {};
219
+ };
@@ -0,0 +1,95 @@
1
+ import { Rpc } from "@opencode/plugin/effect";
2
+ import { Schema } from "effect";
3
+ // `ticket-view` carries the TUI-safe copy of the state list (it must not import
4
+ // the KV-backed store); `test/ticket-view.test.ts` keeps it in step with
5
+ // `src/ticket.ts`, so reusing it here keeps the RPC enum from drifting.
6
+ import { TICKET_STATES } from "./ticket-view.js";
7
+ /**
8
+ * Server-side RPC contract for the Super-agent P0 prototype.
9
+ *
10
+ * The TUI (`tui.tsx`) calls these methods through `client.rpc(Definition)`,
11
+ * so the id, method names and field names are fixed. Schemas are wrapped
12
+ * with `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is
13
+ * what makes the same definition usable from both the Effect server
14
+ * (`ctx.rpc.register`) and the Promise TUI client.
15
+ */
16
+ export const Definition = Rpc.define({
17
+ id: "opencode.superagent",
18
+ methods: {
19
+ router: {
20
+ input: Schema.toStandardSchemaV1(Schema.Struct({})),
21
+ output: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
22
+ },
23
+ status: {
24
+ input: Schema.toStandardSchemaV1(Schema.Struct({})),
25
+ output: Schema.toStandardSchemaV1(Schema.Struct({
26
+ router: Schema.optionalKey(Schema.String),
27
+ pending: Schema.Number,
28
+ workers: Schema.Array(Schema.Struct({
29
+ sessionID: Schema.String,
30
+ title: Schema.optionalKey(Schema.String),
31
+ threads: Schema.Array(Schema.String),
32
+ state: Schema.Literals(["running", "idle", "failed", "done", "aborted"]),
33
+ })),
34
+ })),
35
+ },
36
+ tickets: {
37
+ input: Schema.toStandardSchemaV1(Schema.Struct({
38
+ threadID: Schema.optionalKey(Schema.String),
39
+ status: Schema.optionalKey(Schema.Literals(TICKET_STATES)),
40
+ limit: Schema.optionalKey(Schema.Number),
41
+ })),
42
+ output: Schema.toStandardSchemaV1(Schema.Struct({ text: Schema.String })),
43
+ errors: {
44
+ "tickets.invalid_status": Schema.toStandardSchemaV1(Schema.Struct({ status: Schema.String })),
45
+ },
46
+ },
47
+ ticket: {
48
+ input: Schema.toStandardSchemaV1(Schema.Struct({ ticketID: Schema.String })),
49
+ output: Schema.toStandardSchemaV1(Schema.Struct({ text: Schema.String })),
50
+ },
51
+ dispatch: {
52
+ input: Schema.toStandardSchemaV1(Schema.Struct({
53
+ task: Schema.String,
54
+ title: Schema.optionalKey(Schema.String),
55
+ threadID: Schema.optionalKey(Schema.String),
56
+ agent: Schema.optionalKey(Schema.String),
57
+ directory: Schema.optionalKey(Schema.String),
58
+ // Required: the RPC path enforces the same work-order gate as the tool.
59
+ ticketID: Schema.String,
60
+ })),
61
+ output: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
62
+ errors: { "dispatch.rejected": Schema.toStandardSchemaV1(Schema.Struct({})) },
63
+ },
64
+ trigger: {
65
+ input: Schema.toStandardSchemaV1(Schema.Struct({
66
+ id: Schema.optionalKey(Schema.String),
67
+ source: Schema.String,
68
+ threadID: Schema.String,
69
+ author: Schema.optionalKey(Schema.String),
70
+ text: Schema.String,
71
+ title: Schema.optionalKey(Schema.String),
72
+ createdAt: Schema.optionalKey(Schema.Number),
73
+ })),
74
+ output: Schema.toStandardSchemaV1(Schema.Struct({
75
+ route: Schema.Literals(["router", "worker", "dropped"]),
76
+ sessionID: Schema.optionalKey(Schema.String),
77
+ })),
78
+ },
79
+ stop: {
80
+ input: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
81
+ output: Schema.toStandardSchemaV1(Schema.Struct({ interrupted: Schema.Boolean })),
82
+ },
83
+ abort: {
84
+ input: Schema.toStandardSchemaV1(Schema.Struct({
85
+ sessionID: Schema.optionalKey(Schema.String),
86
+ reason: Schema.optionalKey(Schema.String),
87
+ all: Schema.optionalKey(Schema.Boolean),
88
+ stale: Schema.optionalKey(Schema.Boolean),
89
+ })),
90
+ output: Schema.toStandardSchemaV1(Schema.Struct({ text: Schema.String })),
91
+ errors: { "abort.rejected": Schema.toStandardSchemaV1(Schema.Struct({})) },
92
+ },
93
+ },
94
+ events: {},
95
+ });
@@ -0,0 +1,58 @@
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 ThreadDetailInput: Schema.Struct<{
5
+ readonly threadID: Schema.String;
6
+ readonly limit: Schema.optionalKey<Schema.Number>;
7
+ readonly offset: Schema.optionalKey<Schema.Number>;
8
+ }>;
9
+ export declare const WorkerDetailInput: Schema.Struct<{
10
+ readonly sessionID: Schema.String;
11
+ readonly messages: Schema.optionalKey<Schema.Number>;
12
+ }>;
13
+ export declare const ReportReadInput: Schema.Struct<{
14
+ readonly sessionID: Schema.String;
15
+ readonly offset: Schema.optionalKey<Schema.Number>;
16
+ readonly limit: Schema.optionalKey<Schema.Number>;
17
+ }>;
18
+ interface ThreadDetailInput {
19
+ readonly threadID: string;
20
+ readonly limit?: number;
21
+ readonly offset?: number;
22
+ }
23
+ interface WorkerDetailInput {
24
+ readonly sessionID: string;
25
+ readonly messages?: number;
26
+ }
27
+ interface ReportReadInput {
28
+ readonly sessionID: string;
29
+ readonly offset?: number;
30
+ readonly limit?: number;
31
+ }
32
+ export declare const runThreadDetail: (ctx: Plugin.Context, input: ThreadDetailInput, caller: string) => Effect.Effect<{
33
+ output: {
34
+ text: string;
35
+ };
36
+ content: string;
37
+ }, Tool.Error, never>;
38
+ export declare const runWorkerDetail: (ctx: Plugin.Context, input: WorkerDetailInput, caller: string) => Effect.Effect<{
39
+ output: {
40
+ text: string;
41
+ };
42
+ content: string;
43
+ }, Tool.Error, never>;
44
+ /** Page through the full report kept at `report:<sessionID>`. */
45
+ export declare const runReportRead: (ctx: Plugin.Context, input: ReportReadInput, caller: string) => Effect.Effect<{
46
+ output: {
47
+ text: string;
48
+ };
49
+ content: string;
50
+ }, Tool.Error, never>;
51
+ /**
52
+ * Router-only recall tools plus capture of triggers delivered straight to
53
+ * workers (they never pass the pending queue, so the prompt hook records
54
+ * them from the admitted text). Both tools reuse the `status` permission
55
+ * action, which the router session already allows.
56
+ */
57
+ export declare const registerRecall: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
58
+ export {};
@@ -0,0 +1,309 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { Effect, Schema } from "effect";
3
+ import { threadDirs } from "./dirs.js";
4
+ import { requireRouter } from "./guard.js";
5
+ import { loadReport, REPORT_DEFAULT_LIMIT, REPORT_MAX_LIMIT, REPORT_TEXT_LIMIT, sliceReport } from "./report.js";
6
+ import { decodeTrigger, saveTrigger, store, toSessionID, TRIGGER_PREFIX, upsertDirectTrigger, } from "./state.js";
7
+ import { specFor, toolPermission } from "./tools.js";
8
+ export const ThreadDetailInput = Schema.Struct({
9
+ threadID: Schema.String,
10
+ limit: Schema.optionalKey(Schema.Number),
11
+ offset: Schema.optionalKey(Schema.Number),
12
+ });
13
+ export const WorkerDetailInput = Schema.Struct({
14
+ sessionID: Schema.String,
15
+ messages: Schema.optionalKey(Schema.Number),
16
+ });
17
+ export const ReportReadInput = Schema.Struct({
18
+ sessionID: Schema.String,
19
+ offset: Schema.optionalKey(Schema.Number),
20
+ limit: Schema.optionalKey(Schema.Number),
21
+ });
22
+ const DetailOutput = Schema.Struct({ text: Schema.String });
23
+ /** Single response budget: compact text, truncated with a note when over. */
24
+ const MAX_TEXT = 6000;
25
+ const THREAD_ITEM_LIMIT = 300;
26
+ /** Per-message text in `worker_detail`; overridable through plugin options. */
27
+ const MESSAGE_ITEM_LIMIT = 1500;
28
+ const MESSAGE_ITEM_MIN = 200;
29
+ const MESSAGE_ITEM_MAX = 4000;
30
+ const DEFAULT_THREAD_LIMIT = 20;
31
+ const MAX_THREAD_TAKE = 200;
32
+ /** KV fallback is bounded: one page read at a time, so a recall never fans out into per-id reads. */
33
+ const KV_SCAN_PAGE = 100;
34
+ const KV_SCAN_MAX_ENTRIES = 2_000;
35
+ /** At most this many older triggers are pulled from KV for one `thread_detail` call. */
36
+ const KV_BACKFILL_MAX = 20;
37
+ const DEFAULT_MESSAGE_COUNT = 5;
38
+ const MAX_MESSAGE_COUNT = 10;
39
+ function truncate(text, limit, hint) {
40
+ if (text.length <= limit)
41
+ return text;
42
+ const suffix = `(${text.length - limit} chars truncated)${hint === undefined ? "" : ` ${hint}`}`;
43
+ return `${text.slice(0, limit)}...${suffix}`;
44
+ }
45
+ /** Plugin option `worker_detail_message_limit`, clamped so one message cannot eat the whole budget. */
46
+ function messageItemLimit(ctx) {
47
+ const raw = ctx.options["worker_detail_message_limit"];
48
+ const value = typeof raw === "number" ? raw : Number(raw);
49
+ if (!Number.isFinite(value))
50
+ return MESSAGE_ITEM_LIMIT;
51
+ return Math.min(MESSAGE_ITEM_MAX, Math.max(MESSAGE_ITEM_MIN, Math.floor(value)));
52
+ }
53
+ function fitBudget(text) {
54
+ if (text.length <= MAX_TEXT)
55
+ return text;
56
+ return `${text.slice(0, MAX_TEXT)}\n...(truncated to ${MAX_TEXT} chars)`;
57
+ }
58
+ function workerLine(sessionID) {
59
+ const worker = store.workers.get(sessionID);
60
+ if (worker === undefined)
61
+ return `- ${sessionID} untracked`;
62
+ const threads = worker.threads.length === 0 ? "-" : worker.threads.join(",");
63
+ const dir = worker.directory ?? "-";
64
+ return `- ${worker.sessionID} ${worker.title ?? "-"} [${threads}] ${worker.state} dir=${dir} ${worker.lastReport ?? "-"}`;
65
+ }
66
+ export const runThreadDetail = Effect.fn("superagent.threadDetail")(function* (ctx, input, caller) {
67
+ yield* requireRouter(caller, "thread_detail");
68
+ const take = Math.min(MAX_THREAD_TAKE, input.limit === undefined ? DEFAULT_THREAD_LIMIT : Math.max(1, Math.floor(input.limit)));
69
+ const offset = input.offset === undefined ? 0 : Math.max(0, Math.floor(input.offset));
70
+ const memory = [...store.triggers.values()]
71
+ .filter((record) => record.threadID === input.threadID)
72
+ .sort((left, right) => left.createdAt - right.createdAt);
73
+ // Memory keeps only the newest 200 triggers. When the requested window
74
+ // starts before that, scan the trigger prefix once (paged, bounded) and keep
75
+ // the older records. This is a prefix scan, not one KV read per missing id,
76
+ // so a stale thread cannot fan out into hundreds of round trips.
77
+ let triggers = memory;
78
+ let kvFilled = 0;
79
+ let kvTruncated = false;
80
+ if (offset + take > memory.length) {
81
+ const seen = new Set(memory.map((record) => record.id));
82
+ const found = [];
83
+ let scanned = 0;
84
+ let after;
85
+ // Scan the whole bounded prefix first, then keep the newest missing
86
+ // records: stopping at the first 20 matches would return key order, not
87
+ // the most recent triggers of the thread.
88
+ while (scanned < KV_SCAN_MAX_ENTRIES) {
89
+ const page = yield* ctx.storage
90
+ .scan({ prefix: TRIGGER_PREFIX, after, limit: KV_SCAN_PAGE })
91
+ .pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
92
+ if (page.entries.length === 0)
93
+ break;
94
+ scanned += page.entries.length;
95
+ for (const entry of page.entries) {
96
+ const record = decodeTrigger(entry.value);
97
+ if (record === undefined || record.threadID !== input.threadID || seen.has(record.id))
98
+ continue;
99
+ seen.add(record.id);
100
+ found.push(record);
101
+ }
102
+ after = page.next;
103
+ if (after === undefined)
104
+ break;
105
+ }
106
+ found.sort((left, right) => right.createdAt - left.createdAt);
107
+ const older = found.slice(0, KV_BACKFILL_MAX);
108
+ // Still more pages or more matching records than the cap: say so instead of
109
+ // pretending the list is complete.
110
+ if (after !== undefined || found.length > KV_BACKFILL_MAX)
111
+ kvTruncated = true;
112
+ kvFilled = older.length;
113
+ if (older.length > 0)
114
+ triggers = [...memory, ...older].sort((left, right) => left.createdAt - right.createdAt);
115
+ }
116
+ const total = triggers.length;
117
+ const end = Math.max(0, total - offset);
118
+ const start = Math.max(0, end - take);
119
+ const shown = triggers.slice(start, end);
120
+ const workers = [...store.workers.values()].filter((worker) => worker.threadID === input.threadID || worker.threads.includes(input.threadID));
121
+ const source = kvFilled > 0 ? `memory ${memory.length} + KV ${kvFilled}` : `memory ${memory.length}`;
122
+ const lines = [
123
+ `thread ${input.threadID}: ${total} trigger(s) (${source}), ${workers.length} worker(s)`,
124
+ `directory: ${threadDirs.get(input.threadID) ?? "(no binding, use gateway default and confirm with user)"}`,
125
+ `triggers (offset=${offset}, showing ${shown.length} of ${total}):`,
126
+ ];
127
+ for (const record of shown) {
128
+ const at = new Date(record.createdAt).toISOString();
129
+ lines.push(`- [${at}] ${record.author ?? "unknown"} (${record.route ?? "queued"}): ${truncate(record.text, THREAD_ITEM_LIMIT)}`);
130
+ }
131
+ if (shown.length === 0)
132
+ lines.push("(no trigger in this range)");
133
+ if (offset >= total) {
134
+ lines.push(`offset ${offset} 超出已知 ${total} 条;thread_detail 每次最多从 KV 回源 ${KV_BACKFILL_MAX} 条。`);
135
+ }
136
+ if (kvTruncated) {
137
+ lines.push(`KV 回源已截断:本次扫描 ${KV_SCAN_MAX_ENTRIES} 条、最多补 ${KV_BACKFILL_MAX} 条,仍有更早的 trigger 未列出。`);
138
+ }
139
+ lines.push(`workers (${workers.length}):`);
140
+ for (const worker of workers)
141
+ lines.push(workerLine(worker.sessionID).slice(0, THREAD_ITEM_LIMIT));
142
+ const text = fitBudget(lines.join("\n"));
143
+ return {
144
+ output: { text },
145
+ content: text,
146
+ };
147
+ });
148
+ function assistantTexts(messages, count) {
149
+ const collected = [];
150
+ for (let index = messages.length - 1; index >= 0 && collected.length < count; index--) {
151
+ const message = messages[index];
152
+ if (typeof message !== "object" || message === null)
153
+ continue;
154
+ const typed = message;
155
+ if (typed.type !== "assistant" || !Array.isArray(typed.content))
156
+ continue;
157
+ const text = typed.content
158
+ .flatMap((part) => {
159
+ if (typeof part !== "object" || part === null)
160
+ return [];
161
+ const candidate = part;
162
+ return candidate.type === "text" && typeof candidate.text === "string" ? [candidate.text] : [];
163
+ })
164
+ .join("\n")
165
+ .trim();
166
+ if (text !== "")
167
+ collected.push(text);
168
+ }
169
+ return collected.reverse();
170
+ }
171
+ export const runWorkerDetail = Effect.fn("superagent.workerDetail")(function* (ctx, input, caller) {
172
+ yield* requireRouter(caller, "worker_detail");
173
+ const count = input.messages === undefined
174
+ ? DEFAULT_MESSAGE_COUNT
175
+ : Math.min(MAX_MESSAGE_COUNT, Math.max(1, Math.floor(input.messages)));
176
+ const worker = store.workers.get(input.sessionID);
177
+ const stored = yield* loadReport(ctx, input.sessionID);
178
+ const messages = yield* ctx.session
179
+ .context({ sessionID: toSessionID(input.sessionID) })
180
+ .pipe(Effect.catch(() => Effect.succeed([])));
181
+ const texts = assistantTexts(messages, count);
182
+ const handle = stored === undefined
183
+ ? "none (无全文记录:Worker 还没结束过,或没有 assistant 文本)"
184
+ : `${stored.length} chars${stored.truncated ? ` (仅存前 ${stored.text.length})` : ""},用 report_read 读取全文`;
185
+ const lines = worker === undefined
186
+ ? [`worker ${input.sessionID}: not tracked by the router`]
187
+ : [
188
+ `worker ${worker.sessionID} ${worker.title ?? "-"} [${worker.threads.join(",") || "-"}] ${worker.state}`,
189
+ `directory: ${worker.directory ?? "(gateway default)"}`,
190
+ `report: ${worker.lastReport ?? "none"}`,
191
+ `report full: ${handle}`,
192
+ ];
193
+ const perMessage = messageItemLimit(ctx);
194
+ const hint = `用 report_read(sessionID: "${input.sessionID}") 读取全文`;
195
+ lines.push(`recent assistant messages (last ${texts.length} of up to ${count}):`);
196
+ texts.forEach((text, index) => {
197
+ lines.push(`${index + 1}. ${truncate(text, perMessage, hint)}`);
198
+ });
199
+ if (texts.length === 0)
200
+ lines.push("(no assistant text found)");
201
+ const text = fitBudget(lines.join("\n"));
202
+ return {
203
+ output: { text },
204
+ content: text,
205
+ };
206
+ });
207
+ /** Page through the full report kept at `report:<sessionID>`. */
208
+ export const runReportRead = Effect.fn("superagent.reportRead")(function* (ctx, input, caller) {
209
+ yield* requireRouter(caller, "report_read");
210
+ const record = yield* loadReport(ctx, input.sessionID);
211
+ if (record === undefined) {
212
+ return yield* new Tool.Error({
213
+ message: `no stored report for ${input.sessionID}: 该 Worker 没有全文报告(可能还没结束过,或没有 assistant 文本);先用 worker_detail 看摘要`,
214
+ });
215
+ }
216
+ const rawOffset = input.offset ?? 0;
217
+ const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.floor(rawOffset)) : 0;
218
+ if (offset >= record.text.length) {
219
+ return yield* new Tool.Error({
220
+ message: `offset ${offset} 超出已存全文长度 ${record.text.length}${record.truncated ? `(原文 ${record.length} 字,已截断存储)` : ""}`,
221
+ });
222
+ }
223
+ const rawLimit = input.limit ?? REPORT_DEFAULT_LIMIT;
224
+ const limit = Number.isFinite(rawLimit)
225
+ ? Math.min(REPORT_MAX_LIMIT, Math.max(1, Math.floor(rawLimit)))
226
+ : REPORT_DEFAULT_LIMIT;
227
+ const slice = sliceReport(record, offset, limit);
228
+ const lines = [
229
+ `report ${record.sessionID}: 全文 ${record.length} 字,已存 ${record.text.length} 字${record.truncated ? `(超出 ${REPORT_TEXT_LIMIT} 字上限,已截断保存)` : ""}`,
230
+ `captured ${new Date(record.capturedAt).toISOString()} · 本次 [${slice.offset}, ${slice.end}) · limit=${limit}`,
231
+ "",
232
+ slice.text,
233
+ ];
234
+ if (slice.hasMore) {
235
+ lines.push("", `还有后续:report_read(sessionID: "${record.sessionID}", offset: ${slice.nextOffset})`);
236
+ }
237
+ else {
238
+ lines.push("", "已到末尾。");
239
+ }
240
+ const text = lines.join("\n");
241
+ return { output: { text }, content: text };
242
+ });
243
+ const TRIGGER_HEADER = /^\[trigger:(.+?)\] source=(\S+) thread=(\S+) author=(\S+) at=(\S+)(?:\n([\s\S]*))?$/;
244
+ /** Parse the worker-visible trigger format back into a record (see `formatTrigger`). */
245
+ function parseTriggerHeader(text) {
246
+ const match = TRIGGER_HEADER.exec(text);
247
+ if (match === null)
248
+ return undefined;
249
+ const id = match[1];
250
+ const source = match[2];
251
+ const threadID = match[3];
252
+ const author = match[4];
253
+ const at = match[5];
254
+ if (id === undefined || source === undefined || threadID === undefined || author === undefined || at === undefined) {
255
+ return undefined;
256
+ }
257
+ const parsed = Date.parse(at);
258
+ return {
259
+ id,
260
+ source,
261
+ threadID,
262
+ ...(author === "unknown" ? {} : { author }),
263
+ text: match[6] ?? "",
264
+ createdAt: Number.isNaN(parsed) ? Date.now() : parsed,
265
+ };
266
+ }
267
+ /**
268
+ * Router-only recall tools plus capture of triggers delivered straight to
269
+ * workers (they never pass the pending queue, so the prompt hook records
270
+ * them from the admitted text). Both tools reuse the `status` permission
271
+ * action, which the router session already allows.
272
+ */
273
+ export const registerRecall = Effect.fn("superagent.registerRecall")(function* (ctx) {
274
+ yield* ctx.tool.transform((editor) => {
275
+ editor.add({
276
+ name: "thread_detail",
277
+ description: specFor("thread_detail").description,
278
+ input: ThreadDetailInput,
279
+ output: DetailOutput,
280
+ options: { permission: toolPermission(specFor("thread_detail")) },
281
+ execute: (input, context) => runThreadDetail(ctx, input, context.sessionID),
282
+ });
283
+ editor.add({
284
+ name: "worker_detail",
285
+ description: specFor("worker_detail").description,
286
+ input: WorkerDetailInput,
287
+ output: DetailOutput,
288
+ options: { permission: toolPermission(specFor("worker_detail")) },
289
+ execute: (input, context) => runWorkerDetail(ctx, input, context.sessionID),
290
+ });
291
+ editor.add({
292
+ name: "report_read",
293
+ description: specFor("report_read").description,
294
+ input: ReportReadInput,
295
+ output: DetailOutput,
296
+ options: { permission: toolPermission(specFor("report_read")) },
297
+ execute: (input, context) => runReportRead(ctx, input, context.sessionID),
298
+ });
299
+ });
300
+ yield* ctx.session.hook("prompt", (event) => Effect.gen(function* () {
301
+ if (!store.workers.has(event.sessionID))
302
+ return;
303
+ const parsed = parseTriggerHeader(event.prompt.text);
304
+ if (parsed === undefined)
305
+ return;
306
+ const record = upsertDirectTrigger(parsed, event.sessionID);
307
+ yield* saveTrigger(ctx, record);
308
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to record direct trigger", { cause }))));
309
+ });