@mirrowel/opencode-subagent-explorer 0.2.0-dev.1 → 0.2.0-dev.3

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,55 @@ 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
+ // Two alias sources: the after-hook metadata stamp (terminal
231
+ // completed parts) and the before-hook description annotation -
232
+ // aborted/error parts never receive the metadata stamp, but the
233
+ // annotation ("(@alias variant)" appended at call time) persists.
234
+ const description = part.state?.input?.description
235
+ const descriptionMatch = typeof description === "string" ? description.match(/\s+\(@(\S+) variant\)$/) : undefined
236
+ const alias = (typeof variantMeta?.alias === "string" ? variantMeta.alias : undefined) ?? descriptionMatch?.[1]
237
+ const routedAgent = variantMeta?.routedAgent
238
+ if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
239
+ map.set(child, {
240
+ alias,
241
+ parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, ""),
242
+ })
243
+ }
211
244
  }
212
245
  }
246
+ if (list.length < ATTRIBUTION_PAGE_SIZE) break
247
+ const last = list[list.length - 1] as { id?: string; info?: { id?: string } }
248
+ const next = last?.info?.id ?? last?.id
249
+ if (!next || seenCursors.has(next)) break
250
+ seenCursors.add(next)
251
+ before = next
213
252
  }
214
253
  return map
215
254
  }
@@ -309,12 +348,35 @@ export function isHiddenAgent(agent: string | undefined, hidden: Set<string>, co
309
348
  return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:")
310
349
  }
311
350
 
351
+ /** Badge decision for explorer rows: variant children and children of
352
+ * AV-managed parents never carry the hidden badge - a hidden AV parent is
353
+ * agent-variants' base-disable ("must use a variant"), not a background
354
+ * agent, and the decision must not depend on attribution windows. Only
355
+ * genuinely hidden non-AV agents (plugin background agents, hidden custom
356
+ * agents without variants) keep the badge. */
357
+ export function rowHidden(
358
+ variant: unknown,
359
+ agent: string | undefined,
360
+ avParents: { has: (id: string) => boolean },
361
+ hidden: Set<string>,
362
+ configAgents: Set<string>,
363
+ ): boolean {
364
+ if (variant) return false
365
+ if (agent && avParents.has(agent)) return false
366
+ return isHiddenAgent(agent, hidden, configAgents)
367
+ }
368
+
312
369
  /** Deletes one session through whichever delete method the host client
313
370
  * exposes (`session.delete`, `session.remove`). Returns false when no
314
- * method exists or the call failed. */
371
+ * method exists or the call failed. An `{error}` envelope without `data`
372
+ * (the runtime's non-throwing failure shape) counts as a failure, not a
373
+ * success - the server's remove handler swallows internal errors and can
374
+ * still answer, so callers verify after batch deletes. */
315
375
  export async function deleteSession(client: unknown, sessionID: string): Promise<boolean> {
316
376
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
317
377
  if (!namespace) return false
378
+ const isErrorEnvelope = (value: unknown): boolean =>
379
+ !!value && typeof value === "object" && "error" in (value as Record<string, unknown>) && !("data" in (value as Record<string, unknown>))
318
380
  const shapes: (() => Promise<unknown>)[] = []
319
381
  if (typeof namespace.delete === "function") {
320
382
  shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }))
@@ -324,7 +386,9 @@ export async function deleteSession(client: unknown, sessionID: string): Promise
324
386
  }
325
387
  for (const call of shapes) {
326
388
  const result = await withTimeout(call)
327
- if (result !== undefined) return true
389
+ if (result === undefined) continue
390
+ if (isErrorEnvelope(result)) continue
391
+ return true
328
392
  }
329
393
  return false
330
394
  }
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
  }