@mirrowel/opencode-subagent-explorer 0.2.0-dev.1 → 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/src/sessions.ts CHANGED
@@ -82,16 +82,26 @@ export function normalizeSession(raw: unknown): SessionLite | undefined {
82
82
  }
83
83
 
84
84
  /** Lists sessions for a directory. Tries the v2 client's flat params first,
85
- * 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
+
86
96
  export async function fetchSessions(client: unknown, directory: string): Promise<SessionLite[] | undefined> {
87
97
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
88
98
  if (!namespace?.list) return undefined
89
99
  const calls: (() => Promise<unknown>)[] = [
90
- () => namespace.list({ directory }),
91
- () => namespace.list({ query: { directory } }),
100
+ () => namespace.list({ directory, limit: SESSION_LIST_LIMIT }),
101
+ () => namespace.list({ query: { directory, limit: SESSION_LIST_LIMIT } }),
92
102
  ]
93
103
  for (const call of calls) {
94
- const result = unwrap(await withTimeout(call))
104
+ const result = unwrap(await withTimeout(call, 8000))
95
105
  if (Array.isArray(result)) return result.map(normalizeSession).filter((s): s is SessionLite => !!s)
96
106
  }
97
107
  return undefined
@@ -170,10 +180,16 @@ function stripJsoncComments(text: string): string {
170
180
  }
171
181
 
172
182
  /** child session id -> the AV variant that created it (from the parent's
173
- * task-part metadata). Windowed like AV's own correlation: only recent
174
- * history is scanned, deep/old tasks simply stay unattributed. */
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). */
175
188
  export type VariantAttribution = Map<string, { alias: string; parent: string }>
176
189
 
190
+ const ATTRIBUTION_PAGE_SIZE = 100
191
+ const ATTRIBUTION_PAGES = 5
192
+
177
193
  export async function fetchVariantAttribution(
178
194
  client: unknown,
179
195
  parentSessionID: string | undefined,
@@ -184,32 +200,49 @@ export async function fetchVariantAttribution(
184
200
  if (avParents.size === 0 || !parentSessionID) return map
185
201
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
186
202
  if (!namespace || typeof namespace.messages !== "function") return map
187
- const messages = await withTimeout(() => {
188
- // Try the flat (v2-gen) shape first, then the legacy {query} wrapper.
189
- const flat = namespace.messages({ sessionID: parentSessionID, directory, limit: 50 })
190
- return flat instanceof Promise ? flat : namespace.messages({ path: { id: parentSessionID }, query: { directory, limit: 50 } })
191
- })
192
- const list = Array.isArray(messages)
193
- ? messages
194
- : messages && typeof messages === "object"
195
- ? ((messages as { data?: unknown }).data ?? messages)
196
- : undefined
197
- if (!Array.isArray(list)) return map
198
- for (const message of list as Array<{ parts?: Array<Record<string, any>> }>) {
199
- for (const part of message.parts ?? []) {
200
- if (part?.type !== "tool" || part.tool !== "task") continue
201
- const metadata = part.state?.metadata as Record<string, any> | undefined
202
- const child = metadata?.sessionId
203
- const variantMeta = metadata?.agentVariants as Record<string, unknown> | undefined
204
- const alias = variantMeta?.alias
205
- const routedAgent = variantMeta?.routedAgent
206
- if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
207
- map.set(child, {
208
- alias,
209
- parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, ""),
210
- })
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
+ }
211
238
  }
212
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
213
246
  }
214
247
  return map
215
248
  }
@@ -309,12 +342,35 @@ export function isHiddenAgent(agent: string | undefined, hidden: Set<string>, co
309
342
  return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:")
310
343
  }
311
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
+
312
363
  /** Deletes one session through whichever delete method the host client
313
364
  * exposes (`session.delete`, `session.remove`). Returns false when no
314
- * 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. */
315
369
  export async function deleteSession(client: unknown, sessionID: string): Promise<boolean> {
316
370
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
317
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>))
318
374
  const shapes: (() => Promise<unknown>)[] = []
319
375
  if (typeof namespace.delete === "function") {
320
376
  shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }))
@@ -324,7 +380,9 @@ export async function deleteSession(client: unknown, sessionID: string): Promise
324
380
  }
325
381
  for (const call of shapes) {
326
382
  const result = await withTimeout(call)
327
- if (result !== undefined) return true
383
+ if (result === undefined) continue
384
+ if (isErrorEnvelope(result)) continue
385
+ return true
328
386
  }
329
387
  return false
330
388
  }
package/src/wizard.tsx CHANGED
@@ -27,9 +27,9 @@ import {
27
27
  fetchVariantAttribution,
28
28
  formatAge,
29
29
  hiddenAgentIDs,
30
- isHiddenAgent,
31
30
  loadAvSidecar,
32
31
  parentCandidates,
32
+ rowHidden,
33
33
  type CleanupPlan,
34
34
  type SessionLite,
35
35
  type SessionTreeNode,
@@ -97,14 +97,14 @@ export function explorerRows(tree: SessionTreeNode[], data: ExplorerData): Explo
97
97
  const walk = (nodes: SessionTreeNode[], depth: number) => {
98
98
  for (const node of nodes) {
99
99
  const variant = data.variantOf.get(node.session.id)
100
+ const agent = node.session.agent
100
101
  rows.push({
101
102
  session: node.session,
102
103
  depth,
103
104
  running: data.activeIDs?.has(node.session.id) ?? false,
104
- // Variant children are normal sessions re-attributed to their alias;
105
- // only the base agent itself (or non-AV hidden agents) carries the
106
- // hidden badge.
107
- hidden: variant ? false : 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
108
  variant,
109
109
  })
110
110
  walk(node.children, depth + 1)
@@ -529,7 +529,26 @@ async function deleteOne(api: TuiPluginApi, row: ExplorerRow): Promise<boolean>
529
529
  return ok
530
530
  }
531
531
 
532
- 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> {
533
552
  if (plan.deletable.length === 0) {
534
553
  await showInfo(api, {
535
554
  title: "Nothing to clean up",
@@ -545,18 +564,49 @@ async function runCleanup(api: TuiPluginApi, plan: CleanupPlan, rootTitle: strin
545
564
  })
546
565
  if (!confirmed) return 0
547
566
  let deleted = 0
567
+ const [progressDone, setProgressDone] = createSignal(0)
568
+ showCleanupProgress(api, progressDone, plan.deletable.length)
548
569
  for (const session of plan.deletable) {
549
- if (await deleteSession(api.client, session.id)) deleted += 1
570
+ if (await deleteSession(api.client, session.id)) {
571
+ deleted += 1
572
+ setProgressDone(deleted)
573
+ }
550
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()
551
580
  const failed = plan.deletable.length - deleted
552
581
  api.ui.toast({
553
- variant: failed > 0 ? "warning" : "default",
582
+ variant: failed > 0 || remainingIdle > 0 ? "warning" : "default",
554
583
  title: "Cleanup finished",
555
- 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).`,
556
590
  })
557
591
  return deleted
558
592
  }
559
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
+
560
610
  function showRootPicker(api: TuiPluginApi, sessions: SessionLite[], currentRoot: string | undefined): Promise<string | undefined> {
561
611
  return new Promise((resolve) => {
562
612
  let settled = false
@@ -881,7 +931,7 @@ export async function mainMenu(api: TuiPluginApi): Promise<void> {
881
931
  continue
882
932
  }
883
933
  if (action.type === "cleanup") {
884
- await runCleanup(api, cleanupTargets(tree, data.activeIDs), rootSession?.title ?? rootID)
934
+ await runCleanup(api, cleanupTargets(tree, data.activeIDs), rootSession?.title ?? rootID, directory, rootID)
885
935
  continue
886
936
  }
887
937
  }