@oh-my-pi/pi-coding-agent 14.8.0 → 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 (60) 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/extensions/types.ts +10 -3
  13. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  14. package/src/hashline/anchors.ts +113 -0
  15. package/src/hashline/apply.ts +732 -0
  16. package/src/hashline/bigrams.json +649 -0
  17. package/src/hashline/constants.ts +8 -0
  18. package/src/hashline/diff-preview.ts +43 -0
  19. package/src/hashline/diff.ts +56 -0
  20. package/src/hashline/execute.ts +268 -0
  21. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  22. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  23. package/src/hashline/index.ts +14 -0
  24. package/src/hashline/input.ts +110 -0
  25. package/src/hashline/parser.ts +220 -0
  26. package/src/hashline/prefixes.ts +101 -0
  27. package/src/hashline/recovery.ts +72 -0
  28. package/src/hashline/stream.ts +123 -0
  29. package/src/hashline/types.ts +69 -0
  30. package/src/hashline/utils.ts +3 -0
  31. package/src/index.ts +1 -1
  32. package/src/lsp/index.ts +1 -1
  33. package/src/lsp/render.ts +4 -0
  34. package/src/memories/index.ts +13 -4
  35. package/src/modes/components/assistant-message.ts +55 -9
  36. package/src/modes/components/welcome.ts +114 -38
  37. package/src/modes/controllers/event-controller.ts +3 -1
  38. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  39. package/src/modes/controllers/input-controller.ts +8 -1
  40. package/src/modes/interactive-mode.ts +50 -11
  41. package/src/modes/prompt-action-autocomplete.ts +3 -0
  42. package/src/modes/rpc/rpc-client.ts +53 -2
  43. package/src/modes/rpc/rpc-mode.ts +67 -1
  44. package/src/modes/rpc/rpc-types.ts +17 -2
  45. package/src/modes/types.ts +4 -1
  46. package/src/modes/utils/ui-helpers.ts +3 -1
  47. package/src/prompts/agents/reviewer.md +14 -0
  48. package/src/prompts/tools/hashline.md +57 -10
  49. package/src/sdk.ts +4 -3
  50. package/src/session/agent-session.ts +195 -30
  51. package/src/session/compaction/branch-summarization.ts +4 -2
  52. package/src/session/compaction/compaction.ts +22 -3
  53. package/src/task/executor.ts +21 -2
  54. package/src/task/index.ts +4 -1
  55. package/src/tools/ast-edit.ts +1 -1
  56. package/src/tools/match-line-format.ts +1 -1
  57. package/src/tools/read.ts +1 -1
  58. package/src/utils/file-mentions.ts +1 -1
  59. package/src/utils/title-generator.ts +11 -0
  60. package/src/edit/modes/hashline.ts +0 -2039
@@ -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
@@ -965,6 +965,7 @@ export interface SummaryOptions {
965
965
  remoteEndpoint?: string;
966
966
  remoteInstructions?: string;
967
967
  initiatorOverride?: MessageAttribution;
968
+ metadata?: Record<string, unknown>;
968
969
  }
969
970
 
970
971
  export async function generateSummary(
@@ -1020,7 +1021,14 @@ export async function generateSummary(
1020
1021
  const response = await completeSimple(
1021
1022
  model,
1022
1023
  { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
1023
- { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
1024
+ {
1025
+ maxTokens,
1026
+ signal,
1027
+ apiKey,
1028
+ reasoning: Effort.High,
1029
+ initiatorOverride: options?.initiatorOverride,
1030
+ metadata: options?.metadata,
1031
+ },
1024
1032
  );
1025
1033
 
1026
1034
  if (response.stopReason === "error") {
@@ -1069,7 +1077,14 @@ async function generateShortSummary(
1069
1077
  systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
1070
1078
  messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1071
1079
  },
1072
- { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
1080
+ {
1081
+ maxTokens,
1082
+ signal,
1083
+ apiKey,
1084
+ reasoning: Effort.High,
1085
+ initiatorOverride: options?.initiatorOverride,
1086
+ metadata: options?.metadata,
1087
+ },
1073
1088
  );
1074
1089
 
1075
1090
  if (response.stopReason === "error") {
@@ -1249,6 +1264,7 @@ export async function compact(
1249
1264
  remoteEndpoint: settings.remoteEnabled === false ? undefined : settings.remoteEndpoint,
1250
1265
  remoteInstructions: options?.remoteInstructions,
1251
1266
  initiatorOverride: options?.initiatorOverride,
1267
+ metadata: options?.metadata,
1252
1268
  };
1253
1269
 
1254
1270
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -1304,6 +1320,7 @@ export async function compact(
1304
1320
  apiKey,
1305
1321
  signal,
1306
1322
  summaryOptions.initiatorOverride,
1323
+ summaryOptions.metadata,
1307
1324
  ),
1308
1325
  ]);
1309
1326
  // Merge into single summary
@@ -1339,6 +1356,7 @@ export async function compact(
1339
1356
  extraContext: options?.extraContext,
1340
1357
  remoteEndpoint: summaryOptions.remoteEndpoint,
1341
1358
  initiatorOverride: summaryOptions.initiatorOverride,
1359
+ metadata: summaryOptions.metadata,
1342
1360
  },
1343
1361
  );
1344
1362
 
@@ -1370,6 +1388,7 @@ async function generateTurnPrefixSummary(
1370
1388
  apiKey: string,
1371
1389
  signal?: AbortSignal,
1372
1390
  initiatorOverride?: MessageAttribution,
1391
+ metadata?: Record<string, unknown>,
1373
1392
  ): Promise<string> {
1374
1393
  const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
1375
1394
 
@@ -1387,7 +1406,7 @@ async function generateTurnPrefixSummary(
1387
1406
  const response = await completeSimple(
1388
1407
  model,
1389
1408
  { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
1390
- { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride },
1409
+ { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride, metadata },
1391
1410
  );
1392
1411
 
1393
1412
  if (response.stopReason === "error") {
@@ -10,7 +10,7 @@ import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import type { TSchema } from "@sinclair/typebox";
11
11
  import Ajv, { type ValidateFunction } from "ajv";
12
12
  import { ModelRegistry } from "../config/model-registry";
13
- import { resolveModelOverride } from "../config/model-resolver";
13
+ import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
14
14
  import type { PromptTemplate } from "../config/prompt-templates";
15
15
  import { Settings } from "../config/settings";
16
16
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
@@ -144,6 +144,11 @@ export interface ExecutorOptions {
144
144
  index: number;
145
145
  id: string;
146
146
  modelOverride?: string | string[];
147
+ /**
148
+ * Active model selector of the parent session, used as an auth-aware fallback
149
+ * if the resolved subagent model has no working credentials. See #985.
150
+ */
151
+ parentActiveModelPattern?: string;
147
152
  thinkingLevel?: ThinkingLevel;
148
153
  outputSchema?: unknown;
149
154
  /** Parent task recursion depth (0 = top-level, 1 = first child, etc.) */
@@ -944,7 +949,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
944
949
  model,
945
950
  thinkingLevel: resolvedThinkingLevel,
946
951
  explicitThinkingLevel,
947
- } = resolveModelOverride(modelPatterns, modelRegistry, settings);
952
+ authFallbackUsed,
953
+ } = await resolveModelOverrideWithAuthFallback(
954
+ modelPatterns,
955
+ options.parentActiveModelPattern,
956
+ modelRegistry,
957
+ settings,
958
+ );
959
+ if (authFallbackUsed && model) {
960
+ logger.warn("Subagent model has no working credentials; falling back to parent session model", {
961
+ requested: modelPatterns,
962
+ parentModel: options.parentActiveModelPattern,
963
+ resolvedProvider: model.provider,
964
+ resolvedModel: model.id,
965
+ });
966
+ }
948
967
  const effectiveThinkingLevel = explicitThinkingLevel
949
968
  ? resolvedThinkingLevel
950
969
  : (thinkingLevel ?? resolvedThinkingLevel);
package/src/task/index.ts CHANGED
@@ -583,11 +583,12 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
583
583
  // Apply per-agent model override from settings (highest priority)
584
584
  const agentModelOverrides = this.session.settings.get("task.agentModelOverrides");
585
585
  const settingsModelOverride = agentModelOverrides[agentName];
586
+ const parentActiveModelPattern = this.session.getActiveModelString?.();
586
587
  const modelOverride = resolveAgentModelPatterns({
587
588
  settingsOverride: settingsModelOverride,
588
589
  agentModel: effectiveAgent.model,
589
590
  settings: this.session.settings,
590
- activeModelPattern: this.session.getActiveModelString?.(),
591
+ activeModelPattern: parentActiveModelPattern,
591
592
  fallbackModelPattern: this.session.getModelString?.(),
592
593
  });
593
594
  const thinkingLevelOverride = effectiveAgent.thinkingLevel;
@@ -843,6 +844,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
843
844
  id: task.id,
844
845
  taskDepth,
845
846
  modelOverride,
847
+ parentActiveModelPattern,
846
848
  thinkingLevel: thinkingLevelOverride,
847
849
  outputSchema: effectiveOutputSchema,
848
850
  sessionFile,
@@ -900,6 +902,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
900
902
  id: task.id,
901
903
  taskDepth,
902
904
  modelOverride,
905
+ parentActiveModelPattern,
903
906
  thinkingLevel: thinkingLevelOverride,
904
907
  outputSchema: effectiveOutputSchema,
905
908
  sessionFile,
@@ -5,8 +5,8 @@ import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
- import { computeLineHash, HL_BODY_SEP } from "../edit/line-hash";
9
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
+ import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
10
10
  import type { Theme } from "../modes/theme/theme";
11
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
12
12
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
@@ -1,4 +1,4 @@
1
- import { computeLineHash } from "../edit/line-hash";
1
+ import { computeLineHash } from "../hashline/hash";
2
2
 
3
3
  /**
4
4
  * Format a single line of match output for grep/ast-grep style results.
package/src/tools/read.ts CHANGED
@@ -9,9 +9,9 @@ import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { type Static, Type } from "@sinclair/typebox";
11
11
  import { getFileReadCache } from "../edit/file-read-cache";
12
- import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../edit/line-hash";
13
12
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
14
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
+ import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
15
15
  import { parseInternalUrl } from "../internal-urls/parse";
16
16
  import type { InternalUrl } from "../internal-urls/types";
17
17
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -11,7 +11,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
11
11
  import type { ImageContent } from "@oh-my-pi/pi-ai";
12
12
  import { glob } from "@oh-my-pi/pi-natives";
13
13
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
14
- import { formatHashLines } from "../edit/line-hash";
14
+ import { formatHashLines } from "../hashline/hash";
15
15
  import type { FileMentionMessage } from "../session/messages";
16
16
  import {
17
17
  DEFAULT_MAX_BYTES,