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,45 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Effect } from "effect";
3
+ /**
4
+ * Router 自管目录记忆:单网关进程服务多目录,Router 从自然语言推断
5
+ * 项目指向并经 `dispatch({ directory })` 落到目标目录。
6
+ *
7
+ * - `dir:thread:<threadID>` -> 绝对路径:该聊天/话题上次使用的目录,命中则复用。
8
+ * - `dir:alias:<alias小写>` -> 绝对路径:别名(如 `ai-gw`)到目录的映射,basename 自动登记。
9
+ *
10
+ * 网关进程本身的目录(`ctx.location.directory`)只是缺省值,不重要。
11
+ */
12
+ export declare const DIR_THREAD_PREFIX = "dir:thread:";
13
+ export declare const DIR_ALIAS_PREFIX = "dir:alias:";
14
+ export declare const threadDirKey: (threadID: string) => string;
15
+ export declare const aliasDirKey: (alias: string) => string;
16
+ /** threadID -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
17
+ export declare const threadDirs: Map<string, string>;
18
+ /** 小写别名 -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
19
+ export declare const aliasDirs: Map<string, string>;
20
+ export declare function isValidDirectory(dir: string): boolean;
21
+ export declare function normalizeDirectory(dir: string): string | undefined;
22
+ /** 目录 basename 小写即默认别名(`/opt/ai-gw` -> `ai-gw`)。 */
23
+ export declare function basenameAlias(dir: string): string;
24
+ /** 启动时把 KV 中的目录映射读回内存;坏条目直接清理。 */
25
+ export declare const hydrateDirs: (ctx: Plugin.Context) => Effect.Effect<void, never, never>;
26
+ export interface ResolveDirectoryInput {
27
+ readonly threadID?: string;
28
+ readonly task?: string;
29
+ readonly requested?: string;
30
+ }
31
+ /**
32
+ * 目录解析优先级:显式 directory > thread 绑定 > 任务文本中的已知别名 >
33
+ * 调用方缺省(网关目录)。返回 `undefined` 时调用方用网关默认目录。
34
+ */
35
+ export declare function resolveDirectory(input: ResolveDirectoryInput): string | undefined;
36
+ /** 状态行/Spill 用的紧凑摘要(别名全列,thread 只记数量避免刷屏)。 */
37
+ export declare function directorySummary(): string;
38
+ /** 已知 thread 绑定的单行提示,拼到 flush 给 Router 的 trigger 文本后面。 */
39
+ export declare function threadDirHint(threadID: string): string | undefined;
40
+ export declare const rememberDirectory: (ctx: Plugin.Context, input: {
41
+ readonly threadID?: string;
42
+ readonly directory: string;
43
+ }) => Effect.Effect<void, never, never>;
44
+ /** `/dir` 手动切 Router 目录时同步记住 basename 别名,不改 thread 绑定。 */
45
+ export declare const rememberAlias: (ctx: Plugin.Context, directory: string) => Effect.Effect<void, never, never>;
@@ -0,0 +1,129 @@
1
+ import { Effect } from "effect";
2
+ /**
3
+ * Router 自管目录记忆:单网关进程服务多目录,Router 从自然语言推断
4
+ * 项目指向并经 `dispatch({ directory })` 落到目标目录。
5
+ *
6
+ * - `dir:thread:<threadID>` -> 绝对路径:该聊天/话题上次使用的目录,命中则复用。
7
+ * - `dir:alias:<alias小写>` -> 绝对路径:别名(如 `ai-gw`)到目录的映射,basename 自动登记。
8
+ *
9
+ * 网关进程本身的目录(`ctx.location.directory`)只是缺省值,不重要。
10
+ */
11
+ export const DIR_THREAD_PREFIX = "dir:thread:";
12
+ export const DIR_ALIAS_PREFIX = "dir:alias:";
13
+ export const threadDirKey = (threadID) => `${DIR_THREAD_PREFIX}${threadID}`;
14
+ export const aliasDirKey = (alias) => `${DIR_ALIAS_PREFIX}${alias.toLowerCase()}`;
15
+ /** threadID -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
16
+ export const threadDirs = new Map();
17
+ /** 小写别名 -> 绝对路径(内存镜像,hydrate 时从 KV 读回)。 */
18
+ export const aliasDirs = new Map();
19
+ export function isValidDirectory(dir) {
20
+ return dir.startsWith("/") && dir.length >= 1 && dir.length <= 500;
21
+ }
22
+ export function normalizeDirectory(dir) {
23
+ const trimmed = dir.trim();
24
+ if (!isValidDirectory(trimmed))
25
+ return undefined;
26
+ if (trimmed.length > 1 && trimmed.endsWith("/"))
27
+ return trimmed.slice(0, -1);
28
+ return trimmed;
29
+ }
30
+ /** 目录 basename 小写即默认别名(`/opt/ai-gw` -> `ai-gw`)。 */
31
+ export function basenameAlias(dir) {
32
+ const clean = dir.length > 1 && dir.endsWith("/") ? dir.slice(0, -1) : dir;
33
+ const parts = clean.split("/").filter((part) => part !== "");
34
+ return (parts[parts.length - 1] ?? "").toLowerCase();
35
+ }
36
+ function asString(value) {
37
+ return typeof value === "string" ? value : undefined;
38
+ }
39
+ /** 启动时把 KV 中的目录映射读回内存;坏条目直接清理。 */
40
+ export const hydrateDirs = Effect.fn("superagent.hydrateDirs")(function* (ctx) {
41
+ for (const prefix of [DIR_THREAD_PREFIX, DIR_ALIAS_PREFIX]) {
42
+ let after;
43
+ do {
44
+ const page = yield* ctx.storage
45
+ .scan({ prefix, after, limit: 100 })
46
+ .pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
47
+ for (const entry of page.entries) {
48
+ const dir = asString(entry.value);
49
+ if (dir === undefined || !isValidDirectory(dir)) {
50
+ yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
51
+ continue;
52
+ }
53
+ if (prefix === DIR_THREAD_PREFIX)
54
+ threadDirs.set(entry.key.slice(prefix.length), dir);
55
+ else
56
+ aliasDirs.set(entry.key.slice(prefix.length).toLowerCase(), dir);
57
+ }
58
+ after = page.next;
59
+ } while (after !== undefined);
60
+ }
61
+ });
62
+ /**
63
+ * 目录解析优先级:显式 directory > thread 绑定 > 任务文本中的已知别名 >
64
+ * 调用方缺省(网关目录)。返回 `undefined` 时调用方用网关默认目录。
65
+ */
66
+ export function resolveDirectory(input) {
67
+ if (input.requested !== undefined) {
68
+ const normalized = normalizeDirectory(input.requested);
69
+ if (normalized !== undefined)
70
+ return normalized;
71
+ }
72
+ if (input.threadID !== undefined) {
73
+ const bound = threadDirs.get(input.threadID);
74
+ if (bound !== undefined)
75
+ return bound;
76
+ }
77
+ const task = input.task?.toLowerCase() ?? "";
78
+ if (task !== "") {
79
+ const aliases = [...aliasDirs.entries()].sort((left, right) => right[0].length - left[0].length);
80
+ for (const [alias, dir] of aliases) {
81
+ if (alias.length >= 2 && task.includes(alias))
82
+ return dir;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ /** 状态行/Spill 用的紧凑摘要(别名全列,thread 只记数量避免刷屏)。 */
88
+ export function directorySummary() {
89
+ const aliases = [...aliasDirs.entries()].map(([alias, dir]) => `${alias}=${dir}`).join(",");
90
+ return `dirs: threads=${threadDirs.size} aliases=${aliases === "" ? "-" : aliases}`;
91
+ }
92
+ /** 已知 thread 绑定的单行提示,拼到 flush 给 Router 的 trigger 文本后面。 */
93
+ export function threadDirHint(threadID) {
94
+ const bound = threadDirs.get(threadID);
95
+ if (bound === undefined)
96
+ return undefined;
97
+ return `已知目录:${bound}(该 thread 上次使用,命中则复用 dispatch directory=${bound};换目录需用户确认)`;
98
+ }
99
+ export const rememberDirectory = Effect.fn("superagent.rememberDirectory")(function* (ctx, input) {
100
+ const normalized = normalizeDirectory(input.directory);
101
+ if (normalized === undefined)
102
+ return;
103
+ if (input.threadID !== undefined) {
104
+ threadDirs.set(input.threadID, normalized);
105
+ yield* ctx.storage
106
+ .set(threadDirKey(input.threadID), normalized)
107
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread directory", { cause })));
108
+ }
109
+ const alias = basenameAlias(normalized);
110
+ if (alias !== "") {
111
+ aliasDirs.set(alias, normalized);
112
+ yield* ctx.storage
113
+ .set(aliasDirKey(alias), normalized)
114
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist alias directory", { cause })));
115
+ }
116
+ });
117
+ /** `/dir` 手动切 Router 目录时同步记住 basename 别名,不改 thread 绑定。 */
118
+ export const rememberAlias = Effect.fn("superagent.rememberAlias")(function* (ctx, directory) {
119
+ const normalized = normalizeDirectory(directory);
120
+ if (normalized === undefined)
121
+ return;
122
+ const alias = basenameAlias(normalized);
123
+ if (alias === "")
124
+ return;
125
+ aliasDirs.set(alias, normalized);
126
+ yield* ctx.storage
127
+ .set(aliasDirKey(alias), normalized)
128
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist alias directory", { cause })));
129
+ });
@@ -0,0 +1,38 @@
1
+ import { type Plugin } from "@opencode/plugin/effect";
2
+ import { Tool } from "@opencode/schema/tool";
3
+ import { Effect, Schema } from "effect";
4
+ export declare const DispatchInput: Schema.Struct<{
5
+ readonly task: Schema.String;
6
+ readonly title: Schema.optionalKey<Schema.String>;
7
+ readonly threadID: Schema.optionalKey<Schema.String>;
8
+ readonly agent: Schema.optionalKey<Schema.String>;
9
+ readonly directory: Schema.optionalKey<Schema.String>;
10
+ readonly ticketID: Schema.String;
11
+ }>;
12
+ interface DispatchInput {
13
+ readonly task: string;
14
+ readonly title?: string;
15
+ readonly threadID?: string;
16
+ readonly agent?: string;
17
+ readonly directory?: string;
18
+ readonly ticketID: string;
19
+ }
20
+ /**
21
+ * The one dispatch implementation, shared by the `dispatch` tool and the
22
+ * `dispatch` RPC. Both paths pass the same ticket gate: `ticketID` is required
23
+ * by the schema and re-checked here, so a direct RPC call cannot skip the
24
+ * work order either.
25
+ *
26
+ * There is deliberately no `internal` bypass: no watchdog/resume path calls
27
+ * dispatch today (the watchdog reports to the Router and resume prompts an
28
+ * existing worker session), so every caller of this function is a real task
29
+ * dispatch and must be backed by a ticket.
30
+ */
31
+ export declare const runDispatch: (ctx: Plugin.Context, input: DispatchInput, caller: string) => Effect.Effect<{
32
+ output: {
33
+ sessionID: string & import("effect/Brand").Brand<"SessionID">;
34
+ };
35
+ content: string;
36
+ }, Tool.Error, never>;
37
+ export declare const registerTools: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
38
+ export {};
@@ -0,0 +1,296 @@
1
+ import { Agent } from "@opencode/plugin/effect";
2
+ import { AbsolutePath } from "@opencode/schema/schema";
3
+ import { Tool } from "@opencode/schema/tool";
4
+ import { Effect, Schema } from "effect";
5
+ import { WORKER_AGENT } from "./agents.js";
6
+ import { normalizeDirectory, rememberDirectory, resolveDirectory } from "./dirs.js";
7
+ import { superagentMetadata, workerTitle } from "./session-meta.js";
8
+ import { encodeThread, linkTriggerToWorker, saveWorker, store, threadKey, toSessionID, workerKey } from "./state.js";
9
+ import { activeTicketForWorker, activeTicketSummary, closeTicket, currentTrigger, linkTicketToWorker, requireDispatchTicket, ticketStats, } from "./ticket.js";
10
+ import { specFor } from "./tools.js";
11
+ export const DispatchInput = Schema.Struct({
12
+ task: Schema.String,
13
+ title: Schema.optionalKey(Schema.String),
14
+ threadID: Schema.optionalKey(Schema.String),
15
+ agent: Schema.optionalKey(Schema.String),
16
+ directory: Schema.optionalKey(Schema.String),
17
+ ticketID: Schema.String,
18
+ });
19
+ const StatusOutput = Schema.Struct({ text: Schema.String });
20
+ const DispatchOutput = Schema.Struct({ sessionID: Schema.String });
21
+ /**
22
+ * Tickets with a dispatch currently in flight in this process. `runDispatch`
23
+ * checks and inserts in one synchronous block, so two concurrent dispatches of
24
+ * the same ticket cannot both pass: the loser fails with an explicit error
25
+ * instead of creating a second worker session. The claim is released on
26
+ * success, failure and interruption via `Effect.ensuring`.
27
+ */
28
+ const inFlightTickets = new Set();
29
+ /** Synchronous check-and-claim: only the first caller for a ticket gets `true`. */
30
+ function claimDispatch(ticketID) {
31
+ if (inFlightTickets.has(ticketID))
32
+ return false;
33
+ inFlightTickets.add(ticketID);
34
+ return true;
35
+ }
36
+ function releaseDispatch(ticketID) {
37
+ inFlightTickets.delete(ticketID);
38
+ }
39
+ /** The ticket header a worker sees first: objective and acceptance, verbatim. */
40
+ function ticketHeader(ticket) {
41
+ const acceptance = ticket.acceptance.map((item, index) => `${index + 1}. ${item}`).join("\n");
42
+ return `工单 ${ticket.id}: ${ticket.objective}\n验收:\n${acceptance}\n`;
43
+ }
44
+ function workerPrompt(threadID, title, task, ticket) {
45
+ const subject = title === undefined ? "" : `Title: ${title}\n`;
46
+ return `${ticketHeader(ticket)}Thread: ${threadID}\n${subject}Task:\n${task}\n\nEnd with a short report: what changed, how it was verified, remaining risks.`;
47
+ }
48
+ function statusText() {
49
+ const stats = ticketStats();
50
+ const active = activeTicketSummary(undefined, 5);
51
+ const lines = [
52
+ `router: ${store.routerID ?? "none"} pending: ${store.pending.length} workers: ${store.workers.size} tickets: open=${stats.open} in_progress=${stats.inProgress} blocked=${stats.blocked} closed=${stats.closed}`,
53
+ ];
54
+ if (active !== "")
55
+ lines.push(`active tickets: ${active}`);
56
+ for (const worker of store.workers.values()) {
57
+ const id = worker.sessionID;
58
+ const title = worker.title ?? "-";
59
+ const threads = worker.threads.length === 0 ? "-" : worker.threads.join(",");
60
+ const report = worker.lastReport ?? "-";
61
+ const dir = worker.directory ?? "-";
62
+ lines.push(`- ${id} ${title} [${threads}] ${worker.state} dir=${dir} ${report}`.slice(0, 220));
63
+ }
64
+ return lines.join("\n");
65
+ }
66
+ /**
67
+ * Undo a half-created dispatch: fail the ticket bound to the worker, detach
68
+ * the worker from its thread affinity lists, forget the worker record and stop
69
+ * its session. Every step is best-effort after the first failure, so one
70
+ * storage problem cannot leave the whole dispatch half-rolled-back.
71
+ */
72
+ const rollbackDispatch = Effect.fn("superagent.rollbackDispatch")(function* (ctx, sessionID, reason) {
73
+ // Fail the ticket while the worker → ticket edge still exists, so the
74
+ // Router sees why the dispatch never started instead of a stuck in_progress.
75
+ const ticket = activeTicketForWorker(sessionID);
76
+ if (ticket !== undefined) {
77
+ yield* closeTicket(ctx, {
78
+ ticketID: ticket.id,
79
+ outcome: "failed",
80
+ summary: `dispatch rollback: ${reason}`,
81
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to fail ticket for rolled-back worker", {
82
+ sessionID,
83
+ ticketID: ticket.id,
84
+ cause,
85
+ })));
86
+ }
87
+ // Thread affinity is written before the prompt; drop the worker from every
88
+ // list it was added to so the thread falls back to the Router.
89
+ const worker = store.workers.get(sessionID);
90
+ if (worker !== undefined) {
91
+ for (const threadID of worker.threads) {
92
+ const current = store.threads.get(threadID);
93
+ if (current === undefined || !current.includes(sessionID))
94
+ continue;
95
+ const next = current.filter((id) => id !== sessionID);
96
+ if (next.length === 0) {
97
+ store.threads.delete(threadID);
98
+ yield* ctx.storage.remove(threadKey(threadID)).pipe(Effect.catch(() => Effect.void));
99
+ }
100
+ else {
101
+ store.threads.set(threadID, next);
102
+ yield* ctx.storage
103
+ .set(threadKey(threadID), encodeThread({ threadID, workers: next, updatedAt: Date.now() }))
104
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
105
+ }
106
+ }
107
+ }
108
+ store.workers.delete(sessionID);
109
+ yield* ctx.storage
110
+ .remove(workerKey(sessionID))
111
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to remove rolled-back worker", { sessionID, cause })));
112
+ yield* ctx.session
113
+ .interrupt({ sessionID: toSessionID(sessionID) })
114
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to interrupt rolled-back worker", { sessionID, cause })));
115
+ });
116
+ /**
117
+ * The one dispatch implementation, shared by the `dispatch` tool and the
118
+ * `dispatch` RPC. Both paths pass the same ticket gate: `ticketID` is required
119
+ * by the schema and re-checked here, so a direct RPC call cannot skip the
120
+ * work order either.
121
+ *
122
+ * There is deliberately no `internal` bypass: no watchdog/resume path calls
123
+ * dispatch today (the watchdog reports to the Router and resume prompts an
124
+ * existing worker session), so every caller of this function is a real task
125
+ * dispatch and must be backed by a ticket.
126
+ */
127
+ export const runDispatch = Effect.fn("superagent.dispatch")(function* (ctx, input, caller) {
128
+ if (store.routerID === undefined || caller !== store.routerID) {
129
+ return yield* new Tool.Error({ message: "dispatch is only available to the router session" });
130
+ }
131
+ const routerID = store.routerID;
132
+ // Every dispatch is backed by a ticket: the gate refuses a missing ticket,
133
+ // an unknown/closed one, a thread mismatch and a ticket whose worker is
134
+ // still running, so no dispatch can bypass the work-order record.
135
+ const ticket = yield* requireDispatchTicket(input.ticketID, input.threadID);
136
+ // Claim the ticket synchronously before any further dispatch step. Check
137
+ // and insert happen in one uninterrupted block, so two concurrent calls can
138
+ // never both see the ticket as free and race into `sessions.create`; the
139
+ // loser gets an explicit error instead of a second worker.
140
+ if (!claimDispatch(ticket.id)) {
141
+ return yield* new Tool.Error({ message: `dispatch already in progress for ticket ${ticket.id}` });
142
+ }
143
+ return yield* completeDispatch(ctx, input, routerID, ticket).pipe(
144
+ // Success, failure and interruption all release the claim.
145
+ Effect.ensuring(Effect.sync(() => releaseDispatch(ticket.id))));
146
+ });
147
+ /**
148
+ * Body of a dispatch that already owns the ticket claim. `runDispatch` is the
149
+ * only caller and releases the claim on every exit path.
150
+ */
151
+ const completeDispatch = Effect.fn("superagent.dispatchClaimed")(function* (ctx, input, routerID, ticket) {
152
+ const agents = ctx.agent;
153
+ const sessions = ctx.session;
154
+ const requested = input.agent ?? WORKER_AGENT;
155
+ if (input.directory !== undefined && normalizeDirectory(input.directory) === undefined) {
156
+ return yield* new Tool.Error({ message: "dispatch directory must be an absolute path, e.g. /opt/ai-gw" });
157
+ }
158
+ // The ticket carries its thread; the dispatch must agree with it and
159
+ // inherits it when the call omits one.
160
+ const threadID = input.threadID ?? ticket.threadID;
161
+ const agentResolved = yield* agents.get({ agentID: Agent.ID.make(requested) }).pipe(Effect.map((result) => result.data.id), Effect.catch((cause) => Effect.logWarning("superagent worker agent missing, creating session without it", {
162
+ agent: requested,
163
+ cause,
164
+ }).pipe(Effect.as(undefined))));
165
+ // Mirror the router session's explicit model so the worker resolves the
166
+ // same model. When the router has no explicit model (default resolution),
167
+ // pass nothing so the worker follows the same default resolution.
168
+ // An explicitly requested agent keeps its own model priority: no override.
169
+ const routerInherit = yield* sessions.get({ sessionID: toSessionID(routerID) }).pipe(Effect.map((info) => ({ model: info.model, directory: info.location.directory })), Effect.catch((cause) => Effect.logWarning("superagent router model unavailable, creating worker without it", {
170
+ cause,
171
+ }).pipe(Effect.as(undefined))));
172
+ const routerModel = routerInherit?.model;
173
+ const routerDirectory = routerInherit?.directory;
174
+ // Single gateway, Router-managed directories: explicit directory wins,
175
+ // otherwise the ticket's directory, then thread binding / alias match,
176
+ // otherwise follow Router. The gateway process directory itself is irrelevant.
177
+ const requestedDir = input.directory === undefined ? undefined : normalizeDirectory(input.directory);
178
+ const ticketDir = ticket.directory === undefined ? undefined : normalizeDirectory(ticket.directory);
179
+ const rememberedDir = resolveDirectory({
180
+ threadID,
181
+ task: input.task,
182
+ requested: requestedDir ?? ticketDir,
183
+ });
184
+ const targetDirectory = rememberedDir ?? routerDirectory;
185
+ const threads = [threadID];
186
+ const current = currentTrigger();
187
+ const authorizing = current !== undefined && current.threadID === threadID ? current.id : ticket.authorization.triggerID;
188
+ const triggerID = authorizing === "" || authorizing === "unknown" ? undefined : authorizing;
189
+ const created = yield* sessions
190
+ .create({
191
+ // The prefix keeps workers recognizable in session lists; metadata, not
192
+ // this title, is what identifies the session after a user rename.
193
+ title: workerTitle({
194
+ task: input.task,
195
+ ...(input.title === undefined ? {} : { title: input.title }),
196
+ threadID,
197
+ }),
198
+ ...(agentResolved === undefined ? {} : { agent: agentResolved }),
199
+ ...(input.agent === undefined && routerModel !== undefined ? { model: routerModel } : {}),
200
+ ...(targetDirectory === undefined ? {} : { location: { directory: AbsolutePath.make(targetDirectory) } }),
201
+ metadata: superagentMetadata("worker", threads),
202
+ })
203
+ .pipe(Effect.mapError((cause) => new Tool.Error({ message: `failed to create worker session: ${String(cause)}` })));
204
+ const record = {
205
+ sessionID: created.id,
206
+ title: created.title ?? input.title,
207
+ // Forward edges: worker → primary thread and worker → authorizing trigger.
208
+ // The ticket's authorization is the trigger that created the work order;
209
+ // a newer router trigger on the same thread wins because it is the one
210
+ // that actually asked for this dispatch.
211
+ threadID,
212
+ ...(triggerID === undefined ? {} : { triggerID }),
213
+ threads,
214
+ state: "running",
215
+ createdAt: Date.now(),
216
+ ...(targetDirectory === undefined ? {} : { directory: targetDirectory }),
217
+ };
218
+ store.workers.set(created.id, record);
219
+ yield* saveWorker(ctx, record);
220
+ // Complete the reverse edge (trigger → worker) so the dispatch can be traced
221
+ // from the message that caused it. A missing trigger is not an error: the
222
+ // ticket remains the durable record in that case.
223
+ if (triggerID !== undefined) {
224
+ yield* linkTriggerToWorker(ctx, triggerID, created.id).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to link trigger to worker", { cause })));
225
+ }
226
+ if (targetDirectory !== undefined) {
227
+ yield* rememberDirectory(ctx, { threadID, directory: targetDirectory }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to remember directory", { cause })));
228
+ }
229
+ // Record the worker on its ticket (open → in_progress) before the prompt
230
+ // lands, so the watcher can close it even if the worker fails immediately.
231
+ // The binding is part of the dispatch: if it fails, the half-created worker
232
+ // is rolled back instead of running outside the ticket it was dispatched for.
233
+ const linkFailure = yield* linkTicketToWorker(ctx, {
234
+ ticketID: ticket.id,
235
+ sessionID: created.id,
236
+ ...(targetDirectory === undefined ? {} : { note: targetDirectory }),
237
+ }).pipe(Effect.as(undefined), Effect.catchTag("Tool.Error", (error) => Effect.succeed(error.message)));
238
+ if (linkFailure !== undefined) {
239
+ yield* rollbackDispatch(ctx, created.id, `could not bind ticket ${ticket.id} to the new worker (${linkFailure})`);
240
+ return yield* new Tool.Error({
241
+ message: `dispatch aborted: could not bind ticket ${ticket.id} to the new worker (${linkFailure})`,
242
+ });
243
+ }
244
+ const existing = store.threads.get(threadID) ?? [];
245
+ if (!existing.includes(created.id))
246
+ existing.push(created.id);
247
+ // Affinity lists persist after workers end; cap them so long-lived
248
+ // threads don't grow unbounded in memory and KV.
249
+ if (existing.length > 20)
250
+ existing.splice(0, existing.length - 20);
251
+ store.threads.set(threadID, existing);
252
+ yield* ctx.storage
253
+ .set(threadKey(threadID), encodeThread({ threadID, workers: [...existing], updatedAt: Date.now() }))
254
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
255
+ // Prompt delivery is part of the dispatch: if the worker never receives its
256
+ // task, the ticket, thread affinity and worker record must not stay behind.
257
+ const promptFailure = yield* sessions
258
+ .prompt({
259
+ sessionID: created.id,
260
+ text: workerPrompt(threadID, input.title, input.task, ticket),
261
+ delivery: "queue",
262
+ })
263
+ .pipe(Effect.as(undefined), Effect.catch((cause) => Effect.succeed(String(cause))));
264
+ if (promptFailure !== undefined) {
265
+ yield* rollbackDispatch(ctx, created.id, `failed to prompt worker session: ${promptFailure}`);
266
+ return yield* new Tool.Error({
267
+ message: `dispatch aborted: failed to prompt worker session: ${promptFailure}`,
268
+ });
269
+ }
270
+ const where = targetDirectory === undefined ? "gateway default directory" : targetDirectory;
271
+ return {
272
+ output: { sessionID: created.id },
273
+ content: `Worker ${created.id} started in ${where} in the background and will report back. Do not poll; wait for its report. Ticket ${ticket.id} is now in_progress.`,
274
+ };
275
+ });
276
+ export const registerTools = Effect.fn("superagent.registerTools")(function* (ctx) {
277
+ yield* ctx.tool.transform((editor) => {
278
+ editor.add({
279
+ name: "dispatch",
280
+ description: specFor("dispatch").description,
281
+ input: DispatchInput,
282
+ output: DispatchOutput,
283
+ execute: (input, context) => runDispatch(ctx, input, context.sessionID),
284
+ });
285
+ editor.add({
286
+ name: "status",
287
+ description: specFor("status").description,
288
+ input: Schema.Struct({}),
289
+ output: StatusOutput,
290
+ execute: () => {
291
+ const text = statusText();
292
+ return Effect.succeed({ output: { text }, content: text });
293
+ },
294
+ });
295
+ });
296
+ });
@@ -0,0 +1,3 @@
1
+ import type { TriggerInput } from "./state.js";
2
+ /** Single-line header plus the (possibly truncated) body. */
3
+ export declare function formatTrigger(trigger: TriggerInput): string;
@@ -0,0 +1,14 @@
1
+ const LIMIT = 800;
2
+ function truncate(text) {
3
+ if (text.length <= LIMIT)
4
+ return text;
5
+ return `${text.slice(0, LIMIT)}...(${text.length - LIMIT} chars truncated)`;
6
+ }
7
+ /** Single-line header plus the (possibly truncated) body. */
8
+ export function formatTrigger(trigger) {
9
+ const id = trigger.id ?? `${trigger.source}:${trigger.threadID}:${trigger.createdAt ?? Date.now()}`;
10
+ const author = trigger.author ?? "unknown";
11
+ const at = new Date(trigger.createdAt ?? Date.now()).toISOString();
12
+ const header = `[trigger:${id}] source=${trigger.source} thread=${trigger.threadID} author=${author} at=${at}`;
13
+ return `${header}\n${truncate(trigger.text)}`;
14
+ }
@@ -0,0 +1,4 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { Effect } from "effect";
3
+ /** Every router-only tool shares this guard: the caller must be the live router session. */
4
+ export declare const requireRouter: (caller: string, tool: string) => Effect.Effect<undefined, Tool.Error, never>;
@@ -0,0 +1,9 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { Effect } from "effect";
3
+ import { store } from "./state.js";
4
+ /** Every router-only tool shares this guard: the caller must be the live router session. */
5
+ export const requireRouter = Effect.fn("superagent.requireRouter")(function* (caller, tool) {
6
+ if (store.routerID === undefined || caller !== store.routerID) {
7
+ return yield* new Tool.Error({ message: `${tool} is only available to the router session` });
8
+ }
9
+ });
@@ -0,0 +1 @@
1
+ export declare const ROUTER_PROMPT = "\u4F60\u662F Superagent Router\uFF0C\u53EA\u8D1F\u8D23\u4E0E\u7528\u6237\u4EA4\u4E92\u548C\u4EFB\u52A1\u5206\u6D3E\uFF0C\u4E0D\u6267\u884C\u5177\u4F53\u5DE5\u4F5C\u3002\n\n- \u6BCF\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u8C03\u7528 dispatch \u5DE5\u5177\u6D3E\u7ED9 Worker\uFF0C\u53EF\u5E76\u884C\u591A\u6B21\u8C03\u7528\uFF1Bdispatch \u5F3A\u5236\u5E26 ticketID\uFF1A\u5148 ticket_create \u5EFA\u5355\uFF0C\u518D\u6D3E\u6D3B\uFF08\u89C1\u4E0B\uFF09\u3002\n- \u4F60\u53EA\u6709 dispatch\u3001ticket_create\u3001ticket_list\u3001ticket_update\u3001ticket_close\u3001status\u3001thread_detail\u3001worker_detail\u3001report_read\u3001abort_session \u548C sleep \u5341\u4E00\u4E2A\u5DE5\u5177\uFF1B\u4E0D\u8981\u5C1D\u8BD5\u8BFB\u6587\u4EF6\u3001\u8DD1\u547D\u4EE4\u3001\u6539\u4EE3\u7801\u3002\n- Goal \u6A21\u5F0F\u4E0B dispatch \u540E\u53EF\u8C03\u7528 sleep\uFF08\u9ED8\u8BA4 30 \u79D2\uFF0C\u8303\u56F4 1..600\uFF09\u6682\u505C\uFF0C\u7ED9 Worker \u7559\u51FA\u6267\u884C\u65F6\u95F4\uFF1B\u4E0D\u8981\u7A7A\u8F6C\u8F6E\u8BE2 Worker\u3002\n- Worker \u5B8C\u6210\u540E\u4F1A\u6536\u5230 report\uFF1B\u6C47\u603B\u540E\u7EDF\u4E00\u56DE\u590D\u7528\u6237\uFF0C\u4E0D\u8981\u8F6C\u53D1\u539F\u59CB\u65E5\u5FD7\u3002\u4E8B\u4EF6\u91CC\u7684\u300C\u5168\u6587\u957F\u5EA6 N\uFF0C\u53EF\u7528 report_read \u8BFB\u53D6\u300D\u6307\u5411\u5B8C\u6574\u5168\u6587\uFF0C\u62A5\u544A\u88AB\u622A\u65AD\u65F6\u7528 report_read \u6309 offset \u7FFB\u9875\u8BFB\u5B8C\uFF0C\u4E0D\u8981\u51ED\u6458\u8981\u731C\u7ED3\u8BBA\u3002\n- \u7528\u6237\u5728\u540C\u4E00 thread \u8FFD\u95EE\u65F6\uFF0C\u8BE5\u4E8B\u4EF6\u4F1A\u76F4\u6295\u8D1F\u8D23\u8BE5 thread \u7684 Worker\uFF08\u8FD0\u884C\u4E2D\u76F4\u63A5\u6536\uFF0C\u5DF2\u7ED3\u675F\u4F1A\u88AB\u5524\u8D77\u7EE7\u7EED\uFF09\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u53E6\u8D77\u65B0\u4EFB\u52A1\uFF1B\u65B0 thread \u624D\u9700\u8981 dispatch \u5EFA Worker\u3002\n- \u9700\u8981\u8BE6\u60C5\u65F6\u8C03 status \u67E5\u770B\u5404 Worker \u7684 thread \u5F52\u5C5E\u4E0E\u8FD1\u51B5\u3002\n- \u8BE6\u7EC6\u4FE1\u606F\u4E0D\u5728 system \u91CC\uFF0C\u9700\u8981\u65F6\u8C03 thread_detail/worker_detail/report_read\uFF08Spill\uFF09\uFF0C\u4E0D\u8981\u731C\u3002\n- \u7528\u6237\u8981\u6C42\u7EC8\u6B62\u6216\u6E05\u7406 Worker \u65F6\u8C03 abort_session\uFF1A\u5355\u4E2A\u4F1A\u8BDD\u4F20 sessionID\uFF0C\u6279\u91CF\u6E05\u7406\u5DF2\u7ED3\u675F\u7684\u4F20 stale:true\uFF0C\u5168\u90E8\u7EC8\u6B62\u4F20 all:true\uFF1B\u7EC8\u6B62\u540E\u8BE5 thread \u7684\u4E0B\u4E00\u6761\u6D88\u606F\u56DE\u843D\u5230\u4F60\u8FD9\u91CC\u91CD\u65B0 dispatch\u3002\n- \u56DE\u590D\u4FDD\u6301\u7B80\u77ED\uFF0C\u53EA\u5728\u9700\u8981\u51B3\u7B56\u6216\u9A8C\u6536\u65F6\u5411\u7528\u6237\u63D0\u95EE\u3002\n\n\u76EE\u5F55\u81EA\u7BA1\uFF08\u5355\u7F51\u5173\u591A\u76EE\u5F55\uFF0C\u7F51\u5173\u8FDB\u7A0B\u76EE\u5F55\u4E0D\u91CD\u8981\uFF09\uFF1A\n- dispatch \u652F\u6301 directory\uFF08\u7EDD\u5BF9\u8DEF\u5F84\uFF09\uFF1AWorker \u4F1A\u843D\u5230\u8BE5\u76EE\u5F55\u5E72\u6D3B\uFF1B\u6BCF\u6B21\u6D3E\u53D1\u5FC5\u987B\u5E26 directory\u3002\n- \u63A8\u65AD\u4F18\u5148\u7EA7\uFF1A\u6D88\u606F\u4E2D\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 /opt/ai-gw\uFF09> \u5386\u53F2 thread \u7ED1\u5B9A\uFF08\u7CFB\u7EDF\u4E8B\u4EF6\u6587\u672C\u4F1A\u6807\u6CE8\u201C\u5DF2\u77E5\u76EE\u5F55\u201D\uFF09> \u5DF2\u77E5\u522B\u540D\u6620\u5C04\uFF08\u89C1\u72B6\u6001\u884C\u7684 dirs\uFF09> \u7F51\u5173\u9ED8\u8BA4\u76EE\u5F55\u3002\n- \u522B\u540D\uFF08\u5982 ai-gw\u3001\u7F51\u5173\uFF09\u547D\u4E2D\u5DF2\u77E5\u6620\u5C04\u5219\u76F4\u63A5\u590D\u7528\u5BF9\u5E94\u76EE\u5F55\uFF1Bthread \u7ED1\u5B9A\u547D\u4E2D\u5219\u76F4\u63A5\u590D\u7528\u3002\n- \u65B0\u522B\u540D/\u65B0\u8DEF\u5F84\u9996\u6B21\u51FA\u73B0\u65F6\uFF1A\u7528\u6700\u53EF\u80FD\u7684\u76EE\u5F55\u6D3E\u53D1\uFF0C\u5E76\u5728\u56DE\u590D\u7528\u6237\u65F6\u786E\u8BA4\uFF08\u4F8B\uFF1A\u5DF2\u5728 /opt/ai-gw \u6D3E\u53D1\uFF0C\u5982\u4E0D\u5BF9\u8BF7\u544A\u8BC9\u6211\u6B63\u786E\u8DEF\u5F84\uFF0C\u6211\u4F1A\u8BB0\u4F4F\uFF09\uFF1B\u7EDD\u4E0D\u7F16\u9020\u4E0D\u5B58\u5728\u7684\u8DEF\u5F84\uFF0C\u7528\u6237\u53EA\u8BF4\u522B\u540D\u4E14\u65E0\u6620\u5C04\u65F6\u7528\u7F51\u5173\u9ED8\u8BA4\u76EE\u5F55\u6D3E\u53D1\u5E76\u8FFD\u95EE\u6B63\u786E\u8DEF\u5F84\u3002\n- \u670D\u52A1\u7AEF\u4F1A\u81EA\u52A8\u8BB0\u4F4F thread->directory \u548C\u76EE\u5F55 basename \u522B\u540D\uFF08\u5982 /opt/ai-gw \u8BB0\u4F4F ai-gw\uFF09\uFF0C\u4E0B\u6B21\u81EA\u52A8\u590D\u7528\uFF1B\u540C\u4E00 thread \u8FFD\u95EE\u9ED8\u8BA4\u56DE\u5230\u539F Worker\uFF08\u76EE\u5F55\u4E00\u81F4\uFF09\uFF0C\u6362\u76EE\u5F55\u624D\u9700\u65B0 dispatch\u3002\n- \u7528\u6237\u663E\u5F0F\u8981\u6C42\u6362\u76EE\u5F55\u65F6\u624D\u5207\uFF0C\u65E5\u5E38\u9760\u4F60\u81EA\u52A8\u63A8\u65AD\u3002\n\n\u5DE5\u5355\u4F18\u5148\uFF08\u603B\u5219\uFF0C\u5148\u4E8E\u4E0B\u9762\u7684\u5224\u5B9A\u7EC6\u5219\uFF09\uFF1A\n- \u6536\u5230\u4EFB\u52A1\u7684\u7B2C\u4E00\u52A8\u4F5C\u662F ticket_create \u767B\u8BB0\u610F\u56FE\uFF0C\u518D\u51B3\u5B9A\u5E76\u884C\u8FD8\u662F\u4E32\u884C\u3002\u5DE5\u5355\u662F\u8D26\u672C\uFF0C\u4E0D\u662F\u6D3E\u53D1\u7684\u526F\u4EA7\u54C1\uFF1A\u4EFB\u52A1\u5728\u6D3E\u53D1\u524D\u5C31\u5FC5\u987B\u5DF2\u7ECF\u5B58\u5728\u4E8E\u8D26\u672C\u91CC\u3002\n- \u4E00\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u4E00\u5F20\u5355\u3002\u5927\u4EFB\u52A1\u62C6\u5B50\u4EFB\u52A1\u65F6\uFF0C\u5148\u7528 parentTicketID \u628A\u5168\u90E8\u5B50\u5355\u5EFA\u597D\uFF0C\u518D\u51B3\u5B9A\u6267\u884C\u65B9\u5F0F\u3002\n- \u7528\u6237\u4E00\u6761\u6D88\u606F\u91CC\u542B\u591A\u4E2A\u72EC\u7ACB\u4EFB\u52A1\u65F6\uFF0C\u5FC5\u987B\u62C6\u6210\u591A\u5F20\u5DE5\u5355\u5206\u522B\u767B\u8BB0\uFF08\u53EF\u7528 parentTicketID \u5173\u8054\uFF09\uFF0C\u4E0D\u5F97\u7528\u4E00\u5F20\u5355\u8986\u76D6\u591A\u4EF6\u4E8B\u3002\n- \u80FD\u5E76\u884C\u7684\u5B50\u5355\u5206\u522B dispatch \u7ED9\u4E0D\u540C Worker\uFF1B\u4E0D\u80FD\u5E76\u884C\u65F6\u540C\u6837\u5148\u5EFA\u5B8C\u5168\u90E8\u5B50\u5355\uFF0C\u518D\u7531\u4E00\u4E2A Worker \u4E32\u884C\u5B8C\u6210\u3002\u7EDD\u4E0D\u300C\u5E72\u5B8C\u518D\u8865\u5355\u300D\u3002\n- \u5B8C\u6210\u540E\u6309\u5B50\u5355\u9010\u5F20 ticket_close \u7ED3\u5355\uFF1B\u6CA1\u505A\u5B8C\u6216\u88AB\u53D6\u4EE3\u7684\u5355\u663E\u5F0F\u7F6E\u4E3A failed/cancelled/superseded\uFF0C\u4E0D\u7559\u957F\u671F in_progress\u3002\n- \u4F8B\u5916\uFF1A\u7EAF\u67E5\u8BE2\u3001\u53EA\u8BFB\u95EE\u7B54\u4E0D\u5EFA\u5355\uFF08\u4E0D\u4EA7\u751F\u4EA4\u4ED8\u7269\uFF09\uFF1B\u4E00\u65E6\u51FA\u73B0\u300C\u8981\u505A\u67D0\u4EF6\u4E8B\u300D\u7684\u610F\u56FE\u5C31\u7ACB\u523B\u5EFA\u5355\u3002\n\n\u5DE5\u5355\u5224\u5B9A\u7EC6\u5219\uFF08\u5F00\u65B0\u5355\u8FD8\u662F\u590D\u7528\uFF09\uFF1A\n1. \u5F3A\u5236\u89C4\u5219\uFF1A\u7528\u6237\u660E\u786E\u4EA4\u4EE3\u4E00\u4E2A\u65B0\u7684\u3001\u9700\u8981\u4EA4\u4ED8\u7ED3\u679C\u7684\u4EFB\u52A1\u65F6\uFF0C\u5FC5\u987B\u5148 ticket_create \u5EFA\u5355\uFF0C\u518D dispatch({ ticketID }) \u6D3E\u6D3B\u3002\u6CA1\u6709\u8C41\u514D\uFF1Adispatch \u7F3A ticketID\u3001\u5355\u4E0D\u5B58\u5728/\u5DF2\u5173\u95ED\u3001thread \u4E0D\u7B26\u3001\u6216\u8BE5\u5355\u5DF2\u6709\u8FD0\u884C\u4E2D Worker\uFF0C\u5DE5\u5177\u4F1A\u76F4\u63A5\u62A5\u9519\uFF0C\u6D3E\u6D3B\u4E0D\u4F1A\u53D1\u751F\u3002\n2. \u88AB\u62D2\u540E\u7684\u8865\u6551\uFF1A\u7F3A\u5355\u6216\u5355\u5DF2\u5173 \u2192 \u5148\u8865\u5EFA\u5355\u518D\u91CD\u6D3E\uFF1Bthread \u4E0D\u7B26 \u2192 \u7528\u8BE5\u5355\u81EA\u5DF1\u7684 threadID \u91CD\u6D3E\u6216\u4E3A\u8BE5 thread \u5EFA\u65B0\u5355\uFF1B\u5DF2\u6709\u8FD0\u884C\u4E2D Worker \u2192 \u5148\u7B49\u5B83\u7ED3\u675F\u6216 abort_session \u7EC8\u6B62\uFF08\u539F\u5355\u4F1A\u7ED3\u4E3A failed\uFF09\uFF0C\u518D ticket_create \u65B0\u5355\u91CD\u6D3E\u3002\u7EDD\u4E0D\u5141\u8BB8\u7ED5\u8FC7\u5DE5\u5355\u786C\u6D3E\u3002\n3. ticket_create \u5FC5\u987B\u5199\u6E05 objective\uFF08\u8981\u505A\u4EC0\u4E48\uFF09\u4E0E acceptance\uFF08\u600E\u4E48\u7B97\u5B8C\u6210\uFF0C1-10 \u6761\uFF09\uFF0C\u5E76\u5E26 directory\uFF08\u7EDD\u5BF9\u8DEF\u5F84\uFF09\uFF1Bacceptance \u60F3\u4E0D\u51FA\u6765\u5C31\u5148\u95EE\u7528\u6237\uFF0C\u7EDD\u4E0D\u5EFA\u7A7A\u5355\u6216\u5360\u4F4D\u5355\u3002\n4. \u540C\u4E00 thread \u91CD\u590D\u4EA4\u4EE3\u540C\u4E00\u4E2A\u4EFB\u52A1\u65F6\u76F4\u63A5\u5EFA\u5355\uFF0C\u5DE5\u5177\u4F1A\u6309 dedupeKey \u590D\u7528\u5DF2\u6709 open/in_progress \u5355\uFF1A\u8FD4\u56DE reused=true \u5C31\u6CBF\u7528\u8BE5 ticketID\uFF0C\u4E0D\u8981\u53E6\u5F00\u65B0\u5355\u3002\n5. \u540C\u4E00\u4E2A\u4EFB\u52A1\u62C6\u6210\u591A\u4E2A Worker \u65F6\uFF0C\u6BCF\u5355\u4E00\u4E2A ticketID\uFF1B\u7236\u4EFB\u52A1\u7528 parentTicketID \u5173\u8054\u3002\n6. \u63A8\u8FDB\u72B6\u6001\u7528 ticket_update\uFF08open \u2192 in_progress \u2192 blocked/done/failed/cancelled/superseded\uFF09\uFF0C\u4E0D\u8981\u8DF3\u6B65\uFF1BWorker \u7ED3\u675F\u4F1A\u81EA\u52A8\u7ED3\u5355\uFF0C\u9700\u8981\u4E3B\u52A8\u653E\u5F03\u6216\u6539\u5224\u65F6\u624D\u7528 ticket_close(done|failed|cancelled, summary)\u3002\n7. \u540C\u4E00 thread \u7684\u8FFD\u95EE\u82E5\u5C5E\u4E8E\u5DF2\u5F00\u5355\u4EFB\u52A1\uFF0C\u76F4\u63A5\u5728\u8BE5 thread \u7EE7\u7EED\uFF0C\u4E0D\u8981\u53E6\u5F00\u65B0\u5355\uFF1B\u53EA\u6709\u786E\u5B9E\u662F\u65B0\u4EFB\u52A1\u624D\u5EFA\u65B0\u5355\u3002\n8. \u72B6\u6001\u884C\u7ED9\u51FA\u5DE5\u5355\u8BA1\u6570\u4E0E\u5F53\u524D thread \u8FDB\u884C\u4E2D\u7684 ticketID+\u6807\u9898\uFF1B\u8981\u5B8C\u6574\u5217\u8868\u6216\u9A8C\u6536\u6807\u51C6\u65F6\u7528 ticket_list \u6216 /ticket <id>\uFF0C\u4E0D\u8981\u731C\u3002";
@@ -0,0 +1,37 @@
1
+ export const ROUTER_PROMPT = `你是 Superagent Router,只负责与用户交互和任务分派,不执行具体工作。
2
+
3
+ - 每个独立任务调用 dispatch 工具派给 Worker,可并行多次调用;dispatch 强制带 ticketID:先 ticket_create 建单,再派活(见下)。
4
+ - 你只有 dispatch、ticket_create、ticket_list、ticket_update、ticket_close、status、thread_detail、worker_detail、report_read、abort_session 和 sleep 十一个工具;不要尝试读文件、跑命令、改代码。
5
+ - Goal 模式下 dispatch 后可调用 sleep(默认 30 秒,范围 1..600)暂停,给 Worker 留出执行时间;不要空转轮询 Worker。
6
+ - Worker 完成后会收到 report;汇总后统一回复用户,不要转发原始日志。事件里的「全文长度 N,可用 report_read 读取」指向完整全文,报告被截断时用 report_read 按 offset 翻页读完,不要凭摘要猜结论。
7
+ - 用户在同一 thread 追问时,该事件会直投负责该 thread 的 Worker(运行中直接收,已结束会被唤起继续),除非用户明确另起新任务;新 thread 才需要 dispatch 建 Worker。
8
+ - 需要详情时调 status 查看各 Worker 的 thread 归属与近况。
9
+ - 详细信息不在 system 里,需要时调 thread_detail/worker_detail/report_read(Spill),不要猜。
10
+ - 用户要求终止或清理 Worker 时调 abort_session:单个会话传 sessionID,批量清理已结束的传 stale:true,全部终止传 all:true;终止后该 thread 的下一条消息回落到你这里重新 dispatch。
11
+ - 回复保持简短,只在需要决策或验收时向用户提问。
12
+
13
+ 目录自管(单网关多目录,网关进程目录不重要):
14
+ - dispatch 支持 directory(绝对路径):Worker 会落到该目录干活;每次派发必须带 directory。
15
+ - 推断优先级:消息中的绝对路径(如 /opt/ai-gw)> 历史 thread 绑定(系统事件文本会标注“已知目录”)> 已知别名映射(见状态行的 dirs)> 网关默认目录。
16
+ - 别名(如 ai-gw、网关)命中已知映射则直接复用对应目录;thread 绑定命中则直接复用。
17
+ - 新别名/新路径首次出现时:用最可能的目录派发,并在回复用户时确认(例:已在 /opt/ai-gw 派发,如不对请告诉我正确路径,我会记住);绝不编造不存在的路径,用户只说别名且无映射时用网关默认目录派发并追问正确路径。
18
+ - 服务端会自动记住 thread->directory 和目录 basename 别名(如 /opt/ai-gw 记住 ai-gw),下次自动复用;同一 thread 追问默认回到原 Worker(目录一致),换目录才需新 dispatch。
19
+ - 用户显式要求换目录时才切,日常靠你自动推断。
20
+
21
+ 工单优先(总则,先于下面的判定细则):
22
+ - 收到任务的第一动作是 ticket_create 登记意图,再决定并行还是串行。工单是账本,不是派发的副产品:任务在派发前就必须已经存在于账本里。
23
+ - 一个独立任务一张单。大任务拆子任务时,先用 parentTicketID 把全部子单建好,再决定执行方式。
24
+ - 用户一条消息里含多个独立任务时,必须拆成多张工单分别登记(可用 parentTicketID 关联),不得用一张单覆盖多件事。
25
+ - 能并行的子单分别 dispatch 给不同 Worker;不能并行时同样先建完全部子单,再由一个 Worker 串行完成。绝不「干完再补单」。
26
+ - 完成后按子单逐张 ticket_close 结单;没做完或被取代的单显式置为 failed/cancelled/superseded,不留长期 in_progress。
27
+ - 例外:纯查询、只读问答不建单(不产生交付物);一旦出现「要做某件事」的意图就立刻建单。
28
+
29
+ 工单判定细则(开新单还是复用):
30
+ 1. 强制规则:用户明确交代一个新的、需要交付结果的任务时,必须先 ticket_create 建单,再 dispatch({ ticketID }) 派活。没有豁免:dispatch 缺 ticketID、单不存在/已关闭、thread 不符、或该单已有运行中 Worker,工具会直接报错,派活不会发生。
31
+ 2. 被拒后的补救:缺单或单已关 → 先补建单再重派;thread 不符 → 用该单自己的 threadID 重派或为该 thread 建新单;已有运行中 Worker → 先等它结束或 abort_session 终止(原单会结为 failed),再 ticket_create 新单重派。绝不允许绕过工单硬派。
32
+ 3. ticket_create 必须写清 objective(要做什么)与 acceptance(怎么算完成,1-10 条),并带 directory(绝对路径);acceptance 想不出来就先问用户,绝不建空单或占位单。
33
+ 4. 同一 thread 重复交代同一个任务时直接建单,工具会按 dedupeKey 复用已有 open/in_progress 单:返回 reused=true 就沿用该 ticketID,不要另开新单。
34
+ 5. 同一个任务拆成多个 Worker 时,每单一个 ticketID;父任务用 parentTicketID 关联。
35
+ 6. 推进状态用 ticket_update(open → in_progress → blocked/done/failed/cancelled/superseded),不要跳步;Worker 结束会自动结单,需要主动放弃或改判时才用 ticket_close(done|failed|cancelled, summary)。
36
+ 7. 同一 thread 的追问若属于已开单任务,直接在该 thread 继续,不要另开新单;只有确实是新任务才建新单。
37
+ 8. 状态行给出工单计数与当前 thread 进行中的 ticketID+标题;要完整列表或验收标准时用 ticket_list 或 /ticket <id>,不要猜。`;