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

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.
@@ -1,10 +1,3 @@
1
- /**
2
- * Core subagent-session data layer. Pure tree/classification logic plus
3
- * fail-soft client seams. Every client call is attached to its namespace
4
- * object (v2-gen class methods throw when detached), feature-detected,
5
- * timeout-bounded, and envelope-unwrapping ({data} vs raw) so one code path
6
- * serves the v1 and v2 hosts and the studio embed alike.
7
- */
8
1
  export type SessionLite = {
9
2
  id: string;
10
3
  parentID?: string;
@@ -37,7 +30,33 @@ export declare function fetchSessions(client: unknown, directory: string): Promi
37
30
  /** Running session ids via `session.active()` with the `session.status()`
38
31
  * fallback (busy/retry = running). `undefined` when neither exists or both
39
32
  * fail — callers must treat that as "unknown", never "idle". */
33
+ /**
34
+ * Running session ids via `session.active()` with the `session.status()`
35
+ * fallback (busy/retry/unknown-type = running). Mirrors the Config Studio
36
+ * reload coordinator's proven probing: PRESENCE in the active map means
37
+ * running (values may be falsy and must not be filtered), the status map
38
+ * keys carry `.type` (not `.status`), and all calls stay attached to the
39
+ * namespace object (SDK methods dereference `this`). `undefined` when
40
+ * neither exists or both fail - callers must treat that as "unknown",
41
+ * never "idle".
42
+ */
40
43
  export declare function fetchActiveSessionIDs(client: unknown): Promise<Set<string> | undefined>;
44
+ /** Minimal agent-variants sidecar view: which parents exist and which have
45
+ * the base disabled. Absent file / parse errors -> empty (fail-soft). */
46
+ export type AvSidecar = {
47
+ parents: Map<string, {
48
+ disableBase: boolean;
49
+ }>;
50
+ };
51
+ export declare function loadAvSidecar(globalConfigDir: string | undefined): AvSidecar;
52
+ /** child session id -> the AV variant that created it (from the parent's
53
+ * task-part metadata). Windowed like AV's own correlation: only recent
54
+ * history is scanned, deep/old tasks simply stay unattributed. */
55
+ export type VariantAttribution = Map<string, {
56
+ alias: string;
57
+ parent: string;
58
+ }>;
59
+ export declare function fetchVariantAttribution(client: unknown, parentSessionID: string | undefined, directory: string | undefined, avParents: Set<string>): Promise<VariantAttribution>;
41
60
  /** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
42
61
  * are ignored (not part of this session's tree). Guarded against cycles. */
43
62
  export declare function buildSessionTree(sessions: SessionLite[], rootID: string): SessionTreeNode[];
package/dist/sessions.js 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
  const CALL_TIMEOUT_MS = 5000;
9
10
  /** OpenCode's built-in agent ids (v1): anything else that is not in the
10
11
  * user's config was registered at runtime (plugins) and is treated as
@@ -73,28 +74,35 @@ export async function fetchSessions(client, directory) {
73
74
  /** Running session ids via `session.active()` with the `session.status()`
74
75
  * fallback (busy/retry = running). `undefined` when neither exists or both
75
76
  * fail — callers must treat that as "unknown", never "idle". */
77
+ /**
78
+ * Running session ids via `session.active()` with the `session.status()`
79
+ * fallback (busy/retry/unknown-type = running). Mirrors the Config Studio
80
+ * reload coordinator's proven probing: PRESENCE in the active map means
81
+ * running (values may be falsy and must not be filtered), the status map
82
+ * keys carry `.type` (not `.status`), and all calls stay attached to the
83
+ * namespace object (SDK methods dereference `this`). `undefined` when
84
+ * neither exists or both fail - callers must treat that as "unknown",
85
+ * never "idle".
86
+ */
76
87
  export async function fetchActiveSessionIDs(client) {
77
88
  const namespace = client?.session;
78
89
  if (!namespace)
79
90
  return undefined;
80
91
  if (typeof namespace.active === "function") {
81
- const active = unwrap(await withTimeout(() => namespace.active()));
82
- if (active && typeof active === "object") {
83
- const ids = new Set();
84
- for (const [id, value] of Object.entries(active)) {
85
- if (value)
86
- ids.add(id);
87
- }
88
- return ids;
92
+ const result = await withTimeout(() => namespace.active());
93
+ const data = result && typeof result === "object" ? result.data : undefined;
94
+ if (data && typeof data === "object" && !Array.isArray(data)) {
95
+ return new Set(Object.keys(data));
89
96
  }
90
97
  }
91
98
  if (typeof namespace.status === "function") {
92
- const status = unwrap(await withTimeout(() => namespace.status()));
93
- if (status && typeof status === "object") {
99
+ const result = await withTimeout(() => namespace.status());
100
+ const data = result && typeof result === "object" ? result.data : undefined;
101
+ if (data && typeof data === "object" && !Array.isArray(data)) {
94
102
  const ids = new Set();
95
- for (const [id, value] of Object.entries(status)) {
96
- const state = value?.status ?? value;
97
- if (state === "busy" || state === "retry")
103
+ for (const [id, value] of Object.entries(data)) {
104
+ const type = value && typeof value === "object" ? (value.type ?? value.status) : value;
105
+ if (type === undefined || type === "busy" || type === "retry")
98
106
  ids.add(id);
99
107
  }
100
108
  return ids;
@@ -102,6 +110,72 @@ export async function fetchActiveSessionIDs(client) {
102
110
  }
103
111
  return undefined;
104
112
  }
113
+ export function loadAvSidecar(globalConfigDir) {
114
+ const parents = new Map();
115
+ if (!globalConfigDir)
116
+ return { parents };
117
+ try {
118
+ const path = `${globalConfigDir.replace(/[\\/]+$/, "")}/agent-variants.jsonc`;
119
+ if (!existsSync(path))
120
+ return { parents };
121
+ const raw = readFileSync(path, "utf8");
122
+ const json = JSON.parse(stripJsoncComments(raw));
123
+ for (const [parent, entry] of Object.entries(json.agents ?? {})) {
124
+ if (!entry || typeof entry !== "object")
125
+ continue;
126
+ parents.set(parent, { disableBase: entry.disable_base === true && entry.disable !== true });
127
+ }
128
+ }
129
+ catch {
130
+ // Not our file to complain about - AV may simply not be installed.
131
+ }
132
+ return { parents };
133
+ }
134
+ function stripJsoncComments(text) {
135
+ // Block comments, line comments, and trailing commas before } or ].
136
+ return text
137
+ .replace(/\/\*[\s\S]*?\*\//g, "")
138
+ .replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
139
+ .replace(/,(\s*[}\]])/g, "$1");
140
+ }
141
+ export async function fetchVariantAttribution(client, parentSessionID, directory, avParents) {
142
+ const map = new Map();
143
+ if (avParents.size === 0 || !parentSessionID)
144
+ return map;
145
+ const namespace = client?.session;
146
+ if (!namespace || typeof namespace.messages !== "function")
147
+ return map;
148
+ const messages = await withTimeout(() => {
149
+ // Try the flat (v2-gen) shape first, then the legacy {query} wrapper.
150
+ const flat = namespace.messages({ sessionID: parentSessionID, directory, limit: 50 });
151
+ return flat instanceof Promise ? flat : namespace.messages({ path: { id: parentSessionID }, query: { directory, limit: 50 } });
152
+ });
153
+ const list = Array.isArray(messages)
154
+ ? messages
155
+ : messages && typeof messages === "object"
156
+ ? (messages.data ?? messages)
157
+ : undefined;
158
+ if (!Array.isArray(list))
159
+ return map;
160
+ for (const message of list) {
161
+ for (const part of message.parts ?? []) {
162
+ if (part?.type !== "tool" || part.tool !== "task")
163
+ continue;
164
+ const metadata = part.state?.metadata;
165
+ const child = metadata?.sessionId;
166
+ const variantMeta = metadata?.agentVariants;
167
+ const alias = variantMeta?.alias;
168
+ const routedAgent = variantMeta?.routedAgent;
169
+ if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
170
+ map.set(child, {
171
+ alias,
172
+ parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, ""),
173
+ });
174
+ }
175
+ }
176
+ }
177
+ return map;
178
+ }
105
179
  /** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
106
180
  * are ignored (not part of this session's tree). Guarded against cycles. */
107
181
  export function buildSessionTree(sessions, rootID) {
package/dist/tui.js CHANGED
@@ -66,6 +66,7 @@ import { useTerminalDimensions } from "@opentui/solid";
66
66
  import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
67
67
 
68
68
  // src/sessions.ts
69
+ import { existsSync, readFileSync } from "fs";
69
70
  var CALL_TIMEOUT_MS = 5000;
70
71
  var BUILTIN_AGENTS = new Set(["build", "plan"]);
71
72
  function withTimeout(fn, ms = CALL_TIMEOUT_MS) {
@@ -127,23 +128,20 @@ async function fetchActiveSessionIDs(client) {
127
128
  if (!namespace)
128
129
  return;
129
130
  if (typeof namespace.active === "function") {
130
- const active = unwrap(await withTimeout(() => namespace.active()));
131
- if (active && typeof active === "object") {
132
- const ids = new Set;
133
- for (const [id, value] of Object.entries(active)) {
134
- if (value)
135
- ids.add(id);
136
- }
137
- return ids;
131
+ const result = await withTimeout(() => namespace.active());
132
+ const data = result && typeof result === "object" ? result.data : undefined;
133
+ if (data && typeof data === "object" && !Array.isArray(data)) {
134
+ return new Set(Object.keys(data));
138
135
  }
139
136
  }
140
137
  if (typeof namespace.status === "function") {
141
- const status = unwrap(await withTimeout(() => namespace.status()));
142
- if (status && typeof status === "object") {
138
+ const result = await withTimeout(() => namespace.status());
139
+ const data = result && typeof result === "object" ? result.data : undefined;
140
+ if (data && typeof data === "object" && !Array.isArray(data)) {
143
141
  const ids = new Set;
144
- for (const [id, value] of Object.entries(status)) {
145
- const state = value?.status ?? value;
146
- if (state === "busy" || state === "retry")
142
+ for (const [id, value] of Object.entries(data)) {
143
+ const type = value && typeof value === "object" ? value.type ?? value.status : value;
144
+ if (type === undefined || type === "busy" || type === "retry")
147
145
  ids.add(id);
148
146
  }
149
147
  return ids;
@@ -151,6 +149,60 @@ async function fetchActiveSessionIDs(client) {
151
149
  }
152
150
  return;
153
151
  }
152
+ function loadAvSidecar(globalConfigDir) {
153
+ const parents = new Map;
154
+ if (!globalConfigDir)
155
+ return { parents };
156
+ try {
157
+ const path = `${globalConfigDir.replace(/[\\/]+$/, "")}/agent-variants.jsonc`;
158
+ if (!existsSync(path))
159
+ return { parents };
160
+ const raw = readFileSync(path, "utf8");
161
+ const json = JSON.parse(stripJsoncComments(raw));
162
+ for (const [parent, entry] of Object.entries(json.agents ?? {})) {
163
+ if (!entry || typeof entry !== "object")
164
+ continue;
165
+ parents.set(parent, { disableBase: entry.disable_base === true && entry.disable !== true });
166
+ }
167
+ } catch {}
168
+ return { parents };
169
+ }
170
+ function stripJsoncComments(text) {
171
+ return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'\\])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
172
+ }
173
+ async function fetchVariantAttribution(client, parentSessionID, directory, avParents) {
174
+ const map = new Map;
175
+ if (avParents.size === 0 || !parentSessionID)
176
+ return map;
177
+ const namespace = client?.session;
178
+ if (!namespace || typeof namespace.messages !== "function")
179
+ return map;
180
+ const messages = await withTimeout(() => {
181
+ const flat = namespace.messages({ sessionID: parentSessionID, directory, limit: 50 });
182
+ return flat instanceof Promise ? flat : namespace.messages({ path: { id: parentSessionID }, query: { directory, limit: 50 } });
183
+ });
184
+ const list = Array.isArray(messages) ? messages : messages && typeof messages === "object" ? messages.data ?? messages : undefined;
185
+ if (!Array.isArray(list))
186
+ return map;
187
+ for (const message of list) {
188
+ for (const part of message.parts ?? []) {
189
+ if (part?.type !== "tool" || part.tool !== "task")
190
+ continue;
191
+ const metadata = part.state?.metadata;
192
+ const child = metadata?.sessionId;
193
+ const variantMeta = metadata?.agentVariants;
194
+ const alias = variantMeta?.alias;
195
+ const routedAgent = variantMeta?.routedAgent;
196
+ if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
197
+ map.set(child, {
198
+ alias,
199
+ parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, "")
200
+ });
201
+ }
202
+ }
203
+ }
204
+ return map;
205
+ }
154
206
  function buildSessionTree(sessions, rootID) {
155
207
  const byParent = new Map;
156
208
  for (const session of sessions) {
@@ -313,28 +365,41 @@ function parentCandidates(sessions) {
313
365
  }
314
366
 
315
367
  // src/wizard.tsx
316
- async function loadExplorerData(api, directory) {
368
+ function globalConfigDirOf(api) {
369
+ const config = api.state.path?.config;
370
+ if (!config)
371
+ return;
372
+ const index = Math.max(config.lastIndexOf("/"), config.lastIndexOf("\\"));
373
+ return index > 0 ? config.slice(0, index) : undefined;
374
+ }
375
+ async function loadExplorerData(api, directory, rootOverride) {
317
376
  const sessions = await fetchSessions(api.client, directory);
318
377
  if (!sessions)
319
378
  return;
320
- const activeIDs = await fetchActiveSessionIDs(api.client);
379
+ const [activeIDs, avSidecar] = await Promise.all([fetchActiveSessionIDs(api.client), Promise.resolve(loadAvSidecar(globalConfigDirOf(api)))]);
380
+ const rootID = rootOverride ?? currentSessionID(api.currentSessionID?.(), sessions);
381
+ const variantOf = await fetchVariantAttribution(api.client, rootID, directory, new Set(avSidecar.parents.keys()));
321
382
  return {
322
383
  sessions,
323
384
  activeIDs,
324
385
  activeKnown: activeIDs !== undefined,
325
386
  hidden: hiddenAgentIDs(api.state.config),
326
- configAgents: configAgentIDs(api.state.config)
387
+ configAgents: configAgentIDs(api.state.config),
388
+ variantOf,
389
+ avParents: avSidecar.parents
327
390
  };
328
391
  }
329
392
  function explorerRows(tree, data) {
330
393
  const rows = [];
331
394
  const walk = (nodes, depth) => {
332
395
  for (const node of nodes) {
396
+ const variant = data.variantOf.get(node.session.id);
333
397
  rows.push({
334
398
  session: node.session,
335
399
  depth,
336
400
  running: data.activeIDs?.has(node.session.id) ?? false,
337
- hidden: isHiddenAgent(node.session.agent, data.hidden, data.configAgents)
401
+ hidden: variant ? false : isHiddenAgent(node.session.agent, data.hidden, data.configAgents),
402
+ variant
338
403
  });
339
404
  walk(node.children, depth + 1);
340
405
  }
@@ -342,6 +407,9 @@ function explorerRows(tree, data) {
342
407
  walk(tree, 0);
343
408
  return rows;
344
409
  }
410
+ function agentLabelOf(row) {
411
+ return row.variant?.alias ?? row.session.agent ?? "unknown";
412
+ }
345
413
  function themeOf(api) {
346
414
  return api.theme.current;
347
415
  }
@@ -714,7 +782,7 @@ function SubagentTreeDialog(props) {
714
782
  _$setProp(_el$30, "flexShrink", 0);
715
783
  _$setProp(_el$30, "wrapMode", "none");
716
784
  _$setProp(_el$30, "overflow", "hidden");
717
- _$insert(_el$31, () => truncate(row.session.agent ?? "agent", agentWidth));
785
+ _$insert(_el$31, () => truncate(agentLabelOf(row), agentWidth));
718
786
  _$insert(_el$28, _$createComponent(Show, {
719
787
  get when() {
720
788
  return row.hidden;
@@ -1039,7 +1107,7 @@ function showConfirm(api, props) {
1039
1107
  async function deleteOne(api, row) {
1040
1108
  const label = `${rowTitle(row)}${row.hidden ? " [hidden agent]" : ""}`;
1041
1109
  const summary = `${label}
1042
- agent: ${row.session.agent ?? "unknown"}
1110
+ ${row.variant ? `variant: ${row.variant.alias} (of ${row.variant.parent})` : `agent: ${row.session.agent ?? "unknown"}`}
1043
1111
  last activity: ${formatAge(row.session.updated ?? row.session.created)}
1044
1112
 
1045
1113
  Deleting a session permanently removes it, its messages, and its history. The parent session keeps the task result text; only the jump target is lost.`;
@@ -1118,9 +1186,19 @@ function showRootPicker(api, sessions, currentRoot) {
1118
1186
  }), () => done(undefined));
1119
1187
  });
1120
1188
  }
1121
- async function showDetails(api, row) {
1189
+ async function showDetails(api, row, data) {
1122
1190
  const details = await fetchSessionDetails(api.client, row.session.id);
1123
- const lines = [`title: ${rowTitle(row)}`, `agent: ${row.session.agent ?? "unknown"}${row.hidden ? " (hidden/background)" : ""}`, `session: ${row.session.id}`, `status: ${row.running ? "running" : "idle"}`, `created: ${formatAge(row.session.created)}`, `last activity: ${formatAge(row.session.updated ?? row.session.created)}`];
1191
+ const parentAgent = row.session.agent ?? "unknown";
1192
+ const lines = [`title: ${rowTitle(row)}`, row.variant ? `variant: ${row.variant.alias} (of ${row.variant.parent})` : `agent: ${parentAgent}${row.hidden ? " (hidden/background)" : ""}`];
1193
+ if (row.variant) {
1194
+ const parentEntry = data.avParents.get(row.variant.parent);
1195
+ if (parentEntry) {
1196
+ lines.push(`base ${row.variant.parent}: ${parentEntry.disableBase ? "disabled (variants only)" : "enabled"}`);
1197
+ }
1198
+ } else if (data.avParents.has(parentAgent)) {
1199
+ lines.push(`base ${parentAgent}: ${data.avParents.get(parentAgent)?.disableBase ? "disabled (variants only)" : "enabled"}`);
1200
+ }
1201
+ lines.push(`session: ${row.session.id}`, `status: ${row.running ? "running" : "idle"}`, `created: ${formatAge(row.session.created)}`, `last activity: ${formatAge(row.session.updated ?? row.session.created)}`);
1124
1202
  if (details) {
1125
1203
  lines.push(`messages: ${details.messageCount}`);
1126
1204
  if (details.model)
@@ -1566,7 +1644,7 @@ async function mainMenu(api) {
1566
1644
  }
1567
1645
  let rootOverride;
1568
1646
  while (true) {
1569
- const data = await loadExplorerData(api, directory);
1647
+ const data = await loadExplorerData(api, directory, rootOverride);
1570
1648
  if (!data) {
1571
1649
  await showInfo(api, {
1572
1650
  title: "Subagent Explorer",
@@ -1614,7 +1692,7 @@ The host client does not expose session.list for this directory.`
1614
1692
  continue;
1615
1693
  }
1616
1694
  if (action.type === "details") {
1617
- await showDetails(api, action.row);
1695
+ await showDetails(api, action.row, data);
1618
1696
  continue;
1619
1697
  }
1620
1698
  if (action.type === "cleanup") {
package/dist/wizard.d.ts CHANGED
@@ -10,12 +10,16 @@
10
10
  * import mainMenu from here and drive it with their own TuiHostApi instance.
11
11
  */
12
12
  import type { TuiHostApi as TuiPluginApi } from "./tui-host.js";
13
- import { type SessionLite, type SessionTreeNode } from "./sessions.js";
13
+ import { type SessionLite, type SessionTreeNode, type VariantAttribution } from "./sessions.js";
14
14
  export type ExplorerRow = {
15
15
  session: SessionLite;
16
16
  depth: number;
17
17
  running: boolean;
18
18
  hidden: boolean;
19
+ variant?: {
20
+ alias: string;
21
+ parent: string;
22
+ };
19
23
  };
20
24
  export type ExplorerData = {
21
25
  sessions: SessionLite[];
@@ -23,6 +27,12 @@ export type ExplorerData = {
23
27
  activeKnown: boolean;
24
28
  hidden: Set<string>;
25
29
  configAgents: Set<string>;
30
+ /** child session id -> creating AV variant (recent history only). */
31
+ variantOf: VariantAttribution;
32
+ /** AV sidecar parents (empty when agent-variants is not installed). */
33
+ avParents: Map<string, {
34
+ disableBase: boolean;
35
+ }>;
26
36
  };
27
37
  export type TreeAction = {
28
38
  type: "delete";
@@ -41,8 +51,9 @@ export type TreeAction = {
41
51
  } | {
42
52
  type: "exit";
43
53
  };
44
- export declare function loadExplorerData(api: TuiPluginApi, directory: string): Promise<ExplorerData | undefined>;
54
+ export declare function loadExplorerData(api: TuiPluginApi, directory: string, rootOverride?: string): Promise<ExplorerData | undefined>;
45
55
  export declare function explorerRows(tree: SessionTreeNode[], data: ExplorerData): ExplorerRow[];
56
+ export declare function agentLabelOf(row: Pick<ExplorerRow, "session" | "variant">): string;
46
57
  /**
47
58
  * Pure dialog-height budget (same math as Config Studio's computeDialogRows):
48
59
  * OpenCode's dialog backdrop anchors at terminalHeight / 4, so a panel
package/dist/wizard.js CHANGED
@@ -13,6 +13,7 @@ import { useTerminalDimensions } from "@opentui/solid";
13
13
  import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
14
14
 
15
15
  // src/sessions.ts
16
+ import { existsSync, readFileSync } from "fs";
16
17
  var CALL_TIMEOUT_MS = 5000;
17
18
  var BUILTIN_AGENTS = new Set(["build", "plan"]);
18
19
  function withTimeout(fn, ms = CALL_TIMEOUT_MS) {
@@ -74,23 +75,20 @@ async function fetchActiveSessionIDs(client) {
74
75
  if (!namespace)
75
76
  return;
76
77
  if (typeof namespace.active === "function") {
77
- const active = unwrap(await withTimeout(() => namespace.active()));
78
- if (active && typeof active === "object") {
79
- const ids = new Set;
80
- for (const [id, value] of Object.entries(active)) {
81
- if (value)
82
- ids.add(id);
83
- }
84
- return ids;
78
+ const result = await withTimeout(() => namespace.active());
79
+ const data = result && typeof result === "object" ? result.data : undefined;
80
+ if (data && typeof data === "object" && !Array.isArray(data)) {
81
+ return new Set(Object.keys(data));
85
82
  }
86
83
  }
87
84
  if (typeof namespace.status === "function") {
88
- const status = unwrap(await withTimeout(() => namespace.status()));
89
- if (status && typeof status === "object") {
85
+ const result = await withTimeout(() => namespace.status());
86
+ const data = result && typeof result === "object" ? result.data : undefined;
87
+ if (data && typeof data === "object" && !Array.isArray(data)) {
90
88
  const ids = new Set;
91
- for (const [id, value] of Object.entries(status)) {
92
- const state = value?.status ?? value;
93
- if (state === "busy" || state === "retry")
89
+ for (const [id, value] of Object.entries(data)) {
90
+ const type = value && typeof value === "object" ? value.type ?? value.status : value;
91
+ if (type === undefined || type === "busy" || type === "retry")
94
92
  ids.add(id);
95
93
  }
96
94
  return ids;
@@ -98,6 +96,60 @@ async function fetchActiveSessionIDs(client) {
98
96
  }
99
97
  return;
100
98
  }
99
+ function loadAvSidecar(globalConfigDir) {
100
+ const parents = new Map;
101
+ if (!globalConfigDir)
102
+ return { parents };
103
+ try {
104
+ const path = `${globalConfigDir.replace(/[\\/]+$/, "")}/agent-variants.jsonc`;
105
+ if (!existsSync(path))
106
+ return { parents };
107
+ const raw = readFileSync(path, "utf8");
108
+ const json = JSON.parse(stripJsoncComments(raw));
109
+ for (const [parent, entry] of Object.entries(json.agents ?? {})) {
110
+ if (!entry || typeof entry !== "object")
111
+ continue;
112
+ parents.set(parent, { disableBase: entry.disable_base === true && entry.disable !== true });
113
+ }
114
+ } catch {}
115
+ return { parents };
116
+ }
117
+ function stripJsoncComments(text) {
118
+ return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'\\])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
119
+ }
120
+ async function fetchVariantAttribution(client, parentSessionID, directory, avParents) {
121
+ const map = new Map;
122
+ if (avParents.size === 0 || !parentSessionID)
123
+ return map;
124
+ const namespace = client?.session;
125
+ if (!namespace || typeof namespace.messages !== "function")
126
+ return map;
127
+ const messages = await withTimeout(() => {
128
+ const flat = namespace.messages({ sessionID: parentSessionID, directory, limit: 50 });
129
+ return flat instanceof Promise ? flat : namespace.messages({ path: { id: parentSessionID }, query: { directory, limit: 50 } });
130
+ });
131
+ const list = Array.isArray(messages) ? messages : messages && typeof messages === "object" ? messages.data ?? messages : undefined;
132
+ if (!Array.isArray(list))
133
+ return map;
134
+ for (const message of list) {
135
+ for (const part of message.parts ?? []) {
136
+ if (part?.type !== "tool" || part.tool !== "task")
137
+ continue;
138
+ const metadata = part.state?.metadata;
139
+ const child = metadata?.sessionId;
140
+ const variantMeta = metadata?.agentVariants;
141
+ const alias = variantMeta?.alias;
142
+ const routedAgent = variantMeta?.routedAgent;
143
+ if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
144
+ map.set(child, {
145
+ alias,
146
+ parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, "")
147
+ });
148
+ }
149
+ }
150
+ }
151
+ return map;
152
+ }
101
153
  function buildSessionTree(sessions, rootID) {
102
154
  const byParent = new Map;
103
155
  for (const session of sessions) {
@@ -260,28 +312,41 @@ function parentCandidates(sessions) {
260
312
  }
261
313
 
262
314
  // src/wizard.tsx
263
- async function loadExplorerData(api, directory) {
315
+ function globalConfigDirOf(api) {
316
+ const config = api.state.path?.config;
317
+ if (!config)
318
+ return;
319
+ const index = Math.max(config.lastIndexOf("/"), config.lastIndexOf("\\"));
320
+ return index > 0 ? config.slice(0, index) : undefined;
321
+ }
322
+ async function loadExplorerData(api, directory, rootOverride) {
264
323
  const sessions = await fetchSessions(api.client, directory);
265
324
  if (!sessions)
266
325
  return;
267
- const activeIDs = await fetchActiveSessionIDs(api.client);
326
+ const [activeIDs, avSidecar] = await Promise.all([fetchActiveSessionIDs(api.client), Promise.resolve(loadAvSidecar(globalConfigDirOf(api)))]);
327
+ const rootID = rootOverride ?? currentSessionID(api.currentSessionID?.(), sessions);
328
+ const variantOf = await fetchVariantAttribution(api.client, rootID, directory, new Set(avSidecar.parents.keys()));
268
329
  return {
269
330
  sessions,
270
331
  activeIDs,
271
332
  activeKnown: activeIDs !== undefined,
272
333
  hidden: hiddenAgentIDs(api.state.config),
273
- configAgents: configAgentIDs(api.state.config)
334
+ configAgents: configAgentIDs(api.state.config),
335
+ variantOf,
336
+ avParents: avSidecar.parents
274
337
  };
275
338
  }
276
339
  function explorerRows(tree, data) {
277
340
  const rows = [];
278
341
  const walk = (nodes, depth) => {
279
342
  for (const node of nodes) {
343
+ const variant = data.variantOf.get(node.session.id);
280
344
  rows.push({
281
345
  session: node.session,
282
346
  depth,
283
347
  running: data.activeIDs?.has(node.session.id) ?? false,
284
- hidden: isHiddenAgent(node.session.agent, data.hidden, data.configAgents)
348
+ hidden: variant ? false : isHiddenAgent(node.session.agent, data.hidden, data.configAgents),
349
+ variant
285
350
  });
286
351
  walk(node.children, depth + 1);
287
352
  }
@@ -289,6 +354,9 @@ function explorerRows(tree, data) {
289
354
  walk(tree, 0);
290
355
  return rows;
291
356
  }
357
+ function agentLabelOf(row) {
358
+ return row.variant?.alias ?? row.session.agent ?? "unknown";
359
+ }
292
360
  function themeOf(api) {
293
361
  return api.theme.current;
294
362
  }
@@ -661,7 +729,7 @@ function SubagentTreeDialog(props) {
661
729
  _$setProp(_el$30, "flexShrink", 0);
662
730
  _$setProp(_el$30, "wrapMode", "none");
663
731
  _$setProp(_el$30, "overflow", "hidden");
664
- _$insert(_el$31, () => truncate(row.session.agent ?? "agent", agentWidth));
732
+ _$insert(_el$31, () => truncate(agentLabelOf(row), agentWidth));
665
733
  _$insert(_el$28, _$createComponent(Show, {
666
734
  get when() {
667
735
  return row.hidden;
@@ -986,7 +1054,7 @@ function showConfirm(api, props) {
986
1054
  async function deleteOne(api, row) {
987
1055
  const label = `${rowTitle(row)}${row.hidden ? " [hidden agent]" : ""}`;
988
1056
  const summary = `${label}
989
- agent: ${row.session.agent ?? "unknown"}
1057
+ ${row.variant ? `variant: ${row.variant.alias} (of ${row.variant.parent})` : `agent: ${row.session.agent ?? "unknown"}`}
990
1058
  last activity: ${formatAge(row.session.updated ?? row.session.created)}
991
1059
 
992
1060
  Deleting a session permanently removes it, its messages, and its history. The parent session keeps the task result text; only the jump target is lost.`;
@@ -1065,9 +1133,19 @@ function showRootPicker(api, sessions, currentRoot) {
1065
1133
  }), () => done(undefined));
1066
1134
  });
1067
1135
  }
1068
- async function showDetails(api, row) {
1136
+ async function showDetails(api, row, data) {
1069
1137
  const details = await fetchSessionDetails(api.client, row.session.id);
1070
- const lines = [`title: ${rowTitle(row)}`, `agent: ${row.session.agent ?? "unknown"}${row.hidden ? " (hidden/background)" : ""}`, `session: ${row.session.id}`, `status: ${row.running ? "running" : "idle"}`, `created: ${formatAge(row.session.created)}`, `last activity: ${formatAge(row.session.updated ?? row.session.created)}`];
1138
+ const parentAgent = row.session.agent ?? "unknown";
1139
+ const lines = [`title: ${rowTitle(row)}`, row.variant ? `variant: ${row.variant.alias} (of ${row.variant.parent})` : `agent: ${parentAgent}${row.hidden ? " (hidden/background)" : ""}`];
1140
+ if (row.variant) {
1141
+ const parentEntry = data.avParents.get(row.variant.parent);
1142
+ if (parentEntry) {
1143
+ lines.push(`base ${row.variant.parent}: ${parentEntry.disableBase ? "disabled (variants only)" : "enabled"}`);
1144
+ }
1145
+ } else if (data.avParents.has(parentAgent)) {
1146
+ lines.push(`base ${parentAgent}: ${data.avParents.get(parentAgent)?.disableBase ? "disabled (variants only)" : "enabled"}`);
1147
+ }
1148
+ lines.push(`session: ${row.session.id}`, `status: ${row.running ? "running" : "idle"}`, `created: ${formatAge(row.session.created)}`, `last activity: ${formatAge(row.session.updated ?? row.session.created)}`);
1071
1149
  if (details) {
1072
1150
  lines.push(`messages: ${details.messageCount}`);
1073
1151
  if (details.model)
@@ -1513,7 +1591,7 @@ async function mainMenu(api) {
1513
1591
  }
1514
1592
  let rootOverride;
1515
1593
  while (true) {
1516
- const data = await loadExplorerData(api, directory);
1594
+ const data = await loadExplorerData(api, directory, rootOverride);
1517
1595
  if (!data) {
1518
1596
  await showInfo(api, {
1519
1597
  title: "Subagent Explorer",
@@ -1561,7 +1639,7 @@ The host client does not expose session.list for this directory.`
1561
1639
  continue;
1562
1640
  }
1563
1641
  if (action.type === "details") {
1564
- await showDetails(api, action.row);
1642
+ await showDetails(api, action.row, data);
1565
1643
  continue;
1566
1644
  }
1567
1645
  if (action.type === "cleanup") {
@@ -1572,6 +1650,7 @@ The host client does not expose session.list for this directory.`
1572
1650
  }
1573
1651
  export {
1574
1652
  SubagentTreeDialog,
1653
+ agentLabelOf,
1575
1654
  computeDialogRows,
1576
1655
  explorerRows,
1577
1656
  loadExplorerData,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirrowel/opencode-subagent-explorer",
3
- "version": "0.1.0-dev.1",
3
+ "version": "0.2.0-dev.1",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for exploring and cleaning up subagent sessions of the current session",
6
6
  "license": "MIT",
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
@@ -99,26 +100,34 @@ export async function fetchSessions(client: unknown, directory: string): Promise
99
100
  /** Running session ids via `session.active()` with the `session.status()`
100
101
  * fallback (busy/retry = running). `undefined` when neither exists or both
101
102
  * fail — callers must treat that as "unknown", never "idle". */
103
+ /**
104
+ * Running session ids via `session.active()` with the `session.status()`
105
+ * fallback (busy/retry/unknown-type = running). Mirrors the Config Studio
106
+ * reload coordinator's proven probing: PRESENCE in the active map means
107
+ * running (values may be falsy and must not be filtered), the status map
108
+ * keys carry `.type` (not `.status`), and all calls stay attached to the
109
+ * namespace object (SDK methods dereference `this`). `undefined` when
110
+ * neither exists or both fail - callers must treat that as "unknown",
111
+ * never "idle".
112
+ */
102
113
  export async function fetchActiveSessionIDs(client: unknown): Promise<Set<string> | undefined> {
103
114
  const namespace = (client as { session?: Record<string, any> } | undefined)?.session
104
115
  if (!namespace) return undefined
105
116
  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
117
+ const result = await withTimeout(() => namespace.active())
118
+ const data = result && typeof result === "object" ? (result as { data?: unknown }).data : undefined
119
+ if (data && typeof data === "object" && !Array.isArray(data)) {
120
+ return new Set(Object.keys(data as Record<string, unknown>))
113
121
  }
114
122
  }
115
123
  if (typeof namespace.status === "function") {
116
- const status = unwrap(await withTimeout(() => namespace.status()))
117
- if (status && typeof status === "object") {
124
+ const result = await withTimeout(() => namespace.status())
125
+ const data = result && typeof result === "object" ? (result as { data?: unknown }).data : undefined
126
+ if (data && typeof data === "object" && !Array.isArray(data)) {
118
127
  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)
128
+ for (const [id, value] of Object.entries(data as Record<string, any>)) {
129
+ const type = value && typeof value === "object" ? (value.type ?? value.status) : value
130
+ if (type === undefined || type === "busy" || type === "retry") ids.add(id)
122
131
  }
123
132
  return ids
124
133
  }
@@ -126,6 +135,85 @@ export async function fetchActiveSessionIDs(client: unknown): Promise<Set<string
126
135
  return undefined
127
136
  }
128
137
 
138
+ /** Minimal agent-variants sidecar view: which parents exist and which have
139
+ * the base disabled. Absent file / parse errors -> empty (fail-soft). */
140
+ export type AvSidecar = {
141
+ parents: Map<string, { disableBase: boolean }>
142
+ }
143
+
144
+ export function loadAvSidecar(globalConfigDir: string | undefined): AvSidecar {
145
+ const parents = new Map<string, { disableBase: boolean }>()
146
+ if (!globalConfigDir) return { parents }
147
+ try {
148
+ const path = `${globalConfigDir.replace(/[\\/]+$/, "")}/agent-variants.jsonc`
149
+ if (!existsSync(path)) return { parents }
150
+ const raw = readFileSync(path, "utf8")
151
+ const json = JSON.parse(stripJsoncComments(raw)) as {
152
+ agents?: Record<string, { disable_base?: boolean; disable?: boolean }>
153
+ }
154
+ for (const [parent, entry] of Object.entries(json.agents ?? {})) {
155
+ if (!entry || typeof entry !== "object") continue
156
+ parents.set(parent, { disableBase: entry.disable_base === true && entry.disable !== true })
157
+ }
158
+ } catch {
159
+ // Not our file to complain about - AV may simply not be installed.
160
+ }
161
+ return { parents }
162
+ }
163
+
164
+ function stripJsoncComments(text: string): string {
165
+ // Block comments, line comments, and trailing commas before } or ].
166
+ return text
167
+ .replace(/\/\*[\s\S]*?\*\//g, "")
168
+ .replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
169
+ .replace(/,(\s*[}\]])/g, "$1")
170
+ }
171
+
172
+ /** 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. */
175
+ export type VariantAttribution = Map<string, { alias: string; parent: string }>
176
+
177
+ export async function fetchVariantAttribution(
178
+ client: unknown,
179
+ parentSessionID: string | undefined,
180
+ directory: string | undefined,
181
+ avParents: Set<string>,
182
+ ): Promise<VariantAttribution> {
183
+ const map: VariantAttribution = new Map()
184
+ if (avParents.size === 0 || !parentSessionID) return map
185
+ const namespace = (client as { session?: Record<string, any> } | undefined)?.session
186
+ 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
+ })
211
+ }
212
+ }
213
+ }
214
+ return map
215
+ }
216
+
129
217
  /** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
130
218
  * are ignored (not part of this session's tree). Guarded against cycles. */
131
219
  export function buildSessionTree(sessions: SessionLite[], rootID: string): SessionTreeNode[] {
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
  isHiddenAgent,
31
+ loadAvSidecar,
30
32
  parentCandidates,
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)
77
100
  rows.push({
78
101
  session: node.session,
79
102
  depth,
80
103
  running: data.activeIDs?.has(node.session.id) ?? false,
81
- hidden: isHiddenAgent(node.session.agent, data.hidden, data.configAgents),
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),
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?",
@@ -556,16 +587,29 @@ function showRootPicker(api: TuiPluginApi, sessions: SessionLite[], currentRoot:
556
587
  })
557
588
  }
558
589
 
559
- async function showDetails(api: TuiPluginApi, row: ExplorerRow): Promise<void> {
590
+ async function showDetails(api: TuiPluginApi, row: ExplorerRow, data: ExplorerData): Promise<void> {
560
591
  const details = await fetchSessionDetails(api.client, row.session.id)
592
+ const parentAgent = row.session.agent ?? "unknown"
561
593
  const lines = [
562
594
  `title: ${rowTitle(row)}`,
563
- `agent: ${row.session.agent ?? "unknown"}${row.hidden ? " (hidden/background)" : ""}`,
595
+ row.variant
596
+ ? `variant: ${row.variant.alias} (of ${row.variant.parent})`
597
+ : `agent: ${parentAgent}${row.hidden ? " (hidden/background)" : ""}`,
598
+ ]
599
+ if (row.variant) {
600
+ const parentEntry = data.avParents.get(row.variant.parent)
601
+ if (parentEntry) {
602
+ lines.push(`base ${row.variant.parent}: ${parentEntry.disableBase ? "disabled (variants only)" : "enabled"}`)
603
+ }
604
+ } else if (data.avParents.has(parentAgent)) {
605
+ lines.push(`base ${parentAgent}: ${data.avParents.get(parentAgent)?.disableBase ? "disabled (variants only)" : "enabled"}`)
606
+ }
607
+ lines.push(
564
608
  `session: ${row.session.id}`,
565
609
  `status: ${row.running ? "running" : "idle"}`,
566
610
  `created: ${formatAge(row.session.created)}`,
567
611
  `last activity: ${formatAge(row.session.updated ?? row.session.created)}`,
568
- ]
612
+ )
569
613
  if (details) {
570
614
  lines.push(`messages: ${details.messageCount}`)
571
615
  if (details.model) lines.push(`last model: ${details.model}`)
@@ -793,7 +837,7 @@ export async function mainMenu(api: TuiPluginApi): Promise<void> {
793
837
  }
794
838
  let rootOverride: string | undefined
795
839
  while (true) {
796
- const data = await loadExplorerData(api, directory)
840
+ const data = await loadExplorerData(api, directory, rootOverride)
797
841
  if (!data) {
798
842
  await showInfo(api, {
799
843
  title: "Subagent Explorer",
@@ -833,7 +877,7 @@ export async function mainMenu(api: TuiPluginApi): Promise<void> {
833
877
  continue
834
878
  }
835
879
  if (action.type === "details") {
836
- await showDetails(api, action.row)
880
+ await showDetails(api, action.row, data)
837
881
  continue
838
882
  }
839
883
  if (action.type === "cleanup") {