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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -31,13 +24,40 @@ export type CleanupPlan = {
31
24
  export declare const BUILTIN_AGENTS: Set<string>;
32
25
  export declare function withTimeout<T>(fn: () => Promise<T>, ms?: number): Promise<T | undefined>;
33
26
  export declare function normalizeSession(raw: unknown): SessionLite | undefined;
34
- /** Lists sessions for a directory. Tries the v2 client's flat params first,
35
- * then the v1 `{query}` shape; unwraps both envelope styles. Fail-soft. */
36
27
  export declare function fetchSessions(client: unknown, directory: string): Promise<SessionLite[] | undefined>;
37
28
  /** Running session ids via `session.active()` with the `session.status()`
38
29
  * fallback (busy/retry = running). `undefined` when neither exists or both
39
30
  * fail — callers must treat that as "unknown", never "idle". */
31
+ /**
32
+ * Running session ids via `session.active()` with the `session.status()`
33
+ * fallback (busy/retry/unknown-type = running). Mirrors the Config Studio
34
+ * reload coordinator's proven probing: PRESENCE in the active map means
35
+ * running (values may be falsy and must not be filtered), the status map
36
+ * keys carry `.type` (not `.status`), and all calls stay attached to the
37
+ * namespace object (SDK methods dereference `this`). `undefined` when
38
+ * neither exists or both fail - callers must treat that as "unknown",
39
+ * never "idle".
40
+ */
40
41
  export declare function fetchActiveSessionIDs(client: unknown): Promise<Set<string> | undefined>;
42
+ /** Minimal agent-variants sidecar view: which parents exist and which have
43
+ * the base disabled. Absent file / parse errors -> empty (fail-soft). */
44
+ export type AvSidecar = {
45
+ parents: Map<string, {
46
+ disableBase: boolean;
47
+ }>;
48
+ };
49
+ export declare function loadAvSidecar(globalConfigDir: string | undefined): AvSidecar;
50
+ /** child session id -> the AV variant that created it (from the parent's
51
+ * task-part metadata). Paged backward through the parent's history via the
52
+ * `before` message cursor (up to ATTRIBUTION_PAGES pages), so large trees
53
+ * attribute most children instead of only the newest ~15 tasks; very deep
54
+ * history simply stays unattributed (the row then shows the parent agent,
55
+ * never a hidden badge - see explorerRows). */
56
+ export type VariantAttribution = Map<string, {
57
+ alias: string;
58
+ parent: string;
59
+ }>;
60
+ export declare function fetchVariantAttribution(client: unknown, parentSessionID: string | undefined, directory: string | undefined, avParents: Set<string>): Promise<VariantAttribution>;
41
61
  /** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
42
62
  * are ignored (not part of this session's tree). Guarded against cycles. */
43
63
  export declare function buildSessionTree(sessions: SessionLite[], rootID: string): SessionTreeNode[];
@@ -60,9 +80,21 @@ export declare function configAgentIDs(config: unknown): Set<string>;
60
80
  * in the config, or the agent was registered at runtime (not config-known,
61
81
  * not built-in, not an agent-variants clone). */
62
82
  export declare function isHiddenAgent(agent: string | undefined, hidden: Set<string>, configAgents: Set<string>): boolean;
83
+ /** Badge decision for explorer rows: variant children and children of
84
+ * AV-managed parents never carry the hidden badge - a hidden AV parent is
85
+ * agent-variants' base-disable ("must use a variant"), not a background
86
+ * agent, and the decision must not depend on attribution windows. Only
87
+ * genuinely hidden non-AV agents (plugin background agents, hidden custom
88
+ * agents without variants) keep the badge. */
89
+ export declare function rowHidden(variant: unknown, agent: string | undefined, avParents: {
90
+ has: (id: string) => boolean;
91
+ }, hidden: Set<string>, configAgents: Set<string>): boolean;
63
92
  /** Deletes one session through whichever delete method the host client
64
93
  * exposes (`session.delete`, `session.remove`). Returns false when no
65
- * method exists or the call failed. */
94
+ * method exists or the call failed. An `{error}` envelope without `data`
95
+ * (the runtime's non-throwing failure shape) counts as a failure, not a
96
+ * success - the server's remove handler swallows internal errors and can
97
+ * still answer, so callers verify after batch deletes. */
66
98
  export declare function deleteSession(client: unknown, sessionID: string): Promise<boolean>;
67
99
  export type SessionDetails = {
68
100
  messageCount: number;
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
@@ -54,17 +55,26 @@ export function normalizeSession(raw) {
54
55
  };
55
56
  }
56
57
  /** Lists sessions for a directory. Tries the v2 client's flat params first,
57
- * then the v1 `{query}` shape; unwraps both envelope styles. Fail-soft. */
58
+ * then the v1 `{query}` shape; unwraps both envelope styles. Fail-soft.
59
+ *
60
+ * EXPLICIT LARGE LIMIT: OpenCode's Session.list defaults to a 100-row page
61
+ * (ordered by time_updated DESC, session.ts `limit ?? 100`) - a plain call
62
+ * silently hides older sessions, which truncated large trees (100+ subagents
63
+ * showed ~85) and made cleanups miss everything past the first page. The
64
+ * server imposes no max on limit (NonNegativeInt), and its `start` cursor
65
+ * filters to NEWER sessions (gte), so downward paging is impossible by
66
+ * design - one large bounded page is the honest shape. */
67
+ const SESSION_LIST_LIMIT = 5000;
58
68
  export async function fetchSessions(client, directory) {
59
69
  const namespace = client?.session;
60
70
  if (!namespace?.list)
61
71
  return undefined;
62
72
  const calls = [
63
- () => namespace.list({ directory }),
64
- () => namespace.list({ query: { directory } }),
73
+ () => namespace.list({ directory, limit: SESSION_LIST_LIMIT }),
74
+ () => namespace.list({ query: { directory, limit: SESSION_LIST_LIMIT } }),
65
75
  ];
66
76
  for (const call of calls) {
67
- const result = unwrap(await withTimeout(call));
77
+ const result = unwrap(await withTimeout(call, 8000));
68
78
  if (Array.isArray(result))
69
79
  return result.map(normalizeSession).filter((s) => !!s);
70
80
  }
@@ -73,28 +83,35 @@ export async function fetchSessions(client, directory) {
73
83
  /** Running session ids via `session.active()` with the `session.status()`
74
84
  * fallback (busy/retry = running). `undefined` when neither exists or both
75
85
  * fail — callers must treat that as "unknown", never "idle". */
86
+ /**
87
+ * Running session ids via `session.active()` with the `session.status()`
88
+ * fallback (busy/retry/unknown-type = running). Mirrors the Config Studio
89
+ * reload coordinator's proven probing: PRESENCE in the active map means
90
+ * running (values may be falsy and must not be filtered), the status map
91
+ * keys carry `.type` (not `.status`), and all calls stay attached to the
92
+ * namespace object (SDK methods dereference `this`). `undefined` when
93
+ * neither exists or both fail - callers must treat that as "unknown",
94
+ * never "idle".
95
+ */
76
96
  export async function fetchActiveSessionIDs(client) {
77
97
  const namespace = client?.session;
78
98
  if (!namespace)
79
99
  return undefined;
80
100
  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;
101
+ const result = await withTimeout(() => namespace.active());
102
+ const data = result && typeof result === "object" ? result.data : undefined;
103
+ if (data && typeof data === "object" && !Array.isArray(data)) {
104
+ return new Set(Object.keys(data));
89
105
  }
90
106
  }
91
107
  if (typeof namespace.status === "function") {
92
- const status = unwrap(await withTimeout(() => namespace.status()));
93
- if (status && typeof status === "object") {
108
+ const result = await withTimeout(() => namespace.status());
109
+ const data = result && typeof result === "object" ? result.data : undefined;
110
+ if (data && typeof data === "object" && !Array.isArray(data)) {
94
111
  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")
112
+ for (const [id, value] of Object.entries(data)) {
113
+ const type = value && typeof value === "object" ? (value.type ?? value.status) : value;
114
+ if (type === undefined || type === "busy" || type === "retry")
98
115
  ids.add(id);
99
116
  }
100
117
  return ids;
@@ -102,6 +119,96 @@ export async function fetchActiveSessionIDs(client) {
102
119
  }
103
120
  return undefined;
104
121
  }
122
+ export function loadAvSidecar(globalConfigDir) {
123
+ const parents = new Map();
124
+ if (!globalConfigDir)
125
+ return { parents };
126
+ try {
127
+ const path = `${globalConfigDir.replace(/[\\/]+$/, "")}/agent-variants.jsonc`;
128
+ if (!existsSync(path))
129
+ return { parents };
130
+ const raw = readFileSync(path, "utf8");
131
+ const json = JSON.parse(stripJsoncComments(raw));
132
+ for (const [parent, entry] of Object.entries(json.agents ?? {})) {
133
+ if (!entry || typeof entry !== "object")
134
+ continue;
135
+ parents.set(parent, { disableBase: entry.disable_base === true && entry.disable !== true });
136
+ }
137
+ }
138
+ catch {
139
+ // Not our file to complain about - AV may simply not be installed.
140
+ }
141
+ return { parents };
142
+ }
143
+ function stripJsoncComments(text) {
144
+ // Block comments, line comments, and trailing commas before } or ].
145
+ return text
146
+ .replace(/\/\*[\s\S]*?\*\//g, "")
147
+ .replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
148
+ .replace(/,(\s*[}\]])/g, "$1");
149
+ }
150
+ const ATTRIBUTION_PAGE_SIZE = 100;
151
+ const ATTRIBUTION_PAGES = 5;
152
+ export async function fetchVariantAttribution(client, parentSessionID, directory, avParents) {
153
+ const map = new Map();
154
+ if (avParents.size === 0 || !parentSessionID)
155
+ return map;
156
+ const namespace = client?.session;
157
+ if (!namespace || typeof namespace.messages !== "function")
158
+ return map;
159
+ const fetchPage = (before) => {
160
+ const query = { directory, limit: ATTRIBUTION_PAGE_SIZE };
161
+ if (before)
162
+ query.before = before;
163
+ // Try the flat (v2-gen) shape first, then the legacy {path, query} wrapper.
164
+ const flat = namespace.messages({ sessionID: parentSessionID, ...query });
165
+ return flat instanceof Promise ? flat : namespace.messages({ path: { id: parentSessionID }, query });
166
+ };
167
+ const asList = (value) => {
168
+ if (Array.isArray(value))
169
+ return value;
170
+ if (value && typeof value === "object") {
171
+ const unwrapped = value.data;
172
+ if (Array.isArray(unwrapped))
173
+ return unwrapped;
174
+ }
175
+ return undefined;
176
+ };
177
+ let before;
178
+ const seenCursors = new Set();
179
+ for (let page = 0; page < ATTRIBUTION_PAGES; page++) {
180
+ const messages = await withTimeout(() => fetchPage(before), 8000);
181
+ const list = asList(messages);
182
+ if (!list || list.length === 0)
183
+ break;
184
+ for (const message of list) {
185
+ for (const part of message.parts ?? []) {
186
+ if (part?.type !== "tool" || part.tool !== "task")
187
+ continue;
188
+ const metadata = part.state?.metadata;
189
+ const child = metadata?.sessionId;
190
+ const variantMeta = metadata?.agentVariants;
191
+ const alias = variantMeta?.alias;
192
+ const routedAgent = variantMeta?.routedAgent;
193
+ if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
194
+ map.set(child, {
195
+ alias,
196
+ parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, ""),
197
+ });
198
+ }
199
+ }
200
+ }
201
+ if (list.length < ATTRIBUTION_PAGE_SIZE)
202
+ break;
203
+ const last = list[list.length - 1];
204
+ const next = last?.info?.id ?? last?.id;
205
+ if (!next || seenCursors.has(next))
206
+ break;
207
+ seenCursors.add(next);
208
+ before = next;
209
+ }
210
+ return map;
211
+ }
105
212
  /** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
106
213
  * are ignored (not part of this session's tree). Guarded against cycles. */
107
214
  export function buildSessionTree(sessions, rootID) {
@@ -200,13 +307,30 @@ export function isHiddenAgent(agent, hidden, configAgents) {
200
307
  return true;
201
308
  return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:");
202
309
  }
310
+ /** Badge decision for explorer rows: variant children and children of
311
+ * AV-managed parents never carry the hidden badge - a hidden AV parent is
312
+ * agent-variants' base-disable ("must use a variant"), not a background
313
+ * agent, and the decision must not depend on attribution windows. Only
314
+ * genuinely hidden non-AV agents (plugin background agents, hidden custom
315
+ * agents without variants) keep the badge. */
316
+ export function rowHidden(variant, agent, avParents, hidden, configAgents) {
317
+ if (variant)
318
+ return false;
319
+ if (agent && avParents.has(agent))
320
+ return false;
321
+ return isHiddenAgent(agent, hidden, configAgents);
322
+ }
203
323
  /** Deletes one session through whichever delete method the host client
204
324
  * exposes (`session.delete`, `session.remove`). Returns false when no
205
- * method exists or the call failed. */
325
+ * method exists or the call failed. An `{error}` envelope without `data`
326
+ * (the runtime's non-throwing failure shape) counts as a failure, not a
327
+ * success - the server's remove handler swallows internal errors and can
328
+ * still answer, so callers verify after batch deletes. */
206
329
  export async function deleteSession(client, sessionID) {
207
330
  const namespace = client?.session;
208
331
  if (!namespace)
209
332
  return false;
333
+ const isErrorEnvelope = (value) => !!value && typeof value === "object" && "error" in value && !("data" in value);
210
334
  const shapes = [];
211
335
  if (typeof namespace.delete === "function") {
212
336
  shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }));
@@ -216,8 +340,11 @@ export async function deleteSession(client, sessionID) {
216
340
  }
217
341
  for (const call of shapes) {
218
342
  const result = await withTimeout(call);
219
- if (result !== undefined)
220
- return true;
343
+ if (result === undefined)
344
+ continue;
345
+ if (isErrorEnvelope(result))
346
+ continue;
347
+ return true;
221
348
  }
222
349
  return false;
223
350
  }