@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/dist/sessions.d.ts +18 -5
- package/dist/sessions.js +86 -33
- package/dist/tui.js +249 -143
- package/dist/wizard.js +249 -143
- package/package.json +1 -1
- package/src/sessions.ts +90 -32
- package/src/wizard.tsx +60 -10
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).
|
|
174
|
-
*
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
-
|
|
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))
|
|
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:
|
|
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
|
}
|