@gajae-code/coding-agent 0.4.5 → 0.5.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 (87) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/types/commands/harness.d.ts +3 -0
  3. package/dist/types/config/model-profile-activation.d.ts +11 -2
  4. package/dist/types/config/model-profiles.d.ts +7 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/model-resolver.d.ts +2 -0
  7. package/dist/types/config/models-config-schema.d.ts +30 -0
  8. package/dist/types/config/settings-schema.d.ts +4 -3
  9. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
  10. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  11. package/dist/types/harness-control-plane/owner.d.ts +1 -1
  12. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  13. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  14. package/dist/types/harness-control-plane/types.d.ts +4 -0
  15. package/dist/types/hindsight/mental-models.d.ts +5 -5
  16. package/dist/types/modes/components/model-selector.d.ts +1 -12
  17. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  18. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  19. package/dist/types/sdk.d.ts +5 -0
  20. package/dist/types/session/agent-session.d.ts +2 -0
  21. package/dist/types/session/blob-store.d.ts +20 -1
  22. package/dist/types/session/session-manager.d.ts +24 -6
  23. package/dist/types/session/streaming-output.d.ts +3 -2
  24. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  25. package/dist/types/task/receipt.d.ts +1 -0
  26. package/dist/types/task/types.d.ts +7 -0
  27. package/dist/types/thinking-metadata.d.ts +16 -0
  28. package/dist/types/thinking.d.ts +3 -12
  29. package/dist/types/tools/index.d.ts +2 -0
  30. package/dist/types/tools/resolve.d.ts +0 -10
  31. package/dist/types/utils/tool-choice.d.ts +14 -1
  32. package/package.json +7 -7
  33. package/src/cli.ts +8 -4
  34. package/src/commands/harness.ts +36 -2
  35. package/src/commands/launch.ts +2 -2
  36. package/src/commands/session.ts +3 -1
  37. package/src/config/model-profile-activation.ts +15 -3
  38. package/src/config/model-profiles.ts +255 -56
  39. package/src/config/model-resolver.ts +9 -6
  40. package/src/config/models-config-schema.ts +1 -0
  41. package/src/config/settings-schema.ts +6 -3
  42. package/src/coordinator-mcp/server.ts +54 -23
  43. package/src/cursor.ts +16 -2
  44. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  45. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  46. package/src/export/html/index.ts +13 -9
  47. package/src/gjc-runtime/team-runtime.ts +33 -7
  48. package/src/gjc-runtime/tmux-common.ts +15 -0
  49. package/src/gjc-runtime/tmux-sessions.ts +19 -11
  50. package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
  51. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  52. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  53. package/src/harness-control-plane/owner.ts +78 -27
  54. package/src/harness-control-plane/receipt-spool.ts +128 -0
  55. package/src/harness-control-plane/state-machine.ts +27 -6
  56. package/src/harness-control-plane/storage.ts +23 -0
  57. package/src/harness-control-plane/types.ts +4 -0
  58. package/src/hindsight/mental-models.ts +17 -16
  59. package/src/internal-urls/docs-index.generated.ts +2 -2
  60. package/src/modes/components/assistant-message.ts +26 -14
  61. package/src/modes/components/diff.ts +97 -0
  62. package/src/modes/components/model-selector.ts +353 -181
  63. package/src/modes/components/tool-execution.ts +30 -13
  64. package/src/modes/controllers/selector-controller.ts +33 -42
  65. package/src/modes/rpc/rpc-client.ts +3 -2
  66. package/src/modes/rpc/rpc-mode.ts +44 -14
  67. package/src/modes/rpc/rpc-types.ts +5 -2
  68. package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
  69. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  70. package/src/sdk.ts +29 -2
  71. package/src/secrets/obfuscator.ts +102 -27
  72. package/src/session/agent-session.ts +105 -20
  73. package/src/session/blob-store.ts +89 -3
  74. package/src/session/session-manager.ts +309 -58
  75. package/src/session/streaming-output.ts +185 -122
  76. package/src/session/tool-choice-queue.ts +23 -0
  77. package/src/task/executor.ts +69 -6
  78. package/src/task/receipt.ts +5 -0
  79. package/src/task/render.ts +21 -1
  80. package/src/task/types.ts +8 -0
  81. package/src/thinking-metadata.ts +51 -0
  82. package/src/thinking.ts +26 -46
  83. package/src/tools/bash.ts +1 -1
  84. package/src/tools/index.ts +2 -0
  85. package/src/tools/resolve.ts +93 -18
  86. package/src/utils/edit-mode.ts +1 -1
  87. package/src/utils/tool-choice.ts +45 -16
@@ -244,7 +244,7 @@ import { parseCommandArgs } from "../utils/command-args";
244
244
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
245
245
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
246
246
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
247
- import { buildNamedToolChoice } from "../utils/tool-choice";
247
+ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-choice";
248
248
  import type { AuthStorage } from "./auth-storage";
249
249
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
250
250
  import {
@@ -839,6 +839,8 @@ export type BeforeAgentStartInternalMessage = Pick<
839
839
  "customType" | "content" | "display" | "details" | "attribution"
840
840
  >;
841
841
 
842
+ type ProviderReplaySourceCacheEntry = { source: string; hash: bigint };
843
+
842
844
  /**
843
845
  * Internal (first-party, non-user-hook) contributor invoked at the active
844
846
  * before-agent-start point alongside the extension runner. Returns an optional
@@ -863,6 +865,7 @@ export class AgentSession {
863
865
 
864
866
  #scopedModels: ScopedModelSelection[];
865
867
  #thinkingLevel: ThinkingLevel | undefined;
868
+ #activeModelProfile: string | undefined;
866
869
  #promptTemplates: PromptTemplate[];
867
870
  #slashCommands: FileSlashCommand[];
868
871
 
@@ -1056,6 +1059,7 @@ export class AgentSession {
1056
1059
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1057
1060
  #obfuscator: SecretObfuscator | undefined;
1058
1061
  #checkpointState: CheckpointState | undefined = undefined;
1062
+ #providerReplaySourceCache = new WeakMap<AgentMessage, ProviderReplaySourceCacheEntry>();
1059
1063
  #pendingRewindReport: string | undefined = undefined;
1060
1064
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1061
1065
  #promptGeneration = 0;
@@ -1419,7 +1423,7 @@ export class AgentSession {
1419
1423
  recordSkip("unsupported-role");
1420
1424
  return undefined;
1421
1425
  }
1422
- const cloned = structuredClone(message) as Message;
1426
+ const cloned = cloneJsonValueForForkSeed(message) as Message;
1423
1427
  if ("providerPayload" in cloned) {
1424
1428
  delete (cloned as { providerPayload?: unknown }).providerPayload;
1425
1429
  }
@@ -1466,7 +1470,7 @@ export class AgentSession {
1466
1470
  }
1467
1471
  return {
1468
1472
  messages,
1469
- agentMessages: messages.map(message => structuredClone(message) as AgentMessage),
1473
+ agentMessages: messages.map(message => cloneJsonValueForForkSeed(message) as AgentMessage),
1470
1474
  metadata: {
1471
1475
  sourceSessionId: this.sessionId,
1472
1476
  parentMessageCount: providerMessages.length,
@@ -4588,7 +4592,7 @@ export class AgentSession {
4588
4592
  : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
4589
4593
  await this.refreshGjcSubskillTools();
4590
4594
 
4591
- if (eagerTodoPrelude) {
4595
+ if (eagerTodoPrelude?.toolChoice) {
4592
4596
  this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
4593
4597
  label: "eager-todo",
4594
4598
  });
@@ -4749,6 +4753,9 @@ export class AgentSession {
4749
4753
  if (lastAssistant && !options?.skipCompactionCheck) {
4750
4754
  await this.#checkCompaction(lastAssistant, false);
4751
4755
  }
4756
+ if (!options?.skipCompactionCheck) {
4757
+ await this.#checkEstimatedContextBeforePrompt();
4758
+ }
4752
4759
 
4753
4760
  // Build messages array (session context, eager todo prelude, then active prompt message)
4754
4761
  const messages: AgentMessage[] = [];
@@ -5728,6 +5735,14 @@ export class AgentSession {
5728
5735
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
5729
5736
  }
5730
5737
 
5738
+ setActiveModelProfile(name: string | undefined): void {
5739
+ this.#activeModelProfile = name;
5740
+ }
5741
+
5742
+ getActiveModelProfile(): string | undefined {
5743
+ return this.#activeModelProfile;
5744
+ }
5745
+
5731
5746
  /**
5732
5747
  * Set model temporarily (for this session only).
5733
5748
  * Validates API key, saves to session log but NOT to settings.
@@ -6530,6 +6545,31 @@ export class AgentSession {
6530
6545
  }
6531
6546
  }
6532
6547
  }
6548
+
6549
+ async #checkEstimatedContextBeforePrompt(): Promise<void> {
6550
+ const model = this.model;
6551
+ if (!model) return;
6552
+ const contextWindow = model.contextWindow ?? 0;
6553
+ if (contextWindow <= 0) return;
6554
+ const compactionSettings = this.settings.getGroup("compaction");
6555
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
6556
+
6557
+ let contextTokens = this.#estimateContextTokens().tokens;
6558
+ const maxOutputTokens = model.maxTokens ?? 0;
6559
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6560
+
6561
+ const pruneResult = await this.#pruneToolOutputs();
6562
+ if (pruneResult) {
6563
+ contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6564
+ }
6565
+ if (shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) {
6566
+ await this.#runAutoCompaction("threshold", false, false, {
6567
+ continueAfterMaintenance: false,
6568
+ deferHandoffMaintenance: false,
6569
+ });
6570
+ }
6571
+ }
6572
+
6533
6573
  #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
6534
6574
  const toolCallId = this.#lastSuccessfulYieldToolCallId;
6535
6575
  if (!toolCallId) return false;
@@ -6627,7 +6667,7 @@ export class AgentSession {
6627
6667
  });
6628
6668
  }
6629
6669
 
6630
- #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice: ToolChoice } | undefined {
6670
+ #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
6631
6671
  const eagerTodosEnabled = this.settings.get("todo.eager");
6632
6672
  const todosEnabled = this.settings.get("todo.enabled");
6633
6673
  if (!eagerTodosEnabled || !todosEnabled) {
@@ -6661,13 +6701,15 @@ export class AgentSession {
6661
6701
  return undefined;
6662
6702
  }
6663
6703
 
6664
- const todoWriteToolChoice = buildNamedToolChoice("todo_write", this.model);
6665
- if (!todoWriteToolChoice) {
6666
- logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo_write", {
6704
+ const todoWriteToolChoiceResult = buildNamedToolChoiceResult("todo_write", this.model);
6705
+ const todoWriteToolChoice = todoWriteToolChoiceResult.exactNamed ? todoWriteToolChoiceResult.choice : undefined;
6706
+ if (!todoWriteToolChoiceResult.exactNamed) {
6707
+ logger.debug("Eager todo enforcement degraded; sending reminder without forced tool choice", {
6667
6708
  modelApi: this.model?.api,
6668
6709
  modelId: this.model?.id,
6710
+ resolvedLevel: todoWriteToolChoiceResult.resolved?.resolvedLevel,
6711
+ reason: todoWriteToolChoiceResult.resolved?.reason,
6669
6712
  });
6670
- return undefined;
6671
6713
  }
6672
6714
 
6673
6715
  const eagerTodoReminder = prompt.render(eagerTodoPrompt);
@@ -7049,11 +7091,37 @@ export class AgentSession {
7049
7091
  }
7050
7092
  }
7051
7093
 
7094
+ #getProviderReplaySource(message: AgentMessage): ProviderReplaySourceCacheEntry {
7095
+ const cached = this.#providerReplaySourceCache.get(message);
7096
+ if (cached) return cached;
7097
+ const source = JSON.stringify(this.#normalizeSessionMessageForProviderReplay(message));
7098
+ const hash = this.#hashProviderReplaySource(source);
7099
+ const entry = { source, hash };
7100
+ this.#providerReplaySourceCache.set(message, entry);
7101
+ return entry;
7102
+ }
7103
+
7104
+ #hashProviderReplaySource(source: string): bigint {
7105
+ return Bun.hash.xxHash64(source);
7106
+ }
7107
+
7052
7108
  #didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
7053
- return (
7054
- JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !==
7055
- JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message)))
7056
- );
7109
+ if (previousMessages.length !== nextMessages.length) return true;
7110
+
7111
+ const previousSources: ProviderReplaySourceCacheEntry[] = [];
7112
+ const nextSources: ProviderReplaySourceCacheEntry[] = [];
7113
+ for (let i = 0; i < previousMessages.length; i++) {
7114
+ const previous = this.#getProviderReplaySource(previousMessages[i]!);
7115
+ const next = this.#getProviderReplaySource(nextMessages[i]!);
7116
+ if (previous.hash !== next.hash) return true;
7117
+ previousSources.push(previous);
7118
+ nextSources.push(next);
7119
+ }
7120
+
7121
+ for (let i = 0; i < previousSources.length; i++) {
7122
+ if (previousSources[i]!.source !== nextSources[i]!.source) return true;
7123
+ }
7124
+ return false;
7057
7125
  }
7058
7126
 
7059
7127
  #getModelKey(model: Model): string {
@@ -7258,17 +7326,24 @@ export class AgentSession {
7258
7326
  reason: "overflow" | "threshold" | "idle",
7259
7327
  willRetry: boolean,
7260
7328
  deferred = false,
7329
+ options?: { continueAfterMaintenance?: boolean; deferHandoffMaintenance?: boolean },
7261
7330
  ): Promise<void> {
7262
7331
  const compactionSettings = this.settings.getGroup("compaction");
7263
7332
  if (compactionSettings.strategy === "off") return;
7264
7333
  if (reason !== "idle" && !compactionSettings.enabled) return;
7265
7334
  const generation = this.#promptGeneration;
7266
- if (!deferred && reason !== "overflow" && reason !== "idle" && compactionSettings.strategy === "handoff") {
7335
+ if (
7336
+ options?.deferHandoffMaintenance !== false &&
7337
+ !deferred &&
7338
+ reason !== "overflow" &&
7339
+ reason !== "idle" &&
7340
+ compactionSettings.strategy === "handoff"
7341
+ ) {
7267
7342
  this.#schedulePostPromptTask(
7268
7343
  async signal => {
7269
7344
  await Promise.resolve();
7270
7345
  if (signal.aborted) return;
7271
- await this.#runAutoCompaction(reason, willRetry, true);
7346
+ await this.#runAutoCompaction(reason, willRetry, true, options);
7272
7347
  },
7273
7348
  { generation },
7274
7349
  );
@@ -7277,6 +7352,7 @@ export class AgentSession {
7277
7352
 
7278
7353
  let action: "context-full" | "handoff" =
7279
7354
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
7355
+ const continueAfterMaintenance = options?.continueAfterMaintenance !== false;
7280
7356
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
7281
7357
  // Abort any older auto-compaction before installing this run's controller.
7282
7358
  this.#autoCompactionAbortController?.abort();
@@ -7316,7 +7392,12 @@ export class AgentSession {
7316
7392
  aborted: false,
7317
7393
  willRetry: false,
7318
7394
  });
7319
- if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
7395
+ if (
7396
+ continueAfterMaintenance &&
7397
+ !autoCompactionSignal.aborted &&
7398
+ reason !== "idle" &&
7399
+ compactionSettings.autoContinue !== false
7400
+ ) {
7320
7401
  this.#scheduleAutoContinuePrompt(generation);
7321
7402
  }
7322
7403
  return;
@@ -7378,7 +7459,7 @@ export class AgentSession {
7378
7459
  stopReason: tail?.stopReason,
7379
7460
  });
7380
7461
  }
7381
- } else if (reason !== "idle" && this.agent.hasQueuedMessages()) {
7462
+ } else if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
7382
7463
  this.#scheduleAgentContinue({
7383
7464
  delayMs: 100,
7384
7465
  generation,
@@ -7386,7 +7467,7 @@ export class AgentSession {
7386
7467
  onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
7387
7468
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
7388
7469
  });
7389
- } else if (reason !== "idle" && compactionSettings.autoContinue !== false) {
7470
+ } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
7390
7471
  this.#scheduleAutoContinuePrompt(generation);
7391
7472
  }
7392
7473
  return;
@@ -7607,7 +7688,7 @@ export class AgentSession {
7607
7688
  onError: error => this.#logCompactionContinuationError("overflow_retry", error),
7608
7689
  });
7609
7690
  }
7610
- } else if (reason !== "idle" && this.agent.hasQueuedMessages()) {
7691
+ } else if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
7611
7692
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
7612
7693
  // Kick the loop so queued messages are actually delivered.
7613
7694
  this.#scheduleAgentContinue({
@@ -7617,7 +7698,7 @@ export class AgentSession {
7617
7698
  onSkip: reason => this.#logCompactionContinuationSkipped("queued_continue", reason),
7618
7699
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
7619
7700
  });
7620
- } else if (reason !== "idle" && compactionSettings.autoContinue !== false) {
7701
+ } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
7621
7702
  this.#scheduleAutoContinuePrompt(generation);
7622
7703
  }
7623
7704
  } catch (error) {
@@ -9768,3 +9849,7 @@ export class AgentSession {
9768
9849
  return this.#extensionRunner;
9769
9850
  }
9770
9851
  }
9852
+
9853
+ function cloneJsonValueForForkSeed<T>(value: T): T {
9854
+ return JSON.parse(JSON.stringify(value)) as T;
9855
+ }
@@ -95,6 +95,77 @@ export class BlobStore {
95
95
  }
96
96
  }
97
97
 
98
+ export class EphemeralBlobStore extends BlobStore {
99
+ /**
100
+ * Bounded LRU byte budget for the in-memory buffer cache. Keeps recent
101
+ * resident blobs hot for rematerialization after the weak materialized
102
+ * view is collected, without re-pinning the whole session in RAM.
103
+ */
104
+ static readonly #BUFFER_CACHE_MAX_BYTES = 8 * 1024 * 1024;
105
+
106
+ #bufferCache = new Map<string, Buffer>();
107
+ #bufferCacheBytes = 0;
108
+
109
+ constructor(dir: string) {
110
+ super(dir);
111
+ fs.rmSync(dir, { recursive: true, force: true });
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ }
114
+
115
+ #cachePut(hash: string, data: Buffer): void {
116
+ const existing = this.#bufferCache.get(hash);
117
+ if (existing) {
118
+ this.#bufferCache.delete(hash);
119
+ this.#bufferCacheBytes -= existing.byteLength;
120
+ }
121
+ if (data.byteLength > EphemeralBlobStore.#BUFFER_CACHE_MAX_BYTES) return;
122
+ this.#bufferCache.set(hash, data);
123
+ this.#bufferCacheBytes += data.byteLength;
124
+ for (const [oldHash, oldData] of this.#bufferCache) {
125
+ if (this.#bufferCacheBytes <= EphemeralBlobStore.#BUFFER_CACHE_MAX_BYTES) break;
126
+ this.#bufferCache.delete(oldHash);
127
+ this.#bufferCacheBytes -= oldData.byteLength;
128
+ }
129
+ }
130
+
131
+ putSync(data: Buffer): BlobPutResult {
132
+ const result = super.putSync(data);
133
+ this.#cachePut(result.hash, Buffer.from(data));
134
+ return result;
135
+ }
136
+
137
+ getSync(hash: string): Buffer | null {
138
+ const cached = this.#bufferCache.get(hash);
139
+ if (cached) {
140
+ const blobPath = path.join(this.dir, hash);
141
+ if (fs.existsSync(blobPath)) {
142
+ // Refresh LRU recency on hit.
143
+ this.#bufferCache.delete(hash);
144
+ this.#bufferCache.set(hash, cached);
145
+ return Buffer.from(cached);
146
+ }
147
+ this.#bufferCache.delete(hash);
148
+ this.#bufferCacheBytes -= cached.byteLength;
149
+ }
150
+ const data = super.getSync(hash);
151
+ if (data) this.#cachePut(hash, Buffer.from(data));
152
+ return data;
153
+ }
154
+
155
+ clear(): void {
156
+ this.#bufferCache.clear();
157
+ this.#bufferCacheBytes = 0;
158
+ fs.rmSync(this.dir, { recursive: true, force: true });
159
+ fs.mkdirSync(this.dir, { recursive: true });
160
+ }
161
+
162
+ dispose(): void {
163
+ this.#bufferCache.clear();
164
+ this.#bufferCacheBytes = 0;
165
+ fs.rmSync(this.dir, { recursive: true, force: true });
166
+ }
167
+ }
168
+
98
169
  export class MemoryBlobStore extends BlobStore {
99
170
  #blobs = new Map<string, Buffer>();
100
171
 
@@ -132,6 +203,18 @@ export class MemoryBlobStore extends BlobStore {
132
203
  }
133
204
  }
134
205
 
206
+ export class ResidentBlobMissingError extends Error {
207
+ constructor(
208
+ readonly hash: string,
209
+ readonly kind: "text" | "imageUrl" | "imageData",
210
+ readonly sessionId?: string,
211
+ readonly sessionFile?: string,
212
+ ) {
213
+ super(`Missing resident ${kind} blob: ${hash}`);
214
+ this.name = "ResidentBlobMissingError";
215
+ }
216
+ }
217
+
135
218
  /** Check if a data string is a blob reference. */
136
219
  export function isBlobRef(data: string): boolean {
137
220
  return data.startsWith(BLOB_PREFIX);
@@ -240,13 +323,16 @@ export function resolveImageDataSync(blobStore: BlobStore, data: string): string
240
323
  }
241
324
 
242
325
  /** Synchronously resolve a blob reference back to utf8 text. */
243
- export function resolveTextBlobSync(blobStore: BlobStore, data: string): string {
326
+ export function resolveTextBlobSync(
327
+ blobStore: BlobStore,
328
+ data: string,
329
+ context?: { kind?: "text"; sessionId?: string; sessionFile?: string },
330
+ ): string {
244
331
  const hash = parseBlobRef(data);
245
332
  if (!hash) return data;
246
333
  const buffer = blobStore.getSync(hash);
247
334
  if (!buffer) {
248
- logger.warn("Blob not found for text reference", { hash });
249
- return data;
335
+ throw new ResidentBlobMissingError(hash, context?.kind ?? "text", context?.sessionId, context?.sessionFile);
250
336
  }
251
337
  return buffer.toString("utf8");
252
338
  }