sidekick-shared 0.28.3 → 0.29.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.
Files changed (151) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +188 -27
  3. package/dist/aggregation/EventAggregator.d.ts +19 -0
  4. package/dist/aggregation/EventAggregator.js +174 -35
  5. package/dist/aggregation/snapshot.d.ts +37 -8
  6. package/dist/aggregation/snapshot.js +31 -9
  7. package/dist/aggregation/types.d.ts +13 -0
  8. package/dist/authStatus.d.ts +54 -0
  9. package/dist/authStatus.js +91 -0
  10. package/dist/browser.d.ts +16 -6
  11. package/dist/browser.js +24 -2
  12. package/dist/codex.d.ts +23 -0
  13. package/dist/codex.js +44 -0
  14. package/dist/codexProfiles.d.ts +1 -0
  15. package/dist/codexProfiles.js +8 -11
  16. package/dist/context/sessionContext.js +4 -0
  17. package/dist/esm/aggregation/EventAggregator.js +174 -35
  18. package/dist/esm/aggregation/snapshot.js +31 -9
  19. package/dist/esm/authStatus.js +87 -0
  20. package/dist/esm/browser.js +5 -2
  21. package/dist/esm/codex.js +18 -0
  22. package/dist/esm/codexProfiles.js +8 -11
  23. package/dist/esm/context/sessionContext.js +4 -0
  24. package/dist/esm/formatters/toolSummary.js +4 -1
  25. package/dist/esm/formatting.js +10 -1
  26. package/dist/esm/index.js +11 -6
  27. package/dist/esm/modelCatalog.js +7 -5
  28. package/dist/esm/modelContext.js +4 -3
  29. package/dist/esm/modelInfo.js +425 -47
  30. package/dist/esm/node.js +9 -3
  31. package/dist/esm/observedContextWindows.js +2 -1
  32. package/dist/esm/ownKey.js +26 -0
  33. package/dist/esm/parsers/codexParser.js +351 -54
  34. package/dist/esm/parsers/codexRolloutOrigin.js +107 -0
  35. package/dist/esm/parsers/openCodeParser.js +85 -22
  36. package/dist/esm/parsers/subagentScanner.js +33 -12
  37. package/dist/esm/parsers/subagentTraceParser.js +97 -37
  38. package/dist/esm/pricing.js +18 -0
  39. package/dist/esm/pricingCatalog.js +17 -9
  40. package/dist/esm/promptHistory.js +66 -4
  41. package/dist/esm/providerFailure.js +124 -3
  42. package/dist/esm/providers/claudeCode.js +10 -2
  43. package/dist/esm/providers/codex.js +84 -62
  44. package/dist/esm/providers/codexDatabase.js +3 -19
  45. package/dist/esm/providers/codexRollouts.js +24 -1
  46. package/dist/esm/providers/openCode.js +9 -17
  47. package/dist/esm/report/htmlReportGenerator.js +42 -5
  48. package/dist/esm/report/transcriptParser.js +13 -11
  49. package/dist/esm/schemas/providerFailure.js +3 -1
  50. package/dist/esm/schemas/sessionEvent.js +35 -14
  51. package/dist/esm/schemas/transcript.js +4 -0
  52. package/dist/esm/sessionPromptHistory.js +2 -2
  53. package/dist/esm/statusline/payload.js +9 -0
  54. package/dist/esm/subagentSpawn.js +163 -0
  55. package/dist/esm/testing/credentialGuard.js +87 -5
  56. package/dist/esm/toolCallOrigins.js +310 -0
  57. package/dist/esm/transcript.js +46 -3
  58. package/dist/esm/turns/assistantTurn.js +3 -2
  59. package/dist/esm/usage/claudeUsageDedupe.js +68 -0
  60. package/dist/esm/usage/usageEvents.js +146 -17
  61. package/dist/esm/usage/usageReports.js +39 -6
  62. package/dist/esm/watchers/eventBridge.js +15 -0
  63. package/dist/esm/watchers/factory.js +5 -3
  64. package/dist/esm/watchers/jsonlTail.js +442 -58
  65. package/dist/esm/watchers/jsonlWatcher.js +51 -4
  66. package/dist/esm/watchers/providerReaderWatcher.js +13 -0
  67. package/dist/esm/zaiQuota.js +2 -1
  68. package/dist/formatters/toolSummary.js +4 -1
  69. package/dist/formatting.d.ts +26 -1
  70. package/dist/formatting.js +11 -1
  71. package/dist/index.d.ts +22 -11
  72. package/dist/index.js +44 -12
  73. package/dist/modelCatalog.js +7 -5
  74. package/dist/modelContext.js +4 -3
  75. package/dist/modelInfo.d.ts +185 -16
  76. package/dist/modelInfo.js +434 -49
  77. package/dist/node.d.ts +16 -5
  78. package/dist/node.js +33 -2
  79. package/dist/observedContextWindows.js +2 -1
  80. package/dist/ownKey.d.ts +16 -0
  81. package/dist/ownKey.js +30 -0
  82. package/dist/parsers/codexParser.d.ts +118 -0
  83. package/dist/parsers/codexParser.js +352 -54
  84. package/dist/parsers/codexRolloutOrigin.d.ts +49 -0
  85. package/dist/parsers/codexRolloutOrigin.js +111 -0
  86. package/dist/parsers/openCodeParser.d.ts +38 -0
  87. package/dist/parsers/openCodeParser.js +88 -22
  88. package/dist/parsers/subagentScanner.d.ts +19 -6
  89. package/dist/parsers/subagentScanner.js +33 -12
  90. package/dist/parsers/subagentTraceParser.d.ts +25 -5
  91. package/dist/parsers/subagentTraceParser.js +97 -37
  92. package/dist/pricing.d.ts +22 -0
  93. package/dist/pricing.js +50 -0
  94. package/dist/pricingCatalog.d.ts +10 -0
  95. package/dist/pricingCatalog.js +16 -8
  96. package/dist/promptHistory.d.ts +65 -4
  97. package/dist/promptHistory.js +68 -5
  98. package/dist/providerFailure.d.ts +44 -0
  99. package/dist/providerFailure.js +126 -4
  100. package/dist/providers/claudeCode.js +9 -1
  101. package/dist/providers/codex.d.ts +3 -22
  102. package/dist/providers/codex.js +85 -64
  103. package/dist/providers/codexDatabase.d.ts +3 -2
  104. package/dist/providers/codexDatabase.js +4 -22
  105. package/dist/providers/codexRollouts.d.ts +22 -0
  106. package/dist/providers/codexRollouts.js +24 -0
  107. package/dist/providers/openCode.js +8 -16
  108. package/dist/providers/types.d.ts +14 -0
  109. package/dist/report/htmlReportGenerator.js +42 -5
  110. package/dist/report/transcriptParser.d.ts +10 -1
  111. package/dist/report/transcriptParser.js +13 -11
  112. package/dist/schemas/index.d.ts +1 -0
  113. package/dist/schemas/providerFailure.d.ts +39 -0
  114. package/dist/schemas/providerFailure.js +2 -0
  115. package/dist/schemas/sessionEvent.d.ts +27 -10
  116. package/dist/schemas/sessionEvent.js +35 -14
  117. package/dist/schemas/transcript.d.ts +2 -0
  118. package/dist/schemas/transcript.js +4 -0
  119. package/dist/sessionPromptHistory.d.ts +7 -1
  120. package/dist/sessionPromptHistory.js +1 -1
  121. package/dist/statusline/payload.d.ts +10 -0
  122. package/dist/statusline/payload.js +14 -0
  123. package/dist/subagentSpawn.d.ts +80 -0
  124. package/dist/subagentSpawn.js +174 -0
  125. package/dist/testing/credentialGuard.d.ts +14 -0
  126. package/dist/testing/credentialGuard.js +87 -5
  127. package/dist/toolCallOrigins.d.ts +70 -0
  128. package/dist/toolCallOrigins.js +346 -0
  129. package/dist/transcript.d.ts +12 -0
  130. package/dist/transcript.js +46 -3
  131. package/dist/turns/assistantTurn.d.ts +1 -0
  132. package/dist/turns/assistantTurn.js +3 -2
  133. package/dist/types/opencode.d.ts +2 -0
  134. package/dist/usage/claudeUsageDedupe.d.ts +31 -0
  135. package/dist/usage/claudeUsageDedupe.js +68 -0
  136. package/dist/usage/usageEvents.d.ts +54 -6
  137. package/dist/usage/usageEvents.js +146 -17
  138. package/dist/usage/usageReports.d.ts +35 -4
  139. package/dist/usage/usageReports.js +39 -6
  140. package/dist/watchers/eventBridge.js +15 -0
  141. package/dist/watchers/factory.d.ts +6 -0
  142. package/dist/watchers/factory.js +5 -3
  143. package/dist/watchers/jsonlTail.d.ts +124 -4
  144. package/dist/watchers/jsonlTail.js +446 -59
  145. package/dist/watchers/jsonlWatcher.d.ts +18 -1
  146. package/dist/watchers/jsonlWatcher.js +50 -3
  147. package/dist/watchers/providerReaderWatcher.d.ts +7 -0
  148. package/dist/watchers/providerReaderWatcher.js +13 -0
  149. package/dist/watchers/types.d.ts +12 -0
  150. package/dist/zaiQuota.js +2 -1
  151. package/package.json +41 -1
@@ -596,14 +596,51 @@ function renderContentBlock(block, includeThinking, includeToolDetail) {
596
596
  return '';
597
597
  }
598
598
  }
599
- function renderToolUse(block, includeDetail) {
599
+ /** Prefix of the bookkeeping keys session parsers stash inside tool inputs. */
600
+ const SIDEKICK_INPUT_KEY_PREFIX = '_sidekick';
601
+ /**
602
+ * The tool's display identity and user-facing input. Entries built outside
603
+ * the canonical projection may still carry the parsers' `_sidekick*` keys in
604
+ * `toolInput`; those are hidden here and read as the raw identity.
605
+ */
606
+ function toolDisplay(block) {
600
607
  const name = block.toolName || 'unknown';
608
+ const raw = block.toolInput;
609
+ let input = raw;
610
+ let rawToolName = block.rawToolName;
611
+ let mcpServerName = block.mcpServerName;
612
+ if (raw && Object.keys(raw).some((key) => key.startsWith(SIDEKICK_INPUT_KEY_PREFIX))) {
613
+ const clean = {};
614
+ for (const [key, value] of Object.entries(raw)) {
615
+ if (!key.startsWith(SIDEKICK_INPUT_KEY_PREFIX))
616
+ clean[key] = value;
617
+ }
618
+ input = clean;
619
+ if (!rawToolName && typeof raw._sidekickRawToolName === 'string') {
620
+ rawToolName = raw._sidekickRawToolName;
621
+ }
622
+ if (!mcpServerName && typeof raw._sidekickMcpServerName === 'string') {
623
+ mcpServerName = raw._sidekickMcpServerName;
624
+ }
625
+ }
626
+ const parts = [];
627
+ if (rawToolName && rawToolName.trim() && rawToolName !== name)
628
+ parts.push(rawToolName);
629
+ if (mcpServerName && mcpServerName.trim())
630
+ parts.push(`MCP: ${mcpServerName}`);
631
+ const hint = parts.length
632
+ ? ` <span class="tool-raw-name" style="color:var(--text-muted);font-size:11px">${escapeHtml(parts.join(' · '))}</span>`
633
+ : '';
634
+ return { name, hint, input };
635
+ }
636
+ function renderToolUse(block, includeDetail) {
637
+ const { name, hint, input } = toolDisplay(block);
601
638
  if (!includeDetail) {
602
- return `<div class="tool-block" data-block-type="tool"><div style="padding:8px 12px;background:rgba(245,166,35,0.08);color:var(--accent-orange);font-family:monospace;font-size:13px;">${escapeHtml(name)}</div></div>`;
639
+ return `<div class="tool-block" data-block-type="tool"><div style="padding:8px 12px;background:rgba(245,166,35,0.08);color:var(--accent-orange);font-family:monospace;font-size:13px;">${escapeHtml(name)}${hint}</div></div>`;
603
640
  }
604
641
  let inputHtml = '';
605
- if (block.toolInput && Object.keys(block.toolInput).length > 0) {
606
- const inputJson = JSON.stringify(block.toolInput, null, 2);
642
+ if (input && Object.keys(input).length > 0) {
643
+ const inputJson = JSON.stringify(input, null, 2);
607
644
  const highlighted = highlightCodeBlock(inputJson, 'json');
608
645
  inputHtml = `<div class="tool-io">
609
646
  <div class="tool-label">Input</div>
@@ -611,7 +648,7 @@ function renderToolUse(block, includeDetail) {
611
648
  </div>`;
612
649
  }
613
650
  return `<details class="tool-block" data-block-type="tool">
614
- <summary>${escapeHtml(name)}${block.toolUseId ? ` <span style="color:var(--text-muted);font-size:11px">${escapeHtml(block.toolUseId)}</span>` : ''}</summary>
651
+ <summary>${escapeHtml(name)}${hint}${block.toolUseId ? ` <span style="color:var(--text-muted);font-size:11px">${escapeHtml(block.toolUseId)}</span>` : ''}</summary>
615
652
  <div class="tool-content">
616
653
  ${inputHtml}
617
654
  </div>
@@ -42,17 +42,19 @@ function toReportBlock(block) {
42
42
  return block.text ? [{ type: 'text', text: block.text }] : [];
43
43
  case 'thinking':
44
44
  return block.text ? [{ type: 'thinking', text: block.text }] : [];
45
- case 'tool_use':
46
- return [
47
- {
48
- type: 'tool_use',
49
- toolName: block.toolName ?? 'unknown',
50
- toolInput: block.input && typeof block.input === 'object'
51
- ? block.input
52
- : {},
53
- toolUseId: block.toolUseId,
54
- },
55
- ];
45
+ case 'tool_use': {
46
+ const toolBlock = {
47
+ type: 'tool_use',
48
+ toolName: block.toolName ?? 'unknown',
49
+ toolInput: block.input && typeof block.input === 'object'
50
+ ? block.input
51
+ : {},
52
+ toolUseId: block.toolUseId,
53
+ ...(block.rawToolName ? { rawToolName: block.rawToolName } : {}),
54
+ ...(block.mcpServerName ? { mcpServerName: block.mcpServerName } : {}),
55
+ };
56
+ return [toolBlock];
57
+ }
56
58
  case 'tool_result':
57
59
  return [
58
60
  {
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { z } from 'zod';
11
11
  import { ACCOUNT_PROVIDER_IDS } from '../providerIds';
12
- import { PROVIDER_AUTHENTICATION_SOURCES, PROVIDER_AUTHENTICATION_STATES, PROVIDER_CREDENTIAL_KINDS, PROVIDER_FAILURE_CODES, PROVIDER_FAILURE_EVIDENCE_SOURCES, PROVIDER_RECOVERY_ACTIONS, } from '../providerFailure';
12
+ import { CODEX_ERROR_INFO_KINDS, PROVIDER_AUTHENTICATION_SOURCES, PROVIDER_AUTHENTICATION_STATES, PROVIDER_CREDENTIAL_KINDS, PROVIDER_FAILURE_CODES, PROVIDER_FAILURE_EVIDENCE_SOURCES, PROVIDER_RECOVERY_ACTIONS, } from '../providerFailure';
13
13
  export const providerAuthenticationEvidenceSchema = z.object({
14
14
  credentialKind: z.enum(PROVIDER_CREDENTIAL_KINDS),
15
15
  state: z.enum(PROVIDER_AUTHENTICATION_STATES),
@@ -20,6 +20,7 @@ export const providerFailureEvidenceSchema = z.object({
20
20
  source: z.enum(PROVIDER_FAILURE_EVIDENCE_SOURCES),
21
21
  rule: z.enum(PROVIDER_FAILURE_CODES),
22
22
  checkedAt: z.string().optional(),
23
+ codexErrorInfo: z.enum(CODEX_ERROR_INFO_KINDS).optional(),
23
24
  });
24
25
  export const providerRetryAfterSchema = z.discriminatedUnion('kind', [
25
26
  z.object({ kind: z.literal('delay'), delayMs: z.number() }),
@@ -30,6 +31,7 @@ export const providerFailureDiagnosisSchema = z.object({
30
31
  credentialKind: z.enum(PROVIDER_CREDENTIAL_KINDS),
31
32
  diagnosis: z.enum(PROVIDER_FAILURE_CODES),
32
33
  recovery: z.enum(PROVIDER_RECOVERY_ACTIONS),
34
+ retryable: z.boolean().optional(),
33
35
  evidence: z.array(providerFailureEvidenceSchema),
34
36
  authentication: providerAuthenticationEvidenceSchema.optional(),
35
37
  httpStatus: z.number().optional(),
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { z } from 'zod';
10
10
  import { normalizedUsageSchema } from './usageNormalization';
11
+ import { taskNotificationPromptFromAttachment } from '../subagentSpawn';
11
12
  // ── MessageUsage ──
12
13
  export const messageUsageSchema = z.object({
13
14
  input_tokens: z.number(),
@@ -52,7 +53,9 @@ const eventBaseShape = {
52
53
  promptSource: z.string().optional(),
53
54
  cwd: z.string().optional(),
54
55
  gitBranch: z.string().optional(),
55
- permissionMode: permissionModeSchema.optional(),
56
+ // A mode this release doesn't know (Claude Code added `auto`) is dropped
57
+ // rather than rejecting the whole line with its prompt or tool results.
58
+ permissionMode: permissionModeSchema.optional().catch(undefined),
56
59
  rateLimits: z
57
60
  .object({
58
61
  primary: z
@@ -169,22 +172,21 @@ export const sessionEventSchema = z.union([
169
172
  // ── Progress unwrapping ──
170
173
  /** Recursion bound for nested progress envelopes; real data nests 1 level. */
171
174
  const MAX_PROGRESS_DEPTH = 8;
172
- /**
173
- * Extracts canonical SessionEvents from a raw JSONL value.
174
- *
175
- * Claude Code wraps subagent/SDK events as
176
- * `{ type: 'progress', data: { message: <SessionEvent> } }`, which
177
- * `sessionEventSchema` alone rejects. This helper tries a direct parse,
178
- * then unwraps progress envelopes (recursively, in case of nesting).
179
- * Returns zero events for unrecognized input — never throws.
180
- */
181
- export function extractSessionEvents(raw, depth = 0) {
182
- return extractSessionEventsWithProvenance(raw, depth, {});
175
+ export function extractSessionEvents(raw, depthOrOptions = 0) {
176
+ if (typeof depthOrOptions === 'number') {
177
+ return extractSessionEventsWithProvenance(raw, depthOrOptions, {}, {});
178
+ }
179
+ return extractSessionEventsWithProvenance(raw, 0, {}, depthOrOptions ?? {});
183
180
  }
184
- function extractSessionEventsWithProvenance(raw, depth, inherited) {
181
+ function extractSessionEventsWithProvenance(raw, depth, inherited, options) {
185
182
  const direct = sessionEventSchema.safeParse(raw);
186
183
  if (direct.success)
187
184
  return [{ ...inherited, ...direct.data }];
185
+ if (options.includeTaskNotifications) {
186
+ const notification = taskNotificationAttachmentEvent(raw);
187
+ if (notification)
188
+ return [{ ...inherited, ...notification }];
189
+ }
188
190
  if (depth >= MAX_PROGRESS_DEPTH)
189
191
  return [];
190
192
  if (typeof raw === 'object' && raw !== null && raw.type === 'progress') {
@@ -193,12 +195,31 @@ function extractSessionEventsWithProvenance(raw, depth, inherited) {
193
195
  if (typeof data === 'object' && data !== null) {
194
196
  const message = data.message;
195
197
  if (typeof message === 'object' && message !== null) {
196
- return extractSessionEventsWithProvenance(message, depth + 1, provenance);
198
+ return extractSessionEventsWithProvenance(message, depth + 1, provenance, options);
197
199
  }
198
200
  }
199
201
  }
200
202
  return [];
201
203
  }
204
+ /** The `system` event for a task-notification attachment line, or `null`. */
205
+ function taskNotificationAttachmentEvent(raw) {
206
+ const prompt = taskNotificationPromptFromAttachment(raw);
207
+ if (prompt === null)
208
+ return null;
209
+ const line = raw;
210
+ const candidate = {
211
+ type: 'system',
212
+ timestamp: line.timestamp,
213
+ origin: { kind: 'task-notification' },
214
+ message: { role: 'user', sourceLabel: 'task notification', content: prompt },
215
+ };
216
+ for (const key of ['entrypoint', 'isSidechain', 'cwd', 'gitBranch']) {
217
+ if (line[key] !== undefined)
218
+ candidate[key] = line[key];
219
+ }
220
+ const parsed = systemSessionEventSchema.safeParse(candidate);
221
+ return parsed.success ? parsed.data : null;
222
+ }
202
223
  function progressProvenance(raw) {
203
224
  const value = raw;
204
225
  const provenance = {};
@@ -17,6 +17,8 @@ export const canonicalTranscriptBlockSchema = z.object({
17
17
  type: z.enum(['text', 'thinking', 'tool_use', 'tool_result', 'image', 'unknown']),
18
18
  text: z.string().optional(),
19
19
  toolName: z.string().optional(),
20
+ rawToolName: z.string().optional(),
21
+ mcpServerName: z.string().optional(),
20
22
  toolUseId: z.string().optional(),
21
23
  input: z.unknown().optional(),
22
24
  output: z.unknown().optional(),
@@ -28,6 +30,8 @@ export const canonicalTranscriptBlockSchema = z.object({
28
30
  export const canonicalToolCallSchema = z.object({
29
31
  id: z.string().optional(),
30
32
  name: z.string(),
33
+ rawToolName: z.string().optional(),
34
+ mcpServerName: z.string().optional(),
31
35
  timestamp: z.string(),
32
36
  input: z.unknown().optional(),
33
37
  output: z.unknown().optional(),
@@ -14,13 +14,13 @@
14
14
  * and replies or signals that belong to an out-of-scope prompt are dropped.
15
15
  */
16
16
  import * as fs from 'fs';
17
- import { DEFAULT_BOUNDS, compareStrings, emptyStats, listCandidates, resolveRoots, scanFile, } from './promptHistory';
17
+ import { DEFAULT_BOUNDS, PROMPT_HISTORY_ORDINAL_MAX_RECORD_BYTES, compareStrings, emptyStats, listCandidates, resolveRoots, scanFile, } from './promptHistory';
18
18
  import { createSessionRecordExtractor, } from './sessionPromptSignals';
19
19
  const DEFAULT_SESSION_BOUNDS = {
20
20
  maxSessions: 1000,
21
21
  maxSessionBytes: 256 * 1024 * 1024,
22
22
  maxTotalBytes: 256 * 1024 * 1024,
23
- maxRecordBytes: DEFAULT_BOUNDS.maxRecordBytes,
23
+ maxRecordBytes: PROMPT_HISTORY_ORDINAL_MAX_RECORD_BYTES,
24
24
  deadlineMs: 60_000,
25
25
  };
26
26
  const MAX_EXCLUSIONS = 1000;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `sidekick-shared/statusline/payload` — the pure Claude Code status-line
3
+ * stdin parser on its own, for hosts with a tight bundle budget that render
4
+ * their own line. Loads no account, credential, quota-snapshot, or network
5
+ * code (the full `sidekick-shared/statusline` entry does).
6
+ *
7
+ * @module statusline/payload
8
+ */
9
+ export { parseClaudeStatuslinePayload, quotaFromStatuslinePayload, } from './claudeStatuslinePayload';
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Recognizes Claude Code subagent spawns and background-task notifications.
3
+ *
4
+ * Claude Code's subagent tool is `Agent`; `Task` is its earlier name (and the
5
+ * name OpenCode's `task` tool is normalized to). A background agent's spawning
6
+ * tool call gets its result right away ("Async agent launched successfully.")
7
+ * and its completion arrives later as a `<task-notification>` block, delivered
8
+ * either as a user line or as a mid-turn `queued_command` attachment.
9
+ *
10
+ * Pure: no filesystem access, so it is safe for browser bundles.
11
+ *
12
+ * @module subagentSpawn
13
+ */
14
+ /** Tool names that spawn a subagent: `Agent` (current) and `Task` (earlier name). */
15
+ export const SUBAGENT_SPAWN_TOOL_NAMES = Object.freeze(['Agent', 'Task']);
16
+ const SPAWN_TOOLS = new Set(SUBAGENT_SPAWN_TOOL_NAMES);
17
+ /** Whether a tool name spawns a subagent (`Agent` or `Task`). */
18
+ export function isSubagentSpawnTool(name) {
19
+ return typeof name === 'string' && SPAWN_TOOLS.has(name);
20
+ }
21
+ const ASYNC_LAUNCH_PREFIX = /^\s*Async agent launched successfully\b/;
22
+ const AGENT_ID_LINE = /(?:^|\n)[ \t]*agentId:[ \t]*([A-Za-z0-9][\w.@-]*)/;
23
+ /**
24
+ * Plain text of tool-result or message content: a string as-is, or the text
25
+ * blocks of a content array joined by newlines.
26
+ */
27
+ export function contentText(content) {
28
+ if (typeof content === 'string')
29
+ return content;
30
+ if (!Array.isArray(content))
31
+ return '';
32
+ const parts = [];
33
+ for (const block of content) {
34
+ if (!block || typeof block !== 'object')
35
+ continue;
36
+ const typed = block;
37
+ if (typed.type === 'text' && typeof typed.text === 'string')
38
+ parts.push(typed.text);
39
+ }
40
+ return parts.join('\n');
41
+ }
42
+ /**
43
+ * Recognizes the immediate result of a background spawn
44
+ * (`Async agent launched successfully.` … `agentId: <id> …`). Accepts the
45
+ * result text or the raw tool-result content. Returns `null` for anything
46
+ * else, including a finished foreground agent's result.
47
+ */
48
+ export function parseAsyncAgentLaunch(result) {
49
+ const text = contentText(result);
50
+ if (!ASYNC_LAUNCH_PREFIX.test(text))
51
+ return null;
52
+ const match = AGENT_ID_LINE.exec(text);
53
+ return match ? { agentId: match[1] } : {};
54
+ }
55
+ const NOTIFICATION_OPEN = '<task-notification>';
56
+ const NOTIFICATION_CLOSE = '</task-notification>';
57
+ const NOTIFICATION_PREFIX = /^\s*<task-notification>/;
58
+ const TAG_PATTERNS = {
59
+ taskId: /<task-id>\s*([\s\S]*?)\s*<\/task-id>/,
60
+ toolUseId: /<tool-use-id>\s*([\s\S]*?)\s*<\/tool-use-id>/,
61
+ status: /<status>\s*([\s\S]*?)\s*<\/status>/,
62
+ summary: /<summary>\s*([\s\S]*?)\s*<\/summary>/,
63
+ note: /<note>\s*([\s\S]*?)\s*<\/note>/,
64
+ };
65
+ const INTERIM_TEXT = /waiting on its own background work|background work of its own still running/i;
66
+ function tagValue(body, pattern) {
67
+ const value = pattern.exec(body)?.[1]?.trim();
68
+ return value ? value : undefined;
69
+ }
70
+ /**
71
+ * Parses the first `<task-notification>` block in `text`. Whitespace and
72
+ * newlines between tags, missing fields, and unknown extra fields are
73
+ * tolerated; the block needs a `<task-id>`. Fields are read before the
74
+ * agent's own `<result>` output, which may quote anything.
75
+ */
76
+ export function parseTaskNotification(text) {
77
+ if (typeof text !== 'string')
78
+ return null;
79
+ const start = text.indexOf(NOTIFICATION_OPEN);
80
+ if (start < 0)
81
+ return null;
82
+ let body = text.slice(start + NOTIFICATION_OPEN.length);
83
+ const resultAt = body.indexOf('<result>');
84
+ let header = resultAt >= 0 ? body.slice(0, resultAt) : body;
85
+ if (resultAt < 0) {
86
+ const end = body.indexOf(NOTIFICATION_CLOSE);
87
+ if (end >= 0)
88
+ header = body = body.slice(0, end);
89
+ }
90
+ if (!TAG_PATTERNS.taskId.test(header))
91
+ header = body;
92
+ const taskId = tagValue(header, TAG_PATTERNS.taskId);
93
+ if (!taskId)
94
+ return null;
95
+ const toolUseId = tagValue(header, TAG_PATTERNS.toolUseId);
96
+ const status = tagValue(header, TAG_PATTERNS.status);
97
+ const summary = tagValue(header, TAG_PATTERNS.summary);
98
+ const note = tagValue(header, TAG_PATTERNS.note);
99
+ return {
100
+ taskId,
101
+ ...(toolUseId ? { toolUseId } : {}),
102
+ ...(status ? { status } : {}),
103
+ ...(summary ? { summary } : {}),
104
+ interim: INTERIM_TEXT.test(summary ?? '') || INTERIM_TEXT.test(note ?? ''),
105
+ };
106
+ }
107
+ /**
108
+ * The `<task-notification>` prompt of a raw Claude Code attachment line
109
+ * (`{ type: 'attachment', attachment: { type: 'queued_command', commandMode:
110
+ * 'task-notification', prompt } }`), which is how a notification that lands
111
+ * mid-turn is recorded. `null` for any other value.
112
+ */
113
+ export function taskNotificationPromptFromAttachment(raw) {
114
+ if (!raw || typeof raw !== 'object')
115
+ return null;
116
+ const line = raw;
117
+ if (line.type !== 'attachment' || !line.attachment || typeof line.attachment !== 'object') {
118
+ return null;
119
+ }
120
+ const attachment = line.attachment;
121
+ return attachment.type === 'queued_command' &&
122
+ attachment.commandMode === 'task-notification' &&
123
+ typeof attachment.prompt === 'string'
124
+ ? attachment.prompt
125
+ : null;
126
+ }
127
+ /**
128
+ * The synthetic `system` event `extractSessionEvents(raw, {
129
+ * includeTaskNotifications: true })` makes from a mid-turn task-notification
130
+ * attachment. It is bookkeeping, not a message or a prompt.
131
+ */
132
+ export function isTaskNotificationSystemEvent(event) {
133
+ return event.type === 'system' && event.origin?.kind === 'task-notification';
134
+ }
135
+ /**
136
+ * The task notification an event carries: a user line with
137
+ * `origin.kind === 'task-notification'` or whose text starts with
138
+ * `<task-notification>`, or the opt-in synthetic system event. `null` otherwise.
139
+ */
140
+ export function taskNotificationFromEvent(event) {
141
+ if (event.type !== 'user' && event.type !== 'system')
142
+ return null;
143
+ const flagged = event.origin?.kind === 'task-notification';
144
+ if (event.type === 'system' && !flagged)
145
+ return null;
146
+ const text = contentText(event.message?.content);
147
+ if (!flagged && !NOTIFICATION_PREFIX.test(text))
148
+ return null;
149
+ return parseTaskNotification(text);
150
+ }
151
+ /** Maps a notification `<status>` to an outcome; unknown or missing statuses read as `completed`. */
152
+ export function taskNotificationOutcome(status) {
153
+ switch (status?.toLowerCase()) {
154
+ case 'failed':
155
+ return 'failed';
156
+ case 'killed':
157
+ return 'killed';
158
+ case 'stopped':
159
+ return 'stopped';
160
+ default:
161
+ return 'completed';
162
+ }
163
+ }
@@ -10,7 +10,8 @@
10
10
  * - `HOME` (and the Windows equivalents) point at a per-process temp dir,
11
11
  * removed on exit, and the home overrides (`CLAUDE_CONFIG_DIR`,
12
12
  * `CLAUDE_SECURESTORAGE_CONFIG_DIR`, `CODEX_HOME`, `SIDEKICK_CONFIG_DIR`)
13
- * are unset, so every `os.homedir()`-derived path is isolated structurally;
13
+ * are unset (unless named in `preserveEnv` and pointed at a fixture), so
14
+ * every `os.homedir()`-derived path is isolated structurally;
14
15
  * - the real `child_process` module object is patched, which reaches both ESM
15
16
  * named imports (after `syncBuiltinESMExports`) and CommonJS code loaded
16
17
  * through native `require`; per-file `vi.mock('child_process')` factories
@@ -28,6 +29,13 @@ import * as os from 'node:os';
28
29
  import * as path from 'node:path';
29
30
  const GUARD_KEY = '__sidekickCredentialGuard';
30
31
  const DEFAULT_BLOCKED_BINARIES = ['security', 'claude', 'codex'];
32
+ /** Home overrides the guard unsets; only these can be preserved. */
33
+ const SCRUBBED_ENV = [
34
+ 'CLAUDE_CONFIG_DIR',
35
+ 'CLAUDE_SECURESTORAGE_CONFIG_DIR',
36
+ 'CODEX_HOME',
37
+ 'SIDEKICK_CONFIG_DIR',
38
+ ];
31
39
  const CHILD_PROCESS_FUNCTIONS = [
32
40
  'spawn',
33
41
  'spawnSync',
@@ -75,6 +83,8 @@ export function installCredentialGuard(options = {}) {
75
83
  // os.userInfo() reads the passwd entry, so it still names the real home
76
84
  // after HOME is redirected below.
77
85
  const realHome = os.userInfo().homedir;
86
+ // Validated before any side effect, so a refused install changes nothing.
87
+ const preserved = checkPreservedEnv(options, realHome, realFs);
78
88
  const testHome = realFs.mkdtempSync(path.join(os.tmpdir(), 'sidekick-test-home-'));
79
89
  const removeTestHome = realFs.rmSync.bind(realFs);
80
90
  process.once('exit', () => {
@@ -103,10 +113,9 @@ export function installCredentialGuard(options = {}) {
103
113
  process.env.USERPROFILE = testHome;
104
114
  process.env.APPDATA = path.join(testHome, 'AppData', 'Roaming');
105
115
  process.env.LOCALAPPDATA = path.join(testHome, 'AppData', 'Local');
106
- delete process.env.CLAUDE_CONFIG_DIR;
107
- delete process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
108
- delete process.env.CODEX_HOME;
109
- delete process.env.SIDEKICK_CONFIG_DIR;
116
+ for (const name of SCRUBBED_ENV)
117
+ if (!preserved.has(name))
118
+ delete process.env[name];
110
119
  const protectedRoots = path.resolve(realHome) === path.resolve(testHome)
111
120
  ? []
112
121
  : [
@@ -208,6 +217,79 @@ export function installCredentialGuard(options = {}) {
208
217
  globalThis[GUARD_KEY] = state;
209
218
  return state;
210
219
  }
220
+ /** The `preserveEnv` names, after refusing unknown names and unsafe values. */
221
+ function checkPreservedEnv(options, realHome, realFs) {
222
+ const preserved = new Set();
223
+ for (const name of options.preserveEnv ?? []) {
224
+ if (!SCRUBBED_ENV.includes(name)) {
225
+ throw new Error(`installCredentialGuard: preserveEnv accepts only ${SCRUBBED_ENV.join(', ')}; got ${JSON.stringify(name)}.`);
226
+ }
227
+ preserved.add(name);
228
+ }
229
+ if (preserved.size === 0)
230
+ return preserved;
231
+ const roots = [
232
+ path.join(realHome, '.claude'),
233
+ path.join(realHome, '.claude.json'),
234
+ path.join(realHome, '.codex'),
235
+ path.join(realHome, '.config', 'sidekick'),
236
+ // The Windows default config dir, read before HOME and APPDATA move.
237
+ ...(process.platform === 'win32' && process.env.APPDATA
238
+ ? [path.join(process.env.APPDATA, 'sidekick')]
239
+ : []),
240
+ ...(options.protectedPaths ?? []),
241
+ ];
242
+ for (const name of preserved) {
243
+ const value = process.env[name]?.trim();
244
+ if (!value)
245
+ continue;
246
+ const hit = protectedLocation(value, roots, realFs);
247
+ if (hit) {
248
+ throw new Error(`installCredentialGuard: preserveEnv ${name}=${value} reaches the real protected location ${hit}. Point it at a temporary fixture instead.`);
249
+ }
250
+ }
251
+ return preserved;
252
+ }
253
+ /** The root `target` equals, contains, or lies inside, comparing plain and symlink-resolved paths. */
254
+ function protectedLocation(target, roots, realFs) {
255
+ // Case-insensitive by default on macOS and Windows; folding can only over-refuse.
256
+ const fold = process.platform === 'darwin' || process.platform === 'win32';
257
+ const normal = (value) => {
258
+ const resolved = path.resolve(value);
259
+ return fold ? resolved.toLowerCase() : resolved;
260
+ };
261
+ const within = (parent, child) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
262
+ const related = (a, b) => within(a, b) || within(b, a);
263
+ // Plain paths first, so an obviously unsafe value never reaches the filesystem.
264
+ const plain = normal(target);
265
+ for (const root of roots)
266
+ if (related(normal(root), plain))
267
+ return root;
268
+ const real = normal(realpathOfNearest(target, realFs));
269
+ for (const root of roots) {
270
+ const realRoot = normal(realpathOfNearest(root, realFs));
271
+ if (related(realRoot, real) || related(normal(root), real) || related(realRoot, plain))
272
+ return root;
273
+ }
274
+ return null;
275
+ }
276
+ /** `realpath` of the nearest existing ancestor, with the missing tail re-appended. */
277
+ function realpathOfNearest(target, realFs) {
278
+ let current = path.resolve(target);
279
+ let tail = [];
280
+ for (;;) {
281
+ try {
282
+ return path.join(realFs.realpathSync.native(current), ...tail);
283
+ }
284
+ catch {
285
+ const parent = path.dirname(current);
286
+ if (parent === current)
287
+ return path.resolve(target);
288
+ tail = [path.basename(current), ...tail];
289
+ current = parent;
290
+ }
291
+ }
292
+ }
211
293
  /**
212
294
  * The real module object of a Node built-in: `process.getBuiltinModule`
213
295
  * (Node 20.16+), or `require` in CommonJS on older Node.