pi-subagents-j0k3r 1.1.2 → 1.2.0

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/CHANGELOG.md CHANGED
@@ -2,9 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Added
6
+ - Added opt-in render diagnostics with bounded JSONL logging for subagent UI and completion rendering.
7
+ - Added structured, versioned subagent execution errors covering provider failures, context limits, timeouts, stalls, cancellations, fallback attempts, persistence, tool responses, and history UI.
8
+
5
9
  ### Fixed
6
10
  - Fixed manual task-mode background handoff so it frees the chat only when the user explicitly sends the running subagent to background.
7
11
  - Fixed background completion delivery so notifications arrive while the main agent continues working without triggering an extra follow-up turn.
12
+ - Increased the default task timeout to 20 minutes and inactivity timeout to 4 minutes.
13
+ - Preserved machine-readable failure details while retaining backward-compatible human-readable error messages.
14
+
15
+ ### Documentation
16
+ - Added rendering investigation and solution notes covering terminal synchronization, viewport stability, and renderer trade-offs.
8
17
 
9
18
  ## 1.1.0 - 2026-06-27
10
19
 
package/README.md CHANGED
@@ -139,8 +139,8 @@ Example:
139
139
  {
140
140
  "default_model": "anthropic/claude-sonnet-4-5",
141
141
  "default_effort": "medium",
142
- "timeout_ms": 600000,
143
- "stall_timeout_ms": 120000,
142
+ "timeout_ms": 1200000,
143
+ "stall_timeout_ms": 240000,
144
144
  "max_concurrency": 5,
145
145
  "session_resources": "lean",
146
146
  "history_panel_shortcut": "ctrl+,",
@@ -173,8 +173,8 @@ Example:
173
173
  | `default_model` | current orchestrator model | Fallback model for all subagents. Format: `provider/model-id`. |
174
174
  | `default_effort` | current orchestrator effort | Fallback thinking effort. Also accepts `default_thinking_level` or `thinkingLevel`. |
175
175
  | `model_profiles` | `{}` | Per-agent model/effort overrides scoped to matching definitions. Project-local profiles apply to project-local definitions; global profiles apply to global definitions. |
176
- | `timeout_ms` | `600000` | Total timeout per subagent task. |
177
- | `stall_timeout_ms` | `120000` | Inactivity timeout for a subagent session. |
176
+ | `timeout_ms` | `1200000` | Total timeout per subagent task (20 minutes). |
177
+ | `stall_timeout_ms` | `240000` | Inactivity timeout for a subagent session (4 minutes). |
178
178
  | `max_concurrency` | `5` | Max concurrent subagent tasks per cwd/config pair. |
179
179
  | `session_resources` | `lean` | SDK resource loading mode. `lean` uses the subagent markdown body as the nested session system prompt, skips skills, prompt templates, themes, and context files, and loads extensions in tools-only/safety-hook mode so allowlisted extension tools remain available without startup context injection. Use explicit `full` only when a subagent intentionally needs the full Pi resource set. Also accepts camelCase `sessionResources`. |
180
180
  | `history_panel_shortcut` | `ctrl+,` | OpenCode-mode shortcut used to open the subagents history/detail panel. Accepts `ctrl+<letter>` or `ctrl+,` and also accepts camelCase `historyPanelShortcut`. |
package/index.ts CHANGED
@@ -3,6 +3,8 @@ import { readSubagentsConfig, subagentSourceWarnings } from './src/config.js';
3
3
  import { registerSubagentTools, triggerClaudeBackgroundHandoff } from './src/tools.js';
4
4
  import { runSubagentModelsCommand } from './src/model-profiles-ui.js';
5
5
  import { SubagentsHistoryPanel } from './src/ui.js';
6
+ import { createSubagentsRenderLogger } from './src/render-debug.js';
7
+ import { safeErrorMetadataDetails } from './src/error-metadata.js';
6
8
  import type { SubagentTask } from './src/types.js';
7
9
 
8
10
  type ClaudeBackgroundWidgetEntry = {
@@ -119,6 +121,32 @@ function setMouseTracking(tui: any, enabled: boolean): void {
119
121
  write(enabled ? '\u001b[?1000h\u001b[?1006h' : '\u001b[?1006l\u001b[?1000l');
120
122
  }
121
123
 
124
+ function subagentsPanelMouseWheelDelta(data: string): -1 | 1 | undefined {
125
+ const sgr = data.match(/^\u001b\[<(\d+);\d+;\d+M$/);
126
+ const urxvt = data.match(/^\u001b\[(\d+);\d+;\d+M$/);
127
+ const button = sgr || urxvt ? Number((sgr ?? urxvt)![1]) : data.startsWith('\u001b[M') && data.length >= 6 ? data.charCodeAt(3) - 32 : undefined;
128
+ if (button === undefined || !Number.isFinite(button) || (button & 64) === 0) return undefined;
129
+ return (button & 1) === 0 ? -1 : 1;
130
+ }
131
+
132
+ function classifySubagentsPanelInput(data: string, matchesPanelKey: (data: string, key: string) => boolean): { category: string; action: string } {
133
+ if (matchesPanelKey(data, 'escape') || matchesPanelKey(data, 'ctrl+c') || matchesPanelKey(data, 'q')) return { category: 'lifecycle', action: 'close' };
134
+ if (matchesPanelKey(data, 'ctrl+o') || data === '\u000f') return { category: 'display', action: 'toggle_expand' };
135
+ if (matchesPanelKey(data, 'detailCancel')) return { category: 'task', action: 'cancel_selected' };
136
+ const wheel = subagentsPanelMouseWheelDelta(data);
137
+ if (wheel === -1) return { category: 'scroll', action: 'up' };
138
+ if (wheel === 1) return { category: 'scroll', action: 'down' };
139
+ if (matchesPanelKey(data, 'right')) return { category: 'navigation', action: 'right' };
140
+ if (matchesPanelKey(data, 'left')) return { category: 'navigation', action: 'left' };
141
+ if (matchesPanelKey(data, 'down')) return { category: 'navigation', action: 'down' };
142
+ if (matchesPanelKey(data, 'up')) return { category: 'navigation', action: 'up' };
143
+ if (matchesPanelKey(data, 'pageDown')) return { category: 'scroll', action: 'page_down' };
144
+ if (matchesPanelKey(data, 'pageUp')) return { category: 'scroll', action: 'page_up' };
145
+ if (matchesPanelKey(data, 'home')) return { category: 'scroll', action: 'home' };
146
+ if (matchesPanelKey(data, 'end')) return { category: 'scroll', action: 'end' };
147
+ return { category: 'other', action: 'unmatched' };
148
+ }
149
+
122
150
  function toolFromRegistry(registry: any, name: string): unknown {
123
151
  if (!registry) return undefined;
124
152
  if (typeof registry.get === 'function') return registry.get(name);
@@ -290,6 +318,11 @@ export function completionMessage(task: any): string {
290
318
  ].join('\n');
291
319
  }
292
320
 
321
+ function safeCompletionErrorMetadata(task: any): Record<string, unknown> | undefined {
322
+ if (!task?.error_metadata) return undefined;
323
+ return safeErrorMetadataDetails(task.error_metadata as any);
324
+ }
325
+
293
326
  export function sendSubagentCompletionMessage(pi: any, task: any): void {
294
327
  pi.sendMessage?.({
295
328
  customType: 'subagent-completion',
@@ -307,6 +340,7 @@ export function sendSubagentCompletionMessage(pi: any, task: any): void {
307
340
  usage: task.usage,
308
341
  result: task.result,
309
342
  error: task.error,
343
+ error_metadata: safeCompletionErrorMetadata(task),
310
344
  },
311
345
  },
312
346
  }, {
@@ -438,12 +472,16 @@ export default function subagentsExtension(pi: any): void {
438
472
  await ctx.ui.custom(
439
473
  (tui: any, theme: any, _keybindings: any, done: () => void) => {
440
474
  setMouseTracking(tui, true);
475
+ const config = readSubagentsConfig(cwd);
476
+ const renderLogger = createSubagentsRenderLogger({ cwd, sessionId, config: config.render_debug });
477
+ let nextRenderReason = 'initial';
478
+ let renderCycle = 0;
441
479
  const close = () => {
442
480
  if (refresh) clearInterval(refresh);
481
+ renderLogger.log({ event: 'panel_disposed' });
443
482
  setMouseTracking(tui, false);
444
483
  done();
445
484
  };
446
- const config = readSubagentsConfig(cwd);
447
485
  const baseMatchesKey = createSubagentsPanelKeyMatcher(_keybindings);
448
486
  const panel = new SubagentsHistoryPanel(
449
487
  () => manager.listSessionTasks(cwd, sessionId).slice(0, 100),
@@ -475,13 +513,59 @@ export default function subagentsExtension(pi: any): void {
475
513
  (id: string) => manager.cancel(id, 'cancelled from subagents detail view'),
476
514
  config.detail_cancel_shortcut ?? 'x',
477
515
  );
516
+ renderLogger.log({ event: 'panel_created' });
517
+ renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
478
518
  activePanelCancelSelected = () => panel.cancelSelectedActiveTask();
479
- activePanelRequestRender = () => tui.requestRender?.();
480
- refresh = setInterval(() => tui.requestRender?.(), 1000);
519
+ activePanelRequestRender = () => {
520
+ nextRenderReason = 'external';
521
+ renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
522
+ tui.requestRender?.();
523
+ };
524
+ refresh = setInterval(() => {
525
+ nextRenderReason = 'interval';
526
+ renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
527
+ tui.requestRender?.();
528
+ }, 1000);
481
529
  return {
482
- render: (width: number) => panel.render(width),
530
+ render: (width: number) => {
531
+ const reason = nextRenderReason;
532
+ const currentRenderCycle = ++renderCycle;
533
+ renderLogger.log({
534
+ event: 'render_started',
535
+ reason,
536
+ renderCycle: currentRenderCycle,
537
+ dimensions: {
538
+ stdoutColumns: process.stdout.columns,
539
+ stdoutRows: process.stdout.rows,
540
+ renderWidth: width,
541
+ },
542
+ });
543
+ const startedAt = process.hrtime.bigint();
544
+ const lines = panel.render(width);
545
+ const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
546
+ renderLogger.log({
547
+ event: 'render_completed',
548
+ reason,
549
+ renderCycle: currentRenderCycle,
550
+ durationMs,
551
+ dimensions: {
552
+ stdoutColumns: process.stdout.columns,
553
+ stdoutRows: process.stdout.rows,
554
+ renderWidth: width,
555
+ },
556
+ state: panel.getRenderDebugState(),
557
+ });
558
+ nextRenderReason = 'external';
559
+ return lines;
560
+ },
483
561
  invalidate: () => panel.invalidate(),
484
- handleInput: (data: string) => { panel.handleInput(data); tui.requestRender?.(); },
562
+ handleInput: (data: string) => {
563
+ renderLogger.log({ event: 'input_received', input: classifySubagentsPanelInput(data, baseMatchesKey) });
564
+ nextRenderReason = 'input';
565
+ renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
566
+ panel.handleInput(data);
567
+ tui.requestRender?.();
568
+ },
485
569
  };
486
570
  },
487
571
  undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-j0k3r",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Installable Pi package that adds markdown-defined subagents, delegated task tools, history, and model profiles.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -120,8 +120,8 @@ Recommended `subagents.json` starter:
120
120
  ```json
121
121
  {
122
122
  "mode": "opencode",
123
- "timeout_ms": 600000,
124
- "stall_timeout_ms": 120000,
123
+ "timeout_ms": 1200000,
124
+ "stall_timeout_ms": 240000,
125
125
  "max_concurrency": 5,
126
126
  "debug": false,
127
127
  "session_resources": "lean",
package/src/config.ts CHANGED
@@ -5,11 +5,12 @@ import type { ModelRef, SubagentDefinition, SubagentDefinitionScope, SubagentMod
5
5
 
6
6
  const DEFAULT_TOOLS = ['read', 'memory_context', 'memory_search', 'memory_recall', 'memory_get'];
7
7
  const DEFAULT_MAX_CONCURRENCY = 5;
8
- const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
9
- const DEFAULT_STALL_TIMEOUT_MS = 2 * 60 * 1000;
8
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
9
+ const DEFAULT_STALL_TIMEOUT_MS = 4 * 60 * 1000;
10
10
  const DEFAULT_BACKGROUND_HANDOFF_SHORTCUT = 'ctrl+h';
11
11
  const DEFAULT_HISTORY_PANEL_SHORTCUT = 'ctrl+,';
12
12
  const DEFAULT_DETAIL_CANCEL_SHORTCUT = 'x';
13
+ const DEFAULT_RENDER_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'pi-subagents-render.jsonl');
13
14
  const BLOCKED_SUBAGENT_TOOLS = new Set([
14
15
  'subagent_run',
15
16
  'subagent_list_agents',
@@ -141,6 +142,23 @@ function parseBoolean(value: any, fallback = false): boolean {
141
142
  return fallback;
142
143
  }
143
144
 
145
+ function parseRenderDebugPartial(value: unknown): { enabled?: boolean; path?: string } {
146
+ if (!isPlainObject(value)) return {};
147
+ const partial: { enabled?: boolean; path?: string } = {};
148
+ if (value.enabled === true) partial.enabled = true;
149
+ else if (value.enabled === false) partial.enabled = false;
150
+ if (typeof value.path === 'string' && value.path.trim()) partial.path = value.path.trim();
151
+ return partial;
152
+ }
153
+
154
+ function parseRenderDebugConfig(globalRaw: Record<string, unknown>, projectRaw: Record<string, unknown>): SubagentsConfig['render_debug'] {
155
+ const globalPartial = parseRenderDebugPartial(globalRaw.render_debug ?? globalRaw.renderDebug);
156
+ const projectPartial = parseRenderDebugPartial(projectRaw.render_debug ?? projectRaw.renderDebug);
157
+ const merged = { ...globalPartial, ...projectPartial };
158
+ if (merged.enabled !== true) return undefined;
159
+ return { enabled: true, path: merged.path ?? DEFAULT_RENDER_DEBUG_LOG_PATH };
160
+ }
161
+
144
162
  function parseModelProfile(value: unknown): SubagentModelProfile | undefined {
145
163
  if (!isPlainObject(value)) return undefined;
146
164
  const profile: SubagentModelProfile = {};
@@ -196,6 +214,7 @@ export function readSubagentsConfig(cwd: string): SubagentsConfig {
196
214
  history_panel_shortcut: parseCtrlShortcut(raw.history_panel_shortcut ?? raw.historyPanelShortcut, DEFAULT_HISTORY_PANEL_SHORTCUT),
197
215
  detail_cancel_shortcut: parseDetailShortcut(raw.detail_cancel_shortcut ?? raw.detailCancelShortcut),
198
216
  debug: parseBoolean(raw.debug, false),
217
+ render_debug: parseRenderDebugConfig(globalRaw, projectRaw),
199
218
  };
200
219
  }
201
220
 
@@ -0,0 +1,336 @@
1
+ import { sanitizeInteractionTransportText } from './interaction-channel.js';
2
+ import type { SubagentErrorAttemptRole, SubagentErrorCategory, SubagentErrorMetadata, SubagentErrorPhase, UsageStats } from './types.js';
3
+
4
+ const MESSAGE_LIMIT = 1024;
5
+ const CODE_LIMIT = 128;
6
+ const SOURCE_LIMIT = 256;
7
+ const LAST_ACTIVITY_LIMIT = 512;
8
+ const DETAILS_KEY_LIMIT = 64;
9
+ const DETAILS_VALUE_LIMIT = 512;
10
+ const DETAILS_LIMIT = 16;
11
+ const ATTEMPTS_LIMIT = 2;
12
+ const CAUSE_DEPTH_LIMIT = 2;
13
+
14
+ const RETRYABLE_DEFAULTS: Record<SubagentErrorCategory, boolean> = {
15
+ total_timeout: false,
16
+ stall_timeout: false,
17
+ cancelled: false,
18
+ empty_response_no_tools: false,
19
+ empty_response_after_tools: false,
20
+ context_overflow: false,
21
+ provider_api_error: true,
22
+ provider_auth_error: false,
23
+ provider_rate_limit: true,
24
+ provider_network_error: true,
25
+ tool_failure: false,
26
+ fallback_failed: false,
27
+ unknown_fallback: false,
28
+ malformed_thrown_value: false,
29
+ serialization_failure: false,
30
+ unknown: false,
31
+ };
32
+
33
+ const CATEGORY_SET = new Set<SubagentErrorCategory>(Object.keys(RETRYABLE_DEFAULTS) as SubagentErrorCategory[]);
34
+ const PHASE_SET = new Set<SubagentErrorPhase>(['runner_invoke', 'runner_session', 'assistant_final', 'tool_execution', 'manager', 'user', 'serializer']);
35
+ const ROLE_SET = new Set<SubagentErrorAttemptRole>(['primary', 'fallback']);
36
+
37
+ function limitCodePoints(value: string | undefined, limit: number): string | undefined {
38
+ if (value === undefined) return undefined;
39
+ const chars = Array.from(value);
40
+ return chars.length > limit ? `${chars.slice(0, Math.max(0, limit - 1)).join('')}…` : value;
41
+ }
42
+
43
+ function asciiSafe(value: string | undefined, limit: number): string | undefined {
44
+ if (!value) return undefined;
45
+ const bounded = limitCodePoints(value.replace(/[^\x20-\x7E]+/g, '_'), limit);
46
+ return bounded || undefined;
47
+ }
48
+
49
+ function redactText(value: string | undefined, limit: number): string | undefined {
50
+ if (!value) return undefined;
51
+ let text = sanitizeInteractionTransportText(String(value));
52
+ text = text
53
+ .replace(/authorization\s*:\s*bearer\s+[A-Za-z0-9._\-]+/gi, 'Authorization: [redacted]')
54
+ .replace(/bearer\s+[A-Za-z0-9._\-]+/gi, 'Bearer [redacted]')
55
+ .replace(/\b(sk|pk|rk)-[A-Za-z0-9._\-]+\b/g, '[redacted]')
56
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted]')
57
+ .replace(/(?:^|\s)(?:\/[A-Za-z0-9._-]+)+/g, (match) => match.replace(/\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*/g, '[redacted]'))
58
+ .replace(/\b(?:prompt|system prompt|user prompt|prompt text)\s*:[^|\n\r]*/gi, '[redacted]')
59
+ .replace(/\b(?:system|user|assistant)\s*:[^|\n\r]*/gi, '[redacted]')
60
+ .replace(/SECRET_FILE_BODY[\w-]*/g, '[redacted]')
61
+ .replace(/file contents?[^|\n\r]*/gi, '[redacted]')
62
+ .replace(/\s+/g, ' ')
63
+ .trim();
64
+ return limitCodePoints(text, limit);
65
+ }
66
+
67
+ function normalizeCategory(value: unknown): SubagentErrorCategory {
68
+ return typeof value === 'string' && CATEGORY_SET.has(value as SubagentErrorCategory)
69
+ ? value as SubagentErrorCategory
70
+ : 'unknown';
71
+ }
72
+
73
+ function normalizePhase(value: unknown): SubagentErrorPhase | undefined {
74
+ return typeof value === 'string' && PHASE_SET.has(value as SubagentErrorPhase)
75
+ ? value as SubagentErrorPhase
76
+ : undefined;
77
+ }
78
+
79
+ function normalizeRole(value: unknown): SubagentErrorAttemptRole | undefined {
80
+ return typeof value === 'string' && ROLE_SET.has(value as SubagentErrorAttemptRole)
81
+ ? value as SubagentErrorAttemptRole
82
+ : undefined;
83
+ }
84
+
85
+ function normalizeUsage(value: unknown): UsageStats | undefined {
86
+ if (!value || typeof value !== 'object') return undefined;
87
+ const usage = value as Partial<UsageStats>;
88
+ return {
89
+ input: Number(usage.input ?? 0),
90
+ output: Number(usage.output ?? 0),
91
+ cacheRead: Number(usage.cacheRead ?? 0),
92
+ cacheWrite: Number(usage.cacheWrite ?? 0),
93
+ cost: Number(usage.cost ?? 0),
94
+ contextTokens: Number(usage.contextTokens ?? 0),
95
+ turns: Number(usage.turns ?? 0),
96
+ };
97
+ }
98
+
99
+ function normalizeDetails(value: unknown): Record<string, string> | undefined {
100
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
101
+ const details: Record<string, string> = {};
102
+ for (const [rawKey, rawValue] of Object.entries(value).slice(0, DETAILS_LIMIT)) {
103
+ const key = asciiSafe(rawKey, DETAILS_KEY_LIMIT);
104
+ const text = redactText(typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue), DETAILS_VALUE_LIMIT);
105
+ if (!key || !text) continue;
106
+ details[key] = text;
107
+ }
108
+ return Object.keys(details).length ? details : undefined;
109
+ }
110
+
111
+ function safeMessage(category: SubagentErrorCategory, details?: Record<string, string>): string {
112
+ switch (category) {
113
+ case 'total_timeout':
114
+ return details?.timeout_ms ? `timed out after ${details.timeout_ms}ms` : 'timed out';
115
+ case 'stall_timeout':
116
+ return details?.stall_timeout_ms ? `Subagent stalled for ${details.stall_timeout_ms}ms without final response.` : 'Subagent stalled without final response.';
117
+ case 'cancelled':
118
+ return `Subagent cancelled: ${details?.cancel_reason ?? 'cancelled'}`;
119
+ case 'empty_response_no_tools':
120
+ return 'Subagent finished without a final response.';
121
+ case 'empty_response_after_tools':
122
+ return 'Subagent completed tool execution but did not produce a final response.';
123
+ case 'fallback_failed':
124
+ return 'Subagent fallback failed.';
125
+ case 'unknown_fallback':
126
+ return 'Subagent fallback unavailable after model failure.';
127
+ case 'serialization_failure':
128
+ return 'Subagent error metadata could not be serialized safely.';
129
+ default:
130
+ return category.replace(/_/g, ' ');
131
+ }
132
+ }
133
+
134
+ export function normalizeErrorMetadata(input: Partial<SubagentErrorMetadata> & { category: SubagentErrorCategory } | Partial<SubagentErrorMetadata>): SubagentErrorMetadata {
135
+ try {
136
+ return normalizeErrorMetadataInternal(input, 0);
137
+ } catch {
138
+ return {
139
+ version: 1,
140
+ category: 'serialization_failure',
141
+ message: 'Subagent error metadata could not be serialized safely.',
142
+ retryable: false,
143
+ phase: 'serializer',
144
+ partial_result_available: false,
145
+ };
146
+ }
147
+ }
148
+
149
+ function normalizeErrorMetadataInternal(input: Partial<SubagentErrorMetadata> | undefined, depth: number): SubagentErrorMetadata {
150
+ const category = normalizeCategory(input?.category);
151
+ const details = normalizeDetails(input?.details);
152
+ const message = redactText(typeof input?.message === 'string' ? input.message : safeMessage(category, details), MESSAGE_LIMIT)
153
+ ?? safeMessage(category, details);
154
+ const attempts = Array.isArray(input?.attempts)
155
+ ? input.attempts
156
+ .slice(0, ATTEMPTS_LIMIT)
157
+ .map((attempt, index) => {
158
+ const normalized = normalizeErrorMetadataInternal(attempt, depth + 1);
159
+ const role = normalizeRole(attempt?.role) ?? (index === 0 ? 'primary' : 'fallback');
160
+ return { ...normalized, role };
161
+ })
162
+ : undefined;
163
+ const cause = depth < CAUSE_DEPTH_LIMIT && input?.cause
164
+ ? normalizeErrorMetadataInternal(input.cause, depth + 1)
165
+ : undefined;
166
+ return {
167
+ version: 1,
168
+ category,
169
+ message,
170
+ retryable: typeof input?.retryable === 'boolean' ? input.retryable : RETRYABLE_DEFAULTS[category],
171
+ phase: normalizePhase(input?.phase),
172
+ code: asciiSafe(typeof input?.code === 'string' ? input.code : category, CODE_LIMIT),
173
+ role: normalizeRole(input?.role),
174
+ source: input?.source ? {
175
+ provider: redactText(input.source.provider, SOURCE_LIMIT),
176
+ model: redactText(input.source.model, SOURCE_LIMIT),
177
+ tool: redactText(input.source.tool, SOURCE_LIMIT),
178
+ operation: redactText(input.source.operation, SOURCE_LIMIT),
179
+ } : undefined,
180
+ cause,
181
+ attempts: attempts?.length ? attempts : undefined,
182
+ usage_at_failure: normalizeUsage(input?.usage_at_failure),
183
+ last_activity: redactText(input?.last_activity, LAST_ACTIVITY_LIMIT),
184
+ partial_result_available: Boolean(input?.partial_result_available),
185
+ task_id: redactText(input?.task_id, SOURCE_LIMIT),
186
+ parent_session_id: redactText(input?.parent_session_id, SOURCE_LIMIT),
187
+ details,
188
+ };
189
+ }
190
+
191
+ export function deriveErrorString(metadata: SubagentErrorMetadata): string {
192
+ const normalized = normalizeErrorMetadata(metadata);
193
+ return safeMessage(normalized.category, normalized.details) === 'unknown'
194
+ ? normalized.message
195
+ : safeMessage(normalized.category, normalized.details);
196
+ }
197
+
198
+ export function classifyThrownError(error: unknown, context: { phase?: SubagentErrorPhase; provider?: string; model?: string; operation?: string; retryable?: boolean } = {}): SubagentErrorMetadata {
199
+ const rawMessage = error instanceof Error
200
+ ? error.message
201
+ : typeof error === 'string'
202
+ ? error
203
+ : error && typeof error === 'object' && typeof (error as { message?: unknown }).message === 'string'
204
+ ? String((error as { message: unknown }).message)
205
+ : String(error);
206
+ const message = rawMessage || 'Unknown subagent failure';
207
+ const lower = message.toLowerCase();
208
+ const errorClass = error instanceof Error ? error.constructor.name : typeof error;
209
+ let category: SubagentErrorCategory = 'unknown';
210
+ if (!(error instanceof Error) && typeof error !== 'string') category = 'malformed_thrown_value';
211
+ else if (/auth|api key|invalid key|unauthori[sz]ed|forbidden|401|403|credential/.test(lower)) category = 'provider_auth_error';
212
+ else if (/context|token|maximum|length/.test(lower)) category = 'context_overflow';
213
+ else if (/rate.?limit|quota|429|too many requests/.test(lower)) category = 'provider_rate_limit';
214
+ else if (/econnreset|enotfound|network|socket|timeout|timed out|connection/.test(lower)) category = 'provider_network_error';
215
+ else if (error instanceof Error) category = 'provider_api_error';
216
+ return normalizeErrorMetadata({
217
+ category,
218
+ message,
219
+ retryable: context.retryable,
220
+ phase: context.phase,
221
+ source: {
222
+ provider: context.provider,
223
+ model: context.model,
224
+ operation: context.operation,
225
+ },
226
+ partial_result_available: false,
227
+ details: { error_class: errorClass },
228
+ });
229
+ }
230
+
231
+ export function classifyAssistantFailure(input: {
232
+ stopReason?: string;
233
+ errorMessage?: string;
234
+ sawToolActivity?: boolean;
235
+ provider?: string;
236
+ model?: string;
237
+ }): SubagentErrorMetadata | undefined {
238
+ if (input.stopReason === 'error' || input.errorMessage) {
239
+ return classifyThrownError(new Error(input.errorMessage ?? 'Assistant error'), {
240
+ phase: 'assistant_final',
241
+ provider: input.provider,
242
+ model: input.model,
243
+ operation: 'session.prompt',
244
+ });
245
+ }
246
+ if (input.sawToolActivity) {
247
+ return normalizeErrorMetadata({
248
+ category: 'empty_response_after_tools',
249
+ message: 'Subagent completed tool execution but did not produce a final response.',
250
+ phase: 'assistant_final',
251
+ partial_result_available: false,
252
+ });
253
+ }
254
+ return normalizeErrorMetadata({
255
+ category: 'empty_response_no_tools',
256
+ message: 'Subagent finished without a final response.',
257
+ phase: 'assistant_final',
258
+ partial_result_available: false,
259
+ });
260
+ }
261
+
262
+ export function classifyFallbackFailure(primary: SubagentErrorMetadata, fallback?: SubagentErrorMetadata): SubagentErrorMetadata {
263
+ return normalizeErrorMetadata({
264
+ category: fallback ? 'fallback_failed' : 'unknown_fallback',
265
+ message: fallback ? 'Subagent fallback failed.' : 'Subagent fallback unavailable after model failure.',
266
+ retryable: false,
267
+ partial_result_available: false,
268
+ attempts: fallback
269
+ ? [{ ...normalizeErrorMetadata(primary), role: 'primary' }, { ...normalizeErrorMetadata(fallback), role: 'fallback' }]
270
+ : [{ ...normalizeErrorMetadata(primary), role: 'primary' }],
271
+ });
272
+ }
273
+
274
+ export function enrichErrorMetadata(metadata: SubagentErrorMetadata, snapshot: {
275
+ usage_at_failure?: UsageStats;
276
+ last_activity?: string;
277
+ partial_result_available?: boolean;
278
+ task_id?: string;
279
+ parent_session_id?: string;
280
+ }): SubagentErrorMetadata {
281
+ return normalizeErrorMetadata({
282
+ ...metadata,
283
+ usage_at_failure: snapshot.usage_at_failure ?? metadata.usage_at_failure,
284
+ last_activity: snapshot.last_activity ?? metadata.last_activity,
285
+ partial_result_available: snapshot.partial_result_available ?? metadata.partial_result_available,
286
+ task_id: snapshot.task_id ?? metadata.task_id,
287
+ parent_session_id: snapshot.parent_session_id ?? metadata.parent_session_id,
288
+ });
289
+ }
290
+
291
+ export function serializeErrorMetadata(metadata: unknown): string | null {
292
+ try {
293
+ return JSON.stringify(normalizeErrorMetadata(metadata as Partial<SubagentErrorMetadata>));
294
+ } catch {
295
+ return JSON.stringify(normalizeErrorMetadata({
296
+ category: 'serialization_failure',
297
+ message: 'Subagent error metadata could not be serialized safely.',
298
+ phase: 'serializer',
299
+ partial_result_available: false,
300
+ }));
301
+ }
302
+ }
303
+
304
+ export function parseErrorMetadata(json: unknown): SubagentErrorMetadata | undefined {
305
+ try {
306
+ if (typeof json !== 'string' || !json.trim()) return undefined;
307
+ return normalizeErrorMetadata(JSON.parse(json) as Partial<SubagentErrorMetadata>);
308
+ } catch {
309
+ return undefined;
310
+ }
311
+ }
312
+
313
+ export function safeErrorMetadataDetails(metadata: SubagentErrorMetadata): Record<string, unknown> {
314
+ const normalized = normalizeErrorMetadata(metadata);
315
+ return {
316
+ version: normalized.version,
317
+ category: normalized.category,
318
+ retryable: normalized.retryable,
319
+ phase: normalized.phase,
320
+ code: normalized.code,
321
+ source: normalized.source,
322
+ partial_result_available: normalized.partial_result_available,
323
+ details: normalized.details,
324
+ };
325
+ }
326
+
327
+ export class SubagentStructuredError extends Error {
328
+ readonly error_metadata: SubagentErrorMetadata;
329
+
330
+ constructor(metadata: SubagentErrorMetadata) {
331
+ const normalized = normalizeErrorMetadata(metadata);
332
+ super(deriveErrorString(normalized));
333
+ this.name = 'SubagentStructuredError';
334
+ this.error_metadata = normalized;
335
+ }
336
+ }
package/src/history.ts CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
+ import { parseErrorMetadata, serializeErrorMetadata } from './error-metadata.js';
5
6
  import { boundThreadSnapshot } from './thread-view.js';
6
7
  import type { SubagentTask, SubagentThreadSnapshot } from './types.js';
7
8
 
@@ -97,6 +98,8 @@ export class SubagentHistoryStore {
97
98
  effort_source TEXT,
98
99
  fallback_used INTEGER,
99
100
  error TEXT,
101
+ error_metadata_json TEXT,
102
+ error_category TEXT,
100
103
  result TEXT,
101
104
  thread_snapshot_json TEXT
102
105
  );
@@ -114,18 +117,32 @@ export class SubagentHistoryStore {
114
117
  CREATE INDEX IF NOT EXISTS idx_subagent_events_task ON subagent_events(task_id, created_at);
115
118
  `);
116
119
  ensureColumn(db, 'subagent_tasks', 'system_prompt', 'TEXT');
120
+ ensureColumn(db, 'subagent_tasks', 'error_metadata_json', 'TEXT');
121
+ ensureColumn(db, 'subagent_tasks', 'error_category', 'TEXT');
117
122
  this.dbs.set(file, db);
118
123
  return db;
119
124
  }
120
125
 
121
126
  upsertTask(cwd: string, task: SubagentTask): void {
127
+ let errorMetadataJson: string | null = null;
128
+ let errorCategory: string | null = null;
129
+ if (task.error_metadata !== undefined) {
130
+ try {
131
+ errorMetadataJson = serializeErrorMetadata(task.error_metadata);
132
+ errorCategory = parseErrorMetadata(errorMetadataJson)?.category ?? null;
133
+ } catch {
134
+ errorMetadataJson = null;
135
+ errorCategory = null;
136
+ }
137
+ }
138
+
122
139
  this.db(cwd).prepare(`
123
140
  INSERT INTO subagent_tasks (
124
141
  id, cwd, agent, mode, status, task, context, created_at, session_id, started_at, ended_at,
125
142
  last_activity_at, last_activity, output_preview, prompt, system_prompt, transcript,
126
143
  usage_input, usage_output, usage_cache_read, usage_cache_write, usage_cost, usage_context_tokens, usage_turns,
127
- model, effort, model_source, effort_source, fallback_used, error, result, thread_snapshot_json
128
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
144
+ model, effort, model_source, effort_source, fallback_used, error, error_metadata_json, error_category, result, thread_snapshot_json
145
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
146
  ON CONFLICT(id) DO UPDATE SET
130
147
  status=excluded.status,
131
148
  session_id=excluded.session_id,
@@ -150,6 +167,8 @@ export class SubagentHistoryStore {
150
167
  effort_source=excluded.effort_source,
151
168
  fallback_used=excluded.fallback_used,
152
169
  error=excluded.error,
170
+ error_metadata_json=excluded.error_metadata_json,
171
+ error_category=excluded.error_category,
153
172
  result=excluded.result,
154
173
  thread_snapshot_json=excluded.thread_snapshot_json
155
174
  `).run(
@@ -183,6 +202,8 @@ export class SubagentHistoryStore {
183
202
  value(task.effort_source),
184
203
  task.fallback_used === undefined ? null : task.fallback_used ? 1 : 0,
185
204
  value(task.error),
205
+ errorMetadataJson,
206
+ errorCategory,
186
207
  value(task.result),
187
208
  snapshotJson(task.thread_snapshot),
188
209
  );
@@ -248,6 +269,7 @@ function rowToTask(row: any, options: HistoryReadOptions = {}): SubagentTask {
248
269
  effort_source: row.effort_source ?? undefined,
249
270
  fallback_used: row.fallback_used === null || row.fallback_used === undefined ? undefined : Boolean(row.fallback_used),
250
271
  error: row.error ?? undefined,
272
+ error_metadata: parseErrorMetadata(row.error_metadata_json),
251
273
  result: row.result ?? undefined,
252
274
  thread_snapshot: options.includeSnapshots === false ? undefined : parseSnapshotJson(row.thread_snapshot_json),
253
275
  };