opencode-superagent 0.2.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.
@@ -0,0 +1,97 @@
1
+ import type { WorkerState } from "./worker-state.js";
2
+ /**
3
+ * Pure ticket domain model: records, the state machine, and the selectors
4
+ * shared by the ticket store, the dispatcher and the worker lifecycle.
5
+ *
6
+ * Nothing here touches the KV store, Effect or the worker registry, so this
7
+ * module can be imported from any layer without recreating the state ↔ ticket
8
+ * import cycle. `ticket.ts` keeps the effectful store and re-exports these
9
+ * names, so its public API is unchanged.
10
+ */
11
+ export type TicketState = "open" | "in_progress" | "blocked" | "done" | "failed" | "cancelled" | "superseded";
12
+ export type TicketPriority = "p0" | "p1" | "p2";
13
+ export type TicketCloseOutcome = "done" | "failed" | "cancelled";
14
+ export interface TicketRequester {
15
+ readonly source: string;
16
+ readonly threadID: string;
17
+ readonly author?: string;
18
+ }
19
+ /** Which trigger authorized the ticket: the id plus a short quote of what the user said. */
20
+ export interface TicketAuthorization {
21
+ readonly triggerID: string;
22
+ readonly quote: string;
23
+ readonly at: number;
24
+ }
25
+ export interface TicketAttempt {
26
+ readonly at: number;
27
+ readonly sessionID?: string;
28
+ readonly note?: string;
29
+ }
30
+ export interface TicketHistoryEntry {
31
+ readonly at: number;
32
+ readonly kind: "created" | "state" | "note" | "dispatch" | "closed";
33
+ readonly from?: TicketState;
34
+ readonly to?: TicketState;
35
+ readonly note?: string;
36
+ }
37
+ export interface Ticket {
38
+ readonly id: string;
39
+ state: TicketState;
40
+ readonly title: string;
41
+ readonly objective: string;
42
+ readonly acceptance: ReadonlyArray<string>;
43
+ readonly requester: TicketRequester;
44
+ readonly authorization: TicketAuthorization;
45
+ readonly threadID: string;
46
+ workerSessionID?: string;
47
+ readonly attempts: Array<TicketAttempt>;
48
+ readonly priority: TicketPriority;
49
+ directory?: string;
50
+ readonly dedupeKey: string;
51
+ readonly parentTicketID?: string;
52
+ readonly tags: Array<string>;
53
+ readonly createdAt: number;
54
+ updatedAt: number;
55
+ closedAt?: number;
56
+ closeReason?: string;
57
+ readonly history: Array<TicketHistoryEntry>;
58
+ }
59
+ export declare const PRIORITIES: ReadonlyArray<TicketPriority>;
60
+ export declare const TICKET_STATES: ReadonlyArray<TicketState>;
61
+ /** A ticket can be dispatched while it is one of these. */
62
+ export declare const ACTIVE_STATES: ReadonlyArray<TicketState>;
63
+ export declare function isTerminalState(state: TicketState): boolean;
64
+ export declare function allowedTransitions(state: TicketState): ReadonlyArray<TicketState>;
65
+ /** Same-state moves are no-ops, not transitions. */
66
+ export declare function canTransition(from: TicketState, to: TicketState): boolean;
67
+ /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
68
+ export declare function transitionError(from: TicketState, to: TicketState): string | undefined;
69
+ /** Minimal shape a worker-bound ticket needs, so the selector stays pure and generic. */
70
+ export interface WorkerBoundTicket {
71
+ readonly workerSessionID?: string;
72
+ readonly state: TicketState;
73
+ }
74
+ /**
75
+ * The `in_progress` ticket attached to a worker session, if any. Shared by the
76
+ * dispatch link and worker-end closing so both sides agree on "active ticket
77
+ * for this worker"; insertion order decides ties, as before.
78
+ */
79
+ export declare function findActiveTicketForWorker<T extends WorkerBoundTicket>(candidates: Iterable<T>, sessionID: string): T | undefined;
80
+ /**
81
+ * Ticket outcome for a worker that just ended, or `undefined` when the worker
82
+ * end closes nothing. `idle`/`aborted`/`running` leave the ticket for the abort
83
+ * path or a deliberate re-dispatch.
84
+ */
85
+ export declare function closeOutcomeForWorkerState(state: WorkerState): "done" | "failed" | undefined;
86
+ /**
87
+ * Reason recorded when a worker end closes its ticket: an explicit summary
88
+ * wins, then the caller's note, then a generated `done (worker <id>)` line.
89
+ * The store still compacts it to `SUMMARY_MAX` before persisting, exactly as
90
+ * before this helper existed.
91
+ */
92
+ export declare function workerCloseSummary(input: {
93
+ readonly sessionID: string;
94
+ readonly outcome: "done" | "failed";
95
+ readonly summary?: string | undefined;
96
+ readonly note?: string | undefined;
97
+ }): string;
@@ -0,0 +1,70 @@
1
+ export const PRIORITIES = ["p0", "p1", "p2"];
2
+ export const TICKET_STATES = [
3
+ "open",
4
+ "in_progress",
5
+ "blocked",
6
+ "done",
7
+ "failed",
8
+ "cancelled",
9
+ "superseded",
10
+ ];
11
+ /** A ticket can be dispatched while it is one of these. */
12
+ export const ACTIVE_STATES = ["open", "in_progress"];
13
+ const TRANSITIONS = {
14
+ // A ticket can close from any active state: `done`/`failed` also cover work
15
+ // finished outside the worker (or a dispatch that never started).
16
+ open: ["in_progress", "blocked", "done", "failed", "cancelled", "superseded"],
17
+ in_progress: ["blocked", "done", "failed", "cancelled", "superseded"],
18
+ blocked: ["in_progress", "done", "failed", "cancelled", "superseded"],
19
+ done: [],
20
+ failed: [],
21
+ cancelled: [],
22
+ superseded: [],
23
+ };
24
+ export function isTerminalState(state) {
25
+ return TRANSITIONS[state].length === 0;
26
+ }
27
+ export function allowedTransitions(state) {
28
+ return TRANSITIONS[state];
29
+ }
30
+ /** Same-state moves are no-ops, not transitions. */
31
+ export function canTransition(from, to) {
32
+ return from === to || TRANSITIONS[from].includes(to);
33
+ }
34
+ /** Human-readable reason a move is illegal, or `undefined` when it is allowed. */
35
+ export function transitionError(from, to) {
36
+ if (canTransition(from, to))
37
+ return undefined;
38
+ if (isTerminalState(from))
39
+ return `ticket is already closed (${from}); cannot move to ${to}`;
40
+ return `illegal transition ${from} -> ${to}; allowed: ${TRANSITIONS[from].join(", ")}`;
41
+ }
42
+ /**
43
+ * The `in_progress` ticket attached to a worker session, if any. Shared by the
44
+ * dispatch link and worker-end closing so both sides agree on "active ticket
45
+ * for this worker"; insertion order decides ties, as before.
46
+ */
47
+ export function findActiveTicketForWorker(candidates, sessionID) {
48
+ for (const ticket of candidates) {
49
+ if (ticket.workerSessionID === sessionID && ticket.state === "in_progress")
50
+ return ticket;
51
+ }
52
+ return undefined;
53
+ }
54
+ /**
55
+ * Ticket outcome for a worker that just ended, or `undefined` when the worker
56
+ * end closes nothing. `idle`/`aborted`/`running` leave the ticket for the abort
57
+ * path or a deliberate re-dispatch.
58
+ */
59
+ export function closeOutcomeForWorkerState(state) {
60
+ return state === "done" || state === "failed" ? state : undefined;
61
+ }
62
+ /**
63
+ * Reason recorded when a worker end closes its ticket: an explicit summary
64
+ * wins, then the caller's note, then a generated `done (worker <id>)` line.
65
+ * The store still compacts it to `SUMMARY_MAX` before persisting, exactly as
66
+ * before this helper existed.
67
+ */
68
+ export function workerCloseSummary(input) {
69
+ return input.summary ?? input.note ?? `${input.outcome} (worker ${input.sessionID})`;
70
+ }
@@ -1,7 +1,24 @@
1
1
  import type { Plugin } from "@opencode/plugin/effect";
2
2
  import { Tool } from "@opencode/schema/tool";
3
3
  import { Effect, type Schema } from "effect";
4
- import { type TriggerInput, type WorkerState } from "./state.js";
4
+ import { type Ticket, type TicketAuthorization, type TicketCloseOutcome, type TicketPriority, type TicketState } from "./ticket-state.js";
5
+ export type { Ticket, TicketAttempt, TicketAuthorization, TicketCloseOutcome, TicketHistoryEntry, TicketPriority, TicketRequester, TicketState, } from "./ticket-state.js";
6
+ export { ACTIVE_STATES, allowedTransitions, canTransition, isTerminalState, TICKET_STATES, transitionError, } from "./ticket-state.js";
7
+ export { isWorkerTerminal, TERMINAL_WORKER_STATES } from "./worker-state.js";
8
+ /**
9
+ * Structural copy of the queued trigger input. The ticket layer must not import
10
+ * `state.ts` (that is the cycle this module just broke), and the shape is all
11
+ * `noteRouterTrigger` needs.
12
+ */
13
+ export interface TicketTriggerNoteInput {
14
+ readonly id?: string;
15
+ readonly source: string;
16
+ readonly threadID: string;
17
+ readonly author?: string;
18
+ readonly text: string;
19
+ readonly title?: string;
20
+ readonly createdAt?: number;
21
+ }
5
22
  /**
6
23
  * Ticket (work order) store for the Super-agent P0.
7
24
  *
@@ -23,54 +40,6 @@ import { type TriggerInput, type WorkerState } from "./state.js";
23
40
  *
24
41
  * `done`, `failed`, `cancelled` and `superseded` are terminal.
25
42
  */
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
43
  export declare const TICKET_PREFIX = "ticket:";
75
44
  export declare const TICKET_STATUS_PREFIX = "ticketidx:status:";
76
45
  export declare const TICKET_THREAD_PREFIX = "ticketidx:thread:";
@@ -87,15 +56,6 @@ export declare const INDEX_LIMIT = 200;
87
56
  export declare const MEMORY_LIMIT = 500;
88
57
  export declare const LIST_DEFAULT = 20;
89
58
  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
59
  /** Lowercased, whitespace-collapsed objective, so wording drift does not defeat dedupe. */
100
60
  export declare function normalizeObjective(text: string): string;
101
61
  export declare function dedupeKeyFor(input: {
@@ -122,7 +82,7 @@ export interface RouterTriggerNote {
122
82
  readonly text: string;
123
83
  readonly at: number;
124
84
  }
125
- export declare function noteRouterTrigger(trigger: TriggerInput): void;
85
+ export declare function noteRouterTrigger(trigger: TicketTriggerNoteInput): void;
126
86
  export declare function currentTrigger(): RouterTriggerNote | undefined;
127
87
  /** Reset memory (hydrate and tests): the map and its thread index are rebuilt together. */
128
88
  export declare function resetTickets(): void;
@@ -142,13 +102,6 @@ export declare function ticketStats(): TicketStats;
142
102
  export declare function activeTicketSummary(threadID: string | undefined, limit?: number): string;
143
103
  /** Active ticket for a worker session, used when a worker ends. */
144
104
  export declare function activeTicketForWorker(sessionID: string): Ticket | undefined;
145
- /**
146
- * Worker states after which the ticket may be dispatched again. `idle` is not
147
- * here on purpose: an idle session can still be resumed, so a re-dispatch
148
- * would race a live worker.
149
- */
150
- export declare const TERMINAL_WORKER_STATES: ReadonlyArray<WorkerState>;
151
- export declare function isWorkerTerminal(state: WorkerState): boolean;
152
105
  export declare function validateCreateInput(input: {
153
106
  readonly threadID?: string;
154
107
  readonly title?: string;
@@ -2,8 +2,32 @@ import { Tool } from "@opencode/schema/tool";
2
2
  import { Effect } from "effect";
3
3
  import { createHash } from "node:crypto";
4
4
  import { normalizeDirectory } from "./dirs.js";
5
- import { store } from "./state.js";
5
+ import { ACTIVE_STATES, findActiveTicketForWorker, isTerminalState, PRIORITIES, TICKET_STATES, transitionError, workerCloseSummary, } from "./ticket-state.js";
6
+ import { isWorkerTerminal, workers } from "./worker-state.js";
6
7
  import { displayWidth, wrapText } from "./tools.js";
8
+ export { ACTIVE_STATES, allowedTransitions, canTransition, isTerminalState, TICKET_STATES, transitionError, } from "./ticket-state.js";
9
+ export { isWorkerTerminal, TERMINAL_WORKER_STATES } from "./worker-state.js";
10
+ /**
11
+ * Ticket (work order) store for the Super-agent P0.
12
+ *
13
+ * A ticket is the durable statement of "what the user asked for and how we
14
+ * will know it is done". The Router creates one before dispatching work; the
15
+ * dispatch records the worker session on it and the worker's terminal state
16
+ * closes it. Records live in KV under `ticket:<id>`; the `ticketidx:*` keys
17
+ * are derived accelerators, so hydration always rebuilds them from the
18
+ * record prefix (a lost index never loses a ticket).
19
+ *
20
+ * State machine:
21
+ *
22
+ * open ─┬─▶ in_progress ─┬─▶ done
23
+ * │ ├─▶ failed
24
+ * │ ├─▶ blocked ──▶ in_progress
25
+ * ├─▶ blocked └─▶ cancelled
26
+ * ├─▶ cancelled
27
+ * └─▶ superseded
28
+ *
29
+ * `done`, `failed`, `cancelled` and `superseded` are terminal.
30
+ */
7
31
  export const TICKET_PREFIX = "ticket:";
8
32
  export const TICKET_STATUS_PREFIX = "ticketidx:status:";
9
33
  export const TICKET_THREAD_PREFIX = "ticketidx:thread:";
@@ -20,47 +44,6 @@ export const INDEX_LIMIT = 200;
20
44
  export const MEMORY_LIMIT = 500;
21
45
  export const LIST_DEFAULT = 20;
22
46
  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
47
  /** Lowercased, whitespace-collapsed objective, so wording drift does not defeat dedupe. */
65
48
  export function normalizeObjective(text) {
66
49
  return text.toLowerCase().replace(/\s+/g, " ").trim();
@@ -162,16 +145,7 @@ export function activeTicketSummary(threadID, limit = 3) {
162
145
  }
163
146
  /** Active ticket for a worker session, used when a worker ends. */
164
147
  export function activeTicketForWorker(sessionID) {
165
- return [...tickets.values()].find((ticket) => ticket.workerSessionID === sessionID && ticket.state === "in_progress");
166
- }
167
- /**
168
- * Worker states after which the ticket may be dispatched again. `idle` is not
169
- * here on purpose: an idle session can still be resumed, so a re-dispatch
170
- * would race a live worker.
171
- */
172
- export const TERMINAL_WORKER_STATES = ["done", "failed", "aborted"];
173
- export function isWorkerTerminal(state) {
174
- return TERMINAL_WORKER_STATES.includes(state);
148
+ return findActiveTicketForWorker(tickets.values(), sessionID);
175
149
  }
176
150
  // ── validation ────────────────────────────────────────────────────────────────
177
151
  export function validateCreateInput(input) {
@@ -568,7 +542,7 @@ export const requireDispatchTicket = Effect.fn("superagent.requireDispatchTicket
568
542
  // a re-dispatch exactly like `running`. A worker record that is gone blocks
569
543
  // nothing: there is no live worker left to wait for.
570
544
  const workerID = ticket.workerSessionID;
571
- const worker = workerID === undefined ? undefined : store.workers.get(workerID);
545
+ const worker = workerID === undefined ? undefined : workers.get(workerID);
572
546
  if (workerID !== undefined && worker !== undefined && !isWorkerTerminal(worker.state)) {
573
547
  const article = worker.state === "idle" ? "an" : "a";
574
548
  return yield* new Tool.Error({
@@ -619,7 +593,7 @@ export const closeTicketsForWorker = Effect.fn("superagent.closeTicketsForWorker
619
593
  return yield* closeTicket(ctx, {
620
594
  ticketID: ticket.id,
621
595
  outcome: input.outcome,
622
- summary: input.summary ?? input.note ?? `${input.outcome} (worker ${input.sessionID})`,
596
+ summary: workerCloseSummary(input),
623
597
  ...(input.now === undefined ? {} : { now: input.now }),
624
598
  });
625
599
  });
package/dist/src/tools.js CHANGED
@@ -150,11 +150,14 @@ export const ROUTER_TOOL_SPECS = [
150
150
  name: "status",
151
151
  group: "query",
152
152
  description: "Show a compact summary of the router, pending triggers and worker sessions.",
153
- summary: "查看 Router、pending 触发器数量与全部 Worker 的 thread/状态/最近报告。",
153
+ summary: "查看插件版本、Router、pending 触发器数量与全部 Worker 的 thread/状态/最近报告。",
154
154
  params: [],
155
155
  risk: "read",
156
156
  confirmation: "只读,自动执行(permission: status)。",
157
- usage: ["system 状态行只给计数,细节用 status / thread_detail。"],
157
+ usage: [
158
+ "首行是插件版本与「升级后需重启网关」提示,用来判断网关是否还在跑旧代码。",
159
+ "system 状态行只给计数,细节用 status / thread_detail。",
160
+ ],
158
161
  },
159
162
  {
160
163
  name: "thread_detail",
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Plugin version, read from the package's own `package.json`.
3
+ *
4
+ * The version is the only thing this module exposes: it is deliberately not a
5
+ * diagnostics dump, so logging it can never leak config, tokens, absolute
6
+ * paths or session ids.
7
+ */
8
+ /** Shown when no candidate `package.json` can be read or parsed. */
9
+ export declare const UNKNOWN_VERSION = "0.0.0-unknown";
10
+ /** Extract `version` from raw `package.json` text; `undefined` when unusable. */
11
+ export declare function versionFromPackageJson(raw: string): string | undefined;
12
+ export type ReadFile = (file: string) => string | undefined;
13
+ /**
14
+ * Read the first usable candidate, falling back to {@link UNKNOWN_VERSION}.
15
+ *
16
+ * `read` is injectable so the fallback path is testable without touching the
17
+ * filesystem: pass `() => undefined` for a missing package, or a stub that
18
+ * returns unparsable JSON.
19
+ */
20
+ export declare function loadVersion(read?: ReadFile): string;
21
+ /** Cached plugin version; the process never changes version at runtime. */
22
+ export declare function pluginVersion(): string;
23
+ /**
24
+ * One-line startup notice. Says what is loaded and that upgrading an already
25
+ * running gateway is not enough — the plugin has no hot reload, so a service
26
+ * restart is required before the new code is active.
27
+ */
28
+ export declare function versionNotice(): string;
@@ -0,0 +1,76 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Plugin version, read from the package's own `package.json`.
5
+ *
6
+ * The version is the only thing this module exposes: it is deliberately not a
7
+ * diagnostics dump, so logging it can never leak config, tokens, absolute
8
+ * paths or session ids.
9
+ */
10
+ /** Shown when no candidate `package.json` can be read or parsed. */
11
+ export const UNKNOWN_VERSION = "0.0.0-unknown";
12
+ /**
13
+ * `package.json` locations relative to this module, most specific first:
14
+ * - source (dev / tests): `src/version.ts` → `../package.json`
15
+ * - build output: `dist/src/version.js` → `../../package.json`
16
+ *
17
+ * Both are tried so the same code works in the repo and in a packed tarball.
18
+ */
19
+ const CANDIDATES = ["../package.json", "../../package.json"];
20
+ /** Extract `version` from raw `package.json` text; `undefined` when unusable. */
21
+ export function versionFromPackageJson(raw) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ if (typeof parsed !== "object" || parsed === null)
30
+ return undefined;
31
+ const value = parsed.version;
32
+ if (typeof value !== "string")
33
+ return undefined;
34
+ const trimmed = value.trim();
35
+ return trimmed === "" ? undefined : trimmed;
36
+ }
37
+ function readRelative(file) {
38
+ try {
39
+ return readFileSync(path.resolve(import.meta.dir, file), "utf8");
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * Read the first usable candidate, falling back to {@link UNKNOWN_VERSION}.
47
+ *
48
+ * `read` is injectable so the fallback path is testable without touching the
49
+ * filesystem: pass `() => undefined` for a missing package, or a stub that
50
+ * returns unparsable JSON.
51
+ */
52
+ export function loadVersion(read = readRelative) {
53
+ for (const candidate of CANDIDATES) {
54
+ const raw = read(candidate);
55
+ if (raw === undefined)
56
+ continue;
57
+ const version = versionFromPackageJson(raw);
58
+ if (version !== undefined)
59
+ return version;
60
+ }
61
+ return UNKNOWN_VERSION;
62
+ }
63
+ let cached;
64
+ /** Cached plugin version; the process never changes version at runtime. */
65
+ export function pluginVersion() {
66
+ cached ??= loadVersion();
67
+ return cached;
68
+ }
69
+ /**
70
+ * One-line startup notice. Says what is loaded and that upgrading an already
71
+ * running gateway is not enough — the plugin has no hot reload, so a service
72
+ * restart is required before the new code is active.
73
+ */
74
+ export function versionNotice() {
75
+ return `superagent ${pluginVersion()} loaded; restart the OpenCode gateway after upgrading (no hot reload)`;
76
+ }
@@ -3,6 +3,7 @@ import { buildReport, lastAssistantText, reportNotice, reportSummary, saveReport
3
3
  import { ensureRouter } from "./router.js";
4
4
  import { saveWorker, store, toSessionID } from "./state.js";
5
5
  import { closeTicketsForWorker } from "./ticket.js";
6
+ import { closeOutcomeForWorkerState } from "./ticket-state.js";
6
7
  /**
7
8
  * Capture one finished report: the complete text goes to `report:<sessionID>`
8
9
  * first, then the worker record keeps a short summary for status lines and the
@@ -75,11 +76,12 @@ const handleEnded = Effect.fn("superagent.workerEnded")(function* (ctx, sessionI
75
76
  // A worker that reached a terminal state closes its in-progress ticket.
76
77
  // Interruptions (`idle`) leave it open: the abort tool closes it explicitly
77
78
  // and a plain interrupt may still be resumed with a follow-up.
78
- if (state === "done" || state === "failed") {
79
+ const closeOutcome = closeOutcomeForWorkerState(state);
80
+ if (closeOutcome !== undefined) {
79
81
  yield* closeTicketsForWorker(ctx, {
80
82
  sessionID,
81
- outcome: state,
82
- summary: worker.lastReport ?? report ?? detail ?? `${state} (worker ${sessionID})`,
83
+ outcome: closeOutcome,
84
+ summary: worker.lastReport ?? report ?? detail ?? `${closeOutcome} (worker ${sessionID})`,
83
85
  }).pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to close ticket for ended worker", { sessionID, cause })));
84
86
  }
85
87
  // Keep the thread subscriptions after the worker ends: follow-ups on the
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Worker records and the in-memory worker registry they live in.
3
+ *
4
+ * This module is intentionally dependency-free: `state.ts` owns KV persistence
5
+ * and the rest of the store, while the ticket layer only needs to look up a
6
+ * worker's state when it gates a dispatch. Keeping the record type and the
7
+ * registry map here lets `ticket.ts` avoid importing `state.ts`, which is what
8
+ * breaks the state ↔ ticket import cycle. `store.workers` in `state.ts` is the
9
+ * same map instance, so both layers always see one registry.
10
+ */
11
+ export type WorkerState = "running" | "idle" | "failed" | "done" | "aborted";
12
+ export declare const WORKER_STATES: ReadonlyArray<WorkerState>;
13
+ export interface WorkerRecord {
14
+ readonly sessionID: string;
15
+ readonly title?: string;
16
+ /**
17
+ * Primary thread this worker was dispatched for. Legacy records decode to
18
+ * `""` and `hydrate` backfills it from `thread.workers`, so every live record
19
+ * carries a forward link to its thread without losing the multi-thread
20
+ * `threads` list.
21
+ */
22
+ threadID: string;
23
+ /** Trigger that authorized the dispatch, when the record knows it. */
24
+ triggerID?: string;
25
+ readonly threads: Array<string>;
26
+ state: WorkerState;
27
+ readonly createdAt: number;
28
+ /** Last persisted write. Refreshed by `saveWorker`, so an aborted record keeps a traceable timestamp. */
29
+ updatedAt?: number;
30
+ lastReport?: string;
31
+ directory?: string;
32
+ }
33
+ /** Canonical in-memory worker registry; `store.workers` points at this map. */
34
+ export declare const workers: Map<string, WorkerRecord>;
35
+ /**
36
+ * Worker states after which the ticket may be dispatched again. `idle` is not
37
+ * here on purpose: an idle session can still be resumed, so a re-dispatch
38
+ * would race a live worker.
39
+ */
40
+ export declare const TERMINAL_WORKER_STATES: ReadonlyArray<WorkerState>;
41
+ export declare function isWorkerTerminal(state: WorkerState): boolean;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Worker records and the in-memory worker registry they live in.
3
+ *
4
+ * This module is intentionally dependency-free: `state.ts` owns KV persistence
5
+ * and the rest of the store, while the ticket layer only needs to look up a
6
+ * worker's state when it gates a dispatch. Keeping the record type and the
7
+ * registry map here lets `ticket.ts` avoid importing `state.ts`, which is what
8
+ * breaks the state ↔ ticket import cycle. `store.workers` in `state.ts` is the
9
+ * same map instance, so both layers always see one registry.
10
+ */
11
+ export const WORKER_STATES = ["running", "idle", "failed", "done", "aborted"];
12
+ /** Canonical in-memory worker registry; `store.workers` points at this map. */
13
+ export const workers = new Map();
14
+ /**
15
+ * Worker states after which the ticket may be dispatched again. `idle` is not
16
+ * here on purpose: an idle session can still be resumed, so a re-dispatch
17
+ * would race a live worker.
18
+ */
19
+ export const TERMINAL_WORKER_STATES = ["done", "failed", "aborted"];
20
+ export function isWorkerTerminal(state) {
21
+ return TERMINAL_WORKER_STATES.includes(state);
22
+ }
package/dist/tui.js CHANGED
@@ -151,11 +151,14 @@ var ROUTER_TOOL_SPECS = [
151
151
  name: "status",
152
152
  group: "query",
153
153
  description: "Show a compact summary of the router, pending triggers and worker sessions.",
154
- summary: "\u67E5\u770B Router\u3001pending \u89E6\u53D1\u5668\u6570\u91CF\u4E0E\u5168\u90E8 Worker \u7684 thread/\u72B6\u6001/\u6700\u8FD1\u62A5\u544A\u3002",
154
+ summary: "\u67E5\u770B\u63D2\u4EF6\u7248\u672C\u3001Router\u3001pending \u89E6\u53D1\u5668\u6570\u91CF\u4E0E\u5168\u90E8 Worker \u7684 thread/\u72B6\u6001/\u6700\u8FD1\u62A5\u544A\u3002",
155
155
  params: [],
156
156
  risk: "read",
157
157
  confirmation: "\u53EA\u8BFB\uFF0C\u81EA\u52A8\u6267\u884C\uFF08permission: status\uFF09\u3002",
158
- usage: ["system \u72B6\u6001\u884C\u53EA\u7ED9\u8BA1\u6570\uFF0C\u7EC6\u8282\u7528 status / thread_detail\u3002"]
158
+ usage: [
159
+ "\u9996\u884C\u662F\u63D2\u4EF6\u7248\u672C\u4E0E\u300C\u5347\u7EA7\u540E\u9700\u91CD\u542F\u7F51\u5173\u300D\u63D0\u793A\uFF0C\u7528\u6765\u5224\u65AD\u7F51\u5173\u662F\u5426\u8FD8\u5728\u8DD1\u65E7\u4EE3\u7801\u3002",
160
+ "system \u72B6\u6001\u884C\u53EA\u7ED9\u8BA1\u6570\uFF0C\u7EC6\u8282\u7528 status / thread_detail\u3002"
161
+ ]
159
162
  },
160
163
  {
161
164
  name: "thread_detail",
@@ -476,6 +479,7 @@ var Definition = Rpc.define({
476
479
  status: {
477
480
  input: Schema.toStandardSchemaV1(Schema.Struct({})),
478
481
  output: Schema.toStandardSchemaV1(Schema.Struct({
482
+ version: Schema.optionalKey(Schema.String),
479
483
  router: Schema.optionalKey(Schema.String),
480
484
  pending: Schema.Number,
481
485
  workers: Schema.Array(Schema.Struct({
@@ -530,7 +534,8 @@ var Definition = Rpc.define({
530
534
  },
531
535
  stop: {
532
536
  input: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Schema.String })),
533
- output: Schema.toStandardSchemaV1(Schema.Struct({ interrupted: Schema.Boolean }))
537
+ output: Schema.toStandardSchemaV1(Schema.Struct({ interrupted: Schema.Boolean })),
538
+ errors: { "stop.rejected": Schema.toStandardSchemaV1(Schema.Struct({})) }
534
539
  },
535
540
  abort: {
536
541
  input: Schema.toStandardSchemaV1(Schema.Struct({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-superagent",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "description": "Superagent: a resident router agent that dispatches background worker agents in OpenCode",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "prepare": "bun run build",
44
44
  "prepack": "bun run build",
45
45
  "test": "bun test",
46
+ "test:e2e": "bun run e2e/plugin-load.e2e.ts",
46
47
  "typecheck": "bun check --no-script",
47
48
  "typecheck:tsc": "tsc --noEmit"
48
49
  },