@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.
- package/CHANGELOG.md +14 -0
- package/dist/types/async/job-manager.d.ts +6 -0
- package/dist/types/dap/client.d.ts +2 -1
- package/dist/types/edit/read-file.d.ts +6 -0
- package/dist/types/eval/js/context-manager.d.ts +3 -0
- package/dist/types/eval/js/executor.d.ts +1 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/runtime/process-lifecycle.d.ts +108 -0
- package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +17 -1
- package/dist/types/session/artifacts.d.ts +4 -1
- package/dist/types/session/streaming-output.d.ts +5 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
- package/dist/types/tools/sqlite-reader.d.ts +2 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +153 -39
- package/src/config/file-lock.ts +9 -1
- package/src/dap/client.ts +105 -64
- package/src/dap/session.ts +44 -7
- package/src/edit/read-file.ts +19 -1
- package/src/eval/js/context-manager.ts +228 -65
- package/src/eval/js/executor.ts +2 -0
- package/src/eval/js/index.ts +1 -0
- package/src/eval/js/worker-core.ts +10 -6
- package/src/eval/py/executor.ts +68 -19
- package/src/eval/py/kernel.ts +46 -22
- package/src/eval/py/runner.py +68 -14
- package/src/exec/bash-executor.ts +49 -13
- package/src/gjc-runtime/tmux-gc.ts +86 -37
- package/src/gjc-runtime/tmux-sessions.ts +44 -6
- package/src/internal-urls/artifact-protocol.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/lsp/client.ts +64 -26
- package/src/lsp/index.ts +2 -1
- package/src/lsp/lspmux.ts +33 -9
- package/src/lsp/types.ts +2 -0
- package/src/modes/bridge/bridge-mode.ts +21 -0
- package/src/modes/components/assistant-message.ts +10 -2
- package/src/modes/components/bash-execution.ts +5 -1
- package/src/modes/components/eval-execution.ts +5 -1
- package/src/modes/components/model-selector.ts +34 -2
- package/src/modes/components/oauth-selector.ts +5 -0
- package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
- package/src/modes/components/skill-message.ts +24 -16
- package/src/modes/components/tool-execution.ts +6 -0
- package/src/modes/controllers/extension-ui-controller.ts +33 -6
- package/src/modes/controllers/input-controller.ts +5 -0
- package/src/modes/controllers/selector-controller.ts +6 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/runtime/process-lifecycle.ts +400 -0
- package/src/runtime-mcp/manager.ts +164 -50
- package/src/runtime-mcp/transports/http.ts +12 -11
- package/src/runtime-mcp/transports/stdio.ts +64 -38
- package/src/runtime-mcp/types.ts +3 -0
- package/src/sdk.ts +27 -0
- package/src/session/agent-session.ts +168 -22
- package/src/session/artifacts.ts +17 -2
- package/src/session/blob-store.ts +36 -2
- package/src/session/session-manager.ts +29 -13
- package/src/session/streaming-output.ts +54 -3
- package/src/slash-commands/builtin-registry.ts +30 -3
- package/src/slash-commands/helpers/fast-status-report.ts +111 -0
- package/src/tools/archive-reader.ts +10 -1
- package/src/tools/bash.ts +11 -4
- package/src/tools/browser/tab-supervisor.ts +22 -0
- package/src/tools/browser.ts +38 -4
- package/src/tools/read.ts +11 -12
- 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,
|
|
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:
|
|
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 =
|
|
71
|
+
this.#process = spawnOwnedProcess([this.config.command, ...args], {
|
|
62
72
|
cwd: this.config.cwd ?? getProjectDir(),
|
|
63
73
|
env,
|
|
64
74
|
stdin: "pipe",
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
193
|
+
stdin.write(`${JSON.stringify(response)}\n`);
|
|
194
|
+
stdin.flush();
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
#handleClose(): void {
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
297
|
+
stdin.write(message);
|
|
298
|
+
stdin.flush();
|
|
289
299
|
}
|
|
290
300
|
|
|
291
301
|
async close(): Promise<void> {
|
|
292
|
-
|
|
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
|
-
|
|
322
|
+
const stdin = this.#getStdin();
|
|
302
323
|
const process = this.#process;
|
|
324
|
+
this.#process = null;
|
|
303
325
|
if (process) {
|
|
304
|
-
|
|
305
|
-
await
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
if (this.#readLoop) {
|
|
331
|
+
if (!fromReadLoop && this.#readLoop) {
|
|
311
332
|
await this.#readLoop.catch(() => {});
|
|
312
|
-
|
|
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
|
|
package/src/runtime-mcp/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
7374
|
-
|
|
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
|
-
|
|
9512
|
-
|
|
9513
|
-
|
|
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 === "
|
|
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
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
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
|
|
package/src/session/artifacts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|