@mirrowel/opencode-subagent-explorer 0.1.0-dev.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,318 @@
1
+ /**
2
+ * Core subagent-session data layer. Pure tree/classification logic plus
3
+ * fail-soft client seams. Every client call is attached to its namespace
4
+ * object (v2-gen class methods throw when detached), feature-detected,
5
+ * timeout-bounded, and envelope-unwrapping ({data} vs raw) so one code path
6
+ * serves the v1 and v2 hosts and the studio embed alike.
7
+ */
8
+
9
+ export type SessionLite = {
10
+ id: string
11
+ parentID?: string
12
+ title?: string
13
+ agent?: string
14
+ directory?: string
15
+ created?: number
16
+ updated?: number
17
+ }
18
+
19
+ export type SessionTreeNode = {
20
+ session: SessionLite
21
+ depth: number
22
+ children: SessionTreeNode[]
23
+ }
24
+
25
+ export type CleanupPlan = {
26
+ /** Idle sessions, ordered leaf-first so children die before parents. */
27
+ deletable: SessionLite[]
28
+ /** Sessions spared because they are running right now. */
29
+ sparedActive: SessionLite[]
30
+ }
31
+
32
+ const CALL_TIMEOUT_MS = 5000
33
+
34
+ /** OpenCode's built-in agent ids (v1): anything else that is not in the
35
+ * user's config was registered at runtime (plugins) and is treated as
36
+ * hidden/background for display purposes. */
37
+ export const BUILTIN_AGENTS = new Set(["build", "plan"])
38
+
39
+ export function withTimeout<T>(fn: () => Promise<T>, ms = CALL_TIMEOUT_MS): Promise<T | undefined> {
40
+ return Promise.race([Promise.resolve().then(fn).catch(() => undefined), new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))])
41
+ }
42
+
43
+ function unwrap<T>(value: T | { data?: T } | undefined): T | undefined {
44
+ if (value && typeof value === "object" && "data" in (value as Record<string, unknown>)) return (value as { data?: T }).data
45
+ return value as T | undefined
46
+ }
47
+
48
+ function toMillis(value: unknown): number | undefined {
49
+ if (typeof value === "number") return value
50
+ if (typeof value === "string") {
51
+ const parsed = Date.parse(value)
52
+ return Number.isNaN(parsed) ? undefined : parsed
53
+ }
54
+ return undefined
55
+ }
56
+
57
+ export function normalizeSession(raw: unknown): SessionLite | undefined {
58
+ if (!raw || typeof raw !== "object") return undefined
59
+ const record = raw as Record<string, any>
60
+ const id = typeof record.id === "string" ? record.id : undefined
61
+ if (!id) return undefined
62
+ const agentRaw = record.agent
63
+ const agent =
64
+ typeof agentRaw === "string"
65
+ ? agentRaw
66
+ : typeof agentRaw?.id === "string"
67
+ ? agentRaw.id
68
+ : typeof agentRaw?.agentID === "string"
69
+ ? agentRaw.agentID
70
+ : undefined
71
+ const time = record.time && typeof record.time === "object" ? record.time : {}
72
+ return {
73
+ id,
74
+ parentID: typeof record.parentID === "string" ? record.parentID : undefined,
75
+ title: typeof record.title === "string" && record.title !== "" ? record.title : undefined,
76
+ agent,
77
+ directory: typeof record.directory === "string" ? record.directory : undefined,
78
+ created: toMillis(time.created ?? record.time_created),
79
+ updated: toMillis(time.updated ?? record.time_updated),
80
+ }
81
+ }
82
+
83
+ /** Lists sessions for a directory. Tries the v2 client's flat params first,
84
+ * then the v1 `{query}` shape; unwraps both envelope styles. Fail-soft. */
85
+ export async function fetchSessions(client: unknown, directory: string): Promise<SessionLite[] | undefined> {
86
+ const namespace = (client as { session?: Record<string, any> } | undefined)?.session
87
+ if (!namespace?.list) return undefined
88
+ const calls: (() => Promise<unknown>)[] = [
89
+ () => namespace.list({ directory }),
90
+ () => namespace.list({ query: { directory } }),
91
+ ]
92
+ for (const call of calls) {
93
+ const result = unwrap(await withTimeout(call))
94
+ if (Array.isArray(result)) return result.map(normalizeSession).filter((s): s is SessionLite => !!s)
95
+ }
96
+ return undefined
97
+ }
98
+
99
+ /** Running session ids via `session.active()` with the `session.status()`
100
+ * fallback (busy/retry = running). `undefined` when neither exists or both
101
+ * fail — callers must treat that as "unknown", never "idle". */
102
+ export async function fetchActiveSessionIDs(client: unknown): Promise<Set<string> | undefined> {
103
+ const namespace = (client as { session?: Record<string, any> } | undefined)?.session
104
+ if (!namespace) return undefined
105
+ if (typeof namespace.active === "function") {
106
+ const active = unwrap(await withTimeout(() => namespace.active()))
107
+ if (active && typeof active === "object") {
108
+ const ids = new Set<string>()
109
+ for (const [id, value] of Object.entries(active as Record<string, unknown>)) {
110
+ if (value) ids.add(id)
111
+ }
112
+ return ids
113
+ }
114
+ }
115
+ if (typeof namespace.status === "function") {
116
+ const status = unwrap(await withTimeout(() => namespace.status()))
117
+ if (status && typeof status === "object") {
118
+ const ids = new Set<string>()
119
+ for (const [id, value] of Object.entries(status as Record<string, any>)) {
120
+ const state = value?.status ?? value
121
+ if (state === "busy" || state === "retry") ids.add(id)
122
+ }
123
+ return ids
124
+ }
125
+ }
126
+ return undefined
127
+ }
128
+
129
+ /** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
130
+ * are ignored (not part of this session's tree). Guarded against cycles. */
131
+ export function buildSessionTree(sessions: SessionLite[], rootID: string): SessionTreeNode[] {
132
+ const byParent = new Map<string, SessionLite[]>()
133
+ for (const session of sessions) {
134
+ if (!session.parentID) continue
135
+ const list = byParent.get(session.parentID) ?? []
136
+ list.push(session)
137
+ byParent.set(session.parentID, list)
138
+ }
139
+ const createdOf = (session: SessionLite) => session.created ?? 0
140
+ const sortSiblings = (list: SessionLite[]) => [...list].sort((a, b) => createdOf(a) - createdOf(b))
141
+ const visited = new Set<string>([rootID])
142
+ const build = (list: SessionLite[], depth: number): SessionTreeNode[] =>
143
+ sortSiblings(list).map((session) => {
144
+ if (visited.has(session.id)) return { session, depth, children: [] }
145
+ visited.add(session.id)
146
+ const children = build(byParent.get(session.id) ?? [], depth + 1)
147
+ return { session, depth, children }
148
+ })
149
+ return build(byParent.get(rootID) ?? [], 0)
150
+ }
151
+
152
+ /** Flattens a tree depth-first, parents before their children. */
153
+ export function collectSubtree(nodes: SessionTreeNode[]): SessionLite[] {
154
+ const out: SessionLite[] = []
155
+ const walk = (list: SessionTreeNode[]) => {
156
+ for (const node of list) {
157
+ out.push(node.session)
158
+ walk(node.children)
159
+ }
160
+ }
161
+ walk(nodes)
162
+ return out
163
+ }
164
+
165
+ /** Splits a tree into deletable (idle) and spared (running) sessions.
166
+ * Deletable order is leaf-first: children are removed before the parents
167
+ * they hang off, so no cascade can leave dangling rows. */
168
+ export function cleanupTargets(nodes: SessionTreeNode[], activeIDs: Set<string> | undefined): CleanupPlan {
169
+ const deletable: SessionLite[] = []
170
+ const sparedActive: SessionLite[] = []
171
+ const walk = (list: SessionTreeNode[]) => {
172
+ for (const node of list) {
173
+ walk(node.children)
174
+ if (activeIDs?.has(node.session.id)) sparedActive.push(node.session)
175
+ else deletable.push(node.session)
176
+ }
177
+ }
178
+ walk(nodes)
179
+ return { deletable, sparedActive }
180
+ }
181
+
182
+ /** The current session: the router's session id when the host provides one,
183
+ * else the most recently updated session in the directory. */
184
+ export function currentSessionID(explicit: string | undefined, sessions: SessionLite[]): string | undefined {
185
+ if (explicit) return explicit
186
+ let newest: SessionLite | undefined
187
+ for (const session of sessions) {
188
+ const stamp = session.updated ?? session.created ?? 0
189
+ const newestStamp = newest?.updated ?? newest?.created ?? 0
190
+ if (!newest || stamp > newestStamp) newest = session
191
+ }
192
+ return newest?.id
193
+ }
194
+
195
+ /** Hidden/background classification: config agents flagged hidden, plus
196
+ * runtime-registered agents OpenCode does not expose in the config. */
197
+ export function hiddenAgentIDs(config: unknown): Set<string> {
198
+ const agents = (config as { agent?: Record<string, any> } | undefined)?.agent
199
+ const hidden = new Set<string>()
200
+ if (!agents || typeof agents !== "object") return hidden
201
+ for (const [id, entry] of Object.entries(agents)) {
202
+ if (entry && typeof entry === "object" && (entry as Record<string, unknown>).hidden === true) hidden.add(id)
203
+ }
204
+ return hidden
205
+ }
206
+
207
+ /** All agent ids the host knows about (config-declared). Anything absent
208
+ * from this set and from the built-ins was registered at runtime. */
209
+ export function configAgentIDs(config: unknown): Set<string> {
210
+ const agents = (config as { agent?: Record<string, any> } | undefined)?.agent
211
+ if (!agents || typeof agents !== "object") return new Set<string>()
212
+ return new Set(Object.keys(agents))
213
+ }
214
+
215
+ /** A session counts as hidden/background when its agent is flagged hidden
216
+ * in the config, or the agent was registered at runtime (not config-known,
217
+ * not built-in, not an agent-variants clone). */
218
+ export function isHiddenAgent(agent: string | undefined, hidden: Set<string>, configAgents: Set<string>): boolean {
219
+ if (!agent) return false
220
+ if (hidden.has(agent)) return true
221
+ return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:")
222
+ }
223
+
224
+ /** Deletes one session through whichever delete method the host client
225
+ * exposes (`session.delete`, `session.remove`). Returns false when no
226
+ * method exists or the call failed. */
227
+ export async function deleteSession(client: unknown, sessionID: string): Promise<boolean> {
228
+ const namespace = (client as { session?: Record<string, any> } | undefined)?.session
229
+ if (!namespace) return false
230
+ const shapes: (() => Promise<unknown>)[] = []
231
+ if (typeof namespace.delete === "function") {
232
+ shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }))
233
+ }
234
+ if (typeof namespace.remove === "function") {
235
+ shapes.push(() => namespace.remove({ sessionID }), () => namespace.remove({ path: { sessionID } }))
236
+ }
237
+ for (const call of shapes) {
238
+ const result = await withTimeout(call)
239
+ if (result !== undefined) return true
240
+ }
241
+ return false
242
+ }
243
+
244
+ export type SessionDetails = {
245
+ messageCount: number
246
+ model?: string
247
+ firstPrompt?: string
248
+ }
249
+
250
+ /** Cheap detail read for the [i] view: last assistant model + first user
251
+ * prompt, from a bounded tail of the session's messages. */
252
+ export async function fetchSessionDetails(client: unknown, sessionID: string): Promise<SessionDetails | undefined> {
253
+ const namespace = (client as { session?: Record<string, any> } | undefined)?.session
254
+ if (!namespace?.messages) return undefined
255
+ const calls: (() => Promise<unknown>)[] = [
256
+ () => namespace.messages({ sessionID, limit: 50 }),
257
+ () => namespace.messages({ path: { id: sessionID }, query: { limit: 50 } }),
258
+ ]
259
+ for (const call of calls) {
260
+ const result = unwrap(await withTimeout(call, 4000))
261
+ const messages = Array.isArray(result) ? result : undefined
262
+ if (!messages) continue
263
+ let model: string | undefined
264
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
265
+ const info = (messages[i] as any)?.info ?? messages[i]
266
+ const modelRef = info?.model
267
+ if (modelRef?.providerID && modelRef?.modelID) {
268
+ model = `${modelRef.providerID}/${modelRef.modelID}${modelRef.variantID ? ` @${modelRef.variantID}` : ""}`
269
+ break
270
+ }
271
+ }
272
+ let firstPrompt: string | undefined
273
+ for (const message of messages) {
274
+ const info = (message as any)?.info ?? message
275
+ if (info?.role !== "user") continue
276
+ const text = firstTextOf((message as any).parts)
277
+ if (text) {
278
+ firstPrompt = text.length > 400 ? `${text.slice(0, 400)}…` : text
279
+ break
280
+ }
281
+ }
282
+ return { messageCount: messages.length, model, firstPrompt }
283
+ }
284
+ return undefined
285
+ }
286
+
287
+ function firstTextOf(parts: unknown): string | undefined {
288
+ if (!Array.isArray(parts)) return undefined
289
+ for (const part of parts) {
290
+ const text = (part as any)?.text ?? (part as any)?.part?.text
291
+ if (typeof text === "string" && text.trim() !== "") return text.trim()
292
+ }
293
+ return undefined
294
+ }
295
+
296
+ export function formatAge(timestamp: number | undefined, now = Date.now()): string {
297
+ if (!timestamp) return "—"
298
+ const delta = Math.max(0, now - timestamp)
299
+ const minutes = Math.floor(delta / 60_000)
300
+ if (minutes < 1) return "just now"
301
+ if (minutes < 60) return `${minutes}m ago`
302
+ const hours = Math.floor(minutes / 60)
303
+ if (hours < 24) return `${hours}h ago`
304
+ const days = Math.floor(hours / 24)
305
+ return `${days}d ago`
306
+ }
307
+
308
+ /** Groups sessions for the session picker: every session in the directory
309
+ * that has children is a potential explorer root. */
310
+ export function parentCandidates(sessions: SessionLite[]): SessionLite[] {
311
+ const parents = new Set<string>()
312
+ for (const session of sessions) {
313
+ if (session.parentID) parents.add(session.parentID)
314
+ }
315
+ return sessions
316
+ .filter((session) => parents.has(session.id))
317
+ .sort((a, b) => (b.updated ?? b.created ?? 0) - (a.updated ?? a.created ?? 0))
318
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Host-agnostic TUI API surface consumed by the wizard (wizard.tsx) and by
3
+ * embedding hosts (Config Studio). This is the exact bounded subset of the
4
+ * OpenCode v1 `TuiPluginApi` the wizard uses; a v1 api instance satisfies it
5
+ * structurally, and `v2-tui.ts` implements it over the OpenCode v2 TUI
6
+ * plugin context. The wizard never touches anything outside this interface,
7
+ * which is what lets one compiled wizard bundle run on both hosts.
8
+ */
9
+
10
+ export type TuiDialogSelectOption<Value = string> = {
11
+ title: string
12
+ value: Value
13
+ description?: string
14
+ category?: string
15
+ disabled?: boolean
16
+ }
17
+
18
+ export type TuiDialogSelectProps<Value = string> = {
19
+ title: string
20
+ placeholder?: string
21
+ options: TuiDialogSelectOption<Value>[]
22
+ current?: Value
23
+ flat?: boolean
24
+ onSelect: (option: TuiDialogSelectOption<Value>) => void
25
+ }
26
+
27
+ export type TuiDialogPromptProps = {
28
+ title: string
29
+ placeholder?: string
30
+ value?: string
31
+ onConfirm: (value: string) => void
32
+ onCancel?: () => void
33
+ }
34
+
35
+ export type TuiDialogConfirmProps = {
36
+ title: string
37
+ message: string
38
+ confirmLabel?: string
39
+ onConfirm: () => void
40
+ onCancel?: () => void
41
+ }
42
+
43
+ export type TuiDialogAlertProps = {
44
+ title: string
45
+ message: string
46
+ onConfirm?: () => void
47
+ }
48
+
49
+ export type TuiHostTheme = {
50
+ text: any
51
+ textMuted: any
52
+ background: any
53
+ backgroundPanel: any
54
+ primary: any
55
+ secondary: any
56
+ accent: any
57
+ success: any
58
+ warning: any
59
+ error: any
60
+ info: any
61
+ }
62
+
63
+ export type TuiHostKeyContext = {
64
+ event?: {
65
+ preventDefault?: () => void
66
+ stopPropagation?: () => void
67
+ }
68
+ }
69
+
70
+ export type TuiHostLayerCommand = {
71
+ name: string
72
+ title?: string
73
+ desc?: string
74
+ run: (ctx: TuiHostKeyContext) => void
75
+ }
76
+
77
+ export type TuiHostKeymapLayer = {
78
+ mode?: string
79
+ priority?: number
80
+ commands: readonly TuiHostLayerCommand[]
81
+ bindings: readonly { key: string; cmd: string; desc?: string }[]
82
+ }
83
+
84
+ /** Command descriptor shared with the palette-category registry. Carries both the v1 (`category`) and v2 (`group`) grouping fields. */
85
+ export type TuiHostPaletteCommand = {
86
+ name: string
87
+ title: string
88
+ desc?: string
89
+ category?: string
90
+ group?: string
91
+ run: () => void | Promise<void>
92
+ }
93
+
94
+ /** Minimal provider/model shape the wizard reads; the v1 SDK's richer Provider remains structurally assignable. */
95
+ export type TuiHostProvider = {
96
+ id: string
97
+ name: string
98
+ models: Record<string, { id: string; name: string; variants?: Record<string, unknown> }>
99
+ }
100
+
101
+ export type TuiHostApi = {
102
+ /** Host version marker (v2 adapter sets 2; v1 leaves undefined). */
103
+ hostVersion?: 1 | 2
104
+ app?: { version?: string }
105
+ /** Raw host client (v1 SDK / v2 OpenCodeClient). Embedders feature-detect. */
106
+ client?: unknown
107
+ kv: {
108
+ get<Value = unknown>(key: string, fallback?: Value): Value
109
+ set(key: string, value: unknown): void
110
+ readonly ready: boolean
111
+ }
112
+ lifecycle: {
113
+ onDispose(fn: () => void): void
114
+ }
115
+ mode: {
116
+ push(mode: string): () => void
117
+ }
118
+ renderer: {
119
+ root?: any
120
+ }
121
+ state: {
122
+ config: any
123
+ provider: readonly TuiHostProvider[]
124
+ path?: { config: string; directory: string; worktree: string }
125
+ }
126
+ /** Router's active session id (v1: route.current walk; v2 adapter maps its
127
+ * own notion). Optional — the explorer falls back to the newest session. */
128
+ currentSessionID?: () => string | undefined
129
+ /**
130
+ * Dialog sizing scope. "standalone" (default): the wizard uses its OWN
131
+ * size kv keys and offers the size picker. "embedded": the wizard reads
132
+ * the embedding host's keys (Config Studio's ui-width/ui-height-percent)
133
+ * and hides the picker — the host's dialog settings rule.
134
+ */
135
+ dialogScope?: "standalone" | "embedded"
136
+ theme: {
137
+ readonly current: TuiHostTheme
138
+ }
139
+ ui: {
140
+ toast(input: { variant?: string; title?: string; message: string; duration?: number }): void
141
+ dialog: {
142
+ replace(renderer: unknown, onClose?: () => void): void
143
+ clear(): void
144
+ setSize(size: string): void
145
+ }
146
+ DialogSelect<Value = string>(props: TuiDialogSelectProps<Value>): void
147
+ DialogPrompt(props: TuiDialogPromptProps): void
148
+ DialogConfirm(props: TuiDialogConfirmProps): void
149
+ DialogAlert(props: TuiDialogAlertProps): void
150
+ }
151
+ keymap: {
152
+ registerLayer(layer: TuiHostKeymapLayer): () => void
153
+ }
154
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,75 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ /**
4
+ * Subagent Explorer TUI plugin entry.
5
+ *
6
+ * Thin wrapper: registers the palette/slash command and bootstraps the
7
+ * explorer wizard library (wizard.tsx). Config Studio imports the wizard
8
+ * library directly when the module is embedded.
9
+ */
10
+
11
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui"
12
+ import type { TuiHostApi } from "./tui-host.js"
13
+ import { createV2TuiSetup } from "./v2-tui.js"
14
+ import { mainMenu } from "./wizard.js"
15
+ import { currentPaletteCategory, declarePaletteCategory, schedulePaletteReconcile } from "./palette-category.js"
16
+
17
+ function registerExplorerCommand(api: import("@opencode-ai/plugin/tui").TuiPluginApi, run: () => Promise<void>) {
18
+ const command = {
19
+ namespace: "palette",
20
+ name: "subagent-explorer.open",
21
+ title: "Subagent Explorer: Open",
22
+ desc: "Explore and delete subagent sessions",
23
+ category: "",
24
+ slashName: "subagent-explorer",
25
+ run,
26
+ }
27
+ command.category = declarePaletteCategory("Subagent Explorer", command)
28
+ schedulePaletteReconcile()
29
+ const apiWithKeymap = api as import("@opencode-ai/plugin/tui").TuiPluginApi & {
30
+ keymap?: {
31
+ registerLayer?: (layer: { commands: Array<typeof command>; bindings: unknown[] }) => () => void
32
+ }
33
+ }
34
+ if (typeof apiWithKeymap.keymap?.registerLayer === "function") {
35
+ return apiWithKeymap.keymap.registerLayer({ commands: [command], bindings: [] })
36
+ }
37
+ return api.command?.register(() => [
38
+ {
39
+ title: "Subagent Explorer: Open",
40
+ value: "subagent-explorer.open",
41
+ description: "Explore and delete subagent sessions",
42
+ category: currentPaletteCategory(),
43
+ slash: {
44
+ name: "subagent-explorer",
45
+ },
46
+ onSelect: run,
47
+ },
48
+ ])
49
+ }
50
+
51
+ const tui: TuiPlugin = async (api) => {
52
+ const unregister = registerExplorerCommand(api, async () => {
53
+ // The v1 api satisfies TuiHostApi structurally; enrich it with the
54
+ // router's current-session signal and the standalone sizing scope.
55
+ const host = api as unknown as TuiHostApi & { route?: { current?: { name: string; params?: { sessionID?: string } } } }
56
+ host.currentSessionID = () => (host.route?.current?.name === "session" ? host.route.current.params?.sessionID : undefined)
57
+ host.dialogScope = "standalone"
58
+ await mainMenu(host)
59
+ })
60
+
61
+ api.lifecycle.onDispose(() => {
62
+ unregister?.()
63
+ })
64
+ }
65
+
66
+ /**
67
+ * Dual-target TUI entry.
68
+ *
69
+ * - OpenCode v1 (strict loader) requires `default.tui` to be a function;
70
+ * excess keys are ignored.
71
+ * - OpenCode v2 requires `default` to be `{ id, setup }`; its hand-written
72
+ * plugin check only inspects `id` + `setup`, so the legacy `tui` factory
73
+ * rides along harmlessly.
74
+ */
75
+ export default { id: "subagent-explorer", tui, setup: createV2TuiSetup() }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * OpenCode v2 server setup for subagent-explorer.
3
+ *
4
+ * Dormant by design: the explorer is TUI-driven and needs no server-side
5
+ * hooks. v2 auto-discovers the ./tui export from server-declared plugins,
6
+ * so this setup only exists to make the plugin loadable from the v2
7
+ * `plugins` config (and to keep the dual-target entry shape uniform).
8
+ */
9
+
10
+ import type { V2PluginContext, V2ServerSetup } from "./v2-types.js"
11
+
12
+ export function createV2ServerSetup(): V2ServerSetup {
13
+ return async (_context: V2PluginContext) => {
14
+ // Intentionally empty: no transforms, no hooks, no registrations.
15
+ return () => undefined
16
+ }
17
+ }