@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/dist/sessions.d.ts +42 -10
- package/dist/sessions.js +147 -20
- package/dist/tui.js +328 -144
- package/dist/wizard.d.ts +13 -2
- package/dist/wizard.js +334 -149
- package/package.json +1 -1
- package/src/palette-category.ts +83 -83
- package/src/sessions.ts +164 -18
- package/src/wizard.tsx +110 -16
package/package.json
CHANGED
package/src/palette-category.ts
CHANGED
|
@@ -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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
117
|
-
|
|
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(
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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}\
|
|
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
|
-
|
|
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))
|
|
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:
|
|
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
|
-
|
|
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
|
}
|