@gajae-code/coding-agent 0.5.1 → 0.5.3

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 (165) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +6 -0
  4. package/dist/types/cli/setup-cli.d.ts +8 -1
  5. package/dist/types/commands/setup.d.ts +7 -0
  6. package/dist/types/config/file-lock.d.ts +24 -2
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +62 -0
  10. package/dist/types/dap/client.d.ts +2 -1
  11. package/dist/types/edit/read-file.d.ts +6 -0
  12. package/dist/types/eval/js/context-manager.d.ts +3 -0
  13. package/dist/types/eval/js/executor.d.ts +1 -0
  14. package/dist/types/exec/bash-executor.d.ts +2 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  16. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  19. package/dist/types/lsp/types.d.ts +2 -0
  20. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +2 -0
  22. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  23. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  24. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -1
  27. package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
  28. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  29. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  30. package/dist/types/modes/theme/theme.d.ts +1 -0
  31. package/dist/types/modes/types.d.ts +1 -1
  32. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  33. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  34. package/dist/types/runtime-mcp/types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +17 -1
  36. package/dist/types/session/artifacts.d.ts +4 -1
  37. package/dist/types/session/history-storage.d.ts +2 -2
  38. package/dist/types/session/session-manager.d.ts +10 -1
  39. package/dist/types/session/streaming-output.d.ts +5 -0
  40. package/dist/types/setup/credential-import.d.ts +79 -0
  41. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  42. package/dist/types/task/executor.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +1 -1
  44. package/dist/types/tools/bash.d.ts +1 -0
  45. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  46. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  47. package/dist/types/tools/subagent-render.d.ts +7 -1
  48. package/dist/types/tools/subagent.d.ts +21 -0
  49. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  50. package/dist/types/web/search/index.d.ts +4 -4
  51. package/dist/types/web/search/provider.d.ts +16 -20
  52. package/dist/types/web/search/providers/base.d.ts +2 -1
  53. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  54. package/dist/types/web/search/types.d.ts +14 -2
  55. package/package.json +7 -7
  56. package/scripts/build-binary.ts +7 -0
  57. package/src/async/job-manager.ts +153 -39
  58. package/src/cli/args.ts +2 -0
  59. package/src/cli/fast-help.ts +2 -0
  60. package/src/cli/setup-cli.ts +138 -3
  61. package/src/commands/setup.ts +5 -1
  62. package/src/commands/ultragoal.ts +3 -1
  63. package/src/config/file-lock-gc.ts +14 -2
  64. package/src/config/file-lock.ts +63 -13
  65. package/src/config/model-profile-activation.ts +15 -3
  66. package/src/config/model-profiles.ts +15 -15
  67. package/src/config/model-registry.ts +21 -1
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +62 -0
  70. package/src/dap/client.ts +105 -64
  71. package/src/dap/session.ts +44 -7
  72. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  73. package/src/edit/read-file.ts +19 -1
  74. package/src/eval/js/context-manager.ts +228 -65
  75. package/src/eval/js/executor.ts +2 -0
  76. package/src/eval/js/index.ts +1 -0
  77. package/src/eval/js/worker-core.ts +10 -6
  78. package/src/eval/py/executor.ts +68 -19
  79. package/src/eval/py/kernel.ts +46 -22
  80. package/src/eval/py/runner.py +68 -14
  81. package/src/exec/bash-executor.ts +49 -13
  82. package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
  83. package/src/gjc-runtime/launch-tmux.ts +3 -4
  84. package/src/gjc-runtime/ralplan-runtime.ts +174 -12
  85. package/src/gjc-runtime/state-runtime.ts +2 -1
  86. package/src/gjc-runtime/state-writer.ts +254 -7
  87. package/src/gjc-runtime/tmux-gc.ts +88 -38
  88. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  89. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  90. package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
  91. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  92. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  93. package/src/harness-control-plane/owner.ts +3 -2
  94. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  95. package/src/hooks/skill-state.ts +121 -2
  96. package/src/internal-urls/artifact-protocol.ts +10 -1
  97. package/src/internal-urls/docs-index.generated.ts +14 -10
  98. package/src/lsp/client.ts +64 -26
  99. package/src/lsp/defaults.json +1 -0
  100. package/src/lsp/index.ts +2 -1
  101. package/src/lsp/lspmux.ts +33 -9
  102. package/src/lsp/types.ts +2 -0
  103. package/src/main.ts +14 -4
  104. package/src/modes/acp/acp-agent.ts +4 -2
  105. package/src/modes/bridge/bridge-mode.ts +23 -1
  106. package/src/modes/components/assistant-message.ts +10 -2
  107. package/src/modes/components/bash-execution.ts +5 -1
  108. package/src/modes/components/eval-execution.ts +5 -1
  109. package/src/modes/components/history-search.ts +5 -2
  110. package/src/modes/components/model-selector.ts +60 -2
  111. package/src/modes/components/oauth-selector.ts +5 -0
  112. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  113. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  114. package/src/modes/components/skill-message.ts +24 -16
  115. package/src/modes/components/tool-execution.ts +6 -0
  116. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  117. package/src/modes/controllers/input-controller.ts +5 -0
  118. package/src/modes/controllers/selector-controller.ts +86 -2
  119. package/src/modes/interactive-mode.ts +11 -1
  120. package/src/modes/rpc/rpc-mode.ts +132 -18
  121. package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
  122. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  123. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  124. package/src/modes/theme/defaults/claude-code.json +100 -0
  125. package/src/modes/theme/defaults/codex.json +100 -0
  126. package/src/modes/theme/defaults/index.ts +6 -0
  127. package/src/modes/theme/defaults/opencode.json +102 -0
  128. package/src/modes/theme/theme.ts +2 -2
  129. package/src/modes/types.ts +1 -1
  130. package/src/modes/utils/ui-helpers.ts +5 -2
  131. package/src/prompts/agents/executor.md +5 -2
  132. package/src/runtime/process-lifecycle.ts +400 -0
  133. package/src/runtime-mcp/manager.ts +164 -50
  134. package/src/runtime-mcp/transports/http.ts +12 -11
  135. package/src/runtime-mcp/transports/stdio.ts +64 -38
  136. package/src/runtime-mcp/types.ts +3 -0
  137. package/src/sdk.ts +39 -1
  138. package/src/session/agent-session.ts +190 -33
  139. package/src/session/artifacts.ts +17 -2
  140. package/src/session/blob-store.ts +36 -2
  141. package/src/session/history-storage.ts +32 -11
  142. package/src/session/session-manager.ts +99 -31
  143. package/src/session/streaming-output.ts +54 -3
  144. package/src/setup/credential-import.ts +429 -0
  145. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  146. package/src/slash-commands/builtin-registry.ts +30 -3
  147. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  148. package/src/task/executor.ts +7 -1
  149. package/src/task/render.ts +18 -7
  150. package/src/tools/archive-reader.ts +10 -1
  151. package/src/tools/ask.ts +4 -2
  152. package/src/tools/bash.ts +11 -4
  153. package/src/tools/browser/tab-supervisor.ts +22 -0
  154. package/src/tools/browser.ts +38 -4
  155. package/src/tools/cron.ts +1 -1
  156. package/src/tools/read.ts +11 -12
  157. package/src/tools/sqlite-reader.ts +19 -5
  158. package/src/tools/subagent-render.ts +119 -29
  159. package/src/tools/subagent.ts +147 -7
  160. package/src/tools/ultragoal-ask-guard.ts +39 -0
  161. package/src/web/search/index.ts +25 -25
  162. package/src/web/search/provider.ts +178 -87
  163. package/src/web/search/providers/base.ts +2 -1
  164. package/src/web/search/providers/openai-compatible.ts +151 -0
  165. package/src/web/search/types.ts +47 -22
@@ -41,6 +41,8 @@ import {
41
41
  calculatePromptTokens,
42
42
  collectEntriesForBranchSummary,
43
43
  compact,
44
+ type EmergencyCompactionSample,
45
+ emergencyCompactionReason,
44
46
  estimateMessageTokensHeuristic,
45
47
  estimateTokens,
46
48
  generateBranchSummary,
@@ -142,6 +144,7 @@ import { onAppendOnlyModeChanged } from "../config/settings";
142
144
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
143
145
  import { loadCapability } from "../discovery";
144
146
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
147
+ import { disposeVmContextsByOwner } from "../eval/js/context-manager";
145
148
  import {
146
149
  disposeKernelSessionsByOwner,
147
150
  executePython as executePythonCommand,
@@ -234,12 +237,14 @@ import {
234
237
  import type { ToolSession } from "../tools";
235
238
  import { AskTool } from "../tools/ask";
236
239
  import { assertEditableFile } from "../tools/auto-generated-guard";
240
+ import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
237
241
  import type { CheckpointState } from "../tools/checkpoint";
238
242
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
239
243
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
240
244
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
241
245
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
242
246
  import { clampTimeout } from "../tools/tool-timeouts";
247
+ import { guardToolForUltragoalAsk } from "../tools/ultragoal-ask-guard";
243
248
  import { parseCommandArgs } from "../utils/command-args";
244
249
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
245
250
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
@@ -906,6 +911,7 @@ export class AgentSession {
906
911
  // Compaction state
907
912
  #compactionAbortController: AbortController | undefined = undefined;
908
913
  #autoCompactionAbortController: AbortController | undefined = undefined;
914
+ #resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
909
915
  #prePromptContextCheckPromise: Promise<void> | undefined = undefined;
910
916
 
911
917
  // Branch summarization state
@@ -1186,6 +1192,7 @@ export class AgentSession {
1186
1192
  };
1187
1193
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1188
1194
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
1195
+ this.#setGuardedAgentTools(this.agent.state.tools);
1189
1196
  this.yieldQueue = new YieldQueue({
1190
1197
  isStreaming: () => this.isStreaming,
1191
1198
  injectStreaming: message => this.agent.followUp(message),
@@ -3185,6 +3192,13 @@ export class AgentSession {
3185
3192
  }
3186
3193
  }
3187
3194
  await shutdownAllLspClients();
3195
+ // F13: release only THIS session's browser tabs on dispose (kill:false → remote
3196
+ // browsers disconnect, headless close gracefully). Scoped by the session id the
3197
+ // browser tool tagged tabs with, so other live sessions' tabs are untouched.
3198
+ // No-op when this session opened no tabs. Failure is logged, not thrown.
3199
+ await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
3200
+ logger.warn("session dispose: releaseTabsForOwner failed", { error }),
3201
+ );
3188
3202
  const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
3189
3203
  if (!pythonExecutionsSettled) {
3190
3204
  logger.warn(
@@ -3192,6 +3206,7 @@ export class AgentSession {
3192
3206
  );
3193
3207
  }
3194
3208
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
3209
+ await disposeVmContextsByOwner(this.#evalKernelOwnerId);
3195
3210
  this.#releasePowerAssertion();
3196
3211
  await this.sessionManager.close();
3197
3212
  this.#closeAllProviderSessions("dispose");
@@ -3690,6 +3705,16 @@ export class AgentSession {
3690
3705
  }) as T;
3691
3706
  }
3692
3707
 
3708
+ #prepareToolForExecution<T extends AgentTool>(tool: T): T {
3709
+ return this.#wrapToolForDeepInterviewMutationGuard(
3710
+ this.#wrapToolForAcpPermission(guardToolForUltragoalAsk(tool, () => this.sessionManager.getCwd())),
3711
+ );
3712
+ }
3713
+
3714
+ #setGuardedAgentTools(tools: AgentTool[]): void {
3715
+ this.agent.setTools(tools.map(tool => this.#prepareToolForExecution(tool)));
3716
+ }
3717
+
3693
3718
  async #applyActiveToolsByName(
3694
3719
  toolNames: string[],
3695
3720
  options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
@@ -3701,7 +3726,7 @@ export class AgentSession {
3701
3726
  for (const name of toolNames) {
3702
3727
  const tool = this.#toolRegistry.get(name);
3703
3728
  if (tool) {
3704
- tools.push(this.#wrapToolForDeepInterviewMutationGuard(this.#wrapToolForAcpPermission(tool)));
3729
+ tools.push(tool);
3705
3730
  validToolNames.push(name);
3706
3731
  }
3707
3732
  }
@@ -3718,7 +3743,7 @@ export class AgentSession {
3718
3743
  this.#selectedDiscoveredToolNames.delete(name);
3719
3744
  }
3720
3745
  }
3721
- this.agent.setTools(tools);
3746
+ this.#setGuardedAgentTools(tools);
3722
3747
 
3723
3748
  // Active tool set changed → discoverable tool list (which excludes already-active tools)
3724
3749
  // is now stale. Invalidate before any prompt-template hook reads the discovery list.
@@ -3976,6 +4001,9 @@ export class AgentSession {
3976
4001
  if (uniqueToolNames.size !== nextToolNames.length) {
3977
4002
  throw new Error("RPC host tool names must be unique");
3978
4003
  }
4004
+ if (uniqueToolNames.has("ask")) {
4005
+ throw new Error('RPC host tool "ask" is reserved and cannot be supplied by the host');
4006
+ }
3979
4007
 
3980
4008
  for (const name of uniqueToolNames) {
3981
4009
  if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) {
@@ -4303,11 +4331,8 @@ export class AgentSession {
4303
4331
  this.#toolRegistry.set(finalTool.name, finalTool);
4304
4332
 
4305
4333
  if (!this.getActiveToolNames().includes(finalTool.name)) {
4306
- const activeTools = [
4307
- ...this.agent.state.tools,
4308
- this.#wrapToolForDeepInterviewMutationGuard(this.#wrapToolForAcpPermission(finalTool)),
4309
- ];
4310
- this.agent.setTools(activeTools);
4334
+ const activeTools = [...this.agent.state.tools, finalTool];
4335
+ this.#setGuardedAgentTools(activeTools);
4311
4336
  this.#invalidateDiscoveryCaches();
4312
4337
  void this.refreshBaseSystemPrompt().catch(error => {
4313
4338
  logger.warn("Failed to refresh system prompt after workflow gate ask tool registration", {
@@ -4339,9 +4364,8 @@ export class AgentSession {
4339
4364
  const activeToolNames = this.getActiveToolNames();
4340
4365
  const activeTools = activeToolNames
4341
4366
  .map(name => this.#toolRegistry.get(name))
4342
- .filter((tool): tool is AgentTool => tool !== undefined)
4343
- .map(tool => this.#wrapToolForAcpPermission(tool));
4344
- this.agent.setTools(activeTools);
4367
+ .filter((tool): tool is AgentTool => tool !== undefined);
4368
+ this.#setGuardedAgentTools(activeTools);
4345
4369
  }
4346
4370
 
4347
4371
  getCheckpointState(): CheckpointState | undefined {
@@ -6005,6 +6029,44 @@ export class AgentSession {
6005
6029
  );
6006
6030
  }
6007
6031
 
6032
+ /**
6033
+ * True when the configured `serviceTier` resolves to `"priority"` for the
6034
+ * given model `provider`. Returns false for scoped tiers that don't match
6035
+ * (e.g. `"openai-only"` on an anthropic provider) and when `provider` is
6036
+ * undefined. This is the canonical provider-aware fast-mode predicate.
6037
+ */
6038
+ isFastForProvider(provider?: string): boolean {
6039
+ // Fast mode applies to a concrete model's provider. With no provider
6040
+ // (no model selected) it cannot apply, even under an unscoped `priority`
6041
+ // tier that `resolveServiceTier` would otherwise pass through.
6042
+ if (provider === undefined) return false;
6043
+ return resolveServiceTier(this.serviceTier, provider) === "priority";
6044
+ }
6045
+
6046
+ /**
6047
+ * Effective service tier applied to task-tool subagent sessions
6048
+ * (executor/architect/planner/critic). They run under `task.serviceTier`
6049
+ * unless it is `"inherit"`, in which case they inherit the main session
6050
+ * tier — mirroring `createSubagentSettings`.
6051
+ */
6052
+ #subagentServiceTier(): ServiceTier | undefined {
6053
+ const configured = this.settings.get("task.serviceTier");
6054
+ if (configured === "inherit") return this.serviceTier;
6055
+ if (configured === "none") return undefined;
6056
+ return configured;
6057
+ }
6058
+
6059
+ /**
6060
+ * Provider-aware fast-mode predicate for task-tool subagent roles, evaluated
6061
+ * against the effective subagent tier (`task.serviceTier`) rather than the
6062
+ * main session tier. Use this for `task.agentModelOverrides` role rows so the
6063
+ * ⚡ glyph reflects the tier the subagent actually runs under.
6064
+ */
6065
+ isFastForSubagentProvider(provider?: string): boolean {
6066
+ if (provider === undefined) return false;
6067
+ return resolveServiceTier(this.#subagentServiceTier(), provider) === "priority";
6068
+ }
6069
+
6008
6070
  /**
6009
6071
  * True when the configured `serviceTier` resolves to `"priority"` for the
6010
6072
  * *currently selected model's provider*. Returns false for scoped tiers
@@ -6012,7 +6074,7 @@ export class AgentSession {
6012
6074
  * no model is selected.
6013
6075
  */
6014
6076
  isFastModeActive(): boolean {
6015
- return resolveServiceTier(this.serviceTier, this.model?.provider) === "priority";
6077
+ return this.isFastForProvider(this.model?.provider);
6016
6078
  }
6017
6079
 
6018
6080
  setServiceTier(serviceTier: ServiceTier | undefined): void {
@@ -6576,11 +6638,55 @@ export class AgentSession {
6576
6638
  }
6577
6639
  }
6578
6640
 
6641
+ /** Test seam: override the emergency-compaction resource sampler so tests never read real RSS. */
6642
+ setResourceSampler(sampler: () => EmergencyCompactionSample): void {
6643
+ this.#resourceSampler = sampler;
6644
+ }
6645
+
6646
+ #defaultResourceSample(): EmergencyCompactionSample {
6647
+ let providerBytes = 0;
6648
+ let imageBytes = 0;
6649
+ for (const message of this.state.messages) {
6650
+ const content = (message as { content?: unknown }).content;
6651
+ if (typeof content === "string") {
6652
+ providerBytes += content.length;
6653
+ } else if (Array.isArray(content)) {
6654
+ for (const block of content) {
6655
+ if (!block || typeof block !== "object") continue;
6656
+ const typed = block as { text?: unknown; data?: unknown };
6657
+ if (typeof typed.text === "string") providerBytes += typed.text.length;
6658
+ if (typeof typed.data === "string") {
6659
+ imageBytes += typed.data.length;
6660
+ providerBytes += typed.data.length;
6661
+ }
6662
+ }
6663
+ }
6664
+ }
6665
+ return {
6666
+ heapUsedBytes: process.memoryUsage().heapUsed,
6667
+ providerBytes,
6668
+ messageCount: this.state.messages.length,
6669
+ imageBytes,
6670
+ };
6671
+ }
6672
+
6579
6673
  async #checkEstimatedContextBeforePromptOnce(pendingMessages: readonly AgentMessage[]): Promise<void> {
6580
6674
  const model = this.model;
6581
6675
  if (!model) return;
6582
6676
  const contextWindow = model.contextWindow ?? 0;
6583
6677
  if (contextWindow <= 0) return;
6678
+ // F6: non-disableable emergency floor — compact before OOM even when token-based
6679
+ // compaction is disabled or its threshold is set too high (weak-hardware protection).
6680
+ const emergencyReason = emergencyCompactionReason(this.#resourceSampler());
6681
+ if (emergencyReason) {
6682
+ logger.warn("Emergency compaction triggered (resource floor exceeded)", { reason: emergencyReason });
6683
+ await this.#runAutoCompaction("overflow", false, false, {
6684
+ continueAfterMaintenance: false,
6685
+ deferHandoffMaintenance: false,
6686
+ force: true,
6687
+ });
6688
+ return;
6689
+ }
6584
6690
  const compactionSettings = this.settings.getGroup("compaction");
6585
6691
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
6586
6692
 
@@ -7232,7 +7338,17 @@ export class AgentSession {
7232
7338
  addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
7233
7339
  }
7234
7340
 
7235
- const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
7341
+ // Last-resort fallback: the largest-context model that shares the ACTIVE
7342
+ // model's provider. Scoping this to the current provider keeps auto-
7343
+ // compaction on the user's configured/custom route instead of silently
7344
+ // defaulting to an unrelated provider (e.g. a stray OpenAI credential
7345
+ // with no remaining credit) just because it happens to be in the bundled
7346
+ // catalog. Cross-provider compaction stays possible, but only when the
7347
+ // user opts in explicitly via modelRoles (handled by the loop above).
7348
+ const fallbackProvider = currentModel?.provider;
7349
+ const sortedByContext = [...availableModels]
7350
+ .filter(model => fallbackProvider === undefined || model.provider === fallbackProvider)
7351
+ .sort((a, b) => b.contextWindow - a.contextWindow);
7236
7352
  for (const model of sortedByContext) {
7237
7353
  if (!seen.has(this.#getModelKey(model))) {
7238
7354
  addCandidate(model);
@@ -7356,11 +7472,13 @@ export class AgentSession {
7356
7472
  reason: "overflow" | "threshold" | "idle",
7357
7473
  willRetry: boolean,
7358
7474
  deferred = false,
7359
- options?: { continueAfterMaintenance?: boolean; deferHandoffMaintenance?: boolean },
7475
+ options?: { continueAfterMaintenance?: boolean; deferHandoffMaintenance?: boolean; force?: boolean },
7360
7476
  ): Promise<void> {
7361
7477
  const compactionSettings = this.settings.getGroup("compaction");
7362
- if (compactionSettings.strategy === "off") return;
7363
- if (reason !== "idle" && !compactionSettings.enabled) return;
7478
+ // `force` is the non-disableable emergency floor (F6): it bypasses the user's
7479
+ // disabled/off settings so a resource-floor breach still compacts before OOM.
7480
+ if (!options?.force && compactionSettings.strategy === "off") return;
7481
+ if (!options?.force && reason !== "idle" && !compactionSettings.enabled) return;
7364
7482
  const generation = this.#promptGeneration;
7365
7483
  if (
7366
7484
  options?.deferHandoffMaintenance !== false &&
@@ -9174,7 +9292,7 @@ export class AgentSession {
9174
9292
  error: String(mcpError),
9175
9293
  });
9176
9294
  this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
9177
- this.agent.setTools(previousTools);
9295
+ this.#setGuardedAgentTools(previousTools);
9178
9296
  this.#baseSystemPrompt = previousBaseSystemPrompt;
9179
9297
  this.agent.setSystemPrompt(previousSystemPrompt);
9180
9298
  }
@@ -9497,17 +9615,15 @@ export class AgentSession {
9497
9615
  */
9498
9616
  getSessionStats(): SessionStats {
9499
9617
  const state = this.state;
9500
- const userMessages = state.messages.filter(m => m.role === "user").length;
9501
- const assistantMessages = state.messages.filter(m => m.role === "assistant").length;
9502
- const toolResults = state.messages.filter(m => m.role === "toolResult").length;
9503
-
9618
+ let userMessages = 0;
9619
+ let assistantMessages = 0;
9620
+ let toolResults = 0;
9504
9621
  let toolCalls = 0;
9505
9622
  let totalInput = 0;
9506
9623
  let totalOutput = 0;
9507
9624
  let totalCacheRead = 0;
9508
9625
  let totalCacheWrite = 0;
9509
9626
  let totalCost = 0;
9510
-
9511
9627
  let totalPremiumRequests = 0;
9512
9628
  const getTaskToolUsage = (details: unknown): Usage | undefined => {
9513
9629
  if (!details || typeof details !== "object") return undefined;
@@ -9517,8 +9633,13 @@ export class AgentSession {
9517
9633
  return usage as Usage;
9518
9634
  };
9519
9635
 
9636
+ // Single pass over messages (replaces three role filters plus a separate usage
9637
+ // loop) so per-turn stats stay O(messages + assistant content blocks), not O(4N).
9520
9638
  for (const message of state.messages) {
9521
- if (message.role === "assistant") {
9639
+ if (message.role === "user") {
9640
+ userMessages += 1;
9641
+ } else if (message.role === "assistant") {
9642
+ assistantMessages += 1;
9522
9643
  const assistantMsg = message as AssistantMessage;
9523
9644
  toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
9524
9645
  totalInput += assistantMsg.usage.input;
@@ -9527,17 +9648,18 @@ export class AgentSession {
9527
9648
  totalCacheWrite += assistantMsg.usage.cacheWrite;
9528
9649
  totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
9529
9650
  totalCost += assistantMsg.usage.cost.total;
9530
- }
9531
-
9532
- if (message.role === "toolResult" && message.toolName === "task") {
9533
- const usage = getTaskToolUsage(message.details);
9534
- if (usage) {
9535
- totalInput += usage.input;
9536
- totalOutput += usage.output;
9537
- totalCacheRead += usage.cacheRead;
9538
- totalCacheWrite += usage.cacheWrite;
9539
- totalPremiumRequests += usage.premiumRequests ?? 0;
9540
- totalCost += usage.cost.total;
9651
+ } else if (message.role === "toolResult") {
9652
+ toolResults += 1;
9653
+ if (message.toolName === "task") {
9654
+ const usage = getTaskToolUsage(message.details);
9655
+ if (usage) {
9656
+ totalInput += usage.input;
9657
+ totalOutput += usage.output;
9658
+ totalCacheRead += usage.cacheRead;
9659
+ totalCacheWrite += usage.cacheWrite;
9660
+ totalPremiumRequests += usage.premiumRequests ?? 0;
9661
+ totalCost += usage.cost.total;
9662
+ }
9541
9663
  }
9542
9664
  }
9543
9665
  }
@@ -9698,11 +9820,46 @@ export class AgentSession {
9698
9820
  return tokens;
9699
9821
  }
9700
9822
 
9823
+ #nativeTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
9824
+
9825
+ /** Cheap content-size signal to invalidate the native token cache on mutation (growth). */
9826
+ /**
9827
+ * Cheap content-size signal to invalidate the native token cache on mutation. Recursively
9828
+ * sums string lengths across the whole message (depth-bounded), so it covers every
9829
+ * provider-visible shape (text/thinking/tool args, toolResult output, tool names, etc.)
9830
+ * without allocating a serialized copy. A size-preserving in-place edit yields only a
9831
+ * benign estimate drift.
9832
+ */
9833
+ #messageTokenSize(value: unknown, depth = 0): number {
9834
+ if (depth > 6) return 0;
9835
+ if (typeof value === "string") return value.length;
9836
+ if (typeof value === "number" || typeof value === "boolean") return 8;
9837
+ if (Array.isArray(value)) {
9838
+ let size = 0;
9839
+ for (const item of value) size += this.#messageTokenSize(item, depth + 1);
9840
+ return size;
9841
+ }
9842
+ if (value && typeof value === "object") {
9843
+ let size = 0;
9844
+ for (const item of Object.values(value)) size += this.#messageTokenSize(item, depth + 1);
9845
+ return size;
9846
+ }
9847
+ return 0;
9848
+ }
9849
+
9701
9850
  #estimateMessageNativeContextTokens(message: AgentMessage): number {
9851
+ // F10/F22: cache the expensive native token count per message object, invalidated by a
9852
+ // cheap content-size signal, so unchanged (stable-size) messages are not re-tokenized on
9853
+ // every pre-prompt estimate. A rare size-preserving in-place edit yields only a benign
9854
+ // token-estimate drift, never wrong output.
9855
+ const len = this.#messageTokenSize(message);
9856
+ const cached = this.#nativeTokenCache.get(message);
9857
+ if (cached && cached.len === len) return cached.tokens;
9702
9858
  let tokens = 0;
9703
9859
  for (const llmMessage of convertToLlm([message])) {
9704
9860
  tokens += estimateTokens(llmMessage);
9705
9861
  }
9862
+ this.#nativeTokenCache.set(message, { len, tokens });
9706
9863
  return tokens;
9707
9864
  }
9708
9865
 
@@ -7,6 +7,11 @@
7
7
  import * as fs from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
 
10
+ import { DEFAULT_ARTIFACT_MAX_BYTES, truncateHeadBytes } from "./streaming-output";
11
+ export interface ArtifactSaveOptions {
12
+ maxBytes?: number;
13
+ }
14
+
10
15
  /**
11
16
  * Manages artifact storage for a session.
12
17
  *
@@ -94,9 +99,19 @@ export class ArtifactManager {
94
99
  * @param toolType Tool name for file extension (e.g., "bash", "read")
95
100
  * @returns Artifact ID (numeric string)
96
101
  */
97
- async save(content: string, toolType: string): Promise<string> {
102
+ async save(content: string, toolType: string, options: ArtifactSaveOptions = {}): Promise<string> {
98
103
  const { id, path } = await this.allocatePath(toolType);
99
- await Bun.write(path, content);
104
+ const maxBytes = Math.max(0, options.maxBytes ?? DEFAULT_ARTIFACT_MAX_BYTES);
105
+ const contentBytes = Buffer.byteLength(content, "utf-8");
106
+ if (contentBytes > maxBytes) {
107
+ const truncated = truncateHeadBytes(content, maxBytes);
108
+ await Bun.write(
109
+ path,
110
+ `${truncated.text}\n[artifact truncated after ${truncated.bytes} bytes; omitted at least ${contentBytes - truncated.bytes} bytes]\n`,
111
+ );
112
+ } else {
113
+ await Bun.write(path, content);
114
+ }
100
115
  return id;
101
116
  }
102
117
 
@@ -167,19 +167,49 @@ export class EphemeralBlobStore extends BlobStore {
167
167
  }
168
168
 
169
169
  export class MemoryBlobStore extends BlobStore {
170
+ /**
171
+ * Generous byte/count LRU bound (F8). Content-addressed resident blobs are fail-closed
172
+ * on miss (callers raise/handle {@link ResidentBlobMissingError}), so evicting the
173
+ * least-recently-used entry on an extremely large session is preferable to unbounded
174
+ * RAM growth. The caps sit well above normal usage and only trip on pathological sizes.
175
+ */
176
+ static readonly #MAX_BYTES = 64 * 1024 * 1024;
177
+ static readonly #MAX_COUNT = 4096;
178
+
170
179
  #blobs = new Map<string, Buffer>();
180
+ #bytes = 0;
171
181
 
172
182
  constructor() {
173
183
  super(":memory:");
174
184
  }
175
185
 
186
+ #store(hash: string, data: Buffer): void {
187
+ const existing = this.#blobs.get(hash);
188
+ if (existing) {
189
+ this.#blobs.delete(hash);
190
+ this.#bytes -= existing.byteLength;
191
+ }
192
+ this.#blobs.set(hash, data);
193
+ this.#bytes += data.byteLength;
194
+ while (
195
+ (this.#bytes > MemoryBlobStore.#MAX_BYTES || this.#blobs.size > MemoryBlobStore.#MAX_COUNT) &&
196
+ this.#blobs.size > 1
197
+ ) {
198
+ const oldest = this.#blobs.keys().next().value;
199
+ if (oldest === undefined) break;
200
+ const evicted = this.#blobs.get(oldest);
201
+ this.#blobs.delete(oldest);
202
+ if (evicted) this.#bytes -= evicted.byteLength;
203
+ }
204
+ }
205
+
176
206
  async put(data: Buffer): Promise<BlobPutResult> {
177
207
  return this.putSync(data);
178
208
  }
179
209
 
180
210
  putSync(data: Buffer): BlobPutResult {
181
211
  const hash = new Bun.SHA256().update(data).digest("hex");
182
- this.#blobs.set(hash, Buffer.from(data));
212
+ this.#store(hash, Buffer.from(data));
183
213
  return {
184
214
  hash,
185
215
  path: `memory:${hash}`,
@@ -195,7 +225,11 @@ export class MemoryBlobStore extends BlobStore {
195
225
 
196
226
  getSync(hash: string): Buffer | null {
197
227
  const data = this.#blobs.get(hash);
198
- return data ? Buffer.from(data) : null;
228
+ if (!data) return null;
229
+ // Refresh LRU recency on hit so hot blobs survive eviction.
230
+ this.#blobs.delete(hash);
231
+ this.#blobs.set(hash, data);
232
+ return Buffer.from(data);
199
233
  }
200
234
 
201
235
  async has(hash: string): Promise<boolean> {
@@ -67,10 +67,14 @@ export class HistoryStorage {
67
67
  // Prepared statements
68
68
  #insertRowStmt: Statement;
69
69
  #recentStmt: Statement;
70
+ #recentByCwdStmt: Statement;
70
71
  #searchStmt: Statement;
72
+ #searchByCwdStmt: Statement;
71
73
  #lastPromptStmt: Statement;
72
74
  // Cache substring-fallback prepared statements keyed by token count.
73
75
  #substringStmts = new Map<number, Statement>();
76
+ // Cache cwd-filtered substring-fallback statements keyed by token count.
77
+ #substringCwdStmts = new Map<number, Statement>();
74
78
 
75
79
  // In-memory cache of last prompt to avoid sync DB reads on add
76
80
  #lastPromptCache: string | null = null;
@@ -94,6 +98,7 @@ CREATE TABLE IF NOT EXISTS history (
94
98
  cwd TEXT
95
99
  );
96
100
  CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
101
+ CREATE INDEX IF NOT EXISTS idx_history_cwd_created_at ON history(cwd, created_at DESC);
97
102
 
98
103
  CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(prompt, content='history', content_rowid='id');
99
104
 
@@ -117,9 +122,15 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
117
122
  this.#recentStmt = this.#db.prepare(
118
123
  "SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?",
119
124
  );
125
+ this.#recentByCwdStmt = this.#db.prepare(
126
+ "SELECT id, prompt, created_at, cwd FROM history WHERE cwd = ? ORDER BY created_at DESC, id DESC LIMIT ?",
127
+ );
120
128
  this.#searchStmt = this.#db.prepare(
121
129
  "SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
122
130
  );
131
+ this.#searchByCwdStmt = this.#db.prepare(
132
+ "SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? AND h.cwd = ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?",
133
+ );
123
134
  this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1");
124
135
 
125
136
  this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)");
@@ -158,12 +169,14 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
158
169
  });
159
170
  }
160
171
 
161
- getRecent(limit: number): HistoryEntry[] {
172
+ getRecent(limit: number, cwd?: string): HistoryEntry[] {
162
173
  const safeLimit = this.#normalizeLimit(limit);
163
174
  if (safeLimit === 0) return [];
164
175
 
165
176
  try {
166
- const rows = this.#recentStmt.all(safeLimit) as HistoryRow[];
177
+ const rows = (
178
+ cwd === undefined ? this.#recentStmt.all(safeLimit) : this.#recentByCwdStmt.all(cwd, safeLimit)
179
+ ) as HistoryRow[];
167
180
  return rows.map(row => this.#toEntry(row));
168
181
  } catch (error) {
169
182
  logger.error("HistoryStorage getRecent failed", { error: String(error) });
@@ -171,7 +184,7 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
171
184
  }
172
185
  }
173
186
 
174
- search(query: string, limit: number): HistoryEntry[] {
187
+ search(query: string, limit: number, cwd?: string): HistoryEntry[] {
175
188
  const safeLimit = this.#normalizeLimit(limit);
176
189
  if (safeLimit === 0) return [];
177
190
 
@@ -184,7 +197,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
184
197
  const ftsQuery = tokens.map(tok => `"${tok.replace(/"/g, '""')}"*`).join(" ");
185
198
  let ftsRows: HistoryRow[] = [];
186
199
  try {
187
- ftsRows = this.#searchStmt.all(ftsQuery, safeLimit) as HistoryRow[];
200
+ ftsRows = (
201
+ cwd === undefined
202
+ ? this.#searchStmt.all(ftsQuery, safeLimit)
203
+ : this.#searchByCwdStmt.all(ftsQuery, cwd, safeLimit)
204
+ ) as HistoryRow[];
188
205
  } catch (error) {
189
206
  // Malformed FTS expression - fall through to substring path.
190
207
  logger.debug("HistoryStorage FTS query failed, using substring only", { error: String(error) });
@@ -199,7 +216,7 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
199
216
  // by safeLimit, ordered by recency - no full-table load into JS.
200
217
  let subRows: HistoryRow[] = [];
201
218
  try {
202
- subRows = this.#searchSubstring(tokens, safeLimit);
219
+ subRows = this.#searchSubstring(tokens, safeLimit, cwd);
203
220
  } catch (error) {
204
221
  logger.error("HistoryStorage substring search failed", { error: String(error) });
205
222
  }
@@ -250,6 +267,7 @@ CREATE TABLE history (
250
267
  cwd TEXT
251
268
  );
252
269
  CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
270
+ CREATE INDEX IF NOT EXISTS idx_history_cwd_created_at ON history(cwd, created_at DESC);
253
271
  INSERT INTO history (id, prompt, created_at, cwd)
254
272
  SELECT id, prompt, created_at, cwd
255
273
  FROM history_legacy;
@@ -282,21 +300,24 @@ END;
282
300
  .filter(tok => tok.length > 0);
283
301
  }
284
302
 
285
- #searchSubstring(tokens: string[], limit: number): HistoryRow[] {
286
- const stmt = this.#getSubstringStmt(tokens.length);
303
+ #searchSubstring(tokens: string[], limit: number, cwd?: string): HistoryRow[] {
304
+ const stmt = this.#getSubstringStmt(tokens.length, cwd !== undefined);
287
305
  const params: unknown[] = tokens.map(tok => `%${escapeLikePattern(tok)}%`);
306
+ if (cwd !== undefined) params.push(cwd);
288
307
  params.push(limit);
289
308
  return stmt.all(...(params as [string, ...unknown[]])) as HistoryRow[];
290
309
  }
291
310
 
292
- #getSubstringStmt(tokenCount: number): Statement {
293
- let stmt = this.#substringStmts.get(tokenCount);
311
+ #getSubstringStmt(tokenCount: number, withCwd: boolean): Statement {
312
+ const cache = withCwd ? this.#substringCwdStmts : this.#substringStmts;
313
+ let stmt = cache.get(tokenCount);
294
314
  if (stmt) return stmt;
295
315
  const whereClause = Array(tokenCount).fill("prompt LIKE ? ESCAPE '\\' COLLATE NOCASE").join(" AND ");
316
+ const cwdClause = withCwd ? " AND cwd = ?" : "";
296
317
  stmt = this.#db.prepare(
297
- `SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
318
+ `SELECT id, prompt, created_at, cwd FROM history WHERE ${whereClause}${cwdClause} ORDER BY created_at DESC, id DESC LIMIT ?`,
298
319
  );
299
- this.#substringStmts.set(tokenCount, stmt);
320
+ cache.set(tokenCount, stmt);
300
321
  return stmt;
301
322
  }
302
323