opencode-superagent 0.1.0 → 0.2.1

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.
@@ -6,8 +6,9 @@ import { WORKER_AGENT } from "./agents.js";
6
6
  import { normalizeDirectory, rememberDirectory, resolveDirectory } from "./dirs.js";
7
7
  import { superagentMetadata, workerTitle } from "./session-meta.js";
8
8
  import { encodeThread, linkTriggerToWorker, saveWorker, store, threadKey, toSessionID, workerKey } from "./state.js";
9
- import { activeTicketSummary, currentTrigger, linkTicketToWorker, requireDispatchTicket, ticketStats, } from "./ticket.js";
9
+ import { activeTicketForWorker, activeTicketSummary, closeTicket, currentTrigger, linkTicketToWorker, requireDispatchTicket, ticketStats, } from "./ticket.js";
10
10
  import { specFor } from "./tools.js";
11
+ import { pluginVersion } from "./version.js";
11
12
  export const DispatchInput = Schema.Struct({
12
13
  task: Schema.String,
13
14
  title: Schema.optionalKey(Schema.String),
@@ -18,6 +19,24 @@ export const DispatchInput = Schema.Struct({
18
19
  });
19
20
  const StatusOutput = Schema.Struct({ text: Schema.String });
20
21
  const DispatchOutput = Schema.Struct({ sessionID: Schema.String });
22
+ /**
23
+ * Tickets with a dispatch currently in flight in this process. `runDispatch`
24
+ * checks and inserts in one synchronous block, so two concurrent dispatches of
25
+ * the same ticket cannot both pass: the loser fails with an explicit error
26
+ * instead of creating a second worker session. The claim is released on
27
+ * success, failure and interruption via `Effect.ensuring`.
28
+ */
29
+ const inFlightTickets = new Set();
30
+ /** Synchronous check-and-claim: only the first caller for a ticket gets `true`. */
31
+ function claimDispatch(ticketID) {
32
+ if (inFlightTickets.has(ticketID))
33
+ return false;
34
+ inFlightTickets.add(ticketID);
35
+ return true;
36
+ }
37
+ function releaseDispatch(ticketID) {
38
+ inFlightTickets.delete(ticketID);
39
+ }
21
40
  /** The ticket header a worker sees first: objective and acceptance, verbatim. */
22
41
  function ticketHeader(ticket) {
23
42
  const acceptance = ticket.acceptance.map((item, index) => `${index + 1}. ${item}`).join("\n");
@@ -27,10 +46,16 @@ function workerPrompt(threadID, title, task, ticket) {
27
46
  const subject = title === undefined ? "" : `Title: ${title}\n`;
28
47
  return `${ticketHeader(ticket)}Thread: ${threadID}\n${subject}Task:\n${task}\n\nEnd with a short report: what changed, how it was verified, remaining risks.`;
29
48
  }
30
- function statusText() {
49
+ /**
50
+ * Compact status text for the `status` tool and its diagnostics readers.
51
+ * Starts with the plugin version so a stale gateway (code upgraded but not
52
+ * restarted) is visible from the first line instead of guessed from behavior.
53
+ */
54
+ export function statusText() {
31
55
  const stats = ticketStats();
32
56
  const active = activeTicketSummary(undefined, 5);
33
57
  const lines = [
58
+ `superagent ${pluginVersion()} — restart the gateway after upgrading (no hot reload)`,
34
59
  `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}`,
35
60
  ];
36
61
  if (active !== "")
@@ -45,8 +70,48 @@ function statusText() {
45
70
  }
46
71
  return lines.join("\n");
47
72
  }
48
- /** Undo a half-created dispatch: forget the worker record and stop its session. */
49
- const rollbackDispatch = Effect.fn("superagent.rollbackDispatch")(function* (ctx, sessionID) {
73
+ /**
74
+ * Undo a half-created dispatch: fail the ticket bound to the worker, detach
75
+ * the worker from its thread affinity lists, forget the worker record and stop
76
+ * its session. Every step is best-effort after the first failure, so one
77
+ * storage problem cannot leave the whole dispatch half-rolled-back.
78
+ */
79
+ const rollbackDispatch = Effect.fn("superagent.rollbackDispatch")(function* (ctx, sessionID, reason) {
80
+ // Fail the ticket while the worker → ticket edge still exists, so the
81
+ // Router sees why the dispatch never started instead of a stuck in_progress.
82
+ const ticket = activeTicketForWorker(sessionID);
83
+ if (ticket !== undefined) {
84
+ yield* closeTicket(ctx, {
85
+ ticketID: ticket.id,
86
+ outcome: "failed",
87
+ summary: `dispatch rollback: ${reason}`,
88
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to fail ticket for rolled-back worker", {
89
+ sessionID,
90
+ ticketID: ticket.id,
91
+ cause,
92
+ })));
93
+ }
94
+ // Thread affinity is written before the prompt; drop the worker from every
95
+ // list it was added to so the thread falls back to the Router.
96
+ const worker = store.workers.get(sessionID);
97
+ if (worker !== undefined) {
98
+ for (const threadID of worker.threads) {
99
+ const current = store.threads.get(threadID);
100
+ if (current === undefined || !current.includes(sessionID))
101
+ continue;
102
+ const next = current.filter((id) => id !== sessionID);
103
+ if (next.length === 0) {
104
+ store.threads.delete(threadID);
105
+ yield* ctx.storage.remove(threadKey(threadID)).pipe(Effect.catch(() => Effect.void));
106
+ }
107
+ else {
108
+ store.threads.set(threadID, next);
109
+ yield* ctx.storage
110
+ .set(threadKey(threadID), encodeThread({ threadID, workers: next, updatedAt: Date.now() }))
111
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
112
+ }
113
+ }
114
+ }
50
115
  store.workers.delete(sessionID);
51
116
  yield* ctx.storage
52
117
  .remove(workerKey(sessionID))
@@ -71,16 +136,32 @@ export const runDispatch = Effect.fn("superagent.dispatch")(function* (ctx, inpu
71
136
  return yield* new Tool.Error({ message: "dispatch is only available to the router session" });
72
137
  }
73
138
  const routerID = store.routerID;
139
+ // Every dispatch is backed by a ticket: the gate refuses a missing ticket,
140
+ // an unknown/closed one, a thread mismatch and a ticket whose worker is
141
+ // still running, so no dispatch can bypass the work-order record.
142
+ const ticket = yield* requireDispatchTicket(input.ticketID, input.threadID);
143
+ // Claim the ticket synchronously before any further dispatch step. Check
144
+ // and insert happen in one uninterrupted block, so two concurrent calls can
145
+ // never both see the ticket as free and race into `sessions.create`; the
146
+ // loser gets an explicit error instead of a second worker.
147
+ if (!claimDispatch(ticket.id)) {
148
+ return yield* new Tool.Error({ message: `dispatch already in progress for ticket ${ticket.id}` });
149
+ }
150
+ return yield* completeDispatch(ctx, input, routerID, ticket).pipe(
151
+ // Success, failure and interruption all release the claim.
152
+ Effect.ensuring(Effect.sync(() => releaseDispatch(ticket.id))));
153
+ });
154
+ /**
155
+ * Body of a dispatch that already owns the ticket claim. `runDispatch` is the
156
+ * only caller and releases the claim on every exit path.
157
+ */
158
+ const completeDispatch = Effect.fn("superagent.dispatchClaimed")(function* (ctx, input, routerID, ticket) {
74
159
  const agents = ctx.agent;
75
160
  const sessions = ctx.session;
76
161
  const requested = input.agent ?? WORKER_AGENT;
77
162
  if (input.directory !== undefined && normalizeDirectory(input.directory) === undefined) {
78
163
  return yield* new Tool.Error({ message: "dispatch directory must be an absolute path, e.g. /opt/ai-gw" });
79
164
  }
80
- // Every dispatch is backed by a ticket: the gate refuses a missing ticket,
81
- // an unknown/closed one, a thread mismatch and a ticket whose worker is
82
- // still running, so no dispatch can bypass the work-order record.
83
- const ticket = yield* requireDispatchTicket(input.ticketID, input.threadID);
84
165
  // The ticket carries its thread; the dispatch must agree with it and
85
166
  // inherits it when the call omits one.
86
167
  const threadID = input.threadID ?? ticket.threadID;
@@ -162,7 +243,7 @@ export const runDispatch = Effect.fn("superagent.dispatch")(function* (ctx, inpu
162
243
  ...(targetDirectory === undefined ? {} : { note: targetDirectory }),
163
244
  }).pipe(Effect.as(undefined), Effect.catchTag("Tool.Error", (error) => Effect.succeed(error.message)));
164
245
  if (linkFailure !== undefined) {
165
- yield* rollbackDispatch(ctx, created.id);
246
+ yield* rollbackDispatch(ctx, created.id, `could not bind ticket ${ticket.id} to the new worker (${linkFailure})`);
166
247
  return yield* new Tool.Error({
167
248
  message: `dispatch aborted: could not bind ticket ${ticket.id} to the new worker (${linkFailure})`,
168
249
  });
@@ -178,13 +259,21 @@ export const runDispatch = Effect.fn("superagent.dispatch")(function* (ctx, inpu
178
259
  yield* ctx.storage
179
260
  .set(threadKey(threadID), encodeThread({ threadID, workers: [...existing], updatedAt: Date.now() }))
180
261
  .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
181
- yield* sessions
262
+ // Prompt delivery is part of the dispatch: if the worker never receives its
263
+ // task, the ticket, thread affinity and worker record must not stay behind.
264
+ const promptFailure = yield* sessions
182
265
  .prompt({
183
266
  sessionID: created.id,
184
267
  text: workerPrompt(threadID, input.title, input.task, ticket),
185
268
  delivery: "queue",
186
269
  })
187
- .pipe(Effect.mapError((cause) => new Tool.Error({ message: `failed to prompt worker session: ${String(cause)}` })));
270
+ .pipe(Effect.as(undefined), Effect.catch((cause) => Effect.succeed(String(cause))));
271
+ if (promptFailure !== undefined) {
272
+ yield* rollbackDispatch(ctx, created.id, `failed to prompt worker session: ${promptFailure}`);
273
+ return yield* new Tool.Error({
274
+ message: `dispatch aborted: failed to prompt worker session: ${promptFailure}`,
275
+ });
276
+ }
188
277
  const where = targetDirectory === undefined ? "gateway default directory" : targetDirectory;
189
278
  return {
190
279
  output: { sessionID: created.id },
@@ -1 +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 \u5341\u4E2A\u5DE5\u5177\uFF1B\u4E0D\u8981\u5C1D\u8BD5\u8BFB\u6587\u4EF6\u3001\u8DD1\u547D\u4EE4\u3001\u6539\u4EE3\u7801\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";
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";
@@ -1,7 +1,8 @@
1
1
  export const ROUTER_PROMPT = `你是 Superagent Router,只负责与用户交互和任务分派,不执行具体工作。
2
2
 
3
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 十个工具;不要尝试读文件、跑命令、改代码。
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。
5
6
  - Worker 完成后会收到 report;汇总后统一回复用户,不要转发原始日志。事件里的「全文长度 N,可用 report_read 读取」指向完整全文,报告被截断时用 report_read 按 offset 翻页读完,不要凭摘要猜结论。
6
7
  - 用户在同一 thread 追问时,该事件会直投负责该 thread 的 Worker(运行中直接收,已结束会被唤起继续),除非用户明确另起新任务;新 thread 才需要 dispatch 建 Worker。
7
8
  - 需要详情时调 status 查看各 Worker 的 thread 归属与近况。
@@ -1,11 +1,13 @@
1
1
  import { Schema } from "effect";
2
2
  /**
3
- * Server-side RPC contract for the Super-agent P0 prototype.
3
+ * The single RPC contract shared by the server and the TUI.
4
4
  *
5
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
6
+ * and `server.ts` registers the very same object (`ServerDefinition =
7
+ * Definition`), so method names, input/output schemas and declared errors
8
+ * cannot drift between the two halves. Schemas are wrapped with
9
+ * `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is what
10
+ * makes the same definition usable from both the Effect server
9
11
  * (`ctx.rpc.register`) and the Promise TUI client.
10
12
  */
11
13
  export declare const Definition: {
@@ -24,6 +26,7 @@ export declare const Definition: {
24
26
  status: {
25
27
  input: import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
26
28
  output: import("effect/StandardSchema").StandardSchemaV1<{
29
+ readonly version?: string | undefined;
27
30
  readonly router?: string | undefined;
28
31
  readonly pending: number;
29
32
  readonly workers: readonly {
@@ -33,6 +36,7 @@ export declare const Definition: {
33
36
  readonly state: "aborted" | "done" | "failed" | "idle" | "running";
34
37
  }[];
35
38
  }, {
39
+ readonly version?: string | undefined;
36
40
  readonly router?: string | undefined;
37
41
  readonly pending: number;
38
42
  readonly workers: readonly {
@@ -42,6 +46,7 @@ export declare const Definition: {
42
46
  readonly state: "aborted" | "done" | "failed" | "idle" | "running";
43
47
  }[];
44
48
  }> & Schema.Struct<{
49
+ readonly version: Schema.optionalKey<Schema.String>;
45
50
  readonly router: Schema.optionalKey<Schema.String>;
46
51
  readonly pending: Schema.Number;
47
52
  readonly workers: Schema.$Array<Schema.Struct<{
@@ -55,15 +60,15 @@ export declare const Definition: {
55
60
  tickets: {
56
61
  input: import("effect/StandardSchema").StandardSchemaV1<{
57
62
  readonly threadID?: string | undefined;
58
- readonly status?: string | undefined;
63
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
59
64
  readonly limit?: number | undefined;
60
65
  }, {
61
66
  readonly threadID?: string | undefined;
62
- readonly status?: string | undefined;
67
+ readonly status?: "blocked" | "cancelled" | "done" | "failed" | "in_progress" | "open" | "superseded" | undefined;
63
68
  readonly limit?: number | undefined;
64
69
  }> & Schema.Struct<{
65
70
  readonly threadID: Schema.optionalKey<Schema.String>;
66
- readonly status: Schema.optionalKey<Schema.String>;
71
+ readonly status: Schema.optionalKey<Schema.Literals<readonly ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"]>>;
67
72
  readonly limit: Schema.optionalKey<Schema.Number>;
68
73
  }>;
69
74
  output: import("effect/StandardSchema").StandardSchemaV1<{
@@ -73,6 +78,15 @@ export declare const Definition: {
73
78
  }> & Schema.Struct<{
74
79
  readonly text: Schema.String;
75
80
  }>;
81
+ errors: {
82
+ "tickets.invalid_status": import("effect/StandardSchema").StandardSchemaV1<{
83
+ readonly status: string;
84
+ }, {
85
+ readonly status: string;
86
+ }> & Schema.Struct<{
87
+ readonly status: Schema.String;
88
+ }>;
89
+ };
76
90
  };
77
91
  ticket: {
78
92
  input: import("effect/StandardSchema").StandardSchemaV1<{
@@ -176,6 +190,9 @@ export declare const Definition: {
176
190
  }> & Schema.Struct<{
177
191
  readonly interrupted: Schema.Boolean;
178
192
  }>;
193
+ errors: {
194
+ "stop.rejected": import("effect/StandardSchema").StandardSchemaV1<{}, {}> & Schema.Struct<{}>;
195
+ };
179
196
  };
180
197
  abort: {
181
198
  input: import("effect/StandardSchema").StandardSchemaV1<{
@@ -1,12 +1,18 @@
1
1
  import { Rpc } from "@opencode/plugin/effect";
2
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";
3
7
  /**
4
- * Server-side RPC contract for the Super-agent P0 prototype.
8
+ * The single RPC contract shared by the server and the TUI.
5
9
  *
6
10
  * The TUI (`tui.tsx`) calls these methods through `client.rpc(Definition)`,
7
- * so the id, method names and field names are fixed. Schemas are wrapped
8
- * with `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is
9
- * what makes the same definition usable from both the Effect server
11
+ * and `server.ts` registers the very same object (`ServerDefinition =
12
+ * Definition`), so method names, input/output schemas and declared errors
13
+ * cannot drift between the two halves. Schemas are wrapped with
14
+ * `Schema.toStandardSchemaV1` exactly like `Goal.Definition`, which is what
15
+ * makes the same definition usable from both the Effect server
10
16
  * (`ctx.rpc.register`) and the Promise TUI client.
11
17
  */
12
18
  export const Definition = Rpc.define({
@@ -19,6 +25,9 @@ export const Definition = Rpc.define({
19
25
  status: {
20
26
  input: Schema.toStandardSchemaV1(Schema.Struct({})),
21
27
  output: Schema.toStandardSchemaV1(Schema.Struct({
28
+ // Optional so an older server that predates the version notice still
29
+ // satisfies the contract; the current server always sends it.
30
+ version: Schema.optionalKey(Schema.String),
22
31
  router: Schema.optionalKey(Schema.String),
23
32
  pending: Schema.Number,
24
33
  workers: Schema.Array(Schema.Struct({
@@ -32,10 +41,13 @@ export const Definition = Rpc.define({
32
41
  tickets: {
33
42
  input: Schema.toStandardSchemaV1(Schema.Struct({
34
43
  threadID: Schema.optionalKey(Schema.String),
35
- status: Schema.optionalKey(Schema.String),
44
+ status: Schema.optionalKey(Schema.Literals(TICKET_STATES)),
36
45
  limit: Schema.optionalKey(Schema.Number),
37
46
  })),
38
47
  output: Schema.toStandardSchemaV1(Schema.Struct({ text: Schema.String })),
48
+ errors: {
49
+ "tickets.invalid_status": Schema.toStandardSchemaV1(Schema.Struct({ status: Schema.String })),
50
+ },
39
51
  },
40
52
  ticket: {
41
53
  input: Schema.toStandardSchemaV1(Schema.Struct({ ticketID: Schema.String })),
@@ -48,7 +60,9 @@ export const Definition = Rpc.define({
48
60
  threadID: Schema.optionalKey(Schema.String),
49
61
  agent: Schema.optionalKey(Schema.String),
50
62
  directory: Schema.optionalKey(Schema.String),
51
- // Required: the RPC path enforces the same work-order gate as the tool.
63
+ // Required by the shape, but the RPC handler never runs it: RPC
64
+ // cannot prove router identity, so `dispatch` fails closed with
65
+ // `dispatch.rejected` and the router uses its tool instead.
52
66
  ticketID: Schema.String,
53
67
  })),
54
68
  output: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
@@ -72,6 +86,10 @@ export const Definition = Rpc.define({
72
86
  stop: {
73
87
  input: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
74
88
  output: Schema.toStandardSchemaV1(Schema.Struct({ interrupted: Schema.Boolean })),
89
+ // Declared in the shared contract, not only on the server: the TUI and
90
+ // the server register the same `Definition`, so a client can tell that
91
+ // `stop` is router-only without importing server internals.
92
+ errors: { "stop.rejected": Schema.toStandardSchemaV1(Schema.Struct({})) },
75
93
  },
76
94
  abort: {
77
95
  input: Schema.toStandardSchemaV1(Schema.Struct({
@@ -54,6 +54,12 @@ export declare function buildReport(sessionID: string, text: string, capturedAt?
54
54
  export declare function reportSummary(record: ReportRecord): string;
55
55
  /** One router-facing line that names the full-text handle. */
56
56
  export declare function reportNotice(record: ReportRecord): string;
57
+ /**
58
+ * Last non-empty assistant text in a session-context snapshot. Shared by the
59
+ * live watcher and startup hydration so a crash-recovered worker gets the
60
+ * same report extraction as one that ended while the plugin was running.
61
+ */
62
+ export declare function lastAssistantText(messages: ReadonlyArray<unknown>): string | undefined;
57
63
  /** Page through a stored report; the caller validates offset against `stored`. */
58
64
  export declare function sliceReport(record: ReportRecord, offset: number, limit: number): ReportSlice;
59
65
  export declare function encodeReport(record: ReportRecord): Schema.Json;
@@ -44,6 +44,35 @@ export function reportNotice(record) {
44
44
  const clip = record.truncated ? `,超出 ${REPORT_TEXT_LIMIT} 字上限仅存前 ${record.text.length} 字` : "";
45
45
  return `全文长度 ${record.length}${clip},可用 report_read 读取(sessionID: ${record.sessionID})`;
46
46
  }
47
+ /**
48
+ * Last non-empty assistant text in a session-context snapshot. Shared by the
49
+ * live watcher and startup hydration so a crash-recovered worker gets the
50
+ * same report extraction as one that ended while the plugin was running.
51
+ */
52
+ export function lastAssistantText(messages) {
53
+ for (let index = messages.length - 1; index >= 0; index--) {
54
+ const message = messages[index];
55
+ if (typeof message !== "object" || message === null)
56
+ continue;
57
+ const typed = message;
58
+ if (typed.type !== "assistant")
59
+ continue;
60
+ if (!Array.isArray(typed.content))
61
+ continue;
62
+ const text = typed.content
63
+ .flatMap((part) => {
64
+ if (typeof part !== "object" || part === null)
65
+ return [];
66
+ const candidate = part;
67
+ return candidate.type === "text" && typeof candidate.text === "string" ? [candidate.text] : [];
68
+ })
69
+ .join("\n")
70
+ .trim();
71
+ if (text !== "")
72
+ return text;
73
+ }
74
+ return undefined;
75
+ }
47
76
  /** Page through a stored report; the caller validates offset against `stored`. */
48
77
  export function sliceReport(record, offset, limit) {
49
78
  const stored = record.text.length;
@@ -22,9 +22,8 @@ export declare const hydratePending: (ctx: Plugin.Context) => Effect.Effect<void
22
22
  */
23
23
  export declare const ensureRouter: (ctx: Plugin.Context) => Effect.Effect<string, unknown, never>;
24
24
  /**
25
- * Merge queued triggers into one synthetic prompt for the router. Items
26
- * leave the queue (memory and KV) only after the injection succeeds; on
27
- * failure they stay queued for the next attempt.
25
+ * Serialised entry point for every flush caller (idle hook, RPC, tests).
26
+ * Concurrent calls wait on `flushLock`, so one batch is delivered once.
28
27
  */
29
28
  export declare const flushPending: (ctx: Plugin.Context) => Effect.Effect<void, unknown, never>;
30
29
  /**
@@ -1,5 +1,5 @@
1
1
  import { Agent } from "@opencode/plugin/effect";
2
- import { Effect, Option } from "effect";
2
+ import { Effect, Option, Semaphore } from "effect";
3
3
  import { ROUTER_AGENT } from "./agents.js";
4
4
  import { threadDirHint } from "./dirs.js";
5
5
  import { formatTrigger } from "./format.js";
@@ -8,6 +8,13 @@ import { ROUTER_KEY, store, toSessionID } from "./state.js";
8
8
  import { noteRouterTrigger } from "./ticket.js";
9
9
  import { ROUTER_PERMISSIONS } from "./tools.js";
10
10
  const MAX_FLUSH = 20;
11
+ /**
12
+ * Serialise flushes. A second caller waits for the in-flight flush to finish,
13
+ * then observes the drained queue and only flushes events enqueued meanwhile,
14
+ * so the same batch is never injected twice. Effect releases the permit on
15
+ * success, failure and interruption.
16
+ */
17
+ const flushLock = Semaphore.makeUnsafe(1);
11
18
  /** KV prefix for crash-safe pending triggers. Owned by the router; `state.ts` stays untouched. */
12
19
  export const PENDING_PREFIX = "pending:";
13
20
  const pendingKey = (id) => `${PENDING_PREFIX}${id}`;
@@ -161,11 +168,15 @@ export const ensureRouter = Effect.fn("superagent.ensureRouter")(function* (ctx)
161
168
  * leave the queue (memory and KV) only after the injection succeeds; on
162
169
  * failure they stay queued for the next attempt.
163
170
  */
164
- export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx) {
171
+ const flushPendingUnlocked = Effect.fn("superagent.flushPendingUnlocked")(function* (ctx) {
165
172
  if (store.pending.length === 0)
166
173
  return;
167
174
  const routerID = yield* ensureRouter(ctx);
168
175
  const batch = store.pending.slice(0, MAX_FLUSH);
176
+ // Identity snapshot of this delivery. Removal below matches these ids
177
+ // instead of the queue prefix, so events enqueued while the injection is
178
+ // in flight survive.
179
+ const batchIDs = new Set(batch.map((item) => pendingID(item.trigger, item.arrivedAt)));
169
180
  const text = [
170
181
  `${batch.length} new event(s) for the router:`,
171
182
  ...batch.map((item) => {
@@ -183,7 +194,14 @@ export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx)
183
194
  });
184
195
  return;
185
196
  }
186
- store.pending.splice(0, batch.length);
197
+ // Remove by delivered id, never by position: the queue may have grown at
198
+ // the tail (or been trimmed elsewhere) while `synthetic` was awaited.
199
+ for (let index = store.pending.length - 1; index >= 0; index -= 1) {
200
+ const item = store.pending[index];
201
+ if (item !== undefined && batchIDs.has(pendingID(item.trigger, item.arrivedAt))) {
202
+ store.pending.splice(index, 1);
203
+ }
204
+ }
187
205
  // Remember what the Router was just told: `ticket_create` copies this
188
206
  // trigger into the ticket's authorization (id + quote).
189
207
  const newest = batch[batch.length - 1];
@@ -193,6 +211,13 @@ export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx)
193
211
  yield* ctx.storage.remove(keyOf(item)).pipe(Effect.catch(() => Effect.void));
194
212
  }
195
213
  });
214
+ /**
215
+ * Serialised entry point for every flush caller (idle hook, RPC, tests).
216
+ * Concurrent calls wait on `flushLock`, so one batch is delivered once.
217
+ */
218
+ export const flushPending = Effect.fn("superagent.flushPending")(function* (ctx) {
219
+ yield* flushLock.withPermit(flushPendingUnlocked(ctx));
220
+ });
196
221
  /**
197
222
  * Router idle hook: when the router goes idle with nothing pending in the
198
223
  * background, flush queued triggers into the same execution. An empty queue
@@ -0,0 +1,35 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Tool } from "@opencode/schema/tool";
3
+ import { Effect, Schema } from "effect";
4
+ /**
5
+ * Router-only `sleep` tool: a bounded, real pause after `dispatch` so the
6
+ * Worker gets execution time before the Router decides to act again. It is a
7
+ * timer, never a busy loop, so the Router session (and every other fiber in
8
+ * the process) stays responsive while it waits.
9
+ */
10
+ export declare const SLEEP_DEFAULT_SECONDS = 30;
11
+ export declare const SLEEP_MIN_SECONDS = 1;
12
+ export declare const SLEEP_MAX_SECONDS = 600;
13
+ export declare const SleepInput: Schema.Struct<{
14
+ readonly seconds: Schema.optionalKey<Schema.Number>;
15
+ }>;
16
+ export declare const SleepOutput: Schema.Struct<{
17
+ readonly slept: Schema.Number;
18
+ readonly text: Schema.String;
19
+ }>;
20
+ interface SleepInput {
21
+ readonly seconds?: number;
22
+ }
23
+ /** Performs the wait; tests inject a no-op so the default 30s never runs. */
24
+ export type Sleeper = (seconds: number) => Effect.Effect<void>;
25
+ /** The production sleeper: a platform timer, not a spin loop. */
26
+ export declare const effectSleeper: Sleeper;
27
+ export declare const runSleep: (input: SleepInput, caller: string, sleeper?: Sleeper | undefined) => Effect.Effect<{
28
+ output: {
29
+ slept: number;
30
+ text: string;
31
+ };
32
+ content: string;
33
+ }, Tool.Error, never>;
34
+ export declare const registerSleep: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
35
+ export {};
@@ -0,0 +1,58 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { Duration, Effect, Schema } from "effect";
3
+ import { requireRouter } from "./guard.js";
4
+ import { specFor, toolPermission } from "./tools.js";
5
+ /**
6
+ * Router-only `sleep` tool: a bounded, real pause after `dispatch` so the
7
+ * Worker gets execution time before the Router decides to act again. It is a
8
+ * timer, never a busy loop, so the Router session (and every other fiber in
9
+ * the process) stays responsive while it waits.
10
+ */
11
+ export const SLEEP_DEFAULT_SECONDS = 30;
12
+ export const SLEEP_MIN_SECONDS = 1;
13
+ export const SLEEP_MAX_SECONDS = 600;
14
+ export const SleepInput = Schema.Struct({
15
+ seconds: Schema.optionalKey(Schema.Number),
16
+ });
17
+ export const SleepOutput = Schema.Struct({
18
+ slept: Schema.Number,
19
+ text: Schema.String,
20
+ });
21
+ /** The production sleeper: a platform timer, not a spin loop. */
22
+ export const effectSleeper = (seconds) => Effect.sleep(Duration.seconds(seconds));
23
+ export const runSleep = Effect.fn("superagent.sleep")(function* (input, caller, sleeper = effectSleeper) {
24
+ yield* requireRouter(caller, "sleep");
25
+ const seconds = input.seconds ?? SLEEP_DEFAULT_SECONDS;
26
+ // The registered schema already rejects non-numbers at the tool boundary;
27
+ // this re-check keeps direct/RPC-style callers from bypassing validation.
28
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
29
+ return yield* new Tool.Error({
30
+ message: `sleep seconds must be a number between ${SLEEP_MIN_SECONDS} and ${SLEEP_MAX_SECONDS} (got ${String(input.seconds)})`,
31
+ });
32
+ }
33
+ if (!Number.isInteger(seconds)) {
34
+ return yield* new Tool.Error({
35
+ message: `sleep seconds must be an integer between ${SLEEP_MIN_SECONDS} and ${SLEEP_MAX_SECONDS} (got ${seconds})`,
36
+ });
37
+ }
38
+ if (seconds < SLEEP_MIN_SECONDS || seconds > SLEEP_MAX_SECONDS) {
39
+ return yield* new Tool.Error({
40
+ message: `sleep seconds must be between ${SLEEP_MIN_SECONDS} and ${SLEEP_MAX_SECONDS} (got ${seconds})`,
41
+ });
42
+ }
43
+ yield* sleeper(seconds);
44
+ const text = `slept ${seconds}s`;
45
+ return { output: { slept: seconds, text }, content: text };
46
+ });
47
+ export const registerSleep = Effect.fn("superagent.registerSleep")(function* (ctx) {
48
+ yield* ctx.tool.transform((editor) => {
49
+ editor.add({
50
+ name: "sleep",
51
+ description: specFor("sleep").description,
52
+ input: SleepInput,
53
+ output: SleepOutput,
54
+ options: { permission: toolPermission(specFor("sleep")) },
55
+ execute: (input, context) => runSleep(input, context.sessionID),
56
+ });
57
+ });
58
+ });