opencode-superagent 0.0.0-stage → 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +333 -2
  3. package/agent/superagent-router.md +7 -0
  4. package/agent/superagent-worker.md +10 -0
  5. package/dist/server.d.ts +3 -0
  6. package/dist/server.js +115 -0
  7. package/dist/src/abort.d.ts +31 -0
  8. package/dist/src/abort.js +176 -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 +20 -0
  12. package/dist/src/context.js +98 -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 +214 -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 +36 -0
  23. package/dist/src/protocol.d.ts +210 -0
  24. package/dist/src/protocol.js +88 -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 +72 -0
  28. package/dist/src/report.js +147 -0
  29. package/dist/src/router.d.ts +35 -0
  30. package/dist/src/router.js +209 -0
  31. package/dist/src/session-meta.d.ts +42 -0
  32. package/dist/src/session-meta.js +91 -0
  33. package/dist/src/state.d.ts +124 -0
  34. package/dist/src/state.js +431 -0
  35. package/dist/src/ticket-tools.d.ts +45 -0
  36. package/dist/src/ticket-tools.js +148 -0
  37. package/dist/src/ticket-view.d.ts +62 -0
  38. package/dist/src/ticket-view.js +135 -0
  39. package/dist/src/ticket.d.ts +246 -0
  40. package/dist/src/ticket.js +700 -0
  41. package/dist/src/tools.d.ts +85 -0
  42. package/dist/src/tools.js +376 -0
  43. package/dist/src/watchdog.d.ts +16 -0
  44. package/dist/src/watchdog.js +77 -0
  45. package/dist/src/watcher.d.ts +11 -0
  46. package/dist/src/watcher.js +181 -0
  47. package/dist/tui.d.ts +3 -0
  48. package/dist/tui.js +1027 -0
  49. package/package.json +79 -4
@@ -0,0 +1,700 @@
1
+ import { Tool } from "@opencode/schema/tool";
2
+ import { Effect } from "effect";
3
+ import { createHash } from "node:crypto";
4
+ import { normalizeDirectory } from "./dirs.js";
5
+ import { store } from "./state.js";
6
+ import { displayWidth, wrapText } from "./tools.js";
7
+ export const TICKET_PREFIX = "ticket:";
8
+ export const TICKET_STATUS_PREFIX = "ticketidx:status:";
9
+ export const TICKET_THREAD_PREFIX = "ticketidx:thread:";
10
+ export const TICKET_ALL_KEY = "ticketidx:all";
11
+ export const TITLE_MAX = 80;
12
+ export const OBJECTIVE_MAX = 2000;
13
+ export const ACCEPTANCE_MAX = 10;
14
+ export const ACCEPTANCE_ITEM_MAX = 300;
15
+ export const QUOTE_MAX = 300;
16
+ export const SUMMARY_MAX = 500;
17
+ /** Index buckets are capped so a long-lived gateway never writes an unbounded list. */
18
+ export const INDEX_LIMIT = 200;
19
+ /** Newest-N tickets kept in memory; older records stay in KV and reappear after hydrate. */
20
+ export const MEMORY_LIMIT = 500;
21
+ export const LIST_DEFAULT = 20;
22
+ export const LIST_MAX = 100;
23
+ const PRIORITIES = ["p0", "p1", "p2"];
24
+ export const TICKET_STATES = [
25
+ "open",
26
+ "in_progress",
27
+ "blocked",
28
+ "done",
29
+ "failed",
30
+ "cancelled",
31
+ "superseded",
32
+ ];
33
+ /** A ticket can be dispatched while it is one of these. */
34
+ export const ACTIVE_STATES = ["open", "in_progress"];
35
+ const TRANSITIONS = {
36
+ // A ticket can close from any active state: `done`/`failed` also cover work
37
+ // finished outside the worker (or a dispatch that never started).
38
+ open: ["in_progress", "blocked", "done", "failed", "cancelled", "superseded"],
39
+ in_progress: ["blocked", "done", "failed", "cancelled", "superseded"],
40
+ blocked: ["in_progress", "done", "failed", "cancelled", "superseded"],
41
+ done: [],
42
+ failed: [],
43
+ cancelled: [],
44
+ superseded: [],
45
+ };
46
+ export function isTerminalState(state) {
47
+ return TRANSITIONS[state].length === 0;
48
+ }
49
+ export function allowedTransitions(state) {
50
+ return TRANSITIONS[state];
51
+ }
52
+ /** Same-state moves are no-ops, not transitions. */
53
+ export function canTransition(from, to) {
54
+ return from === to || TRANSITIONS[from].includes(to);
55
+ }
56
+ /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
57
+ export function transitionError(from, to) {
58
+ if (canTransition(from, to))
59
+ return undefined;
60
+ if (isTerminalState(from))
61
+ return `ticket is already closed (${from}); cannot move to ${to}`;
62
+ return `illegal transition ${from} -> ${to}; allowed: ${TRANSITIONS[from].join(", ")}`;
63
+ }
64
+ /** Lowercased, whitespace-collapsed objective, so wording drift does not defeat dedupe. */
65
+ export function normalizeObjective(text) {
66
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
67
+ }
68
+ export function dedupeKeyFor(input) {
69
+ return createHash("sha1")
70
+ .update(`${input.source}\n${input.threadID}\n${normalizeObjective(input.objective)}`)
71
+ .digest("hex");
72
+ }
73
+ export const ticketKey = (id) => `${TICKET_PREFIX}${id}`;
74
+ export const ticketStatusKey = (state) => `${TICKET_STATUS_PREFIX}${state}`;
75
+ export const ticketThreadKey = (threadID) => `${TICKET_THREAD_PREFIX}${threadID}`;
76
+ /** In-memory mirror of every hydrated ticket, rebuilt from KV at startup. */
77
+ export const tickets = new Map();
78
+ const byThread = new Map();
79
+ let routerTrigger;
80
+ export function noteRouterTrigger(trigger) {
81
+ routerTrigger = {
82
+ id: trigger.id ?? `${trigger.source}:${trigger.threadID}:${trigger.createdAt ?? Date.now()}`,
83
+ source: trigger.source,
84
+ threadID: trigger.threadID,
85
+ ...(trigger.author === undefined ? {} : { author: trigger.author }),
86
+ text: trigger.text,
87
+ at: trigger.createdAt ?? Date.now(),
88
+ };
89
+ }
90
+ export function currentTrigger() {
91
+ return routerTrigger;
92
+ }
93
+ /** Reset memory (hydrate and tests): the map and its thread index are rebuilt together. */
94
+ export function resetTickets() {
95
+ tickets.clear();
96
+ byThread.clear();
97
+ routerTrigger = undefined;
98
+ }
99
+ function remember(ticket) {
100
+ tickets.set(ticket.id, ticket);
101
+ const ids = byThread.get(ticket.threadID) ?? new Set();
102
+ ids.add(ticket.id);
103
+ byThread.set(ticket.threadID, ids);
104
+ }
105
+ function forget(ticket) {
106
+ tickets.delete(ticket.id);
107
+ byThread.get(ticket.threadID)?.delete(ticket.id);
108
+ }
109
+ /** Cap memory by dropping the oldest closed tickets; they remain in KV. */
110
+ function trimMemory() {
111
+ if (tickets.size <= MEMORY_LIMIT)
112
+ return;
113
+ const closable = [...tickets.values()]
114
+ .filter((ticket) => isTerminalState(ticket.state))
115
+ .sort((left, right) => left.updatedAt - right.updatedAt);
116
+ for (const ticket of closable) {
117
+ if (tickets.size <= MEMORY_LIMIT)
118
+ return;
119
+ forget(ticket);
120
+ }
121
+ }
122
+ export function getTicket(ticketID) {
123
+ return tickets.get(ticketID);
124
+ }
125
+ /** Same-thread, same-dedupe-key ticket still open or in progress, if any. */
126
+ export function findDuplicate(threadID, dedupeKey) {
127
+ const ids = byThread.get(threadID);
128
+ const candidates = ids === undefined ? [...tickets.values()] : [...ids].flatMap((id) => tickets.get(id) ?? []);
129
+ return candidates.find((ticket) => ticket.dedupeKey === dedupeKey && ACTIVE_STATES.includes(ticket.state));
130
+ }
131
+ export function isActive(ticket) {
132
+ return ACTIVE_STATES.includes(ticket.state);
133
+ }
134
+ export function ticketStats() {
135
+ const stats = { total: 0, open: 0, inProgress: 0, blocked: 0, closed: 0 };
136
+ for (const ticket of tickets.values()) {
137
+ stats.total += 1;
138
+ if (ticket.state === "open")
139
+ stats.open += 1;
140
+ else if (ticket.state === "in_progress")
141
+ stats.inProgress += 1;
142
+ else if (ticket.state === "blocked")
143
+ stats.blocked += 1;
144
+ else
145
+ stats.closed += 1;
146
+ }
147
+ return stats;
148
+ }
149
+ /** Compact "tk_x title; tk_y title" line for the system status, empty when there is nothing active. */
150
+ export function activeTicketSummary(threadID, limit = 3) {
151
+ const active = [...tickets.values()]
152
+ .filter((ticket) => ACTIVE_STATES.includes(ticket.state))
153
+ .filter((ticket) => threadID === undefined || ticket.threadID === threadID)
154
+ .sort((left, right) => right.updatedAt - left.updatedAt);
155
+ return active
156
+ .slice(0, limit)
157
+ .map((ticket) => `${ticket.id} ${ticket.title}`)
158
+ .join("; ");
159
+ }
160
+ /** Active ticket for a worker session, used when a worker ends. */
161
+ export function activeTicketForWorker(sessionID) {
162
+ return [...tickets.values()].find((ticket) => ticket.workerSessionID === sessionID && ticket.state === "in_progress");
163
+ }
164
+ // ── validation ────────────────────────────────────────────────────────────────
165
+ export function validateCreateInput(input) {
166
+ if ((input.threadID ?? "").trim() === "")
167
+ return "ticket_create needs a threadID";
168
+ const title = (input.title ?? "").trim();
169
+ if (title === "")
170
+ return "ticket_create needs a non-empty title";
171
+ if (title.length > TITLE_MAX)
172
+ return `title must be at most ${TITLE_MAX} characters`;
173
+ const objective = (input.objective ?? "").trim();
174
+ if (objective === "")
175
+ return "ticket_create needs a non-empty objective; ask the user first";
176
+ if (objective.length > OBJECTIVE_MAX)
177
+ return `objective must be at most ${OBJECTIVE_MAX} characters`;
178
+ const acceptance = (input.acceptance ?? []).map((item) => item.trim()).filter((item) => item !== "");
179
+ if (acceptance.length === 0) {
180
+ return "ticket_create needs at least one acceptance criterion; ask the user instead of creating an empty ticket";
181
+ }
182
+ if (acceptance.length > ACCEPTANCE_MAX)
183
+ return `acceptance takes at most ${ACCEPTANCE_MAX} items`;
184
+ if (acceptance.some((item) => item.length > ACCEPTANCE_ITEM_MAX)) {
185
+ return `each acceptance item must be at most ${ACCEPTANCE_ITEM_MAX} characters`;
186
+ }
187
+ if ((input.directory ?? "").trim() === "")
188
+ return "ticket_create needs a directory (absolute path)";
189
+ if (normalizeDirectory(input.directory ?? "") === undefined)
190
+ return "directory must be an absolute path";
191
+ return undefined;
192
+ }
193
+ function compact(text, limit) {
194
+ const clean = text.replace(/\s+/g, " ").trim();
195
+ if (clean.length <= limit)
196
+ return clean;
197
+ return `${clean.slice(0, limit - 1).trimEnd()}…`;
198
+ }
199
+ function clamp(value, min, max) {
200
+ if (!Number.isFinite(value))
201
+ return min;
202
+ return Math.min(max, Math.max(min, Math.floor(value)));
203
+ }
204
+ function newTicketID(now) {
205
+ for (let attempt = 0; attempt < 8; attempt++) {
206
+ const suffix = Math.floor(Math.random() * 36 ** 4)
207
+ .toString(36)
208
+ .padStart(4, "0");
209
+ const id = `tk_${now.toString(36)}_${suffix}`;
210
+ if (!tickets.has(id))
211
+ return id;
212
+ }
213
+ return `tk_${now.toString(36)}_${Math.random().toString(36).slice(2, 6).padEnd(4, "0")}`;
214
+ }
215
+ // ── encoding ──────────────────────────────────────────────────────────────────
216
+ function isRecord(value) {
217
+ return typeof value === "object" && value !== null;
218
+ }
219
+ function optionalString(record, key) {
220
+ const value = record[key];
221
+ return typeof value === "string" ? value : undefined;
222
+ }
223
+ function optionalNumber(record, key) {
224
+ const value = record[key];
225
+ return typeof value === "number" ? value : undefined;
226
+ }
227
+ function stringArray(value) {
228
+ if (!Array.isArray(value))
229
+ return undefined;
230
+ return value.every((item) => typeof item === "string") ? [...value] : undefined;
231
+ }
232
+ export function decodeTicket(value) {
233
+ if (!isRecord(value))
234
+ return undefined;
235
+ const id = optionalString(value, "id");
236
+ const state = optionalString(value, "state");
237
+ const title = optionalString(value, "title");
238
+ const objective = optionalString(value, "objective");
239
+ const threadID = optionalString(value, "threadID");
240
+ const dedupeKey = optionalString(value, "dedupeKey");
241
+ const acceptance = stringArray(value["acceptance"]);
242
+ if (id === undefined || state === undefined || title === undefined || objective === undefined)
243
+ return undefined;
244
+ if (threadID === undefined || dedupeKey === undefined)
245
+ return undefined;
246
+ if (acceptance === undefined || acceptance.length === 0)
247
+ return undefined;
248
+ if (!TICKET_STATES.includes(state))
249
+ return undefined;
250
+ const createdAt = optionalNumber(value, "createdAt");
251
+ if (createdAt === undefined)
252
+ return undefined;
253
+ const priority = optionalString(value, "priority");
254
+ const requester = isRecord(value["requester"]) ? value["requester"] : {};
255
+ const authorization = isRecord(value["authorization"]) ? value["authorization"] : {};
256
+ const attempts = Array.isArray(value["attempts"])
257
+ ? value["attempts"].flatMap((item) => {
258
+ if (!isRecord(item))
259
+ return [];
260
+ const at = optionalNumber(item, "at");
261
+ if (at === undefined)
262
+ return [];
263
+ const sessionID = optionalString(item, "sessionID");
264
+ const note = optionalString(item, "note");
265
+ return [{ at, ...(sessionID === undefined ? {} : { sessionID }), ...(note === undefined ? {} : { note }) }];
266
+ })
267
+ : [];
268
+ const history = Array.isArray(value["history"])
269
+ ? value["history"].flatMap((item) => {
270
+ if (!isRecord(item))
271
+ return [];
272
+ const at = optionalNumber(item, "at");
273
+ const kind = optionalString(item, "kind");
274
+ if (at === undefined || kind === undefined)
275
+ return [];
276
+ const from = optionalString(item, "from");
277
+ const to = optionalString(item, "to");
278
+ const note = optionalString(item, "note");
279
+ return [
280
+ {
281
+ at,
282
+ kind: kind,
283
+ ...(from === undefined ? {} : { from: from }),
284
+ ...(to === undefined ? {} : { to: to }),
285
+ ...(note === undefined ? {} : { note }),
286
+ },
287
+ ];
288
+ })
289
+ : [];
290
+ const workerSessionID = optionalString(value, "workerSessionID");
291
+ const directory = optionalString(value, "directory");
292
+ const parentTicketID = optionalString(value, "parentTicketID");
293
+ const closedAt = optionalNumber(value, "closedAt");
294
+ const closeReason = optionalString(value, "closeReason");
295
+ return {
296
+ id,
297
+ state: state,
298
+ title,
299
+ objective,
300
+ acceptance,
301
+ requester: {
302
+ source: optionalString(requester, "source") ?? "unknown",
303
+ threadID: optionalString(requester, "threadID") ?? threadID,
304
+ ...(optionalString(requester, "author") === undefined ? {} : { author: optionalString(requester, "author") }),
305
+ },
306
+ authorization: {
307
+ triggerID: optionalString(authorization, "triggerID") ?? "unknown",
308
+ quote: optionalString(authorization, "quote") ?? "",
309
+ at: optionalNumber(authorization, "at") ?? createdAt,
310
+ },
311
+ threadID,
312
+ ...(workerSessionID === undefined ? {} : { workerSessionID }),
313
+ attempts,
314
+ priority: PRIORITIES.includes(priority) ? priority : "p1",
315
+ ...(directory === undefined ? {} : { directory }),
316
+ dedupeKey,
317
+ ...(parentTicketID === undefined ? {} : { parentTicketID }),
318
+ tags: stringArray(value["tags"]) ?? [],
319
+ createdAt,
320
+ updatedAt: optionalNumber(value, "updatedAt") ?? createdAt,
321
+ ...(closedAt === undefined ? {} : { closedAt }),
322
+ ...(closeReason === undefined ? {} : { closeReason }),
323
+ history,
324
+ };
325
+ }
326
+ export function encodeTicket(ticket) {
327
+ return {
328
+ id: ticket.id,
329
+ state: ticket.state,
330
+ title: ticket.title,
331
+ objective: ticket.objective,
332
+ acceptance: [...ticket.acceptance],
333
+ requester: {
334
+ source: ticket.requester.source,
335
+ threadID: ticket.requester.threadID,
336
+ ...(ticket.requester.author === undefined ? {} : { author: ticket.requester.author }),
337
+ },
338
+ authorization: {
339
+ triggerID: ticket.authorization.triggerID,
340
+ quote: ticket.authorization.quote,
341
+ at: ticket.authorization.at,
342
+ },
343
+ threadID: ticket.threadID,
344
+ ...(ticket.workerSessionID === undefined ? {} : { workerSessionID: ticket.workerSessionID }),
345
+ attempts: ticket.attempts.map((attempt) => ({
346
+ at: attempt.at,
347
+ ...(attempt.sessionID === undefined ? {} : { sessionID: attempt.sessionID }),
348
+ ...(attempt.note === undefined ? {} : { note: attempt.note }),
349
+ })),
350
+ priority: ticket.priority,
351
+ ...(ticket.directory === undefined ? {} : { directory: ticket.directory }),
352
+ dedupeKey: ticket.dedupeKey,
353
+ ...(ticket.parentTicketID === undefined ? {} : { parentTicketID: ticket.parentTicketID }),
354
+ tags: [...ticket.tags],
355
+ createdAt: ticket.createdAt,
356
+ updatedAt: ticket.updatedAt,
357
+ ...(ticket.closedAt === undefined ? {} : { closedAt: ticket.closedAt }),
358
+ ...(ticket.closeReason === undefined ? {} : { closeReason: ticket.closeReason }),
359
+ history: ticket.history.map((entry) => ({
360
+ at: entry.at,
361
+ kind: entry.kind,
362
+ ...(entry.from === undefined ? {} : { from: entry.from }),
363
+ ...(entry.to === undefined ? {} : { to: entry.to }),
364
+ ...(entry.note === undefined ? {} : { note: entry.note }),
365
+ })),
366
+ };
367
+ }
368
+ // ── persistence ───────────────────────────────────────────────────────────────
369
+ function collect(predicate) {
370
+ return [...tickets.values()]
371
+ .filter(predicate)
372
+ .sort((left, right) => right.createdAt - left.createdAt)
373
+ .slice(0, INDEX_LIMIT)
374
+ .map((ticket) => ticket.id);
375
+ }
376
+ const writeIds = Effect.fn("superagent.persistTicketIndex")(function* (ctx, key, ids) {
377
+ yield* ctx.storage
378
+ .set(key, ids)
379
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist ticket index", { key, cause })));
380
+ });
381
+ const persistTicket = Effect.fn("superagent.persistTicket")(function* (ctx, ticket) {
382
+ yield* ctx.storage
383
+ .set(ticketKey(ticket.id), encodeTicket(ticket))
384
+ .pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist ticket", { cause })));
385
+ });
386
+ /** Only the buckets the ticket touches are rewritten; the record prefix stays authoritative. */
387
+ const persistIndexes = Effect.fn("superagent.persistTicketIndexes")(function* (ctx, ticket) {
388
+ yield* writeIds(ctx, TICKET_ALL_KEY, collect(() => true));
389
+ yield* writeIds(ctx, ticketStatusKey(ticket.state), collect((item) => item.state === ticket.state));
390
+ yield* writeIds(ctx, ticketThreadKey(ticket.threadID), collect((item) => item.threadID === ticket.threadID));
391
+ });
392
+ const rewriteIndexes = Effect.fn("superagent.rewriteTicketIndexes")(function* (ctx) {
393
+ yield* writeIds(ctx, TICKET_ALL_KEY, collect(() => true));
394
+ for (const state of TICKET_STATES) {
395
+ yield* writeIds(ctx, ticketStatusKey(state), collect((ticket) => ticket.state === state));
396
+ }
397
+ for (const threadID of [...new Set([...tickets.values()].map((ticket) => ticket.threadID))]) {
398
+ yield* writeIds(ctx, ticketThreadKey(threadID), collect((ticket) => ticket.threadID === threadID));
399
+ }
400
+ });
401
+ /**
402
+ * Create a ticket, or return the still-open same-key ticket in the same
403
+ * thread (`reused: true`). Objective, acceptance and directory are required;
404
+ * an empty acceptance is rejected so no placeholder ticket can exist.
405
+ */
406
+ export const createTicket = Effect.fn("superagent.createTicket")(function* (ctx, input) {
407
+ const failure = validateCreateInput(input);
408
+ if (failure !== undefined)
409
+ return yield* new Tool.Error({ message: failure });
410
+ const now = input.now ?? Date.now();
411
+ const directory = normalizeDirectory(input.directory);
412
+ if (directory === undefined)
413
+ return yield* new Tool.Error({ message: "directory must be an absolute path" });
414
+ const acceptance = input.acceptance.map((item) => item.trim()).filter((item) => item !== "");
415
+ const dedupeKey = input.dedupeKey?.trim() ||
416
+ dedupeKeyFor({ source: input.source, threadID: input.threadID, objective: input.objective });
417
+ const duplicate = findDuplicate(input.threadID, dedupeKey);
418
+ if (duplicate !== undefined)
419
+ return { ticket: duplicate, reused: true };
420
+ const trigger = routerTrigger;
421
+ const authorization = input.authorization ??
422
+ (trigger === undefined
423
+ ? { triggerID: "unknown", quote: "", at: now }
424
+ : { triggerID: trigger.id, quote: compact(trigger.text, QUOTE_MAX), at: trigger.at });
425
+ const ticket = {
426
+ id: newTicketID(now),
427
+ state: "open",
428
+ title: compact(input.title, TITLE_MAX),
429
+ objective: input.objective.trim(),
430
+ acceptance,
431
+ requester: {
432
+ source: input.source,
433
+ threadID: input.threadID,
434
+ ...(input.author === undefined ? {} : { author: input.author }),
435
+ },
436
+ authorization,
437
+ threadID: input.threadID,
438
+ attempts: [],
439
+ priority: input.priority ?? "p1",
440
+ directory,
441
+ dedupeKey,
442
+ ...(input.parentTicketID === undefined ? {} : { parentTicketID: input.parentTicketID }),
443
+ tags: [...(input.tags ?? [])],
444
+ createdAt: now,
445
+ updatedAt: now,
446
+ history: [{ at: now, kind: "created", to: "open" }],
447
+ };
448
+ remember(ticket);
449
+ trimMemory();
450
+ yield* persistTicket(ctx, ticket);
451
+ yield* persistIndexes(ctx, ticket);
452
+ return { ticket, reused: false };
453
+ });
454
+ /** Move a ticket through the state machine. Illegal moves fail with a `Tool.Error`. */
455
+ export const updateTicket = Effect.fn("superagent.updateTicket")(function* (ctx, input) {
456
+ const ticket = tickets.get(input.ticketID);
457
+ if (ticket === undefined)
458
+ return yield* new Tool.Error({ message: `unknown ticket ${input.ticketID}` });
459
+ const now = input.now ?? Date.now();
460
+ if (input.state !== undefined && input.state !== ticket.state) {
461
+ const failure = transitionError(ticket.state, input.state);
462
+ if (failure !== undefined)
463
+ return yield* new Tool.Error({ message: failure });
464
+ ticket.history.push({
465
+ at: now,
466
+ kind: "state",
467
+ from: ticket.state,
468
+ to: input.state,
469
+ ...(input.note === undefined ? {} : { note: input.note }),
470
+ });
471
+ ticket.state = input.state;
472
+ if (isTerminalState(input.state)) {
473
+ ticket.closedAt = now;
474
+ ticket.closeReason = compact(input.note ?? input.state, SUMMARY_MAX);
475
+ }
476
+ }
477
+ else if (input.note !== undefined) {
478
+ ticket.history.push({ at: now, kind: "note", note: compact(input.note, SUMMARY_MAX) });
479
+ }
480
+ if (input.workerSessionID !== undefined)
481
+ ticket.workerSessionID = input.workerSessionID;
482
+ ticket.updatedAt = now;
483
+ yield* persistTicket(ctx, ticket);
484
+ yield* persistIndexes(ctx, ticket);
485
+ return ticket;
486
+ });
487
+ /** Close a ticket with an outcome, recording summary/closedAt in the record and history. */
488
+ export const closeTicket = Effect.fn("superagent.closeTicket")(function* (ctx, input) {
489
+ const ticket = tickets.get(input.ticketID);
490
+ if (ticket === undefined)
491
+ return yield* new Tool.Error({ message: `unknown ticket ${input.ticketID}` });
492
+ // Re-closing with the same outcome is idempotent; a different outcome is a conflict.
493
+ if (isTerminalState(ticket.state)) {
494
+ if (ticket.state === input.outcome)
495
+ return ticket;
496
+ return yield* new Tool.Error({
497
+ message: `ticket ${ticket.id} is already closed (${ticket.state}); cannot close as ${input.outcome}`,
498
+ });
499
+ }
500
+ const failure = transitionError(ticket.state, input.outcome);
501
+ if (failure !== undefined)
502
+ return yield* new Tool.Error({ message: failure });
503
+ const now = input.now ?? Date.now();
504
+ const summary = compact(input.summary, SUMMARY_MAX);
505
+ ticket.history.push({ at: now, kind: "closed", from: ticket.state, to: input.outcome, note: summary });
506
+ ticket.state = input.outcome;
507
+ ticket.closedAt = now;
508
+ ticket.closeReason = summary;
509
+ ticket.updatedAt = now;
510
+ yield* persistTicket(ctx, ticket);
511
+ yield* persistIndexes(ctx, ticket);
512
+ return ticket;
513
+ });
514
+ /** Newest-updated first; filters are combined with AND. */
515
+ export function listTickets(input = {}) {
516
+ const limit = clamp(input.limit ?? LIST_DEFAULT, 1, LIST_MAX);
517
+ return [...tickets.values()]
518
+ .filter((ticket) => input.threadID === undefined || ticket.threadID === input.threadID)
519
+ .filter((ticket) => input.status === undefined || ticket.state === input.status)
520
+ .filter((ticket) => input.since === undefined || ticket.updatedAt >= input.since)
521
+ .sort((left, right) => right.updatedAt - left.updatedAt)
522
+ .slice(0, limit);
523
+ }
524
+ /**
525
+ * Gate every dispatch must pass: a dispatch always names a ticket, and that
526
+ * ticket must be workable right now. The rules are enforced here (not in the
527
+ * prompt) so a Router cannot dispatch without a ticket, onto a closed ticket,
528
+ * across threads, or onto a ticket that already has a running worker.
529
+ */
530
+ export const requireDispatchTicket = Effect.fn("superagent.requireDispatchTicket")(function* (ticketID, threadID) {
531
+ if (ticketID === undefined || ticketID.trim() === "") {
532
+ return yield* new Tool.Error({ message: "dispatch requires ticketID; create a ticket with ticket_create first" });
533
+ }
534
+ const ticket = tickets.get(ticketID);
535
+ if (ticket === undefined) {
536
+ return yield* new Tool.Error({
537
+ message: `unknown ticket ${ticketID}; create it with ticket_create first, then dispatch({ ticketID })`,
538
+ });
539
+ }
540
+ if (!ACTIVE_STATES.includes(ticket.state)) {
541
+ const hint = ticket.state === "blocked"
542
+ ? 'reopen it with ticket_update({ status: "in_progress" })'
543
+ : "create a new ticket with ticket_create";
544
+ return yield* new Tool.Error({
545
+ message: `ticket ${ticket.id} is ${ticket.state}; only open/in_progress tickets can be dispatched — ${hint}`,
546
+ });
547
+ }
548
+ if (threadID !== undefined && threadID !== ticket.threadID) {
549
+ return yield* new Tool.Error({
550
+ message: `ticket ${ticket.id} belongs to thread ${ticket.threadID}, not ${threadID}; dispatch with threadID=${ticket.threadID} or ticket_create a ticket for ${threadID}`,
551
+ });
552
+ }
553
+ // One ticket never runs two workers at once: the previous worker must be
554
+ // terminal (aborted, failed, done) before anything is dispatched again.
555
+ const workerID = ticket.workerSessionID;
556
+ if (workerID !== undefined && store.workers.get(workerID)?.state === "running") {
557
+ return yield* new Tool.Error({
558
+ message: `ticket ${ticket.id} already has a running worker ${workerID}; wait for it or abort_session it first, then ticket_create a new ticket for the re-dispatch`,
559
+ });
560
+ }
561
+ return ticket;
562
+ });
563
+ /**
564
+ * Attach a dispatched worker to its ticket and move `open` to `in_progress`.
565
+ * Only `open`/`in_progress` tickets accept a worker.
566
+ */
567
+ export const linkTicketToWorker = Effect.fn("superagent.linkTicketToWorker")(function* (ctx, input) {
568
+ const ticket = tickets.get(input.ticketID);
569
+ if (ticket === undefined)
570
+ return yield* new Tool.Error({ message: `unknown ticket ${input.ticketID}` });
571
+ if (!ACTIVE_STATES.includes(ticket.state)) {
572
+ return yield* new Tool.Error({
573
+ message: `ticket ${ticket.id} is ${ticket.state}; only open/in_progress tickets can be dispatched`,
574
+ });
575
+ }
576
+ const now = input.now ?? Date.now();
577
+ ticket.workerSessionID = input.sessionID;
578
+ ticket.attempts.push({
579
+ at: now,
580
+ sessionID: input.sessionID,
581
+ ...(input.note === undefined ? {} : { note: input.note }),
582
+ });
583
+ ticket.history.push({ at: now, kind: "dispatch", note: `worker ${input.sessionID}` });
584
+ if (ticket.state === "open") {
585
+ ticket.history.push({ at: now, kind: "state", from: "open", to: "in_progress" });
586
+ ticket.state = "in_progress";
587
+ }
588
+ ticket.updatedAt = now;
589
+ yield* persistTicket(ctx, ticket);
590
+ yield* persistIndexes(ctx, ticket);
591
+ return ticket;
592
+ });
593
+ /**
594
+ * Close the in-progress ticket attached to a worker that just ended. Tickets
595
+ * in any other state are left alone: a closed ticket never reopens and an
596
+ * aborted worker is handled by its own path.
597
+ */
598
+ export const closeTicketsForWorker = Effect.fn("superagent.closeTicketsForWorker")(function* (ctx, input) {
599
+ const ticket = activeTicketForWorker(input.sessionID);
600
+ if (ticket === undefined)
601
+ return undefined;
602
+ return yield* closeTicket(ctx, {
603
+ ticketID: ticket.id,
604
+ outcome: input.outcome,
605
+ summary: input.summary ?? input.note ?? `${input.outcome} (worker ${input.sessionID})`,
606
+ ...(input.now === undefined ? {} : { now: input.now }),
607
+ });
608
+ });
609
+ /**
610
+ * Load every `ticket:<id>` record and rebuild the indexes. Older workers are
611
+ * never backfilled: a worker with no ticket stays without one, silently.
612
+ */
613
+ export const hydrateTickets = Effect.fn("superagent.hydrateTickets")(function* (ctx) {
614
+ resetTickets();
615
+ let after;
616
+ do {
617
+ const page = yield* ctx.storage
618
+ .scan({ prefix: TICKET_PREFIX, after, limit: 100 })
619
+ .pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
620
+ for (const entry of page.entries) {
621
+ const ticket = decodeTicket(entry.value);
622
+ if (ticket === undefined) {
623
+ yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
624
+ continue;
625
+ }
626
+ remember(ticket);
627
+ }
628
+ after = page.next;
629
+ } while (after !== undefined);
630
+ trimMemory();
631
+ yield* rewriteIndexes(ctx);
632
+ yield* Effect.logInfo("superagent tickets hydrated", { count: tickets.size });
633
+ });
634
+ // ── rendering (shared by tools and the TUI, narrow-terminal first) ─────────────
635
+ export const DETAIL_WIDTH = 52;
636
+ function wrapBullet(text, width) {
637
+ return wrapText(text, width - 2).map((line, index) => (index === 0 ? `· ${line}` : ` ${line}`));
638
+ }
639
+ function wrapLabel(label, text, width) {
640
+ const indent = " ".repeat(displayWidth(label));
641
+ return wrapText(`${label}${text}`, width).map((line, index) => (index === 0 ? line : `${indent}${line}`));
642
+ }
643
+ function shortTime(epoch) {
644
+ const date = new Date(epoch);
645
+ const pad = (value) => `${value}`.padStart(2, "0");
646
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
647
+ }
648
+ export function renderTicketList(items, width = DETAIL_WIDTH) {
649
+ if (items.length === 0)
650
+ return "暂无工单(用户交代新任务时先 ticket_create)";
651
+ const active = items.filter((ticket) => ACTIVE_STATES.includes(ticket.state)).length;
652
+ const lines = [`工单 ${items.length} · 进行中 ${active}`];
653
+ for (const state of TICKET_STATES) {
654
+ const group = items.filter((ticket) => ticket.state === state);
655
+ if (group.length === 0)
656
+ continue;
657
+ lines.push("", `${state} (${group.length})`);
658
+ for (const ticket of group) {
659
+ const worker = ticket.workerSessionID === undefined ? "" : ` w=${ticket.workerSessionID.slice(-6)}`;
660
+ const tail = `${shortTime(ticket.updatedAt)}${worker}`;
661
+ lines.push(...wrapBullet(`${ticket.id} ${ticket.priority} ${ticket.title}`, width));
662
+ lines.push(...wrapBullet(tail, width));
663
+ }
664
+ }
665
+ return lines.join("\n");
666
+ }
667
+ export function renderTicketDetail(ticket, width = DETAIL_WIDTH) {
668
+ const lines = [`${ticket.id} · ${ticket.state} · ${ticket.priority}`, ...wrapText(ticket.title, width)];
669
+ lines.push("", ...wrapLabel("目标:", ticket.objective, width));
670
+ lines.push("", "验收:");
671
+ ticket.acceptance.forEach((item, index) => {
672
+ lines.push(...wrapBullet(`${index + 1}. ${item}`, width));
673
+ });
674
+ lines.push("");
675
+ lines.push(...wrapLabel("thread:", ticket.threadID, width));
676
+ lines.push(...wrapLabel("目录:", ticket.directory ?? "-", width));
677
+ lines.push(...wrapLabel("worker:", ticket.workerSessionID ?? "-", width));
678
+ lines.push(...wrapLabel("尝试:", `${ticket.attempts.length}`, width));
679
+ lines.push(...wrapLabel("建档:", shortTime(ticket.createdAt), width));
680
+ lines.push(...wrapLabel("更新:", shortTime(ticket.updatedAt), width));
681
+ if (ticket.closedAt !== undefined)
682
+ lines.push(...wrapLabel("关闭:", shortTime(ticket.closedAt), width));
683
+ if (ticket.closeReason !== undefined)
684
+ lines.push(...wrapLabel("结论:", ticket.closeReason, width));
685
+ if (ticket.parentTicketID !== undefined)
686
+ lines.push(...wrapLabel("父单:", ticket.parentTicketID, width));
687
+ if (ticket.authorization.quote !== "") {
688
+ lines.push("", ...wrapLabel("原话:", ticket.authorization.quote, width));
689
+ }
690
+ const history = ticket.history.slice(-5);
691
+ if (history.length > 0) {
692
+ lines.push("", `历史(最近 ${history.length} 条):`);
693
+ for (const entry of history) {
694
+ const move = entry.from === undefined ? entry.kind : `${entry.from}→${entry.to ?? entry.kind}`;
695
+ const note = entry.note === undefined ? "" : ` ${entry.note}`;
696
+ lines.push(...wrapBullet(`${shortTime(entry.at)} ${move}${note}`, width));
697
+ }
698
+ }
699
+ return lines.join("\n");
700
+ }