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

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,62 @@ 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
+ // Two alias sources: the after-hook metadata stamp (terminal
192
+ // completed parts) and the before-hook description annotation -
193
+ // aborted/error parts never receive the metadata stamp, but the
194
+ // annotation ("(@alias variant)" appended at call time) persists.
195
+ const description = part.state?.input?.description;
196
+ const descriptionMatch = typeof description === "string" ? description.match(/\s+\(@(\S+) variant\)$/) : undefined;
197
+ const alias = (typeof variantMeta?.alias === "string" ? variantMeta.alias : undefined) ?? descriptionMatch?.[1];
198
+ const routedAgent = variantMeta?.routedAgent;
199
+ if (typeof child === "string" && typeof alias === "string" && !map.has(child)) {
200
+ map.set(child, {
201
+ alias,
202
+ parent: typeof routedAgent === "string" ? routedAgent : alias.replace(/-[a-z0-9-]+$/, ""),
203
+ });
204
+ }
174
205
  }
175
206
  }
207
+ if (list.length < ATTRIBUTION_PAGE_SIZE)
208
+ break;
209
+ const last = list[list.length - 1];
210
+ const next = last?.info?.id ?? last?.id;
211
+ if (!next || seenCursors.has(next))
212
+ break;
213
+ seenCursors.add(next);
214
+ before = next;
176
215
  }
177
216
  return map;
178
217
  }
@@ -274,13 +313,30 @@ export function isHiddenAgent(agent, hidden, configAgents) {
274
313
  return true;
275
314
  return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:");
276
315
  }
316
+ /** Badge decision for explorer rows: variant children and children of
317
+ * AV-managed parents never carry the hidden badge - a hidden AV parent is
318
+ * agent-variants' base-disable ("must use a variant"), not a background
319
+ * agent, and the decision must not depend on attribution windows. Only
320
+ * genuinely hidden non-AV agents (plugin background agents, hidden custom
321
+ * agents without variants) keep the badge. */
322
+ export function rowHidden(variant, agent, avParents, hidden, configAgents) {
323
+ if (variant)
324
+ return false;
325
+ if (agent && avParents.has(agent))
326
+ return false;
327
+ return isHiddenAgent(agent, hidden, configAgents);
328
+ }
277
329
  /** Deletes one session through whichever delete method the host client
278
330
  * exposes (`session.delete`, `session.remove`). Returns false when no
279
- * method exists or the call failed. */
331
+ * method exists or the call failed. An `{error}` envelope without `data`
332
+ * (the runtime's non-throwing failure shape) counts as a failure, not a
333
+ * success - the server's remove handler swallows internal errors and can
334
+ * still answer, so callers verify after batch deletes. */
280
335
  export async function deleteSession(client, sessionID) {
281
336
  const namespace = client?.session;
282
337
  if (!namespace)
283
338
  return false;
339
+ const isErrorEnvelope = (value) => !!value && typeof value === "object" && "error" in value && !("data" in value);
284
340
  const shapes = [];
285
341
  if (typeof namespace.delete === "function") {
286
342
  shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }));
@@ -290,8 +346,11 @@ export async function deleteSession(client, sessionID) {
290
346
  }
291
347
  for (const call of shapes) {
292
348
  const result = await withTimeout(call);
293
- if (result !== undefined)
294
- return true;
349
+ if (result === undefined)
350
+ continue;
351
+ if (isErrorEnvelope(result))
352
+ continue;
353
+ return true;
295
354
  }
296
355
  return false;
297
356
  }