@gajae-code/coding-agent 0.5.2 → 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 (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/dap/client.d.ts +2 -1
  4. package/dist/types/edit/read-file.d.ts +6 -0
  5. package/dist/types/eval/js/context-manager.d.ts +3 -0
  6. package/dist/types/eval/js/executor.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +2 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  11. package/dist/types/modes/components/model-selector.d.ts +2 -0
  12. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  15. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  16. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  17. package/dist/types/runtime-mcp/types.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +17 -1
  19. package/dist/types/session/artifacts.d.ts +4 -1
  20. package/dist/types/session/streaming-output.d.ts +5 -0
  21. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  22. package/dist/types/tools/bash.d.ts +1 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  24. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  25. package/package.json +7 -7
  26. package/src/async/job-manager.ts +153 -39
  27. package/src/config/file-lock.ts +9 -1
  28. package/src/dap/client.ts +105 -64
  29. package/src/dap/session.ts +44 -7
  30. package/src/edit/read-file.ts +19 -1
  31. package/src/eval/js/context-manager.ts +228 -65
  32. package/src/eval/js/executor.ts +2 -0
  33. package/src/eval/js/index.ts +1 -0
  34. package/src/eval/js/worker-core.ts +10 -6
  35. package/src/eval/py/executor.ts +68 -19
  36. package/src/eval/py/kernel.ts +46 -22
  37. package/src/eval/py/runner.py +68 -14
  38. package/src/exec/bash-executor.ts +49 -13
  39. package/src/gjc-runtime/tmux-gc.ts +86 -37
  40. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  41. package/src/internal-urls/artifact-protocol.ts +10 -1
  42. package/src/internal-urls/docs-index.generated.ts +2 -2
  43. package/src/lsp/client.ts +64 -26
  44. package/src/lsp/index.ts +2 -1
  45. package/src/lsp/lspmux.ts +33 -9
  46. package/src/lsp/types.ts +2 -0
  47. package/src/modes/bridge/bridge-mode.ts +21 -0
  48. package/src/modes/components/assistant-message.ts +10 -2
  49. package/src/modes/components/bash-execution.ts +5 -1
  50. package/src/modes/components/eval-execution.ts +5 -1
  51. package/src/modes/components/model-selector.ts +34 -2
  52. package/src/modes/components/oauth-selector.ts +5 -0
  53. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  54. package/src/modes/components/skill-message.ts +24 -16
  55. package/src/modes/components/tool-execution.ts +6 -0
  56. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  57. package/src/modes/controllers/input-controller.ts +5 -0
  58. package/src/modes/controllers/selector-controller.ts +6 -1
  59. package/src/modes/utils/ui-helpers.ts +5 -2
  60. package/src/runtime/process-lifecycle.ts +400 -0
  61. package/src/runtime-mcp/manager.ts +164 -50
  62. package/src/runtime-mcp/transports/http.ts +12 -11
  63. package/src/runtime-mcp/transports/stdio.ts +64 -38
  64. package/src/runtime-mcp/types.ts +3 -0
  65. package/src/sdk.ts +27 -0
  66. package/src/session/agent-session.ts +168 -22
  67. package/src/session/artifacts.ts +17 -2
  68. package/src/session/blob-store.ts +36 -2
  69. package/src/session/session-manager.ts +29 -13
  70. package/src/session/streaming-output.ts +54 -3
  71. package/src/slash-commands/builtin-registry.ts +30 -3
  72. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  73. package/src/tools/archive-reader.ts +10 -1
  74. package/src/tools/bash.ts +11 -4
  75. package/src/tools/browser/tab-supervisor.ts +22 -0
  76. package/src/tools/browser.ts +38 -4
  77. package/src/tools/read.ts +11 -12
  78. package/src/tools/sqlite-reader.ts +19 -5
@@ -5,7 +5,8 @@
5
5
  * Messages are newline-delimited JSON.
6
6
  */
7
7
 
8
- import { getProjectDir, ptree, readJsonl, Snowflake } from "@gajae-code/utils";
8
+ import { getProjectDir, readJsonl, Snowflake } from "@gajae-code/utils";
9
+ import { type OwnedProcess, spawnOwnedProcess } from "../../runtime/process-lifecycle";
9
10
  import type {
10
11
  JsonRpcError,
11
12
  JsonRpcMessage,
@@ -24,7 +25,7 @@ import { toJsonRpcError } from "../../runtime-mcp/types";
24
25
  const CLOSE_WAIT_MS = 1_000;
25
26
 
26
27
  export class StdioTransport implements MCPTransport {
27
- #process: ptree.ChildProcess<"pipe"> | null = null;
28
+ #process: OwnedProcess | null = null;
28
29
  #pendingRequests = new Map<
29
30
  string | number,
30
31
  {
@@ -34,6 +35,8 @@ export class StdioTransport implements MCPTransport {
34
35
  >();
35
36
  #connected = false;
36
37
  #readLoop: Promise<void> | null = null;
38
+ #stderrLoop: Promise<void> | null = null;
39
+ #closePromise: Promise<void> | null = null;
37
40
 
38
41
  onClose?: () => void;
39
42
  onError?: (error: Error) => void;
@@ -46,10 +49,17 @@ export class StdioTransport implements MCPTransport {
46
49
  return this.#connected;
47
50
  }
48
51
 
52
+ get closeBeforeReconnect(): true {
53
+ return true;
54
+ }
55
+
49
56
  /**
50
57
  * Start the subprocess and begin reading.
51
58
  */
52
59
  async connect(): Promise<void> {
60
+ if (this.#closePromise) {
61
+ throw new Error("Transport is closing");
62
+ }
53
63
  if (this.#connected) return;
54
64
 
55
65
  const args = this.config.args ?? [];
@@ -58,11 +68,12 @@ export class StdioTransport implements MCPTransport {
58
68
  ...this.config.env,
59
69
  };
60
70
 
61
- this.#process = ptree.spawn([this.config.command, ...args], {
71
+ this.#process = spawnOwnedProcess([this.config.command, ...args], {
62
72
  cwd: this.config.cwd ?? getProjectDir(),
63
73
  env,
64
74
  stdin: "pipe",
65
- stderr: "full",
75
+ gracefulMs: CLOSE_WAIT_MS,
76
+ name: `mcp-stdio:${this.config.command}`,
66
77
  });
67
78
 
68
79
  this.#connected = true;
@@ -71,13 +82,13 @@ export class StdioTransport implements MCPTransport {
71
82
  this.#readLoop = this.#startReadLoop();
72
83
 
73
84
  // Log stderr for debugging
74
- this.#startStderrLoop();
85
+ this.#stderrLoop = this.#startStderrLoop();
75
86
  }
76
87
 
77
88
  async #startReadLoop(): Promise<void> {
78
- if (!this.#process?.stdout) return;
89
+ if (!this.#process?.child.stdout) return;
79
90
  try {
80
- for await (const line of readJsonl(this.#process.stdout)) {
91
+ for await (const line of readJsonl(this.#process.child.stdout)) {
81
92
  if (!this.#connected) break;
82
93
  try {
83
94
  this.#handleMessage(line as JsonRpcMessage);
@@ -95,9 +106,9 @@ export class StdioTransport implements MCPTransport {
95
106
  }
96
107
 
97
108
  async #startStderrLoop(): Promise<void> {
98
- if (!this.#process?.stderr) return;
109
+ if (!this.#process?.child.stderr) return;
99
110
 
100
- const reader = this.#process.stderr.getReader();
111
+ const reader = this.#process.child.stderr.getReader();
101
112
  const decoder = new TextDecoder();
102
113
 
103
114
  try {
@@ -168,26 +179,23 @@ export class StdioTransport implements MCPTransport {
168
179
  }
169
180
  }
170
181
 
182
+ #getStdin(): Bun.FileSink | null {
183
+ const stdin = this.#process?.child.stdin;
184
+ return typeof stdin === "object" && stdin !== null ? stdin : null;
185
+ }
186
+
171
187
  #sendResponse(id: string | number, result?: unknown, error?: JsonRpcError): void {
172
- if (!this.#connected || !this.#process?.stdin) return;
188
+ const stdin = this.#getStdin();
189
+ if (!this.#connected || !stdin) return;
173
190
  const response = error
174
191
  ? { jsonrpc: "2.0" as const, id, error }
175
192
  : { jsonrpc: "2.0" as const, id, result: result ?? {} };
176
- this.#process.stdin.write(`${JSON.stringify(response)}\n`);
177
- this.#process.stdin.flush();
193
+ stdin.write(`${JSON.stringify(response)}\n`);
194
+ stdin.flush();
178
195
  }
179
196
 
180
197
  #handleClose(): void {
181
- if (!this.#connected) return;
182
- this.#connected = false;
183
-
184
- // Reject all pending requests
185
- for (const [, pending] of this.#pendingRequests) {
186
- pending.reject(new Error("Transport closed"));
187
- }
188
- this.#pendingRequests.clear();
189
-
190
- this.onClose?.();
198
+ void this.#closeInternal(true);
191
199
  }
192
200
 
193
201
  async request<T = unknown>(
@@ -195,7 +203,8 @@ export class StdioTransport implements MCPTransport {
195
203
  params?: Record<string, unknown>,
196
204
  options?: MCPRequestOptions,
197
205
  ): Promise<T> {
198
- if (!this.#connected || !this.#process?.stdin) {
206
+ const stdin = this.#getStdin();
207
+ if (!this.#connected || !stdin) {
199
208
  throw new Error("Transport not connected");
200
209
  }
201
210
 
@@ -261,8 +270,8 @@ export class StdioTransport implements MCPTransport {
261
270
  const message = `${JSON.stringify(request)}\n`;
262
271
  try {
263
272
  // Bun's FileSink has write() method directly
264
- this.#process.stdin.write(message);
265
- this.#process.stdin.flush();
273
+ stdin.write(message);
274
+ stdin.flush();
266
275
  } catch (error: unknown) {
267
276
  cleanup();
268
277
  reject(error instanceof Error ? error : new Error(String(error)));
@@ -272,7 +281,8 @@ export class StdioTransport implements MCPTransport {
272
281
  }
273
282
 
274
283
  async notify(method: string, params?: Record<string, unknown>): Promise<void> {
275
- if (!this.#connected || !this.#process?.stdin) {
284
+ const stdin = this.#getStdin();
285
+ if (!this.#connected || !stdin) {
276
286
  throw new Error("Transport not connected");
277
287
  }
278
288
 
@@ -284,35 +294,51 @@ export class StdioTransport implements MCPTransport {
284
294
 
285
295
  const message = `${JSON.stringify(notification)}\n`;
286
296
  // Bun's FileSink has write() method directly
287
- this.#process.stdin.write(message);
288
- this.#process.stdin.flush();
297
+ stdin.write(message);
298
+ stdin.flush();
289
299
  }
290
300
 
291
301
  async close(): Promise<void> {
292
- if (!this.#connected) return;
302
+ await this.#closeInternal(false);
303
+ }
304
+
305
+ #closeInternal(fromReadLoop: boolean): Promise<void> {
306
+ if (this.#closePromise) return this.#closePromise;
307
+ this.#closePromise = this.#finishClose(fromReadLoop).finally(() => {
308
+ this.#closePromise = null;
309
+ });
310
+ return this.#closePromise;
311
+ }
312
+
313
+ async #finishClose(fromReadLoop: boolean): Promise<void> {
314
+ const wasConnected = this.#connected;
293
315
  this.#connected = false;
294
316
 
295
- // Reject pending requests
296
317
  for (const [, pending] of this.#pendingRequests) {
297
318
  pending.reject(new Error("Transport closed"));
298
319
  }
299
320
  this.#pendingRequests.clear();
300
321
 
301
- // Terminate the subprocess tree and keep the handle until exit is observed.
322
+ const stdin = this.#getStdin();
302
323
  const process = this.#process;
324
+ this.#process = null;
303
325
  if (process) {
304
- process.kill();
305
- await Promise.race([process.exited.catch(() => {}), Bun.sleep(CLOSE_WAIT_MS)]);
306
- this.#process = null;
326
+ stdin?.end();
327
+ await process.dispose().catch(() => {});
328
+ await process.awaitExit({ timeoutMs: CLOSE_WAIT_MS }).catch(() => ({ exited: false, code: null }));
307
329
  }
308
330
 
309
- // Wait for read loop to finish
310
- if (this.#readLoop) {
331
+ if (!fromReadLoop && this.#readLoop) {
311
332
  await this.#readLoop.catch(() => {});
312
- this.#readLoop = null;
333
+ }
334
+ this.#readLoop = null;
335
+
336
+ if (this.#stderrLoop) {
337
+ await this.#stderrLoop.catch(() => {});
338
+ this.#stderrLoop = null;
313
339
  }
314
340
 
315
- this.onClose?.();
341
+ if (wasConnected) this.onClose?.();
316
342
  }
317
343
  }
318
344
 
@@ -225,6 +225,9 @@ export interface MCPTransport {
225
225
  /** Close the transport */
226
226
  close(): Promise<void>;
227
227
 
228
+ /** Whether close must finish before reconnect can safely spawn a replacement. */
229
+ readonly closeBeforeReconnect?: boolean;
230
+
228
231
  /** Whether the transport is connected */
229
232
  readonly connected: boolean;
230
233
 
package/src/sdk.ts CHANGED
@@ -52,6 +52,7 @@ import { resolveConfigValue } from "./config/resolve-config-value";
52
52
  import { getEmbeddedDefaultGjcSkills } from "./defaults/gjc-defaults";
53
53
  import { BUNDLED_GROK_BUILD_EXTENSION_ID, getBundledGrokBuildExtensionFactory } from "./defaults/gjc-grok-cli";
54
54
  import { initializeWithSettings } from "./discovery";
55
+ import { disposeAllVmContexts, disposeVmContextsByOwner } from "./eval/js/context-manager";
55
56
  import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
56
57
  import { TtsrManager } from "./export/ttsr";
57
58
  import type { CustomCommandsLoadResult, LoadedCustomCommand } from "./extensibility/custom-commands";
@@ -414,6 +415,7 @@ function getDefaultAgentDir(): string {
414
415
  */
415
416
  export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise<AuthStorage> {
416
417
  const brokerConfig = await resolveAuthBrokerConfig();
418
+ const credentialRankingMode = resolveCredentialRankingMode();
417
419
  if (brokerConfig) {
418
420
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
419
421
  const initialResult = await client.fetchSnapshot();
@@ -424,6 +426,7 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir(
424
426
  const storage = new AuthStorage(store, {
425
427
  configValueResolver: resolveConfigValue,
426
428
  sourceLabel: `broker ${brokerConfig.url}`,
429
+ credentialRankingMode,
427
430
  });
428
431
  await storage.reload();
429
432
  return storage;
@@ -432,11 +435,25 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir(
432
435
  const storage = await AuthStorage.create(dbPath, {
433
436
  configValueResolver: resolveConfigValue,
434
437
  sourceLabel: `local ${dbPath}`,
438
+ credentialRankingMode,
435
439
  });
436
440
  await storage.reload();
437
441
  return storage;
438
442
  }
439
443
 
444
+ /**
445
+ * Opt-in multi-account credential ranking mode, read from the
446
+ * `GJC_CREDENTIAL_RANKING_MODE` env var. Unset/unknown → `undefined`, leaving
447
+ * {@link AuthStorage}'s default (`balanced`) untouched. `earliest-reset`
448
+ * switches to earliest-expiry-first selection so soon-to-reset tumbling-window
449
+ * quota is drained before it is lost.
450
+ */
451
+ function resolveCredentialRankingMode(): "balanced" | "earliest-reset" | undefined {
452
+ const raw = process.env.GJC_CREDENTIAL_RANKING_MODE?.trim();
453
+ if (raw === "balanced" || raw === "earliest-reset") return raw;
454
+ return undefined;
455
+ }
456
+
440
457
  /**
441
458
  * Discover extensions from cwd.
442
459
  */
@@ -570,6 +587,14 @@ function registerPythonCleanup(): void {
570
587
  postmortem.register("python-cleanup", disposeAllKernelSessions);
571
588
  }
572
589
 
590
+ let jsVmCleanupRegistered = false;
591
+
592
+ function registerJsVmCleanup(): void {
593
+ if (jsVmCleanupRegistered) return;
594
+ jsVmCleanupRegistered = true;
595
+ postmortem.register("js-vm-cleanup", disposeAllVmContexts);
596
+ }
597
+
573
598
  /**
574
599
  * Resolve whether to enable append-only context mode based on the setting and provider.
575
600
  *
@@ -806,6 +831,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
806
831
 
807
832
  registerSshCleanup();
808
833
  registerPythonCleanup();
834
+ registerJsVmCleanup();
809
835
 
810
836
  // Pin authStorage to modelRegistry.authStorage: ModelRegistry.getApiKey() routes refresh
811
837
  // failures through that instance, so any divergent storage handed to the bridge / mcpManager
@@ -2200,6 +2226,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2200
2226
  } else {
2201
2227
  if (hasRegistered) agentRegistry.unregister(resolvedAgentId);
2202
2228
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
2229
+ await disposeVmContextsByOwner(evalKernelOwnerId);
2203
2230
  }
2204
2231
  } catch (cleanupError) {
2205
2232
  logger.warn("Failed to clean up createAgentSession resources after startup error", {
@@ -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,6 +237,7 @@ 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";
@@ -907,6 +911,7 @@ export class AgentSession {
907
911
  // Compaction state
908
912
  #compactionAbortController: AbortController | undefined = undefined;
909
913
  #autoCompactionAbortController: AbortController | undefined = undefined;
914
+ #resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
910
915
  #prePromptContextCheckPromise: Promise<void> | undefined = undefined;
911
916
 
912
917
  // Branch summarization state
@@ -3187,6 +3192,13 @@ export class AgentSession {
3187
3192
  }
3188
3193
  }
3189
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
+ );
3190
3202
  const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
3191
3203
  if (!pythonExecutionsSettled) {
3192
3204
  logger.warn(
@@ -3194,6 +3206,7 @@ export class AgentSession {
3194
3206
  );
3195
3207
  }
3196
3208
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
3209
+ await disposeVmContextsByOwner(this.#evalKernelOwnerId);
3197
3210
  this.#releasePowerAssertion();
3198
3211
  await this.sessionManager.close();
3199
3212
  this.#closeAllProviderSessions("dispose");
@@ -6016,6 +6029,44 @@ export class AgentSession {
6016
6029
  );
6017
6030
  }
6018
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
+
6019
6070
  /**
6020
6071
  * True when the configured `serviceTier` resolves to `"priority"` for the
6021
6072
  * *currently selected model's provider*. Returns false for scoped tiers
@@ -6023,7 +6074,7 @@ export class AgentSession {
6023
6074
  * no model is selected.
6024
6075
  */
6025
6076
  isFastModeActive(): boolean {
6026
- return resolveServiceTier(this.serviceTier, this.model?.provider) === "priority";
6077
+ return this.isFastForProvider(this.model?.provider);
6027
6078
  }
6028
6079
 
6029
6080
  setServiceTier(serviceTier: ServiceTier | undefined): void {
@@ -6587,11 +6638,55 @@ export class AgentSession {
6587
6638
  }
6588
6639
  }
6589
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
+
6590
6673
  async #checkEstimatedContextBeforePromptOnce(pendingMessages: readonly AgentMessage[]): Promise<void> {
6591
6674
  const model = this.model;
6592
6675
  if (!model) return;
6593
6676
  const contextWindow = model.contextWindow ?? 0;
6594
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
+ }
6595
6690
  const compactionSettings = this.settings.getGroup("compaction");
6596
6691
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
6597
6692
 
@@ -7243,7 +7338,17 @@ export class AgentSession {
7243
7338
  addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
7244
7339
  }
7245
7340
 
7246
- 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);
7247
7352
  for (const model of sortedByContext) {
7248
7353
  if (!seen.has(this.#getModelKey(model))) {
7249
7354
  addCandidate(model);
@@ -7367,11 +7472,13 @@ export class AgentSession {
7367
7472
  reason: "overflow" | "threshold" | "idle",
7368
7473
  willRetry: boolean,
7369
7474
  deferred = false,
7370
- options?: { continueAfterMaintenance?: boolean; deferHandoffMaintenance?: boolean },
7475
+ options?: { continueAfterMaintenance?: boolean; deferHandoffMaintenance?: boolean; force?: boolean },
7371
7476
  ): Promise<void> {
7372
7477
  const compactionSettings = this.settings.getGroup("compaction");
7373
- if (compactionSettings.strategy === "off") return;
7374
- 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;
7375
7482
  const generation = this.#promptGeneration;
7376
7483
  if (
7377
7484
  options?.deferHandoffMaintenance !== false &&
@@ -9508,17 +9615,15 @@ export class AgentSession {
9508
9615
  */
9509
9616
  getSessionStats(): SessionStats {
9510
9617
  const state = this.state;
9511
- const userMessages = state.messages.filter(m => m.role === "user").length;
9512
- const assistantMessages = state.messages.filter(m => m.role === "assistant").length;
9513
- const toolResults = state.messages.filter(m => m.role === "toolResult").length;
9514
-
9618
+ let userMessages = 0;
9619
+ let assistantMessages = 0;
9620
+ let toolResults = 0;
9515
9621
  let toolCalls = 0;
9516
9622
  let totalInput = 0;
9517
9623
  let totalOutput = 0;
9518
9624
  let totalCacheRead = 0;
9519
9625
  let totalCacheWrite = 0;
9520
9626
  let totalCost = 0;
9521
-
9522
9627
  let totalPremiumRequests = 0;
9523
9628
  const getTaskToolUsage = (details: unknown): Usage | undefined => {
9524
9629
  if (!details || typeof details !== "object") return undefined;
@@ -9528,8 +9633,13 @@ export class AgentSession {
9528
9633
  return usage as Usage;
9529
9634
  };
9530
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).
9531
9638
  for (const message of state.messages) {
9532
- if (message.role === "assistant") {
9639
+ if (message.role === "user") {
9640
+ userMessages += 1;
9641
+ } else if (message.role === "assistant") {
9642
+ assistantMessages += 1;
9533
9643
  const assistantMsg = message as AssistantMessage;
9534
9644
  toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
9535
9645
  totalInput += assistantMsg.usage.input;
@@ -9538,17 +9648,18 @@ export class AgentSession {
9538
9648
  totalCacheWrite += assistantMsg.usage.cacheWrite;
9539
9649
  totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
9540
9650
  totalCost += assistantMsg.usage.cost.total;
9541
- }
9542
-
9543
- if (message.role === "toolResult" && message.toolName === "task") {
9544
- const usage = getTaskToolUsage(message.details);
9545
- if (usage) {
9546
- totalInput += usage.input;
9547
- totalOutput += usage.output;
9548
- totalCacheRead += usage.cacheRead;
9549
- totalCacheWrite += usage.cacheWrite;
9550
- totalPremiumRequests += usage.premiumRequests ?? 0;
9551
- 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
+ }
9552
9663
  }
9553
9664
  }
9554
9665
  }
@@ -9709,11 +9820,46 @@ export class AgentSession {
9709
9820
  return tokens;
9710
9821
  }
9711
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
+
9712
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;
9713
9858
  let tokens = 0;
9714
9859
  for (const llmMessage of convertToLlm([message])) {
9715
9860
  tokens += estimateTokens(llmMessage);
9716
9861
  }
9862
+ this.#nativeTokenCache.set(message, { len, tokens });
9717
9863
  return tokens;
9718
9864
  }
9719
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