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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,8 +24,6 @@ export type CleanupPlan = {
24
24
  export declare const BUILTIN_AGENTS: Set<string>;
25
25
  export declare function withTimeout<T>(fn: () => Promise<T>, ms?: number): Promise<T | undefined>;
26
26
  export declare function normalizeSession(raw: unknown): SessionLite | undefined;
27
- /** Lists sessions for a directory. Tries the v2 client's flat params first,
28
- * then the v1 `{query}` shape; unwraps both envelope styles. Fail-soft. */
29
27
  export declare function fetchSessions(client: unknown, directory: string): Promise<SessionLite[] | undefined>;
30
28
  /** Running session ids via `session.active()` with the `session.status()`
31
29
  * fallback (busy/retry = running). `undefined` when neither exists or both
@@ -50,8 +48,11 @@ export type AvSidecar = {
50
48
  };
51
49
  export declare function loadAvSidecar(globalConfigDir: string | undefined): AvSidecar;
52
50
  /** 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. */
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). */
55
56
  export type VariantAttribution = Map<string, {
56
57
  alias: string;
57
58
  parent: string;
@@ -79,9 +80,21 @@ export declare function configAgentIDs(config: unknown): Set<string>;
79
80
  * in the config, or the agent was registered at runtime (not config-known,
80
81
  * not built-in, not an agent-variants clone). */
81
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;
82
92
  /** Deletes one session through whichever delete method the host client
83
93
  * exposes (`session.delete`, `session.remove`). Returns false when no
84
- * 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. */
85
98
  export declare function deleteSession(client: unknown, sessionID: string): Promise<boolean>;
86
99
  export type SessionDetails = {
87
100
  messageCount: number;
package/dist/sessions.js CHANGED
@@ -55,17 +55,26 @@ export function normalizeSession(raw) {
55
55
  };
56
56
  }
57
57
  /** Lists sessions for a directory. Tries the v2 client's flat params first,
58
- * 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;
59
68
  export async function fetchSessions(client, directory) {
60
69
  const namespace = client?.session;
61
70
  if (!namespace?.list)
62
71
  return undefined;
63
72
  const calls = [
64
- () => namespace.list({ directory }),
65
- () => namespace.list({ query: { directory } }),
73
+ () => namespace.list({ directory, limit: SESSION_LIST_LIMIT }),
74
+ () => namespace.list({ query: { directory, limit: SESSION_LIST_LIMIT } }),
66
75
  ];
67
76
  for (const call of calls) {
68
- const result = unwrap(await withTimeout(call));
77
+ const result = unwrap(await withTimeout(call, 8000));
69
78
  if (Array.isArray(result))
70
79
  return result.map(normalizeSession).filter((s) => !!s);
71
80
  }
@@ -138,6 +147,8 @@ function stripJsoncComments(text) {
138
147
  .replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
139
148
  .replace(/,(\s*[}\]])/g, "$1");
140
149
  }
150
+ const ATTRIBUTION_PAGE_SIZE = 100;
151
+ const ATTRIBUTION_PAGES = 5;
141
152
  export async function fetchVariantAttribution(client, parentSessionID, directory, avParents) {
142
153
  const map = new Map();
143
154
  if (avParents.size === 0 || !parentSessionID)
@@ -145,34 +156,56 @@ export async function fetchVariantAttribution(client, parentSessionID, directory
145
156
  const namespace = client?.session;
146
157
  if (!namespace || typeof namespace.messages !== "function")
147
158
  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
- });
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
+ }
174
199
  }
175
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;
176
209
  }
177
210
  return map;
178
211
  }
@@ -274,13 +307,30 @@ export function isHiddenAgent(agent, hidden, configAgents) {
274
307
  return true;
275
308
  return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:");
276
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
+ }
277
323
  /** Deletes one session through whichever delete method the host client
278
324
  * exposes (`session.delete`, `session.remove`). Returns false when no
279
- * 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. */
280
329
  export async function deleteSession(client, sessionID) {
281
330
  const namespace = client?.session;
282
331
  if (!namespace)
283
332
  return false;
333
+ const isErrorEnvelope = (value) => !!value && typeof value === "object" && "error" in value && !("data" in value);
284
334
  const shapes = [];
285
335
  if (typeof namespace.delete === "function") {
286
336
  shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }));
@@ -290,8 +340,11 @@ export async function deleteSession(client, sessionID) {
290
340
  }
291
341
  for (const call of shapes) {
292
342
  const result = await withTimeout(call);
293
- if (result !== undefined)
294
- return true;
343
+ if (result === undefined)
344
+ continue;
345
+ if (isErrorEnvelope(result))
346
+ continue;
347
+ return true;
295
348
  }
296
349
  return false;
297
350
  }