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,480 @@
1
+ import { DateTime, Effect } from "effect";
2
+ import { buildReport, lastAssistantText, reportSummary, saveReport } from "./report.js";
3
+ import { closeTicketsForWorker } from "./ticket.js";
4
+ const ROUTES = ["router", "worker"];
5
+ /** Host context for fire-and-forget KV writes from synchronous interception points. Set during hydrate. */
6
+ let persistHost;
7
+ export function triggerIdFor(input) {
8
+ return input.id ?? `${input.source}:${input.threadID}:${input.createdAt ?? Date.now()}`;
9
+ }
10
+ function normalizeTrigger(input) {
11
+ return {
12
+ ...input,
13
+ id: triggerIdFor(input),
14
+ createdAt: input.createdAt ?? Date.now(),
15
+ };
16
+ }
17
+ function schedulePersist(record) {
18
+ const host = persistHost;
19
+ if (host === undefined)
20
+ return;
21
+ const snapshot = { ...record };
22
+ Effect.runFork(persistTrigger(host, snapshot).pipe(Effect.catchCause(() => Effect.void)));
23
+ }
24
+ function rememberQueued(input) {
25
+ const id = input.id ?? triggerIdFor(input);
26
+ const delivered = store.triggers.get(id);
27
+ const record = {
28
+ id,
29
+ source: input.source,
30
+ threadID: input.threadID,
31
+ ...(input.author === undefined ? {} : { author: input.author }),
32
+ text: input.text,
33
+ ...(input.title === undefined ? {} : { title: input.title }),
34
+ createdAt: input.createdAt ?? Date.now(),
35
+ ...(delivered !== undefined && delivered.route !== undefined ? { route: delivered.route } : {}),
36
+ ...(delivered !== undefined && delivered.sessionID !== undefined ? { sessionID: delivered.sessionID } : {}),
37
+ };
38
+ if (!store.triggers.has(id))
39
+ store.triggerTotal += 1;
40
+ store.triggers.set(id, record);
41
+ schedulePersist(record);
42
+ }
43
+ function markFlushed(removed) {
44
+ for (const item of removed) {
45
+ const id = item.trigger.id ?? triggerIdFor(item.trigger);
46
+ const record = store.triggers.get(id);
47
+ if (record === undefined)
48
+ continue;
49
+ record.route = "router";
50
+ if (store.routerID !== undefined)
51
+ record.sessionID = store.routerID;
52
+ schedulePersist(record);
53
+ }
54
+ }
55
+ /**
56
+ * Queue that transparently records trigger history. `server.ts` and
57
+ * `router.ts` keep using plain `push`/`splice`; interception here writes
58
+ * the memory map synchronously and persists to KV in the background, so
59
+ * no caller changes are needed.
60
+ */
61
+ export class PendingQueue extends Array {
62
+ push(...items) {
63
+ const normalized = items.map((item) => ({ trigger: normalizeTrigger(item.trigger), arrivedAt: item.arrivedAt }));
64
+ for (const item of normalized)
65
+ rememberQueued(item.trigger);
66
+ return super.push(...normalized);
67
+ }
68
+ splice(start, deleteCount, ...items) {
69
+ const removed = deleteCount === undefined ? super.splice(start) : super.splice(start, deleteCount, ...items);
70
+ if (removed.length > 0)
71
+ markFlushed(removed);
72
+ return removed;
73
+ }
74
+ }
75
+ /**
76
+ * Record a trigger delivered straight to a worker (it never passes the
77
+ * pending queue). The text comes from the admitted prompt, so it carries
78
+ * the same truncation as the worker-visible format; queued triggers keep
79
+ * their full text via `rememberQueued`.
80
+ */
81
+ export function upsertDirectTrigger(input, sessionID) {
82
+ const existing = store.triggers.get(input.id);
83
+ if (existing !== undefined) {
84
+ if (existing.route === undefined)
85
+ existing.route = "worker";
86
+ if (existing.sessionID === undefined)
87
+ existing.sessionID = sessionID;
88
+ schedulePersist(existing);
89
+ return existing;
90
+ }
91
+ const record = {
92
+ id: input.id,
93
+ source: input.source,
94
+ threadID: input.threadID,
95
+ ...(input.author === undefined ? {} : { author: input.author }),
96
+ text: input.text,
97
+ createdAt: input.createdAt,
98
+ route: "worker",
99
+ sessionID,
100
+ };
101
+ store.triggers.set(input.id, record);
102
+ store.triggerTotal += 1;
103
+ schedulePersist(record);
104
+ return record;
105
+ }
106
+ export const store = {
107
+ routerID: undefined,
108
+ routerBusy: false,
109
+ workers: new Map(),
110
+ threads: new Map(),
111
+ pending: new PendingQueue(),
112
+ triggers: new Map(),
113
+ triggerTotal: 0,
114
+ };
115
+ export const ROUTER_KEY = "router";
116
+ export const WORKER_PREFIX = "worker:";
117
+ export const THREAD_PREFIX = "thread:";
118
+ export const TRIGGER_PREFIX = "trigger:";
119
+ const RECENT_TRIGGER_LIMIT = 200;
120
+ export const workerKey = (sessionID) => `${WORKER_PREFIX}${sessionID}`;
121
+ export const threadKey = (threadID) => `${THREAD_PREFIX}${threadID}`;
122
+ export const triggerKey = (id) => `${TRIGGER_PREFIX}${id}`;
123
+ /** Brands are compile-time only, so a validated session id string converts freely. */
124
+ export const toSessionID = (id) => id;
125
+ /** A Session is running while `time.busy` is newer than the last completed execution's `time.idle`. */
126
+ export function isRunning(info) {
127
+ const busy = info.time.busy;
128
+ if (busy === undefined)
129
+ return false;
130
+ const idle = info.time.idle;
131
+ return idle === undefined || DateTime.toEpochMillis(busy) > DateTime.toEpochMillis(idle);
132
+ }
133
+ const STATES = ["running", "idle", "failed", "done", "aborted"];
134
+ function isRecord(value) {
135
+ return typeof value === "object" && value !== null;
136
+ }
137
+ function optionalString(record, key) {
138
+ const value = record[key];
139
+ return typeof value === "string" ? value : undefined;
140
+ }
141
+ function optionalNumber(record, key) {
142
+ const value = record[key];
143
+ return typeof value === "number" ? value : undefined;
144
+ }
145
+ function stringArray(value) {
146
+ if (!Array.isArray(value))
147
+ return undefined;
148
+ if (!value.every((item) => typeof item === "string"))
149
+ return undefined;
150
+ return [...value];
151
+ }
152
+ export function decodeWorker(value) {
153
+ if (!isRecord(value))
154
+ return undefined;
155
+ if (typeof value["sessionID"] !== "string")
156
+ return undefined;
157
+ if (typeof value["state"] !== "string" || !STATES.includes(value["state"]))
158
+ return undefined;
159
+ const threads = stringArray(value["threads"]);
160
+ if (threads === undefined)
161
+ return undefined;
162
+ const createdAt = optionalNumber(value, "createdAt") ?? 0;
163
+ const triggerID = optionalString(value, "triggerID");
164
+ return {
165
+ sessionID: value["sessionID"],
166
+ title: optionalString(value, "title"),
167
+ // Legacy records have no `threadID`; hydrate backfills it from the thread
168
+ // record (or `threads[0]`) and persists the result.
169
+ threadID: optionalString(value, "threadID") ?? "",
170
+ ...(triggerID === undefined ? {} : { triggerID }),
171
+ threads,
172
+ state: value["state"],
173
+ createdAt,
174
+ updatedAt: optionalNumber(value, "updatedAt") ?? createdAt,
175
+ lastReport: optionalString(value, "lastReport"),
176
+ ...(optionalString(value, "directory") === undefined ? {} : { directory: optionalString(value, "directory") }),
177
+ };
178
+ }
179
+ export function decodeThread(value) {
180
+ if (!isRecord(value))
181
+ return undefined;
182
+ if (typeof value["threadID"] !== "string")
183
+ return undefined;
184
+ const workers = stringArray(value["workers"]);
185
+ if (workers === undefined)
186
+ return undefined;
187
+ return {
188
+ threadID: value["threadID"],
189
+ workers,
190
+ updatedAt: optionalNumber(value, "updatedAt") ?? 0,
191
+ };
192
+ }
193
+ export function decodeTrigger(value) {
194
+ if (!isRecord(value))
195
+ return undefined;
196
+ if (typeof value["id"] !== "string")
197
+ return undefined;
198
+ if (typeof value["source"] !== "string")
199
+ return undefined;
200
+ if (typeof value["threadID"] !== "string")
201
+ return undefined;
202
+ if (typeof value["text"] !== "string")
203
+ return undefined;
204
+ const route = value["route"];
205
+ if (route !== undefined && (typeof route !== "string" || !ROUTES.includes(route)))
206
+ return undefined;
207
+ const sessionID = optionalString(value, "sessionID");
208
+ const workerIDs = stringArray(value["workerIDs"]);
209
+ return {
210
+ id: value["id"],
211
+ source: value["source"],
212
+ threadID: value["threadID"],
213
+ author: optionalString(value, "author"),
214
+ text: value["text"],
215
+ title: optionalString(value, "title"),
216
+ createdAt: optionalNumber(value, "createdAt") ?? 0,
217
+ ...(route === undefined ? {} : { route: route }),
218
+ ...(sessionID === undefined ? {} : { sessionID }),
219
+ ...(workerIDs === undefined || workerIDs.length === 0 ? {} : { workerIDs }),
220
+ };
221
+ }
222
+ export function encodeWorker(record) {
223
+ return {
224
+ sessionID: record.sessionID,
225
+ ...(record.title === undefined ? {} : { title: record.title }),
226
+ ...(record.threadID === "" ? {} : { threadID: record.threadID }),
227
+ ...(record.triggerID === undefined ? {} : { triggerID: record.triggerID }),
228
+ threads: [...record.threads],
229
+ state: record.state,
230
+ createdAt: record.createdAt,
231
+ ...(record.updatedAt === undefined ? {} : { updatedAt: record.updatedAt }),
232
+ ...(record.lastReport === undefined ? {} : { lastReport: record.lastReport }),
233
+ ...(record.directory === undefined ? {} : { directory: record.directory }),
234
+ };
235
+ }
236
+ export function encodeThread(record) {
237
+ return {
238
+ threadID: record.threadID,
239
+ workers: [...record.workers],
240
+ updatedAt: record.updatedAt,
241
+ };
242
+ }
243
+ export function encodeTrigger(record) {
244
+ return {
245
+ id: record.id,
246
+ source: record.source,
247
+ threadID: record.threadID,
248
+ ...(record.author === undefined ? {} : { author: record.author }),
249
+ text: record.text,
250
+ ...(record.title === undefined ? {} : { title: record.title }),
251
+ createdAt: record.createdAt,
252
+ ...(record.route === undefined ? {} : { route: record.route }),
253
+ ...(record.sessionID === undefined ? {} : { sessionID: record.sessionID }),
254
+ ...(record.workerIDs === undefined || record.workerIDs.length === 0 ? {} : { workerIDs: [...record.workerIDs] }),
255
+ };
256
+ }
257
+ const persistWorker = Effect.fn("superagent.persistWorker")(function* (ctx, record) {
258
+ yield* ctx.storage
259
+ .set(workerKey(record.sessionID), encodeWorker(record))
260
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist worker", { cause })));
261
+ });
262
+ const persistTrigger = Effect.fn("superagent.persistTrigger")(function* (ctx, record) {
263
+ yield* ctx.storage
264
+ .set(triggerKey(record.id), encodeTrigger(record))
265
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist trigger", { cause })));
266
+ });
267
+ const removeWorker = Effect.fn("superagent.removeWorker")(function* (ctx, sessionID) {
268
+ store.workers.delete(sessionID);
269
+ yield* ctx.storage
270
+ .remove(workerKey(sessionID))
271
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to remove worker", { cause })));
272
+ });
273
+ /**
274
+ * Reconcile one hydrated `running` record with the live session. A session
275
+ * that is still executing keeps `running`; one that finished while the plugin
276
+ * was down is backfilled the same way the watcher would have handled it:
277
+ * capture the last assistant report, persist the terminal/interrupted state,
278
+ * and close its ticket for `done`/`failed`.
279
+ */
280
+ const reconcileRunningWorker = Effect.fn("superagent.reconcileRunningWorker")(function* (ctx, record, info) {
281
+ if (isRunning(info))
282
+ return;
283
+ const state = info.outcome === "succeeded" ? "done" : info.outcome === "failed" ? "failed" : "idle";
284
+ record.state = state;
285
+ const sessions = ctx.session;
286
+ const messages = typeof sessions.context === "function"
287
+ ? yield* sessions
288
+ .context({ sessionID: toSessionID(record.sessionID) })
289
+ .pipe(Effect.catch(() => Effect.succeed([])))
290
+ : [];
291
+ const report = lastAssistantText(messages);
292
+ if (report !== undefined) {
293
+ const captured = buildReport(record.sessionID, report);
294
+ yield* saveReport(ctx, captured);
295
+ record.lastReport = reportSummary(captured);
296
+ }
297
+ yield* saveWorker(ctx, record);
298
+ if (state === "done" || state === "failed") {
299
+ yield* closeTicketsForWorker(ctx, {
300
+ sessionID: record.sessionID,
301
+ outcome: state,
302
+ summary: record.lastReport ?? report ?? `${state} (worker ${record.sessionID})`,
303
+ }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to close ticket for rehydrated worker", {
304
+ sessionID: record.sessionID,
305
+ cause,
306
+ })));
307
+ }
308
+ yield* Effect.logInfo("superagent rehydrated ended worker", { sessionID: record.sessionID, state });
309
+ });
310
+ /**
311
+ * Load KV state into the in-memory maps. Storage is the unique source of
312
+ * truth; every loaded session is validated with `session.get` and stale
313
+ * entries are cleaned from both memory and KV.
314
+ */
315
+ export const hydrate = Effect.fn("superagent.hydrate")(function* (ctx) {
316
+ persistHost = ctx;
317
+ const sessions = ctx.session;
318
+ const storage = ctx.storage;
319
+ const storedRouter = yield* storage.get(ROUTER_KEY).pipe(Effect.catch(() => Effect.succeed(undefined)));
320
+ if (typeof storedRouter === "string") {
321
+ const info = yield* sessions.get({ sessionID: toSessionID(storedRouter) }).pipe(Effect.map((session) => session), Effect.catch(() => Effect.succeed(undefined)));
322
+ if (info === undefined) {
323
+ yield* storage.remove(ROUTER_KEY).pipe(Effect.catch(() => Effect.void));
324
+ }
325
+ else {
326
+ store.routerID = info.id;
327
+ }
328
+ }
329
+ let after;
330
+ do {
331
+ const page = yield* storage
332
+ .scan({ prefix: WORKER_PREFIX, after, limit: 100 })
333
+ .pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
334
+ for (const entry of page.entries) {
335
+ const record = decodeWorker(entry.value);
336
+ if (record === undefined) {
337
+ yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
338
+ continue;
339
+ }
340
+ const info = yield* sessions.get({ sessionID: toSessionID(record.sessionID) }).pipe(Effect.map((session) => session), Effect.catch(() => Effect.succeed(undefined)));
341
+ if (info === undefined) {
342
+ yield* removeWorker(ctx, record.sessionID);
343
+ continue;
344
+ }
345
+ store.workers.set(record.sessionID, record);
346
+ if (record.state === "running")
347
+ yield* reconcileRunningWorker(ctx, record, info);
348
+ }
349
+ after = page.next;
350
+ } while (after !== undefined);
351
+ let threadAfter;
352
+ do {
353
+ const page = yield* storage
354
+ .scan({ prefix: THREAD_PREFIX, after: threadAfter, limit: 100 })
355
+ .pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
356
+ for (const entry of page.entries) {
357
+ const record = decodeThread(entry.value);
358
+ if (record === undefined) {
359
+ yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
360
+ continue;
361
+ }
362
+ const alive = record.workers.filter((sessionID) => store.workers.has(sessionID));
363
+ if (alive.length === 0) {
364
+ yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
365
+ continue;
366
+ }
367
+ store.threads.set(record.threadID, alive);
368
+ }
369
+ threadAfter = page.next;
370
+ } while (threadAfter !== undefined);
371
+ const collected = [];
372
+ let triggerAfter;
373
+ do {
374
+ const page = yield* storage
375
+ .scan({ prefix: TRIGGER_PREFIX, after: triggerAfter, limit: 100 })
376
+ .pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
377
+ for (const entry of page.entries) {
378
+ const record = decodeTrigger(entry.value);
379
+ if (record === undefined) {
380
+ yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
381
+ continue;
382
+ }
383
+ collected.push(record);
384
+ }
385
+ triggerAfter = page.next;
386
+ } while (triggerAfter !== undefined);
387
+ collected.sort((left, right) => right.createdAt - left.createdAt);
388
+ store.triggerTotal = collected.length;
389
+ for (const record of collected.slice(0, RECENT_TRIGGER_LIMIT)) {
390
+ if (!store.triggers.has(record.id))
391
+ store.triggers.set(record.id, record);
392
+ }
393
+ // ── reference graph backfill (idempotent) ──────────────────────────────────
394
+ // New records carry `threadID`/`triggerID`/`workerIDs`; legacy records only
395
+ // have `threads`. Rebuild a missing edge from what is still authoritative
396
+ // (the thread affinity list, or the opposite edge) and persist only changed
397
+ // records, so a second hydrate writes nothing and only a stat line is logged.
398
+ let backfilledWorkers = 0;
399
+ let backfilledTriggers = 0;
400
+ for (const [threadID, workerIDs] of store.threads) {
401
+ for (const sessionID of workerIDs) {
402
+ const worker = store.workers.get(sessionID);
403
+ if (worker === undefined || worker.threadID !== "")
404
+ continue;
405
+ worker.threadID = threadID;
406
+ backfilledWorkers += 1;
407
+ yield* persistWorker(ctx, worker);
408
+ }
409
+ }
410
+ for (const worker of store.workers.values()) {
411
+ if (worker.threadID !== "")
412
+ continue;
413
+ const primary = worker.threads[0];
414
+ if (primary === undefined)
415
+ continue;
416
+ worker.threadID = primary;
417
+ backfilledWorkers += 1;
418
+ yield* persistWorker(ctx, worker);
419
+ }
420
+ for (const worker of store.workers.values()) {
421
+ const triggerID = worker.triggerID;
422
+ if (triggerID === undefined)
423
+ continue;
424
+ const trigger = store.triggers.get(triggerID);
425
+ if (trigger === undefined)
426
+ continue;
427
+ const ids = trigger.workerIDs ?? [];
428
+ if (ids.includes(worker.sessionID))
429
+ continue;
430
+ trigger.workerIDs = [...ids, worker.sessionID];
431
+ backfilledTriggers += 1;
432
+ yield* persistTrigger(ctx, trigger);
433
+ }
434
+ for (const trigger of store.triggers.values()) {
435
+ for (const sessionID of trigger.workerIDs ?? []) {
436
+ const worker = store.workers.get(sessionID);
437
+ if (worker === undefined || worker.triggerID !== undefined)
438
+ continue;
439
+ worker.triggerID = trigger.id;
440
+ backfilledWorkers += 1;
441
+ yield* persistWorker(ctx, worker);
442
+ }
443
+ }
444
+ if (backfilledWorkers > 0 || backfilledTriggers > 0) {
445
+ yield* Effect.logInfo("superagent reference graph backfilled", {
446
+ workers: backfilledWorkers,
447
+ triggers: backfilledTriggers,
448
+ });
449
+ }
450
+ });
451
+ /** Every worker write refreshes `updatedAt`, so an aborted record keeps a traceable timestamp. */
452
+ export const saveWorker = Effect.fn("superagent.saveWorker")(function* (ctx, record) {
453
+ record.updatedAt = Date.now();
454
+ yield* persistWorker(ctx, record);
455
+ });
456
+ export const saveTrigger = persistTrigger;
457
+ /**
458
+ * Record the worker on the trigger that authorized it (`trigger.workerIDs`),
459
+ * completing the reverse edge of `WorkerRecord.triggerID`. Memory holds only
460
+ * the newest triggers, so an older trigger is read from KV once; a missing
461
+ * trigger is not invented. Repeating the call is a no-op.
462
+ */
463
+ export const linkTriggerToWorker = Effect.fn("superagent.linkTriggerToWorker")(function* (ctx, triggerID, sessionID) {
464
+ const existing = store.triggers.get(triggerID);
465
+ const record = existing ??
466
+ decodeTrigger(yield* ctx.storage.get(triggerKey(triggerID)).pipe(Effect.catch(() => Effect.succeed(undefined))));
467
+ if (record === undefined)
468
+ return false;
469
+ const workerIDs = record.workerIDs ?? [];
470
+ if (workerIDs.includes(sessionID))
471
+ return false;
472
+ record.workerIDs = [...workerIDs, sessionID];
473
+ yield* persistTrigger(ctx, record);
474
+ return true;
475
+ });
476
+ export const saveThread = Effect.fn("superagent.saveThread")(function* (ctx, record) {
477
+ yield* ctx.storage
478
+ .set(threadKey(record.threadID), encodeThread(record))
479
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
480
+ });
@@ -0,0 +1,45 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Effect, Schema } from "effect";
3
+ import { type TicketState } from "./ticket.js";
4
+ /**
5
+ * Router-only ticket tools. They are a thin adapter: validation, the state
6
+ * machine, dedupe and KV persistence all live in `src/ticket.ts`, so the same
7
+ * rules apply to the dispatch and watcher links.
8
+ */
9
+ export declare const TicketCreateInput: Schema.Struct<{
10
+ readonly threadID: Schema.String;
11
+ readonly title: Schema.String;
12
+ readonly objective: Schema.String;
13
+ readonly acceptance: Schema.$Array<Schema.String>;
14
+ readonly directory: Schema.String;
15
+ readonly priority: Schema.optionalKey<Schema.Literals<readonly ["p0", "p1", "p2"]>>;
16
+ readonly parentTicketID: Schema.optionalKey<Schema.String>;
17
+ readonly dedupeKey: Schema.optionalKey<Schema.String>;
18
+ }>;
19
+ export declare const TicketListInput: Schema.Struct<{
20
+ readonly threadID: Schema.optionalKey<Schema.String>;
21
+ readonly status: Schema.optionalKey<Schema.Literals<readonly TicketState[]>>;
22
+ readonly since: Schema.optionalKey<Schema.Number>;
23
+ readonly limit: Schema.optionalKey<Schema.Number>;
24
+ }>;
25
+ export declare const TicketUpdateInput: Schema.Struct<{
26
+ readonly ticketID: Schema.String;
27
+ readonly status: Schema.optionalKey<Schema.Literals<readonly TicketState[]>>;
28
+ readonly note: Schema.optionalKey<Schema.String>;
29
+ }>;
30
+ export declare const TicketCloseInput: Schema.Struct<{
31
+ readonly ticketID: Schema.String;
32
+ readonly outcome: Schema.Literals<readonly ["done", "failed", "cancelled"]>;
33
+ readonly summary: Schema.String;
34
+ }>;
35
+ interface TicketListInput {
36
+ readonly threadID?: string;
37
+ readonly status?: TicketState;
38
+ readonly since?: number;
39
+ readonly limit?: number;
40
+ }
41
+ /** RPC helper for the TUI: ready-to-render text, no router check (the user drove it). */
42
+ export declare function renderTicketsText(input?: TicketListInput): string;
43
+ export declare function renderTicketText(ticketID: string): string;
44
+ export declare const registerTicketTools: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
45
+ export {};
@@ -0,0 +1,148 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { requireRouter } from "./guard.js";
3
+ import { closeTicket, createTicket, currentTrigger, getTicket, listTickets, renderTicketDetail, renderTicketList, TICKET_STATES, updateTicket, } from "./ticket.js";
4
+ import { specFor, toolPermission } from "./tools.js";
5
+ /**
6
+ * Router-only ticket tools. They are a thin adapter: validation, the state
7
+ * machine, dedupe and KV persistence all live in `src/ticket.ts`, so the same
8
+ * rules apply to the dispatch and watcher links.
9
+ */
10
+ export const TicketCreateInput = Schema.Struct({
11
+ threadID: Schema.String,
12
+ title: Schema.String,
13
+ objective: Schema.String,
14
+ acceptance: Schema.Array(Schema.String),
15
+ directory: Schema.String,
16
+ priority: Schema.optionalKey(Schema.Literals(["p0", "p1", "p2"])),
17
+ parentTicketID: Schema.optionalKey(Schema.String),
18
+ dedupeKey: Schema.optionalKey(Schema.String),
19
+ });
20
+ export const TicketListInput = Schema.Struct({
21
+ threadID: Schema.optionalKey(Schema.String),
22
+ status: Schema.optionalKey(Schema.Literals(TICKET_STATES)),
23
+ since: Schema.optionalKey(Schema.Number),
24
+ limit: Schema.optionalKey(Schema.Number),
25
+ });
26
+ export const TicketUpdateInput = Schema.Struct({
27
+ ticketID: Schema.String,
28
+ status: Schema.optionalKey(Schema.Literals(TICKET_STATES)),
29
+ note: Schema.optionalKey(Schema.String),
30
+ });
31
+ export const TicketCloseInput = Schema.Struct({
32
+ ticketID: Schema.String,
33
+ outcome: Schema.Literals(["done", "failed", "cancelled"]),
34
+ summary: Schema.String,
35
+ });
36
+ const TicketRefOutput = Schema.Struct({
37
+ ticketID: Schema.String,
38
+ state: Schema.String,
39
+ reused: Schema.Boolean,
40
+ });
41
+ const TicketTextOutput = Schema.Struct({ text: Schema.String });
42
+ const runTicketCreate = Effect.fn("superagent.ticketCreate")(function* (ctx, input, caller) {
43
+ yield* requireRouter(caller, "ticket_create");
44
+ const trigger = currentTrigger();
45
+ const threadID = input.threadID.trim();
46
+ const requester = trigger?.threadID === threadID ? trigger : undefined;
47
+ const result = yield* createTicket(ctx, {
48
+ threadID,
49
+ title: input.title,
50
+ objective: input.objective,
51
+ acceptance: input.acceptance,
52
+ directory: input.directory,
53
+ source: requester?.source ?? "router",
54
+ ...(requester?.author === undefined ? {} : { author: requester.author }),
55
+ ...(input.priority === undefined ? {} : { priority: input.priority }),
56
+ ...(input.parentTicketID === undefined ? {} : { parentTicketID: input.parentTicketID }),
57
+ ...(input.dedupeKey === undefined ? {} : { dedupeKey: input.dedupeKey }),
58
+ });
59
+ const ticket = result.ticket;
60
+ const content = result.reused
61
+ ? `工单 ${ticket.id} 已存在(reused,state=${ticket.state},priority=${ticket.priority}):${ticket.title}。dispatch 时带上 ticketID=${ticket.id}。`
62
+ : `工单 ${ticket.id} 已建立(state=${ticket.state},priority=${ticket.priority}):${ticket.title}。下一步 dispatch 时带上 ticketID=${ticket.id}。`;
63
+ return {
64
+ output: { ticketID: ticket.id, state: ticket.state, reused: result.reused },
65
+ content,
66
+ };
67
+ });
68
+ const runTicketList = Effect.fn("superagent.ticketList")(function* (input, caller) {
69
+ yield* requireRouter(caller, "ticket_list");
70
+ const text = renderTicketList(listTickets({
71
+ ...(input.threadID === undefined ? {} : { threadID: input.threadID }),
72
+ ...(input.status === undefined ? {} : { status: input.status }),
73
+ ...(input.since === undefined ? {} : { since: input.since }),
74
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
75
+ }));
76
+ return { output: { text }, content: text };
77
+ });
78
+ const runTicketUpdate = Effect.fn("superagent.ticketUpdate")(function* (ctx, input, caller) {
79
+ yield* requireRouter(caller, "ticket_update");
80
+ const ticket = yield* updateTicket(ctx, {
81
+ ticketID: input.ticketID,
82
+ ...(input.status === undefined ? {} : { state: input.status }),
83
+ ...(input.note === undefined ? {} : { note: input.note }),
84
+ });
85
+ const content = `工单 ${ticket.id} → ${ticket.state}:${ticket.title}`;
86
+ return { output: { ticketID: ticket.id, state: ticket.state, reused: false }, content };
87
+ });
88
+ const runTicketClose = Effect.fn("superagent.ticketClose")(function* (ctx, input, caller) {
89
+ yield* requireRouter(caller, "ticket_close");
90
+ const ticket = yield* closeTicket(ctx, {
91
+ ticketID: input.ticketID,
92
+ outcome: input.outcome,
93
+ summary: input.summary,
94
+ });
95
+ const content = `工单 ${ticket.id} 已结单(${ticket.state}):${ticket.closeReason ?? input.summary}`;
96
+ return { output: { ticketID: ticket.id, state: ticket.state, reused: false }, content };
97
+ });
98
+ /** RPC helper for the TUI: ready-to-render text, no router check (the user drove it). */
99
+ export function renderTicketsText(input = {}) {
100
+ return renderTicketList(listTickets({
101
+ ...(input.threadID === undefined ? {} : { threadID: input.threadID }),
102
+ ...(input.status === undefined ? {} : { status: input.status }),
103
+ ...(input.since === undefined ? {} : { since: input.since }),
104
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
105
+ }));
106
+ }
107
+ export function renderTicketText(ticketID) {
108
+ const ticket = getTicket(ticketID.trim());
109
+ if (ticket === undefined)
110
+ return `没有找到工单 ${ticketID}`;
111
+ return renderTicketDetail(ticket);
112
+ }
113
+ export const registerTicketTools = Effect.fn("superagent.registerTicketTools")(function* (ctx) {
114
+ yield* ctx.tool.transform((editor) => {
115
+ editor.add({
116
+ name: "ticket_create",
117
+ description: specFor("ticket_create").description,
118
+ input: TicketCreateInput,
119
+ output: TicketRefOutput,
120
+ options: { permission: toolPermission(specFor("ticket_create")) },
121
+ execute: (input, context) => runTicketCreate(ctx, input, context.sessionID),
122
+ });
123
+ editor.add({
124
+ name: "ticket_list",
125
+ description: specFor("ticket_list").description,
126
+ input: TicketListInput,
127
+ output: TicketTextOutput,
128
+ options: { permission: toolPermission(specFor("ticket_list")) },
129
+ execute: (input, context) => runTicketList(input, context.sessionID),
130
+ });
131
+ editor.add({
132
+ name: "ticket_update",
133
+ description: specFor("ticket_update").description,
134
+ input: TicketUpdateInput,
135
+ output: TicketRefOutput,
136
+ options: { permission: toolPermission(specFor("ticket_update")) },
137
+ execute: (input, context) => runTicketUpdate(ctx, input, context.sessionID),
138
+ });
139
+ editor.add({
140
+ name: "ticket_close",
141
+ description: specFor("ticket_close").description,
142
+ input: TicketCloseInput,
143
+ output: TicketRefOutput,
144
+ options: { permission: toolPermission(specFor("ticket_close")) },
145
+ execute: (input, context) => runTicketClose(ctx, input, context.sessionID),
146
+ });
147
+ });
148
+ });