@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.
- package/dist/sessions.d.ts +26 -7
- package/dist/sessions.js +87 -13
- package/dist/tui.js +101 -23
- package/dist/wizard.d.ts +13 -2
- package/dist/wizard.js +102 -23
- package/package.json +1 -1
- package/src/sessions.ts +100 -12
- package/src/wizard.tsx +54 -10
package/dist/sessions.d.ts
CHANGED
|
@@ -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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
93
|
-
|
|
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(
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
142
|
-
|
|
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(
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
89
|
-
|
|
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(
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
117
|
-
|
|
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(
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
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
|
-
|
|
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)
|
|
77
100
|
rows.push({
|
|
78
101
|
session: node.session,
|
|
79
102
|
depth,
|
|
80
103
|
running: data.activeIDs?.has(node.session.id) ?? false,
|
|
81
|
-
|
|
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
|
|
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?",
|
|
@@ -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
|
-
|
|
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") {
|