@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  13. package/src/hashline/anchors.ts +113 -0
  14. package/src/hashline/apply.ts +732 -0
  15. package/src/hashline/bigrams.json +649 -0
  16. package/src/hashline/constants.ts +8 -0
  17. package/src/hashline/diff-preview.ts +43 -0
  18. package/src/hashline/diff.ts +56 -0
  19. package/src/hashline/execute.ts +268 -0
  20. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  21. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  22. package/src/hashline/index.ts +14 -0
  23. package/src/hashline/input.ts +110 -0
  24. package/src/hashline/parser.ts +220 -0
  25. package/src/hashline/prefixes.ts +101 -0
  26. package/src/hashline/recovery.ts +72 -0
  27. package/src/hashline/stream.ts +123 -0
  28. package/src/hashline/types.ts +69 -0
  29. package/src/hashline/utils.ts +3 -0
  30. package/src/index.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/lsp/render.ts +4 -0
  33. package/src/memories/index.ts +13 -4
  34. package/src/modes/components/assistant-message.ts +55 -9
  35. package/src/modes/components/welcome.ts +114 -38
  36. package/src/modes/controllers/event-controller.ts +3 -1
  37. package/src/modes/controllers/input-controller.ts +8 -1
  38. package/src/modes/interactive-mode.ts +9 -9
  39. package/src/modes/rpc/rpc-client.ts +53 -2
  40. package/src/modes/rpc/rpc-mode.ts +67 -1
  41. package/src/modes/rpc/rpc-types.ts +17 -2
  42. package/src/modes/utils/ui-helpers.ts +3 -1
  43. package/src/prompts/agents/reviewer.md +14 -0
  44. package/src/prompts/tools/hashline.md +57 -10
  45. package/src/sdk.ts +4 -3
  46. package/src/session/agent-session.ts +195 -30
  47. package/src/session/compaction/branch-summarization.ts +4 -2
  48. package/src/session/compaction/compaction.ts +22 -3
  49. package/src/task/executor.ts +21 -2
  50. package/src/task/index.ts +4 -1
  51. package/src/tools/ast-edit.ts +1 -1
  52. package/src/tools/match-line-format.ts +1 -1
  53. package/src/tools/read.ts +1 -1
  54. package/src/utils/file-mentions.ts +1 -1
  55. package/src/utils/title-generator.ts +11 -0
  56. package/src/edit/modes/hashline.ts +0 -2039
@@ -67,7 +67,11 @@ export type RpcCommand =
67
67
  | { id?: string; type: "handoff"; customInstructions?: string }
68
68
 
69
69
  // Messages
70
- | { id?: string; type: "get_messages" };
70
+ | { id?: string; type: "get_messages" }
71
+
72
+ // Login
73
+ | { id?: string; type: "get_login_providers" }
74
+ | { id?: string; type: "login"; providerId: string };
71
75
 
72
76
  // ============================================================================
73
77
  // RPC State
@@ -193,6 +197,16 @@ export type RpcResponse =
193
197
  // Messages
194
198
  | { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
195
199
 
200
+ // Login
201
+ | {
202
+ id?: string;
203
+ type: "response";
204
+ command: "get_login_providers";
205
+ success: true;
206
+ data: { providers: Array<{ id: string; name: string; available: boolean; authenticated: boolean }> };
207
+ }
208
+ | { id?: string; type: "response"; command: "login"; success: true; data: { providerId: string } }
209
+
196
210
  // Error response (any command can fail)
197
211
  | { id?: string; type: "response"; command: string; success: false; error: string };
198
212
 
@@ -244,7 +258,8 @@ export type RpcExtensionUIRequest =
244
258
  widgetPlacement?: "aboveEditor" | "belowEditor";
245
259
  }
246
260
  | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
247
- | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
261
+ | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }
262
+ | { type: "extension_ui_request"; id: string; method: "open_url"; url: string; instructions?: string };
248
263
 
249
264
  // ============================================================================
250
265
  // Host Tool Frames (bidirectional)
@@ -226,7 +226,9 @@ export class UiHelpers {
226
226
  break;
227
227
  }
228
228
  case "assistant": {
229
- const assistantComponent = new AssistantMessageComponent(message, this.ctx.hideThinkingBlock);
229
+ const assistantComponent = new AssistantMessageComponent(message, this.ctx.hideThinkingBlock, () =>
230
+ this.ctx.ui.requestRender(),
231
+ );
230
232
  this.ctx.chatContainer.addChild(assistantComponent);
231
233
  break;
232
234
  }
@@ -77,6 +77,20 @@ Report issue only when ALL conditions hold:
77
77
  - **Proportionate rigor**: Fix doesn't demand rigor absent elsewhere in codebase
78
78
  </criteria>
79
79
 
80
+ <cross-boundary>
81
+ For every new type, variant, or value introduced by the patch that crosses a function or module boundary
82
+ (event, message, command, frame, enum variant, queue item, IPC payload):
83
+ 1. Locate the **dispatch point** — the switch, router, filter chain, handler registry, or loop body
84
+ that receives and routes values of that kind on the **consuming** side.
85
+ 2. Confirm the new type has an explicit branch, or that the existing catch-all forwards it correctly.
86
+ 3. If the new type falls through to a silent drop, no-op, or discard (e.g. an unmatched `if`/`switch`
87
+ that simply returns without processing), report it as a defect.
88
+
89
+ The dispatch point is frequently **outside the diff**. You **MUST** read it before concluding
90
+ the producing side is correct. Tracing only the emitting code while skipping the consuming
91
+ routing logic is the single most common source of missed integration bugs in reviews.
92
+ </cross-boundary>
93
+
80
94
  <priority>
81
95
  |Level|Criteria|Example|
82
96
  |---|---|---|
@@ -10,17 +10,18 @@ Purely textual format. The tool has NO awareness of language, indentation, brack
10
10
  @PATH header: subsequent ops apply to PATH
11
11
  + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `{{hsep}}TEXT` lines
12
12
  < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
13
- - A..B delete the line range (inclusive); `- A` for one line
14
- = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows
13
+ - A..B delete the line range (inclusive).
14
+ = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows.
15
15
  </ops>
16
16
 
17
17
  <rules>
18
18
  - Every line of inserted/replacement content **MUST** be emitted as a payload line starting with `{{hsep}}`.
19
19
  - `{{hsep}}` is syntax, not content. The inserted text begins after the first `{{hsep}}`; use a bare `{{hsep}}` to insert a blank line.
20
20
  - `< A` inserts before line A; `+ A` inserts after line A. `< BOF` / `+ BOF` both prepend; `< EOF` / `+ EOF` both append.
21
- - `= A..B` replaces the inclusive range with the following payload lines. `= A` (or `= A..B`) with no payload blanks the range to a single empty line.
22
- - `- A..B` deletes the inclusive range; omit `..B` for one line.
23
- - Pick the smallest op for the change: pure addition `+`/`<`; pure deletion `-`; `= A..B` ONLY when content inside `A..B` is actually being modified or removed.
21
+ - `= A..B` replaces the inclusive range with the following payload lines. `= A..B` with no payload blanks the range to a single empty line.
22
+ - `- A..B` deletes the inclusive range; `A..A` for one line.
23
+ - **Choose a self-contained syntactic unit first.** If the change touches part of a multiline call, destructuring assignment, control-flow header, wrapper, or other construct, widen the range to include the whole construct before optimizing for size.
24
+ - Only after the range is self-contained, pick the smallest op for the change: pure addition → `+`/`<`; pure deletion → `-`; `= A..B` ONLY when content inside `A..B` is actually being modified or removed.
24
25
  </rules>
25
26
 
26
27
  <brace-shapes>
@@ -35,7 +36,7 @@ When your edit involves brace boundaries (`{` / `}`), prefer these shapes:
35
36
  - **Do not replay the line past your range.** For `= A..B`, never end the payload with content that already exists at B+1. Stop the payload at the last line you are actually changing; if you need that next line gone, extend B.
36
37
  - **Do not duplicate chunks inside one payload.** When emitting a long `=` payload, never paste the same multi-line block twice. If you catch yourself re-emitting an earlier run of lines, stop and rewrite the op.
37
38
  - **Anchor only inside the visible region.** If the read output around your `=`/`-` end anchor is truncated (you cannot see the line at B+1), issue a fresh `read` before editing — anchoring blind drops or duplicates the boundary line.
38
- - **Prefer narrow ops over wide `=`.** A `+`/`<` insert plus a small `-` delete is almost always clearer and safer than a single wide `= A..B` that re-emits unchanged context.
39
+ - **Prefer the narrowest self-contained edit.** Once your range cleanly contains the construct you are changing, a `+`/`<` insert plus a small `-` delete is almost always clearer and safer than a single wide `= A..B` that re-emits unchanged context.
39
40
  </common-failures>
40
41
 
41
42
  <case file="a.ts">
@@ -47,10 +48,22 @@ When your edit involves brace boundaries (`{` / `}`), prefer these shapes:
47
48
  {{hline 6 "}"}}
48
49
  </case>
49
50
 
51
+ <case file="b.ts">
52
+ {{hline 1 "const {"}}
53
+ {{hline 2 "\tevents,"}}
54
+ {{hline 3 "\tresponse,"}}
55
+ {{hline 4 "\trequestId,"}}
56
+ {{hline 5 "} = await getStreamResponse("}}
57
+ {{hline 6 "\trequest,"}}
58
+ {{hline 7 "\tsignal,"}}
59
+ {{hline 8 ");"}}
60
+ {{hline 9 "await notify(requestId);"}}
61
+ </case>
62
+
50
63
  <examples>
51
64
  # Replace one line (preserve the leading tab from the original)
52
65
  @a.ts
53
- = {{hrefr 5}}
66
+ = {{hrefr 5}}..{{hrefr 5}}
54
67
  {{hsep}} return clean.trim().toUpperCase();
55
68
 
56
69
  # Replace a contiguous range with multiple lines
@@ -59,6 +72,19 @@ When your edit involves brace boundaries (`{` / `}`), prefer these shapes:
59
72
  {{hsep}} const clean = (name || DEF).trim();
60
73
  {{hsep}} return clean.length === 0 ? DEF : clean.toUpperCase();
61
74
 
75
+ # Replace a full multiline destructuring/call statement
76
+ @b.ts
77
+ = {{hrefr 1}}..{{hrefr 8}}
78
+ {{hsep}}const {
79
+ {{hsep}} events,
80
+ {{hsep}} response,
81
+ {{hsep}} requestId,
82
+ {{hsep}}} = await getStreamResponse(
83
+ {{hsep}} request,
84
+ {{hsep}} signal,
85
+ {{hsep}} onEvent,
86
+ {{hsep}});
87
+
62
88
  # Insert BEFORE a line
63
89
  @a.ts
64
90
  < {{hrefr 5}}
@@ -80,11 +106,11 @@ When your edit involves brace boundaries (`{` / `}`), prefer these shapes:
80
106
 
81
107
  # Delete a single line
82
108
  @a.ts
83
- - {{hrefr 2}}
109
+ - {{hrefr 2}}..{{hrefr 2}}
84
110
 
85
111
  # Blank a line in place (no payload required)
86
112
  @a.ts
87
- = {{hrefr 2}}
113
+ = {{hrefr 2}}..{{hrefr 2}}
88
114
  </examples>
89
115
 
90
116
  <anti-pattern>
@@ -103,7 +129,28 @@ When your edit involves brace boundaries (`{` / `}`), prefer these shapes:
103
129
  + {{hrefr 1}}
104
130
  {{hsep}}const DEBUG = false;
105
131
 
106
- If your replacement payload would render with even one unchanged line in the diff, you have the wrong op or range. Stop and rewrite as `+`/`<`/`-` plus a narrower `=`.
132
+ # WRONG continuation-fragment payload from the middle of a larger statement.
133
+ @b.ts
134
+ = {{hrefr 5}}..{{hrefr 7}}
135
+ {{hsep}}} = await getStreamResponse(
136
+ {{hsep}} request,
137
+ {{hsep}} signal,
138
+ {{hsep}} onEvent,
139
+
140
+ # RIGHT — widen to the full statement so the payload starts at a self-contained boundary.
141
+ @b.ts
142
+ = {{hrefr 1}}..{{hrefr 8}}
143
+ {{hsep}}const {
144
+ {{hsep}} events,
145
+ {{hsep}} response,
146
+ {{hsep}} requestId,
147
+ {{hsep}}} = await getStreamResponse(
148
+ {{hsep}} request,
149
+ {{hsep}} signal,
150
+ {{hsep}} onEvent,
151
+ {{hsep}});
152
+
153
+ If your replacement payload would render with even one unchanged line in the diff, or if the first or last payload line is only a continuation fragment from a larger construct (`} =`, `);`, `,`, `.method(`), you have the wrong op or range. Stop and widen to a self-contained boundary before minimizing the edit.
107
154
  </anti-pattern>
108
155
 
109
156
  <critical>
package/src/sdk.ts CHANGED
@@ -1675,9 +1675,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1675
1675
  preferWebsockets: preferOpenAICodexWebsockets,
1676
1676
  getToolContext: tc => toolContextStore.getContext(tc),
1677
1677
  getApiKey: async provider => {
1678
- // Use the provider-facing session id for sticky credential selection so cache keys
1679
- // and provider auth affinity stay aligned across fresh benchmark sessions.
1680
- const key = await modelRegistry.getApiKeyForProvider(provider, providerSessionId);
1678
+ // Read agent.sessionId at call time so credential selection stays aligned
1679
+ // with metadataResolver after /new, fork, resume, or branch switches.
1680
+ const key = await modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
1681
1681
  if (!key) {
1682
1682
  throw new Error(`No API key found for provider "${provider}"`);
1683
1683
  }
@@ -1757,6 +1757,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1757
1757
  asyncJobManager,
1758
1758
  agentId: resolvedAgentId,
1759
1759
  agentRegistry,
1760
+ providerSessionId: options.providerSessionId,
1760
1761
  });
1761
1762
  hasSession = true;
1762
1763
 
@@ -13,6 +13,7 @@
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
15
 
16
+ import * as crypto from "node:crypto";
16
17
  import * as fs from "node:fs";
17
18
  import * as path from "node:path";
18
19
 
@@ -67,6 +68,7 @@ import {
67
68
  } from "../config/model-resolver";
68
69
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
69
70
  import type { Settings, SkillsSettings } from "../config/settings";
71
+ import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
70
72
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
71
73
  import {
72
74
  disposeKernelSessionsByOwner,
@@ -148,7 +150,9 @@ import { type EditMode, resolveEditMode } from "../utils/edit-mode";
148
150
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
149
151
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
150
152
  import { buildNamedToolChoice } from "../utils/tool-choice";
153
+ import type { AuthStorage } from "./auth-storage";
151
154
  import {
155
+ type CompactionPreparation,
152
156
  type CompactionResult,
153
157
  calculateContextTokens,
154
158
  calculatePromptTokens,
@@ -157,6 +161,7 @@ import {
157
161
  estimateTokens,
158
162
  generateBranchSummary,
159
163
  prepareCompaction,
164
+ type SummaryOptions,
160
165
  shouldCompact,
161
166
  } from "./compaction";
162
167
  import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "./compaction/pruning";
@@ -249,6 +254,10 @@ export interface AgentSessionConfig {
249
254
  onPayload?: SimpleStreamOptions["onPayload"];
250
255
  /** Provider response hook used by the active session request path */
251
256
  onResponse?: SimpleStreamOptions["onResponse"];
257
+ /** Raw SSE hook used by the active session request path */
258
+ onSseEvent?: SimpleStreamOptions["onSseEvent"];
259
+ /** Per-session raw SSE diagnostic buffer */
260
+ rawSseDebugBuffer?: RawSseDebugBuffer;
252
261
  /** Current session message-to-LLM conversion pipeline */
253
262
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
254
263
  /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
@@ -280,6 +289,13 @@ export interface AgentSessionConfig {
280
289
  agentId?: string;
281
290
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
282
291
  agentRegistry?: AgentRegistry;
292
+ /**
293
+ * Override the provider-facing session ID for all API requests from this session.
294
+ * When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or
295
+ * SDK callers issue probes / prewarming with an explicit `--provider-session-id`
296
+ * so that credential sticky selection is consistent with the session's streaming calls.
297
+ */
298
+ providerSessionId?: string;
283
299
  }
284
300
 
285
301
  /** Options for AgentSession.prompt() */
@@ -400,6 +416,56 @@ function todoClearKey(phaseName: string, taskContent: string): string {
400
416
  return `${phaseName}\u0000${taskContent}`;
401
417
  }
402
418
 
419
+ /**
420
+ * Build the per-request `metadata` payload for the Anthropic provider, shaped
421
+ * like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
422
+ * device_id }`) so the backend buckets requests under one session and attributes
423
+ * them to the authenticated OAuth account when available. Resolved at request
424
+ * time so token refreshes and login/logout transitions don't strand a stale
425
+ * account UUID in memory. `account_uuid` and `device_id` are omitted for
426
+ * non-Anthropic providers to avoid leaking the user's Claude identity to
427
+ * third-party APIs (including Anthropic-format-compatible proxies such as
428
+ * cloudflare-ai-gateway or gitlab-duo).
429
+ *
430
+ * `provider` is the target provider string (e.g. `"anthropic"`) and gates the
431
+ * `account_uuid` and `device_id` lookups — only `"anthropic"` requests carry them.
432
+ *
433
+ * `sessionId` is forwarded to the auth-storage session-sticky lookup so that
434
+ * multi-credential setups attribute to the same OAuth account used for the
435
+ * actual API request rather than always picking the first credential.
436
+ *
437
+ * `authStorage` is treated as optional so test fixtures that stub `modelRegistry`
438
+ * without a real storage layer still work; the resolver simply skips the lookup
439
+ * and emits `{ session_id }` alone, matching the no-OAuth-credential path.
440
+ */
441
+ function buildSessionMetadata(
442
+ sessionId: string,
443
+ provider: string,
444
+ authStorage: AuthStorage | undefined,
445
+ ): Record<string, unknown> {
446
+ const userId: Record<string, string> = { session_id: sessionId };
447
+ // Only look up account_uuid when the request is going to Anthropic. Injecting
448
+ // a Claude OAuth account_uuid into requests bound for other providers (including
449
+ // Anthropic-format-compatible proxies like cloudflare-ai-gateway or gitlab-duo)
450
+ // would leak the user's Anthropic identity to unrelated third-party APIs.
451
+ if (provider === "anthropic") {
452
+ const accountUuid = authStorage?.getOAuthAccountId("anthropic", sessionId);
453
+ if (typeof accountUuid === "string" && accountUuid.length > 0) {
454
+ userId.account_uuid = accountUuid;
455
+ // Derive device_id from account_uuid so the payload matches the real CC
456
+ // getAPIMetadata shape without hardware fingerprinting. A SHA-256 of a
457
+ // namespaced account UUID produces a stable 64-hex value that is
458
+ // indistinguishable from a randomly generated device ID on the wire, is
459
+ // deterministic per account (survives reinstalls), and is auditable: it
460
+ // is derived solely from the OAuth UUID the user already consented to
461
+ // share with Anthropic. Omitted when no OAuth credential is available
462
+ // (API-key callers) to avoid sending a hash of an empty string.
463
+ userId.device_id = crypto.createHash("sha256").update(`omp-device-id-v1:${accountUuid}`).digest("hex");
464
+ }
465
+ }
466
+ return { user_id: JSON.stringify(userId) };
467
+ }
468
+
403
469
  const noOpUIContext: ExtensionUIContext = {
404
470
  select: async (_title, _options, _dialogOptions) => undefined,
405
471
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -503,6 +569,7 @@ export class AgentSession {
503
569
  // Agent identity + registry for IRC relay forwarding to the main session UI.
504
570
  #agentId: string | undefined;
505
571
  #agentRegistry: AgentRegistry | undefined;
572
+ #providerSessionId: string | undefined;
506
573
  // Extension system
507
574
  #extensionRunner: ExtensionRunner | undefined = undefined;
508
575
  #turnIndex = 0;
@@ -525,6 +592,7 @@ export class AgentSession {
525
592
  #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
526
593
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
527
594
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
595
+ #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
528
596
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
529
597
  #rebuildSystemPrompt:
530
598
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
@@ -576,6 +644,7 @@ export class AgentSession {
576
644
  #promptGeneration = 0;
577
645
  #providerSessionState = new Map<string, ProviderSessionState>();
578
646
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
647
+ readonly rawSseDebugBuffer: RawSseDebugBuffer;
579
648
 
580
649
  #startPowerAssertion(): void {
581
650
  if (process.platform !== "darwin") {
@@ -622,7 +691,19 @@ export class AgentSession {
622
691
  this.#toolRegistry = config.toolRegistry ?? new Map();
623
692
  this.#transformContext = config.transformContext ?? (messages => messages);
624
693
  this.#onPayload = config.onPayload;
625
- this.#onResponse = config.onResponse;
694
+ this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
695
+ const configuredOnResponse = config.onResponse;
696
+ this.#onResponse = async (response, model) => {
697
+ this.rawSseDebugBuffer.recordResponse(response, model);
698
+ await configuredOnResponse?.(response, model);
699
+ };
700
+ const configuredOnSseEvent = config.onSseEvent;
701
+ this.#onSseEvent = (event, model) => {
702
+ this.rawSseDebugBuffer.recordEvent(event, model);
703
+ configuredOnSseEvent?.(event, model);
704
+ };
705
+ this.agent.setProviderResponseInterceptor(this.#onResponse);
706
+ this.agent.setRawSseEventInterceptor(this.#onSseEvent);
626
707
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
627
708
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
628
709
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -652,6 +733,7 @@ export class AgentSession {
652
733
  this.#obfuscator = config.obfuscator;
653
734
  this.#agentId = config.agentId;
654
735
  this.#agentRegistry = config.agentRegistry;
736
+ this.#providerSessionId = config.providerSessionId;
655
737
  this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
656
738
  const event: AgentEvent = {
657
739
  type: "message_update",
@@ -662,6 +744,7 @@ export class AgentSession {
662
744
  this.#maybeAbortStreamingEdit(event);
663
745
  });
664
746
  this.agent.providerSessionState = this.#providerSessionState;
747
+ this.#syncAgentSessionId();
665
748
  this.#syncTodoPhasesFromBranch();
666
749
 
667
750
  // Always subscribe to agent events for internal handling
@@ -1987,7 +2070,24 @@ export class AgentSession {
1987
2070
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
1988
2071
  }
1989
2072
 
1990
- /** Keep Hindsight metadata aligned when the underlying agent session id changes. */
2073
+ /**
2074
+ * Set agent.sessionId from the session manager and install a dynamic
2075
+ * metadata resolver so every API request carries `metadata.user_id` shaped
2076
+ * like real Claude Code's `getAPIMetadata` output: `{ session_id,
2077
+ * account_uuid }` (the latter only when an Anthropic OAuth credential with
2078
+ * a known account UUID is loaded). Resolving live keeps the value in sync
2079
+ * with auth-state changes (login/logout, token refresh that surfaces a new
2080
+ * account uuid) without needing to re-call `#syncAgentSessionId()` on every
2081
+ * such event.
2082
+ */
2083
+ #syncAgentSessionId(sessionId?: string): void {
2084
+ const sid = this.#providerSessionId ?? sessionId ?? this.sessionManager.getSessionId();
2085
+ this.agent.sessionId = sid;
2086
+ this.agent.setMetadataResolver((provider: string) =>
2087
+ buildSessionMetadata(sid, provider, this.#modelRegistry.authStorage),
2088
+ );
2089
+ }
2090
+
1991
2091
  #rekeyHindsightMemoryForCurrentSessionId(): void {
1992
2092
  if (resolveMemoryBackend(this.settings).id !== "hindsight") return;
1993
2093
  const sid = this.agent.sessionId;
@@ -2692,13 +2792,22 @@ export class AgentSession {
2692
2792
  }
2693
2793
 
2694
2794
  /** Apply session-level stream hooks to a direct side request. */
2695
- prepareSimpleStreamOptions(options: SimpleStreamOptions): SimpleStreamOptions {
2795
+ prepareSimpleStreamOptions(options: SimpleStreamOptions, provider = "anthropic"): SimpleStreamOptions {
2696
2796
  const sessionOnPayload = this.#onPayload;
2697
2797
  const sessionOnResponse = this.#onResponse;
2698
- if (!sessionOnPayload && !sessionOnResponse) return options;
2798
+ const sessionMetadata = this.agent.metadataForProvider(provider);
2799
+ const sessionOnSseEvent = this.#onSseEvent;
2800
+ if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent) return options;
2699
2801
 
2700
2802
  const preparedOptions: SimpleStreamOptions = { ...options };
2701
2803
 
2804
+ // Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so
2805
+ // they share the same session bucket as Agent.prompt-routed requests on Anthropic
2806
+ // OAuth. Caller-provided metadata wins so explicit overrides are respected.
2807
+ if (sessionMetadata && !options.metadata) {
2808
+ preparedOptions.metadata = sessionMetadata;
2809
+ }
2810
+
2702
2811
  if (sessionOnPayload) {
2703
2812
  if (!options.onPayload) {
2704
2813
  preparedOptions.onPayload = sessionOnPayload;
@@ -2725,6 +2834,18 @@ export class AgentSession {
2725
2834
  }
2726
2835
  }
2727
2836
 
2837
+ if (sessionOnSseEvent) {
2838
+ if (!options.onSseEvent) {
2839
+ preparedOptions.onSseEvent = sessionOnSseEvent;
2840
+ } else {
2841
+ const requestOnSseEvent = options.onSseEvent;
2842
+ preparedOptions.onSseEvent = (event, model) => {
2843
+ sessionOnSseEvent(event, model);
2844
+ requestOnSseEvent(event, model);
2845
+ };
2846
+ }
2847
+ }
2848
+
2728
2849
  return preparedOptions;
2729
2850
  }
2730
2851
 
@@ -2750,7 +2871,7 @@ export class AgentSession {
2750
2871
 
2751
2872
  /** Current session ID */
2752
2873
  get sessionId(): string {
2753
- return this.sessionManager.getSessionId();
2874
+ return this.#providerSessionId ?? this.sessionManager.getSessionId();
2754
2875
  }
2755
2876
 
2756
2877
  /** Current session display name, if set */
@@ -3810,7 +3931,7 @@ export class AgentSession {
3810
3931
  }
3811
3932
  await this.sessionManager.newSession(options);
3812
3933
  this.setTodoPhases([]);
3813
- this.agent.sessionId = this.sessionManager.getSessionId();
3934
+ this.#syncAgentSessionId();
3814
3935
  this.#rekeyHindsightMemoryForCurrentSessionId();
3815
3936
  this.#resetHindsightConversationTrackingIfHindsight();
3816
3937
  this.#steeringMessages = [];
@@ -3905,7 +4026,7 @@ export class AgentSession {
3905
4026
  }
3906
4027
 
3907
4028
  // Update agent session ID
3908
- this.agent.sessionId = this.sessionManager.getSessionId();
4029
+ this.#syncAgentSessionId();
3909
4030
  this.#rekeyHindsightMemoryForCurrentSessionId();
3910
4031
 
3911
4032
  // Emit session_switch event with reason "fork" to hooks
@@ -4286,14 +4407,7 @@ export class AgentSession {
4286
4407
  }
4287
4408
 
4288
4409
  const compactionSettings = this.settings.getGroup("compaction");
4289
- const compactionModel = this.model;
4290
- const apiKey = await this.#modelRegistry.getApiKey(compactionModel, this.sessionId);
4291
- if (!apiKey) {
4292
- throw new Error(`No API key for ${compactionModel.provider}`);
4293
- }
4294
-
4295
4410
  const pathEntries = this.sessionManager.getBranch();
4296
-
4297
4411
  const preparation = prepareCompaction(pathEntries, compactionSettings);
4298
4412
  if (!preparation) {
4299
4413
  // Check why we can't compact
@@ -4363,10 +4477,8 @@ export class AgentSession {
4363
4477
  preserveData ??= hookCompaction.preserveData;
4364
4478
  } else {
4365
4479
  // Generate compaction result
4366
- const result = await compact(
4480
+ const result = await this.#compactWithFallbackModel(
4367
4481
  preparation,
4368
- compactionModel,
4369
- apiKey,
4370
4482
  customInstructions,
4371
4483
  compactionAbortController.signal,
4372
4484
  {
@@ -4616,7 +4728,7 @@ export class AgentSession {
4616
4728
  this.#asyncJobManager?.cancelAll();
4617
4729
  await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
4618
4730
  this.agent.reset();
4619
- this.agent.sessionId = this.sessionManager.getSessionId();
4731
+ this.#syncAgentSessionId();
4620
4732
  this.#rekeyHindsightMemoryForCurrentSessionId();
4621
4733
  this.#resetHindsightConversationTrackingIfHindsight();
4622
4734
  this.#steeringMessages = [];
@@ -5286,6 +5398,50 @@ export class AgentSession {
5286
5398
 
5287
5399
  return candidates;
5288
5400
  }
5401
+ #isCompactionAuthFailure(error: unknown): boolean {
5402
+ if (!(error instanceof Error)) return false;
5403
+ return /auth_unavailable|no auth available/i.test(error.message);
5404
+ }
5405
+
5406
+ #buildCompactionAuthError(): Error {
5407
+ const currentModel = this.model;
5408
+ if (!currentModel) {
5409
+ return new Error(
5410
+ "Compaction requires a model with usable credentials, but no authenticated compaction model is available.",
5411
+ );
5412
+ }
5413
+ return new Error(
5414
+ `Compaction requires usable credentials for ${currentModel.provider}/${currentModel.id}. ` +
5415
+ `Configure ${currentModel.provider} credentials or assign an authenticated fallback role such as modelRoles.smol.`,
5416
+ );
5417
+ }
5418
+
5419
+ async #compactWithFallbackModel(
5420
+ preparation: CompactionPreparation,
5421
+ customInstructions: string | undefined,
5422
+ signal: AbortSignal,
5423
+ options?: SummaryOptions,
5424
+ ): Promise<CompactionResult> {
5425
+ const candidates = this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
5426
+
5427
+ for (const candidate of candidates) {
5428
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
5429
+ if (!apiKey) continue;
5430
+
5431
+ try {
5432
+ return await compact(preparation, candidate, apiKey, customInstructions, signal, {
5433
+ ...options,
5434
+ metadata: this.agent.metadataForProvider(candidate.provider),
5435
+ });
5436
+ } catch (error) {
5437
+ if (!this.#isCompactionAuthFailure(error)) {
5438
+ throw error;
5439
+ }
5440
+ }
5441
+ }
5442
+
5443
+ throw this.#buildCompactionAuthError();
5444
+ }
5289
5445
 
5290
5446
  /**
5291
5447
  * Internal: Run auto-compaction with events.
@@ -5487,6 +5643,7 @@ export class AgentSession {
5487
5643
  promptOverride: hookPrompt,
5488
5644
  extraContext: hookContext,
5489
5645
  remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
5646
+ metadata: this.agent.metadataForProvider(candidate.provider),
5490
5647
  initiatorOverride: "agent",
5491
5648
  });
5492
5649
  break;
@@ -5496,6 +5653,10 @@ export class AgentSession {
5496
5653
  }
5497
5654
 
5498
5655
  const message = error instanceof Error ? error.message : String(error);
5656
+ if (this.#isCompactionAuthFailure(error)) {
5657
+ lastError = this.#buildCompactionAuthError();
5658
+ break;
5659
+ }
5499
5660
  const retryAfterMs = this.#parseRetryAfterMsFromError(message);
5500
5661
  const shouldRetry =
5501
5662
  retrySettings.enabled &&
@@ -6606,15 +6767,18 @@ export class AgentSession {
6606
6767
  systemPrompt: this.systemPrompt,
6607
6768
  messages: llmMessages,
6608
6769
  };
6609
- const options = this.prepareSimpleStreamOptions({
6610
- apiKey,
6611
- sessionId: this.sessionId,
6612
- reasoning: toReasoningEffort(this.thinkingLevel),
6613
- hideThinkingSummary: this.agent.hideThinkingSummary,
6614
- serviceTier: this.serviceTier,
6615
- signal: args.signal,
6616
- toolChoice: "none",
6617
- });
6770
+ const options = this.prepareSimpleStreamOptions(
6771
+ {
6772
+ apiKey,
6773
+ sessionId: this.sessionId,
6774
+ reasoning: toReasoningEffort(this.thinkingLevel),
6775
+ hideThinkingSummary: this.agent.hideThinkingSummary,
6776
+ serviceTier: this.serviceTier,
6777
+ signal: args.signal,
6778
+ toolChoice: "none",
6779
+ },
6780
+ model.provider,
6781
+ );
6618
6782
 
6619
6783
  let replyText = "";
6620
6784
  let assistantMessage: AssistantMessage | undefined;
@@ -6791,7 +6955,7 @@ export class AgentSession {
6791
6955
 
6792
6956
  try {
6793
6957
  await this.sessionManager.setSessionFile(sessionPath);
6794
- this.agent.sessionId = this.sessionManager.getSessionId();
6958
+ this.#syncAgentSessionId();
6795
6959
  this.#rekeyHindsightMemoryForCurrentSessionId();
6796
6960
 
6797
6961
  const sessionContext = this.buildDisplaySessionContext();
@@ -6869,7 +7033,7 @@ export class AgentSession {
6869
7033
  return true;
6870
7034
  } catch (error) {
6871
7035
  this.sessionManager.restoreState(previousSessionState);
6872
- this.agent.sessionId = previousSessionState.sessionId;
7036
+ this.#syncAgentSessionId(previousSessionState.sessionId);
6873
7037
  this.#rekeyHindsightMemoryForCurrentSessionId();
6874
7038
  let restoreMcpError: unknown;
6875
7039
  try {
@@ -6961,7 +7125,7 @@ export class AgentSession {
6961
7125
  this.sessionManager.createBranchedSession(selectedEntry.parentId);
6962
7126
  }
6963
7127
  this.#syncTodoPhasesFromBranch();
6964
- this.agent.sessionId = this.sessionManager.getSessionId();
7128
+ this.#syncAgentSessionId();
6965
7129
  this.#rekeyHindsightMemoryForCurrentSessionId();
6966
7130
  this.#resetHindsightConversationTrackingIfHindsight();
6967
7131
 
@@ -7082,6 +7246,7 @@ export class AgentSession {
7082
7246
  signal: this.#branchSummaryAbortController.signal,
7083
7247
  customInstructions: options.customInstructions,
7084
7248
  reserveTokens: branchSummarySettings.reserveTokens,
7249
+ metadata: this.agent.metadataForProvider(model.provider),
7085
7250
  });
7086
7251
  this.#branchSummaryAbortController = undefined;
7087
7252
  if (result.aborted) {
@@ -75,6 +75,8 @@ export interface GenerateBranchSummaryOptions {
75
75
  customInstructions?: string;
76
76
  /** Tokens reserved for prompt + LLM response (default 16384) */
77
77
  reserveTokens?: number;
78
+ /** Optional metadata forwarded to the underlying API request (e.g. user_id for session attribution). */
79
+ metadata?: Record<string, unknown>;
78
80
  }
79
81
 
80
82
  // ============================================================================
@@ -258,7 +260,7 @@ export async function generateBranchSummary(
258
260
  entries: SessionEntry[],
259
261
  options: GenerateBranchSummaryOptions,
260
262
  ): Promise<BranchSummaryResult> {
261
- const { model, apiKey, signal, customInstructions, reserveTokens = 16384 } = options;
263
+ const { model, apiKey, signal, customInstructions, reserveTokens = 16384, metadata } = options;
262
264
 
263
265
  // Token budget = context window minus reserved space for prompt + response
264
266
  const contextWindow = model.contextWindow || 128000;
@@ -291,7 +293,7 @@ export async function generateBranchSummary(
291
293
  const response = await completeSimple(
292
294
  model,
293
295
  { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
294
- { apiKey, signal, maxTokens: 2048 },
296
+ { apiKey, signal, maxTokens: 2048, metadata },
295
297
  );
296
298
 
297
299
  // Check if aborted or errored