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,135 @@
1
+ /**
2
+ * Sidebar view model for the ticket list.
3
+ *
4
+ * The `tickets` RPC returns `renderTicketList` text rather than structured
5
+ * rows (the server side is deployed separately and needs a service restart to
6
+ * change), so the TUI parses that text back into rows and re-lays them out for
7
+ * the fixed-width sidebar. Everything here is pure and width-aware, which
8
+ * keeps the OpenTUI component in `tui.tsx` to rendering only.
9
+ *
10
+ * `src/ticket.ts` is only imported for types: valuing `TICKET_STATES` here
11
+ * would pull the Effect/KV store into the TUI bundle. `test/ticket-view.test.ts`
12
+ * asserts the local copy stays in step with the real state list.
13
+ */
14
+ import { displayWidth } from "./tools.js";
15
+ export const TICKET_STATES = [
16
+ "open",
17
+ "in_progress",
18
+ "blocked",
19
+ "done",
20
+ "failed",
21
+ "cancelled",
22
+ "superseded",
23
+ ];
24
+ /**
25
+ * `SESSION_SIDEBAR_WIDTH` is 42 columns: 4 of horizontal padding plus the
26
+ * scrollbar column the sidebar reserves on the right leave 37 cells. The panel
27
+ * uses 36 and keeps one cell of slack, so a row can never push the sidebar
28
+ * into a horizontal scroll.
29
+ */
30
+ export const TICKET_PANEL_WIDTH = 36;
31
+ /** Titles are capped below the full remainder so a long one never pushes the id off-screen. */
32
+ export const TICKET_TITLE_MAX = 26;
33
+ /** Terminal tickets are collapsed; the panel is a glance, not an archive. */
34
+ export const TICKET_TERMINAL_LIMIT = 3;
35
+ export const TICKET_PANEL_LIMIT = 20;
36
+ export const TICKET_PANEL_REFRESH_MS = 4000;
37
+ const SHORT_ID_LENGTH = 4;
38
+ export const TICKET_STATE_SYMBOL = {
39
+ open: "○",
40
+ in_progress: "●",
41
+ blocked: "◆",
42
+ done: "✓",
43
+ failed: "✗",
44
+ cancelled: "⊘",
45
+ superseded: "·",
46
+ };
47
+ /** Unfinished states first (running work, then stuck work, then queued), terminal ones last. */
48
+ const STATE_RANK = {
49
+ in_progress: 0,
50
+ blocked: 1,
51
+ open: 2,
52
+ done: 3,
53
+ failed: 4,
54
+ cancelled: 5,
55
+ superseded: 6,
56
+ };
57
+ const TERMINAL = new Set(["done", "failed", "cancelled", "superseded"]);
58
+ const GROUP_LINE = /^(open|in_progress|blocked|done|failed|cancelled|superseded) \(\d+\)$/;
59
+ const ITEM_LINE = /^· (tk_\S+) (p[012])(?: (.*))?$/;
60
+ /** `renderTicketList` writes `MM-DD HH:MM` (plus an optional worker suffix) under every item. */
61
+ const TRAILER_LINE = /^\s{2}\d{2}-\d{2} \d{2}:\d{2}(?: w=\S+)?$/;
62
+ /**
63
+ * Parse `renderTicketList` output. Rows arrive grouped by state and already
64
+ * sorted newest-first inside a group, so the input order is the time order.
65
+ * Unrecognised lines are ignored: a future format change degrades the panel to
66
+ * "no tickets" instead of breaking the sidebar.
67
+ */
68
+ export function parseTicketList(text) {
69
+ const rows = [];
70
+ let state;
71
+ for (const line of text.split("\n")) {
72
+ const group = GROUP_LINE.exec(line);
73
+ if (group?.[1] !== undefined) {
74
+ state = group[1];
75
+ continue;
76
+ }
77
+ const item = ITEM_LINE.exec(line);
78
+ if (item !== null && state !== undefined) {
79
+ rows.push({
80
+ id: item[1],
81
+ priority: item[2],
82
+ state,
83
+ title: (item[3] ?? "").trim(),
84
+ });
85
+ continue;
86
+ }
87
+ // A wrapped title continues on indented lines; trailers are not titles.
88
+ if (state !== undefined && rows.length > 0 && line.startsWith(" ") && !TRAILER_LINE.test(line)) {
89
+ const last = rows[rows.length - 1];
90
+ const continuation = line.trim();
91
+ if (continuation !== "")
92
+ last.title = last.title === "" ? continuation : `${last.title} ${continuation}`;
93
+ }
94
+ }
95
+ return rows;
96
+ }
97
+ /** Truncate to `max` terminal cells (CJK counts as 2) with a trailing ellipsis. */
98
+ export function truncateCells(text, max) {
99
+ if (max <= 0)
100
+ return "";
101
+ if (displayWidth(text) <= max)
102
+ return text;
103
+ let result = "";
104
+ let cells = 0;
105
+ for (const char of text) {
106
+ const width = displayWidth(char);
107
+ if (cells + width > max - 1)
108
+ break;
109
+ result += char;
110
+ cells += width;
111
+ }
112
+ return `${result}…`;
113
+ }
114
+ export function shortTicketID(ticketID) {
115
+ return ticketID.length <= SHORT_ID_LENGTH ? ticketID : ticketID.slice(-SHORT_ID_LENGTH);
116
+ }
117
+ /** Order, truncate and collapse parsed rows into what the sidebar renders. */
118
+ export function ticketPanel(rows, width = TICKET_PANEL_WIDTH, terminalLimit = TICKET_TERMINAL_LIMIT) {
119
+ const titleMax = Math.min(TICKET_TITLE_MAX, width - (1 + 1 + 1 + SHORT_ID_LENGTH));
120
+ // `Array.prototype.sort` is stable, so newest-first survives inside a state.
121
+ const ordered = [...rows].sort((left, right) => STATE_RANK[left.state] - STATE_RANK[right.state]);
122
+ const active = ordered.filter((row) => !TERMINAL.has(row.state));
123
+ const terminal = ordered.filter((row) => TERMINAL.has(row.state));
124
+ return {
125
+ total: rows.length,
126
+ active: active.length,
127
+ hidden: Math.max(0, terminal.length - terminalLimit),
128
+ items: [...active, ...terminal.slice(0, terminalLimit)].map((row) => ({
129
+ id: row.id,
130
+ shortID: shortTicketID(row.id),
131
+ title: truncateCells(row.title, titleMax),
132
+ state: row.state,
133
+ })),
134
+ };
135
+ }
@@ -0,0 +1,246 @@
1
+ import type { Plugin } from "@opencode/plugin/effect";
2
+ import { Tool } from "@opencode/schema/tool";
3
+ import { Effect, type Schema } from "effect";
4
+ import { type TriggerInput } from "./state.js";
5
+ /**
6
+ * Ticket (work order) store for the Super-agent P0.
7
+ *
8
+ * A ticket is the durable statement of "what the user asked for and how we
9
+ * will know it is done". The Router creates one before dispatching work; the
10
+ * dispatch records the worker session on it and the worker's terminal state
11
+ * closes it. Records live in KV under `ticket:<id>`; the `ticketidx:*` keys
12
+ * are derived accelerators, so hydration always rebuilds them from the
13
+ * record prefix (a lost index never loses a ticket).
14
+ *
15
+ * State machine:
16
+ *
17
+ * open ─┬─▶ in_progress ─┬─▶ done
18
+ * │ ├─▶ failed
19
+ * │ ├─▶ blocked ──▶ in_progress
20
+ * ├─▶ blocked └─▶ cancelled
21
+ * ├─▶ cancelled
22
+ * └─▶ superseded
23
+ *
24
+ * `done`, `failed`, `cancelled` and `superseded` are terminal.
25
+ */
26
+ export type TicketState = "open" | "in_progress" | "blocked" | "done" | "failed" | "cancelled" | "superseded";
27
+ export type TicketPriority = "p0" | "p1" | "p2";
28
+ export type TicketCloseOutcome = "done" | "failed" | "cancelled";
29
+ export interface TicketRequester {
30
+ readonly source: string;
31
+ readonly threadID: string;
32
+ readonly author?: string;
33
+ }
34
+ /** Which trigger authorized the ticket: the id plus a short quote of what the user said. */
35
+ export interface TicketAuthorization {
36
+ readonly triggerID: string;
37
+ readonly quote: string;
38
+ readonly at: number;
39
+ }
40
+ export interface TicketAttempt {
41
+ readonly at: number;
42
+ readonly sessionID?: string;
43
+ readonly note?: string;
44
+ }
45
+ export interface TicketHistoryEntry {
46
+ readonly at: number;
47
+ readonly kind: "created" | "state" | "note" | "dispatch" | "closed";
48
+ readonly from?: TicketState;
49
+ readonly to?: TicketState;
50
+ readonly note?: string;
51
+ }
52
+ export interface Ticket {
53
+ readonly id: string;
54
+ state: TicketState;
55
+ readonly title: string;
56
+ readonly objective: string;
57
+ readonly acceptance: ReadonlyArray<string>;
58
+ readonly requester: TicketRequester;
59
+ readonly authorization: TicketAuthorization;
60
+ readonly threadID: string;
61
+ workerSessionID?: string;
62
+ readonly attempts: Array<TicketAttempt>;
63
+ readonly priority: TicketPriority;
64
+ directory?: string;
65
+ readonly dedupeKey: string;
66
+ readonly parentTicketID?: string;
67
+ readonly tags: Array<string>;
68
+ readonly createdAt: number;
69
+ updatedAt: number;
70
+ closedAt?: number;
71
+ closeReason?: string;
72
+ readonly history: Array<TicketHistoryEntry>;
73
+ }
74
+ export declare const TICKET_PREFIX = "ticket:";
75
+ export declare const TICKET_STATUS_PREFIX = "ticketidx:status:";
76
+ export declare const TICKET_THREAD_PREFIX = "ticketidx:thread:";
77
+ export declare const TICKET_ALL_KEY = "ticketidx:all";
78
+ export declare const TITLE_MAX = 80;
79
+ export declare const OBJECTIVE_MAX = 2000;
80
+ export declare const ACCEPTANCE_MAX = 10;
81
+ export declare const ACCEPTANCE_ITEM_MAX = 300;
82
+ export declare const QUOTE_MAX = 300;
83
+ export declare const SUMMARY_MAX = 500;
84
+ /** Index buckets are capped so a long-lived gateway never writes an unbounded list. */
85
+ export declare const INDEX_LIMIT = 200;
86
+ /** Newest-N tickets kept in memory; older records stay in KV and reappear after hydrate. */
87
+ export declare const MEMORY_LIMIT = 500;
88
+ export declare const LIST_DEFAULT = 20;
89
+ export declare const LIST_MAX = 100;
90
+ export declare const TICKET_STATES: ReadonlyArray<TicketState>;
91
+ /** A ticket can be dispatched while it is one of these. */
92
+ export declare const ACTIVE_STATES: ReadonlyArray<TicketState>;
93
+ export declare function isTerminalState(state: TicketState): boolean;
94
+ export declare function allowedTransitions(state: TicketState): ReadonlyArray<TicketState>;
95
+ /** Same-state moves are no-ops, not transitions. */
96
+ export declare function canTransition(from: TicketState, to: TicketState): boolean;
97
+ /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
98
+ export declare function transitionError(from: TicketState, to: TicketState): string | undefined;
99
+ /** Lowercased, whitespace-collapsed objective, so wording drift does not defeat dedupe. */
100
+ export declare function normalizeObjective(text: string): string;
101
+ export declare function dedupeKeyFor(input: {
102
+ readonly source: string;
103
+ readonly threadID: string;
104
+ readonly objective: string;
105
+ }): string;
106
+ export declare const ticketKey: (id: string) => string;
107
+ export declare const ticketStatusKey: (state: TicketState) => string;
108
+ export declare const ticketThreadKey: (threadID: string) => string;
109
+ /** In-memory mirror of every hydrated ticket, rebuilt from KV at startup. */
110
+ export declare const tickets: Map<string, Ticket>;
111
+ /**
112
+ * The trigger most recently delivered to the Router. `ticket_create` copies
113
+ * it into `authorization`, so a ticket always points at what the user said.
114
+ * It is a heuristic: the newest flushed trigger wins, which matches how the
115
+ * Router batches queued events before answering.
116
+ */
117
+ export interface RouterTriggerNote {
118
+ readonly id: string;
119
+ readonly source: string;
120
+ readonly threadID: string;
121
+ readonly author?: string;
122
+ readonly text: string;
123
+ readonly at: number;
124
+ }
125
+ export declare function noteRouterTrigger(trigger: TriggerInput): void;
126
+ export declare function currentTrigger(): RouterTriggerNote | undefined;
127
+ /** Reset memory (hydrate and tests): the map and its thread index are rebuilt together. */
128
+ export declare function resetTickets(): void;
129
+ export declare function getTicket(ticketID: string): Ticket | undefined;
130
+ /** Same-thread, same-dedupe-key ticket still open or in progress, if any. */
131
+ export declare function findDuplicate(threadID: string, dedupeKey: string): Ticket | undefined;
132
+ export declare function isActive(ticket: Ticket): boolean;
133
+ export interface TicketStats {
134
+ readonly total: number;
135
+ readonly open: number;
136
+ readonly inProgress: number;
137
+ readonly blocked: number;
138
+ readonly closed: number;
139
+ }
140
+ export declare function ticketStats(): TicketStats;
141
+ /** Compact "tk_x title; tk_y title" line for the system status, empty when there is nothing active. */
142
+ export declare function activeTicketSummary(threadID: string | undefined, limit?: number): string;
143
+ /** Active ticket for a worker session, used when a worker ends. */
144
+ export declare function activeTicketForWorker(sessionID: string): Ticket | undefined;
145
+ export declare function validateCreateInput(input: {
146
+ readonly threadID?: string;
147
+ readonly title?: string;
148
+ readonly objective?: string;
149
+ readonly acceptance?: ReadonlyArray<string>;
150
+ readonly directory?: string;
151
+ }): string | undefined;
152
+ export declare function decodeTicket(value: Schema.Json | undefined): Ticket | undefined;
153
+ export declare function encodeTicket(ticket: Ticket): Schema.Json;
154
+ export interface CreateTicketInput {
155
+ readonly threadID: string;
156
+ readonly title: string;
157
+ readonly objective: string;
158
+ readonly acceptance: ReadonlyArray<string>;
159
+ readonly directory: string;
160
+ readonly source: string;
161
+ readonly author?: string;
162
+ readonly priority?: TicketPriority;
163
+ readonly parentTicketID?: string;
164
+ readonly dedupeKey?: string;
165
+ readonly tags?: ReadonlyArray<string>;
166
+ /** Defaults to the trigger most recently delivered to the Router. */
167
+ readonly authorization?: TicketAuthorization;
168
+ readonly now?: number;
169
+ }
170
+ export interface CreateTicketResult {
171
+ readonly ticket: Ticket;
172
+ readonly reused: boolean;
173
+ }
174
+ /**
175
+ * Create a ticket, or return the still-open same-key ticket in the same
176
+ * thread (`reused: true`). Objective, acceptance and directory are required;
177
+ * an empty acceptance is rejected so no placeholder ticket can exist.
178
+ */
179
+ export declare const createTicket: (ctx: Plugin.Context, input: CreateTicketInput) => Effect.Effect<{
180
+ ticket: Ticket;
181
+ reused: boolean;
182
+ }, Tool.Error, never>;
183
+ export interface UpdateTicketInput {
184
+ readonly ticketID: string;
185
+ readonly state?: TicketState;
186
+ readonly note?: string;
187
+ readonly workerSessionID?: string;
188
+ readonly now?: number;
189
+ }
190
+ /** Move a ticket through the state machine. Illegal moves fail with a `Tool.Error`. */
191
+ export declare const updateTicket: (ctx: Plugin.Context, input: UpdateTicketInput) => Effect.Effect<Ticket, Tool.Error, never>;
192
+ export interface CloseTicketInput {
193
+ readonly ticketID: string;
194
+ readonly outcome: TicketCloseOutcome;
195
+ readonly summary: string;
196
+ readonly now?: number;
197
+ }
198
+ /** Close a ticket with an outcome, recording summary/closedAt in the record and history. */
199
+ export declare const closeTicket: (ctx: Plugin.Context, input: CloseTicketInput) => Effect.Effect<Ticket, Tool.Error, never>;
200
+ export interface ListTicketsInput {
201
+ readonly threadID?: string;
202
+ readonly status?: TicketState;
203
+ readonly since?: number;
204
+ readonly limit?: number;
205
+ }
206
+ /** Newest-updated first; filters are combined with AND. */
207
+ export declare function listTickets(input?: ListTicketsInput): Array<Ticket>;
208
+ /**
209
+ * Gate every dispatch must pass: a dispatch always names a ticket, and that
210
+ * ticket must be workable right now. The rules are enforced here (not in the
211
+ * prompt) so a Router cannot dispatch without a ticket, onto a closed ticket,
212
+ * across threads, or onto a ticket that already has a running worker.
213
+ */
214
+ export declare const requireDispatchTicket: (ticketID: string | undefined, threadID: string | undefined) => Effect.Effect<Ticket, Tool.Error, never>;
215
+ export interface LinkTicketInput {
216
+ readonly ticketID: string;
217
+ readonly sessionID: string;
218
+ readonly note?: string;
219
+ readonly now?: number;
220
+ }
221
+ /**
222
+ * Attach a dispatched worker to its ticket and move `open` to `in_progress`.
223
+ * Only `open`/`in_progress` tickets accept a worker.
224
+ */
225
+ export declare const linkTicketToWorker: (ctx: Plugin.Context, input: LinkTicketInput) => Effect.Effect<Ticket, Tool.Error, never>;
226
+ export interface CloseForWorkerInput {
227
+ readonly sessionID: string;
228
+ readonly outcome: "done" | "failed";
229
+ readonly summary?: string;
230
+ readonly note?: string;
231
+ readonly now?: number;
232
+ }
233
+ /**
234
+ * Close the in-progress ticket attached to a worker that just ended. Tickets
235
+ * in any other state are left alone: a closed ticket never reopens and an
236
+ * aborted worker is handled by its own path.
237
+ */
238
+ export declare const closeTicketsForWorker: (ctx: Plugin.Context, input: CloseForWorkerInput) => Effect.Effect<Ticket | undefined, Tool.Error, never>;
239
+ /**
240
+ * Load every `ticket:<id>` record and rebuild the indexes. Older workers are
241
+ * never backfilled: a worker with no ticket stays without one, silently.
242
+ */
243
+ export declare const hydrateTickets: (ctx: Plugin.Context) => Effect.Effect<void, never, never>;
244
+ export declare const DETAIL_WIDTH = 52;
245
+ export declare function renderTicketList(items: ReadonlyArray<Ticket>, width?: number): string;
246
+ export declare function renderTicketDetail(ticket: Ticket, width?: number): string;