@mirrowel/opencode-subagent-explorer 0.1.0 → 0.2.0-dev.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirrowel/opencode-subagent-explorer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-dev.2",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for exploring and cleaning up subagent sessions of the current session",
6
6
  "license": "MIT",
@@ -1,83 +1,83 @@
1
- /**
2
- * Shared palette category for Mirrowel OpenCode plugins loaded in the same
3
- * session. Every family plugin declares its own label into a process-wide
4
- * registry at TUI activation; a delayed reconciler joins all declared labels
5
- * (alphabetical, deterministic) and writes the join into every registered
6
- * command object, so the palette shows one combined section. A plugin loaded
7
- * alone keeps its own name; third-party plugins never declare and stay out.
8
- *
9
- * Labels are defined only in each plugin's entry point - this module carries
10
- * no per-plugin data. Keep the logic copies in sync across family repos.
11
- */
12
-
13
- const REGISTRY_KEY = Symbol.for("mirrowel.opencode.paletteCategory")
14
-
15
- // v1 command objects group via `category`; v2 keymap commands use `group`.
16
- // Stamping both keeps one registry meaningful on both hosts.
17
- type CategorizedCommand = { category?: string; group?: string }
18
- type RegistryEntry = { label: string; commands: CategorizedCommand[] }
19
- type Registry = { entries: RegistryEntry[] }
20
-
21
- function registry(): Registry {
22
- const globals = globalThis as { [key: symbol]: Registry | undefined }
23
- if (!globals[REGISTRY_KEY]) globals[REGISTRY_KEY] = { entries: [] }
24
- return globals[REGISTRY_KEY]!
25
- }
26
-
27
- function joinLabels(labels: string[]): string {
28
- if (labels.length <= 1) return labels[0] ?? ""
29
- return `${labels.slice(0, -1).join(", ")} & ${labels[labels.length - 1]!}`
30
- }
31
-
32
- function joinedCategory(): string {
33
- const labels = [...new Set(registry().entries.map((entry) => entry.label))].sort((a, b) => a.localeCompare(b))
34
- return joinLabels(labels)
35
- }
36
-
37
- /**
38
- * Declares this plugin's category label and attaches command objects whose
39
- * `category` field is kept in sync. Returns the current join, so the very
40
- * first registration already renders correctly even before reconcilers run.
41
- */
42
- export function declarePaletteCategory(label: string, ...commands: CategorizedCommand[]): string {
43
- const reg = registry()
44
- let entry = reg.entries.find((item) => item.label === label)
45
- if (!entry) {
46
- entry = { label, commands: [] }
47
- reg.entries.push(entry)
48
- }
49
- for (const command of commands) {
50
- if (command && !entry.commands.includes(command)) entry.commands.push(command)
51
- }
52
- reconcilePaletteCategories()
53
- return joinedCategory()
54
- }
55
-
56
- /** Live join for callback-style command registration (reads fresh each call). */
57
- export function currentPaletteCategory(): string {
58
- return joinedCategory()
59
- }
60
-
61
- /** Writes the current join into every declared command (idempotent). */
62
- export function reconcilePaletteCategories(): void {
63
- const category = joinedCategory()
64
- for (const entry of registry().entries) {
65
- for (const command of entry.commands) {
66
- command.category = category
67
- command.group = category
68
- }
69
- }
70
- }
71
-
72
- /** Delayed reconciles so later-activating siblings join before the user opens the palette. */
73
- export function schedulePaletteReconcile(delaysMs: readonly number[] = [2000, 6000]): void {
74
- for (const delay of delaysMs) {
75
- const timer = setTimeout(() => reconcilePaletteCategories(), delay)
76
- ;(timer as { unref?: () => void }).unref?.()
77
- }
78
- }
79
-
80
- /** Tests only: drop all declarations. */
81
- export function __resetPaletteRegistry(): void {
82
- ;(globalThis as { [key: symbol]: Registry | undefined })[REGISTRY_KEY] = undefined
83
- }
1
+ /**
2
+ * Shared palette category for Mirrowel OpenCode plugins loaded in the same
3
+ * session. Every family plugin declares its own label into a process-wide
4
+ * registry at TUI activation; a delayed reconciler joins all declared labels
5
+ * (alphabetical, deterministic) and writes the join into every registered
6
+ * command object, so the palette shows one combined section. A plugin loaded
7
+ * alone keeps its own name; third-party plugins never declare and stay out.
8
+ *
9
+ * Labels are defined only in each plugin's entry point - this module carries
10
+ * no per-plugin data. Keep the logic copies in sync across family repos.
11
+ */
12
+
13
+ const REGISTRY_KEY = Symbol.for("mirrowel.opencode.paletteCategory")
14
+
15
+ // v1 command objects group via `category`; v2 keymap commands use `group`.
16
+ // Stamping both keeps one registry meaningful on both hosts.
17
+ type CategorizedCommand = { category?: string; group?: string }
18
+ type RegistryEntry = { label: string; commands: CategorizedCommand[] }
19
+ type Registry = { entries: RegistryEntry[] }
20
+
21
+ function registry(): Registry {
22
+ const globals = globalThis as { [key: symbol]: Registry | undefined }
23
+ if (!globals[REGISTRY_KEY]) globals[REGISTRY_KEY] = { entries: [] }
24
+ return globals[REGISTRY_KEY]!
25
+ }
26
+
27
+ function joinLabels(labels: string[]): string {
28
+ if (labels.length <= 1) return labels[0] ?? ""
29
+ return `${labels.slice(0, -1).join(", ")} & ${labels[labels.length - 1]!}`
30
+ }
31
+
32
+ function joinedCategory(): string {
33
+ const labels = [...new Set(registry().entries.map((entry) => entry.label))].sort((a, b) => a.localeCompare(b))
34
+ return joinLabels(labels)
35
+ }
36
+
37
+ /**
38
+ * Declares this plugin's category label and attaches command objects whose
39
+ * `category` field is kept in sync. Returns the current join, so the very
40
+ * first registration already renders correctly even before reconcilers run.
41
+ */
42
+ export function declarePaletteCategory(label: string, ...commands: CategorizedCommand[]): string {
43
+ const reg = registry()
44
+ let entry = reg.entries.find((item) => item.label === label)
45
+ if (!entry) {
46
+ entry = { label, commands: [] }
47
+ reg.entries.push(entry)
48
+ }
49
+ for (const command of commands) {
50
+ if (command && !entry.commands.includes(command)) entry.commands.push(command)
51
+ }
52
+ reconcilePaletteCategories()
53
+ return joinedCategory()
54
+ }
55
+
56
+ /** Live join for callback-style command registration (reads fresh each call). */
57
+ export function currentPaletteCategory(): string {
58
+ return joinedCategory()
59
+ }
60
+
61
+ /** Writes the current join into every declared command (idempotent). */
62
+ export function reconcilePaletteCategories(): void {
63
+ const category = joinedCategory()
64
+ for (const entry of registry().entries) {
65
+ for (const command of entry.commands) {
66
+ command.category = category
67
+ command.group = category
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Delayed reconciles so later-activating siblings join before the user opens the palette. */
73
+ export function schedulePaletteReconcile(delaysMs: readonly number[] = [2000, 6000]): void {
74
+ for (const delay of delaysMs) {
75
+ const timer = setTimeout(() => reconcilePaletteCategories(), delay)
76
+ ;(timer as { unref?: () => void }).unref?.()
77
+ }
78
+ }
79
+
80
+ /** Tests only: drop all declarations. */
81
+ export function __resetPaletteRegistry(): void {
82
+ ;(globalThis as { [key: symbol]: Registry | undefined })[REGISTRY_KEY] = undefined
83
+ }
package/src/sessions.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * timeout-bounded, and envelope-unwrapping ({data} vs raw) so one code path
6
6
  * serves the v1 and v2 hosts and the studio embed alike.
7
7
  */
8
+ import { existsSync, readFileSync } from "node:fs"
8
9
 
9
10
  export type SessionLite = {
10
11
  id: string
@@ -81,16 +82,26 @@ export function normalizeSession(raw: unknown): SessionLite | undefined {
81
82
  }
82
83
 
83
84
  /** 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
+ * then the v1 `{query}` shape; unwraps both envelope styles. Fail-soft.
86
+ *
87
+ * EXPLICIT LARGE LIMIT: OpenCode's Session.list defaults to a 100-row page
88
+ * (ordered by time_updated DESC, session.ts `limit ?? 100`) - a plain call
89
+ * silently hides older sessions, which truncated large trees (100+ subagents
90
+ * showed ~85) and made cleanups miss everything past the first page. The
91
+ * server imposes no max on limit (NonNegativeInt), and its `start` cursor
92
+ * filters to NEWER sessions (gte), so downward paging is impossible by
93
+ * design - one large bounded page is the honest shape. */
94
+ const SESSION_LIST_LIMIT = 5000
95
+
85
96
  export async function fetchSessions(client: unknown, directory: string): Promise<SessionLite[] | undefined> {
86
97
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
87
98
  if (!namespace?.list) return undefined
88
99
  const calls: (() => Promise<unknown>)[] = [
89
- () => namespace.list({ directory }),
90
- () => namespace.list({ query: { directory } }),
100
+ () => namespace.list({ directory, limit: SESSION_LIST_LIMIT }),
101
+ () => namespace.list({ query: { directory, limit: SESSION_LIST_LIMIT } }),
91
102
  ]
92
103
  for (const call of calls) {
93
- const result = unwrap(await withTimeout(call))
104
+ const result = unwrap(await withTimeout(call, 8000))
94
105
  if (Array.isArray(result)) return result.map(normalizeSession).filter((s): s is SessionLite => !!s)
95
106
  }
96
107
  return undefined
@@ -99,26 +110,34 @@ export async function fetchSessions(client: unknown, directory: string): Promise
99
110
  /** Running session ids via `session.active()` with the `session.status()`
100
111
  * fallback (busy/retry = running). `undefined` when neither exists or both
101
112
  * fail — callers must treat that as "unknown", never "idle". */
113
+ /**
114
+ * Running session ids via `session.active()` with the `session.status()`
115
+ * fallback (busy/retry/unknown-type = running). Mirrors the Config Studio
116
+ * reload coordinator's proven probing: PRESENCE in the active map means
117
+ * running (values may be falsy and must not be filtered), the status map
118
+ * keys carry `.type` (not `.status`), and all calls stay attached to the
119
+ * namespace object (SDK methods dereference `this`). `undefined` when
120
+ * neither exists or both fail - callers must treat that as "unknown",
121
+ * never "idle".
122
+ */
102
123
  export async function fetchActiveSessionIDs(client: unknown): Promise<Set<string> | undefined> {
103
124
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
104
125
  if (!namespace) return undefined
105
126
  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
127
+ const result = await withTimeout(() => namespace.active())
128
+ const data = result && typeof result === "object" ? (result as { data?: unknown }).data : undefined
129
+ if (data && typeof data === "object" && !Array.isArray(data)) {
130
+ return new Set(Object.keys(data as Record<string, unknown>))
113
131
  }
114
132
  }
115
133
  if (typeof namespace.status === "function") {
116
- const status = unwrap(await withTimeout(() => namespace.status()))
117
- if (status && typeof status === "object") {
134
+ const result = await withTimeout(() => namespace.status())
135
+ const data = result && typeof result === "object" ? (result as { data?: unknown }).data : undefined
136
+ if (data && typeof data === "object" && !Array.isArray(data)) {
118
137
  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)
138
+ for (const [id, value] of Object.entries(data as Record<string, any>)) {
139
+ const type = value && typeof value === "object" ? (value.type ?? value.status) : value
140
+ if (type === undefined || type === "busy" || type === "retry") ids.add(id)
122
141
  }
123
142
  return ids
124
143
  }
@@ -126,6 +145,108 @@ export async function fetchActiveSessionIDs(client: unknown): Promise<Set<string
126
145
  return undefined
127
146
  }
128
147
 
148
+ /** Minimal agent-variants sidecar view: which parents exist and which have
149
+ * the base disabled. Absent file / parse errors -> empty (fail-soft). */
150
+ export type AvSidecar = {
151
+ parents: Map<string, { disableBase: boolean }>
152
+ }
153
+
154
+ export function loadAvSidecar(globalConfigDir: string | undefined): AvSidecar {
155
+ const parents = new Map<string, { disableBase: boolean }>()
156
+ if (!globalConfigDir) return { parents }
157
+ try {
158
+ const path = `${globalConfigDir.replace(/[\\/]+$/, "")}/agent-variants.jsonc`
159
+ if (!existsSync(path)) return { parents }
160
+ const raw = readFileSync(path, "utf8")
161
+ const json = JSON.parse(stripJsoncComments(raw)) as {
162
+ agents?: Record<string, { disable_base?: boolean; disable?: boolean }>
163
+ }
164
+ for (const [parent, entry] of Object.entries(json.agents ?? {})) {
165
+ if (!entry || typeof entry !== "object") continue
166
+ parents.set(parent, { disableBase: entry.disable_base === true && entry.disable !== true })
167
+ }
168
+ } catch {
169
+ // Not our file to complain about - AV may simply not be installed.
170
+ }
171
+ return { parents }
172
+ }
173
+
174
+ function stripJsoncComments(text: string): string {
175
+ // Block comments, line comments, and trailing commas before } or ].
176
+ return text
177
+ .replace(/\/\*[\s\S]*?\*\//g, "")
178
+ .replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
179
+ .replace(/,(\s*[}\]])/g, "$1")
180
+ }
181
+
182
+ /** child session id -> the AV variant that created it (from the parent's
183
+ * task-part metadata). Paged backward through the parent's history via the
184
+ * `before` message cursor (up to ATTRIBUTION_PAGES pages), so large trees
185
+ * attribute most children instead of only the newest ~15 tasks; very deep
186
+ * history simply stays unattributed (the row then shows the parent agent,
187
+ * never a hidden badge - see explorerRows). */
188
+ export type VariantAttribution = Map<string, { alias: string; parent: string }>
189
+
190
+ const ATTRIBUTION_PAGE_SIZE = 100
191
+ const ATTRIBUTION_PAGES = 5
192
+
193
+ export async function fetchVariantAttribution(
194
+ client: unknown,
195
+ parentSessionID: string | undefined,
196
+ directory: string | undefined,
197
+ avParents: Set<string>,
198
+ ): Promise<VariantAttribution> {
199
+ const map: VariantAttribution = new Map()
200
+ if (avParents.size === 0 || !parentSessionID) return map
201
+ const namespace = (client as { session?: Record<string, any> } | undefined)?.session
202
+ if (!namespace || typeof namespace.messages !== "function") return map
203
+ const fetchPage = (before?: string): Promise<unknown> => {
204
+ const query: Record<string, unknown> = { directory, limit: ATTRIBUTION_PAGE_SIZE }
205
+ if (before) query.before = before
206
+ // Try the flat (v2-gen) shape first, then the legacy {path, query} wrapper.
207
+ const flat = namespace.messages!({ sessionID: parentSessionID, ...query })
208
+ return flat instanceof Promise ? flat : namespace.messages!({ path: { id: parentSessionID }, query })
209
+ }
210
+ const asList = (value: unknown): Array<{ parts?: Array<Record<string, any>> }> | undefined => {
211
+ if (Array.isArray(value)) return value as Array<{ parts?: Array<Record<string, any>> }>
212
+ if (value && typeof value === "object") {
213
+ const unwrapped = (value as { data?: unknown }).data
214
+ if (Array.isArray(unwrapped)) return unwrapped as Array<{ parts?: Array<Record<string, any>> }>
215
+ }
216
+ return undefined
217
+ }
218
+ let before: string | undefined
219
+ const seenCursors = new Set<string>()
220
+ for (let page = 0; page < ATTRIBUTION_PAGES; page++) {
221
+ const messages = await withTimeout(() => fetchPage(before), 8000)
222
+ const list = asList(messages)
223
+ if (!list || list.length === 0) break
224
+ for (const message of list) {
225
+ for (const part of message.parts ?? []) {
226
+ if (part?.type !== "tool" || part.tool !== "task") continue
227
+ const metadata = part.state?.metadata as Record<string, any> | undefined
228
+ const child = metadata?.sessionId
229
+ const variantMeta = metadata?.agentVariants as Record<string, unknown> | undefined
230
+ const alias = variantMeta?.alias
231
+ const routedAgent = variantMeta?.routedAgent
232
+ if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
233
+ map.set(child, {
234
+ alias,
235
+ parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, ""),
236
+ })
237
+ }
238
+ }
239
+ }
240
+ if (list.length < ATTRIBUTION_PAGE_SIZE) break
241
+ const last = list[list.length - 1] as { id?: string; info?: { id?: string } }
242
+ const next = last?.info?.id ?? last?.id
243
+ if (!next || seenCursors.has(next)) break
244
+ seenCursors.add(next)
245
+ before = next
246
+ }
247
+ return map
248
+ }
249
+
129
250
  /** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
130
251
  * are ignored (not part of this session's tree). Guarded against cycles. */
131
252
  export function buildSessionTree(sessions: SessionLite[], rootID: string): SessionTreeNode[] {
@@ -221,12 +342,35 @@ export function isHiddenAgent(agent: string | undefined, hidden: Set<string>, co
221
342
  return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:")
222
343
  }
223
344
 
345
+ /** Badge decision for explorer rows: variant children and children of
346
+ * AV-managed parents never carry the hidden badge - a hidden AV parent is
347
+ * agent-variants' base-disable ("must use a variant"), not a background
348
+ * agent, and the decision must not depend on attribution windows. Only
349
+ * genuinely hidden non-AV agents (plugin background agents, hidden custom
350
+ * agents without variants) keep the badge. */
351
+ export function rowHidden(
352
+ variant: unknown,
353
+ agent: string | undefined,
354
+ avParents: { has: (id: string) => boolean },
355
+ hidden: Set<string>,
356
+ configAgents: Set<string>,
357
+ ): boolean {
358
+ if (variant) return false
359
+ if (agent && avParents.has(agent)) return false
360
+ return isHiddenAgent(agent, hidden, configAgents)
361
+ }
362
+
224
363
  /** Deletes one session through whichever delete method the host client
225
364
  * exposes (`session.delete`, `session.remove`). Returns false when no
226
- * method exists or the call failed. */
365
+ * method exists or the call failed. An `{error}` envelope without `data`
366
+ * (the runtime's non-throwing failure shape) counts as a failure, not a
367
+ * success - the server's remove handler swallows internal errors and can
368
+ * still answer, so callers verify after batch deletes. */
227
369
  export async function deleteSession(client: unknown, sessionID: string): Promise<boolean> {
228
370
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
229
371
  if (!namespace) return false
372
+ const isErrorEnvelope = (value: unknown): boolean =>
373
+ !!value && typeof value === "object" && "error" in (value as Record<string, unknown>) && !("data" in (value as Record<string, unknown>))
230
374
  const shapes: (() => Promise<unknown>)[] = []
231
375
  if (typeof namespace.delete === "function") {
232
376
  shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }))
@@ -236,7 +380,9 @@ export async function deleteSession(client: unknown, sessionID: string): Promise
236
380
  }
237
381
  for (const call of shapes) {
238
382
  const result = await withTimeout(call)
239
- if (result !== undefined) return true
383
+ if (result === undefined) continue
384
+ if (isErrorEnvelope(result)) continue
385
+ return true
240
386
  }
241
387
  return false
242
388
  }
package/src/wizard.tsx CHANGED
@@ -24,13 +24,16 @@ import {
24
24
  fetchActiveSessionIDs,
25
25
  fetchSessionDetails,
26
26
  fetchSessions,
27
+ fetchVariantAttribution,
27
28
  formatAge,
28
29
  hiddenAgentIDs,
29
- isHiddenAgent,
30
+ loadAvSidecar,
30
31
  parentCandidates,
32
+ rowHidden,
31
33
  type CleanupPlan,
32
34
  type SessionLite,
33
35
  type SessionTreeNode,
36
+ type VariantAttribution,
34
37
  } from "./sessions.js"
35
38
 
36
39
  export type ExplorerRow = {
@@ -38,6 +41,7 @@ export type ExplorerRow = {
38
41
  depth: number
39
42
  running: boolean
40
43
  hidden: boolean
44
+ variant?: { alias: string; parent: string }
41
45
  }
42
46
 
43
47
  export type ExplorerData = {
@@ -46,6 +50,10 @@ export type ExplorerData = {
46
50
  activeKnown: boolean
47
51
  hidden: Set<string>
48
52
  configAgents: Set<string>
53
+ /** child session id -> creating AV variant (recent history only). */
54
+ variantOf: VariantAttribution
55
+ /** AV sidecar parents (empty when agent-variants is not installed). */
56
+ avParents: Map<string, { disableBase: boolean }>
49
57
  }
50
58
 
51
59
  export type TreeAction =
@@ -57,16 +65,30 @@ export type TreeAction =
57
65
  | { type: "size" }
58
66
  | { type: "exit" }
59
67
 
60
- export async function loadExplorerData(api: TuiPluginApi, directory: string): Promise<ExplorerData | undefined> {
68
+ function globalConfigDirOf(api: TuiPluginApi): string | undefined {
69
+ const config = api.state.path?.config
70
+ if (!config) return undefined
71
+ const index = Math.max(config.lastIndexOf("/"), config.lastIndexOf("\\"))
72
+ return index > 0 ? config.slice(0, index) : undefined
73
+ }
74
+
75
+ export async function loadExplorerData(api: TuiPluginApi, directory: string, rootOverride?: string): Promise<ExplorerData | undefined> {
61
76
  const sessions = await fetchSessions(api.client, directory)
62
77
  if (!sessions) return undefined
63
- const activeIDs = await fetchActiveSessionIDs(api.client)
78
+ const [activeIDs, avSidecar] = await Promise.all([
79
+ fetchActiveSessionIDs(api.client),
80
+ Promise.resolve(loadAvSidecar(globalConfigDirOf(api))),
81
+ ])
82
+ const rootID = rootOverride ?? currentSessionID(api.currentSessionID?.(), sessions)
83
+ const variantOf = await fetchVariantAttribution(api.client, rootID, directory, new Set(avSidecar.parents.keys()))
64
84
  return {
65
85
  sessions,
66
86
  activeIDs,
67
87
  activeKnown: activeIDs !== undefined,
68
88
  hidden: hiddenAgentIDs(api.state.config),
69
89
  configAgents: configAgentIDs(api.state.config),
90
+ variantOf,
91
+ avParents: avSidecar.parents,
70
92
  }
71
93
  }
72
94
 
@@ -74,11 +96,16 @@ export function explorerRows(tree: SessionTreeNode[], data: ExplorerData): Explo
74
96
  const rows: ExplorerRow[] = []
75
97
  const walk = (nodes: SessionTreeNode[], depth: number) => {
76
98
  for (const node of nodes) {
99
+ const variant = data.variantOf.get(node.session.id)
100
+ const agent = node.session.agent
77
101
  rows.push({
78
102
  session: node.session,
79
103
  depth,
80
104
  running: data.activeIDs?.has(node.session.id) ?? false,
81
- hidden: isHiddenAgent(node.session.agent, data.hidden, data.configAgents),
105
+ // Badge decision is window-independent (see rowHidden): children of
106
+ // AV-managed parents are never badged, attributed or not.
107
+ hidden: rowHidden(variant, agent, data.avParents, data.hidden, data.configAgents),
108
+ variant,
82
109
  })
83
110
  walk(node.children, depth + 1)
84
111
  }
@@ -87,6 +114,10 @@ export function explorerRows(tree: SessionTreeNode[], data: ExplorerData): Explo
87
114
  return rows
88
115
  }
89
116
 
117
+ export function agentLabelOf(row: Pick<ExplorerRow, "session" | "variant">): string {
118
+ return row.variant?.alias ?? row.session.agent ?? "unknown"
119
+ }
120
+
90
121
  function themeOf(api: TuiPluginApi) {
91
122
  return api.theme.current
92
123
  }
@@ -327,7 +358,7 @@ export function SubagentTreeDialog(props: {
327
358
  onMouseUp={() => props.onDone({ type: "delete", row })}
328
359
  >
329
360
  <text fg={titleFg()} flexShrink={0}>{row.running ? "▶" : "·"}</text>
330
- <text width={agentWidth} flexShrink={0} fg={active() ? theme().background : theme().textMuted} wrapMode="none" overflow="hidden"><b>{truncate(row.session.agent ?? "agent", agentWidth)}</b></text>
361
+ <text width={agentWidth} flexShrink={0} fg={active() ? theme().background : theme().textMuted} wrapMode="none" overflow="hidden"><b>{truncate(agentLabelOf(row), agentWidth)}</b></text>
331
362
  <Show when={row.hidden}>
332
363
  <text width={badgeWidth} flexShrink={0} fg={badgeFg()} wrapMode="none" overflow="hidden">{"[hidden]"}</text>
333
364
  </Show>
@@ -480,7 +511,7 @@ function showConfirm(api: TuiPluginApi, props: { title: string; message: string
480
511
 
481
512
  async function deleteOne(api: TuiPluginApi, row: ExplorerRow): Promise<boolean> {
482
513
  const label = `${rowTitle(row)}${row.hidden ? " [hidden agent]" : ""}`
483
- const summary = `${label}\nagent: ${row.session.agent ?? "unknown"}\nlast activity: ${formatAge(row.session.updated ?? row.session.created)}\n\nDeleting a session permanently removes it, its messages, and its history. The parent session keeps the task result text; only the jump target is lost.`
514
+ const summary = `${label}\n${row.variant ? `variant: ${row.variant.alias} (of ${row.variant.parent})` : `agent: ${row.session.agent ?? "unknown"}`}\nlast activity: ${formatAge(row.session.updated ?? row.session.created)}\n\nDeleting a session permanently removes it, its messages, and its history. The parent session keeps the task result text; only the jump target is lost.`
484
515
  const confirmed = row.running
485
516
  ? await showDangerConfirm(api, {
486
517
  title: "Delete RUNNING subagent session?",
@@ -498,7 +529,26 @@ async function deleteOne(api: TuiPluginApi, row: ExplorerRow): Promise<boolean>
498
529
  return ok
499
530
  }
500
531
 
501
- async function runCleanup(api: TuiPluginApi, plan: CleanupPlan, rootTitle: string): Promise<number> {
532
+ function CleanupProgressDialog(props: { api: TuiPluginApi; done: () => number; total: number }) {
533
+ const theme = () => themeOf(props.api)
534
+ const popMode = props.api.mode.push("subagent-explorer.cleanup")
535
+ onCleanup(() => popMode())
536
+ const done = props.done
537
+ const total = props.total
538
+ return (
539
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={2} gap={1}>
540
+ <text fg={theme().text}><b>{`Cleaning up idle subagent sessions…`}</b></text>
541
+ <text fg={theme().textMuted}>{`${done()} / ${total} deleted`}</text>
542
+ <text fg={theme().textMuted}>{"This dialog closes when the batch finishes."}</text>
543
+ </box>
544
+ )
545
+ }
546
+
547
+ function showCleanupProgress(api: TuiPluginApi, done: () => number, total: number): void {
548
+ api.ui.dialog.replace(() => <CleanupProgressDialog api={api} done={done} total={total} />, () => {})
549
+ }
550
+
551
+ async function runCleanup(api: TuiPluginApi, plan: CleanupPlan, rootTitle: string, directory: string, rootID: string): Promise<number> {
502
552
  if (plan.deletable.length === 0) {
503
553
  await showInfo(api, {
504
554
  title: "Nothing to clean up",
@@ -514,18 +564,49 @@ async function runCleanup(api: TuiPluginApi, plan: CleanupPlan, rootTitle: strin
514
564
  })
515
565
  if (!confirmed) return 0
516
566
  let deleted = 0
567
+ const [progressDone, setProgressDone] = createSignal(0)
568
+ showCleanupProgress(api, progressDone, plan.deletable.length)
517
569
  for (const session of plan.deletable) {
518
- if (await deleteSession(api.client, session.id)) deleted += 1
570
+ if (await deleteSession(api.client, session.id)) {
571
+ deleted += 1
572
+ setProgressDone(deleted)
573
+ }
519
574
  }
575
+ // Verify against a fresh (full, un-capped) fetch: the server's remove
576
+ // handler swallows internal errors and still answers, and the host may
577
+ // spawn new children while we work - report what actually remains.
578
+ const remainingIdle = await remainingIdleCount(api, directory, rootID)
579
+ api.ui.dialog.clear()
520
580
  const failed = plan.deletable.length - deleted
521
581
  api.ui.toast({
522
- variant: failed > 0 ? "warning" : "default",
582
+ variant: failed > 0 || remainingIdle > 0 ? "warning" : "default",
523
583
  title: "Cleanup finished",
524
- message: failed > 0 ? `Deleted ${deleted}, failed ${failed}.` : `Deleted ${deleted} session(s).`,
584
+ message:
585
+ remainingIdle > 0
586
+ ? `Deleted ${deleted}. ${remainingIdle} idle session(s) still remain${failed > 0 ? ` (${failed} delete call(s) failed - re-run cleanup or delete rows individually)` : " - re-run cleanup for the newly-idle"}.`
587
+ : failed > 0
588
+ ? `Deleted ${deleted}, failed ${failed}.`
589
+ : `Deleted ${deleted} session(s).`,
525
590
  })
526
591
  return deleted
527
592
  }
528
593
 
594
+ async function remainingIdleCount(api: TuiPluginApi, directory: string, rootID: string): Promise<number> {
595
+ const sessions = await fetchSessions(api.client, directory)
596
+ if (!sessions) return 0
597
+ const active = await fetchActiveSessionIDs(api.client)
598
+ const rows = explorerRows(buildSessionTree(sessions, rootID), {
599
+ sessions,
600
+ activeIDs: active,
601
+ activeKnown: active !== undefined,
602
+ hidden: new Set(),
603
+ configAgents: new Set(),
604
+ variantOf: new Map(),
605
+ avParents: new Map(),
606
+ })
607
+ return rows.filter((row) => !row.running).length
608
+ }
609
+
529
610
  function showRootPicker(api: TuiPluginApi, sessions: SessionLite[], currentRoot: string | undefined): Promise<string | undefined> {
530
611
  return new Promise((resolve) => {
531
612
  let settled = false
@@ -556,16 +637,29 @@ function showRootPicker(api: TuiPluginApi, sessions: SessionLite[], currentRoot:
556
637
  })
557
638
  }
558
639
 
559
- async function showDetails(api: TuiPluginApi, row: ExplorerRow): Promise<void> {
640
+ async function showDetails(api: TuiPluginApi, row: ExplorerRow, data: ExplorerData): Promise<void> {
560
641
  const details = await fetchSessionDetails(api.client, row.session.id)
642
+ const parentAgent = row.session.agent ?? "unknown"
561
643
  const lines = [
562
644
  `title: ${rowTitle(row)}`,
563
- `agent: ${row.session.agent ?? "unknown"}${row.hidden ? " (hidden/background)" : ""}`,
645
+ row.variant
646
+ ? `variant: ${row.variant.alias} (of ${row.variant.parent})`
647
+ : `agent: ${parentAgent}${row.hidden ? " (hidden/background)" : ""}`,
648
+ ]
649
+ if (row.variant) {
650
+ const parentEntry = data.avParents.get(row.variant.parent)
651
+ if (parentEntry) {
652
+ lines.push(`base ${row.variant.parent}: ${parentEntry.disableBase ? "disabled (variants only)" : "enabled"}`)
653
+ }
654
+ } else if (data.avParents.has(parentAgent)) {
655
+ lines.push(`base ${parentAgent}: ${data.avParents.get(parentAgent)?.disableBase ? "disabled (variants only)" : "enabled"}`)
656
+ }
657
+ lines.push(
564
658
  `session: ${row.session.id}`,
565
659
  `status: ${row.running ? "running" : "idle"}`,
566
660
  `created: ${formatAge(row.session.created)}`,
567
661
  `last activity: ${formatAge(row.session.updated ?? row.session.created)}`,
568
- ]
662
+ )
569
663
  if (details) {
570
664
  lines.push(`messages: ${details.messageCount}`)
571
665
  if (details.model) lines.push(`last model: ${details.model}`)
@@ -793,7 +887,7 @@ export async function mainMenu(api: TuiPluginApi): Promise<void> {
793
887
  }
794
888
  let rootOverride: string | undefined
795
889
  while (true) {
796
- const data = await loadExplorerData(api, directory)
890
+ const data = await loadExplorerData(api, directory, rootOverride)
797
891
  if (!data) {
798
892
  await showInfo(api, {
799
893
  title: "Subagent Explorer",
@@ -833,11 +927,11 @@ export async function mainMenu(api: TuiPluginApi): Promise<void> {
833
927
  continue
834
928
  }
835
929
  if (action.type === "details") {
836
- await showDetails(api, action.row)
930
+ await showDetails(api, action.row, data)
837
931
  continue
838
932
  }
839
933
  if (action.type === "cleanup") {
840
- await runCleanup(api, cleanupTargets(tree, data.activeIDs), rootSession?.title ?? rootID)
934
+ await runCleanup(api, cleanupTargets(tree, data.activeIDs), rootSession?.title ?? rootID, directory, rootID)
841
935
  continue
842
936
  }
843
937
  }