@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
|
@@ -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.#
|
|
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
|
-
|
|
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> {
|
|
@@ -889,8 +889,27 @@ async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, ke
|
|
|
889
889
|
);
|
|
890
890
|
}
|
|
891
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Run async tasks with bounded concurrency so an image-heavy resume never materializes
|
|
894
|
+
* every blob's base64 simultaneously (F8: avoids the transient OOM spike of an unbounded
|
|
895
|
+
* Promise.all over all historical images).
|
|
896
|
+
*/
|
|
897
|
+
const BLOB_RESOLVE_CONCURRENCY = 8;
|
|
898
|
+
async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: number): Promise<void> {
|
|
899
|
+
let next = 0;
|
|
900
|
+
const worker = async (): Promise<void> => {
|
|
901
|
+
while (next < tasks.length) {
|
|
902
|
+
const index = next;
|
|
903
|
+
next += 1;
|
|
904
|
+
await tasks[index]!();
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
const workerCount = Math.max(1, Math.min(limit, tasks.length));
|
|
908
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
909
|
+
}
|
|
910
|
+
|
|
892
911
|
async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
|
|
893
|
-
const
|
|
912
|
+
const tasks: Array<() => Promise<void>> = [];
|
|
894
913
|
|
|
895
914
|
for (const entry of entries) {
|
|
896
915
|
if (entry.type === "session") continue;
|
|
@@ -902,22 +921,19 @@ async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobSto
|
|
|
902
921
|
contentArray = entry.content;
|
|
903
922
|
}
|
|
904
923
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
resolveImageData(blobStore, block.data)
|
|
910
|
-
|
|
911
|
-
}),
|
|
912
|
-
);
|
|
924
|
+
tasks.push(async () => {
|
|
925
|
+
if (contentArray) {
|
|
926
|
+
for (const block of contentArray) {
|
|
927
|
+
if (isImageBlock(block) && isBlobRef(block.data)) {
|
|
928
|
+
block.data = await resolveImageData(blobStore, block.data);
|
|
929
|
+
}
|
|
913
930
|
}
|
|
914
931
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
promises.push(resolvePersistedBlobRefs(entry, blobStore));
|
|
932
|
+
await resolvePersistedBlobRefs(entry, blobStore);
|
|
933
|
+
});
|
|
918
934
|
}
|
|
919
935
|
|
|
920
|
-
await
|
|
936
|
+
await runWithConcurrency(tasks, BLOB_RESOLVE_CONCURRENCY);
|
|
921
937
|
}
|
|
922
938
|
|
|
923
939
|
/**
|
|
@@ -15,6 +15,7 @@ function sanitizeOutputChunk(rawChunk: string): string {
|
|
|
15
15
|
export const DEFAULT_MAX_LINES = 3000;
|
|
16
16
|
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
|
17
17
|
export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
|
|
18
|
+
export const DEFAULT_ARTIFACT_MAX_BYTES = 10 * 1024 * 1024; // 10MB
|
|
18
19
|
|
|
19
20
|
const NL = "\n";
|
|
20
21
|
|
|
@@ -41,6 +42,8 @@ export interface OutputSummary {
|
|
|
41
42
|
columnTruncatedLines?: number;
|
|
42
43
|
/** Artifact ID for internal URL access (artifact://<id>) when truncated */
|
|
43
44
|
artifactId?: string;
|
|
45
|
+
/** Bytes omitted from artifact storage after the artifact hard cap was reached. */
|
|
46
|
+
artifactTruncatedBytes?: number;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export interface OutputSinkOptions {
|
|
@@ -61,6 +64,8 @@ export interface OutputSinkOptions {
|
|
|
61
64
|
* writes still respect the budget. Default 0 = no per-line cap.
|
|
62
65
|
*/
|
|
63
66
|
maxColumns?: number;
|
|
67
|
+
/** Hard cap for artifact writes/pending replay. Default DEFAULT_ARTIFACT_MAX_BYTES. */
|
|
68
|
+
artifactMaxBytes?: number;
|
|
64
69
|
onChunk?: (chunk: string) => void;
|
|
65
70
|
/** Minimum ms between onChunk calls. 0 = every chunk (default). */
|
|
66
71
|
chunkThrottleMs?: number;
|
|
@@ -668,6 +673,9 @@ export class OutputSink {
|
|
|
668
673
|
#sawData = false;
|
|
669
674
|
#truncated = false;
|
|
670
675
|
#lastChunkTime = 0;
|
|
676
|
+
#artifactBytes = 0;
|
|
677
|
+
#artifactTruncatedBytes = 0;
|
|
678
|
+
#artifactTruncationNoticeWritten = false;
|
|
671
679
|
|
|
672
680
|
// Per-line column cap streaming state (persists across `push` calls so a
|
|
673
681
|
// long line split across chunks still trips the same trigger).
|
|
@@ -697,6 +705,7 @@ export class OutputSink {
|
|
|
697
705
|
readonly #onRawChunk?: (chunk: string) => void;
|
|
698
706
|
readonly #chunkThrottleMs: number;
|
|
699
707
|
readonly #maxColumns: number;
|
|
708
|
+
readonly #artifactMaxBytes: number;
|
|
700
709
|
|
|
701
710
|
constructor(options?: OutputSinkOptions) {
|
|
702
711
|
const {
|
|
@@ -708,6 +717,7 @@ export class OutputSink {
|
|
|
708
717
|
onChunk,
|
|
709
718
|
chunkThrottleMs = 0,
|
|
710
719
|
onRawChunk,
|
|
720
|
+
artifactMaxBytes = DEFAULT_ARTIFACT_MAX_BYTES,
|
|
711
721
|
} = options ?? {};
|
|
712
722
|
this.#artifactPath = artifactPath;
|
|
713
723
|
this.#artifactId = artifactId;
|
|
@@ -717,6 +727,7 @@ export class OutputSink {
|
|
|
717
727
|
this.#onChunk = onChunk;
|
|
718
728
|
this.#onRawChunk = onRawChunk;
|
|
719
729
|
this.#chunkThrottleMs = chunkThrottleMs;
|
|
730
|
+
this.#artifactMaxBytes = Math.max(0, artifactMaxBytes);
|
|
720
731
|
}
|
|
721
732
|
|
|
722
733
|
#headText(): string {
|
|
@@ -907,6 +918,40 @@ export class OutputSink {
|
|
|
907
918
|
}
|
|
908
919
|
}
|
|
909
920
|
|
|
921
|
+
#artifactTruncationNotice(droppedBytes: number): string {
|
|
922
|
+
return `\n[artifact truncated after ${this.#artifactBytes} bytes; omitted at least ${droppedBytes} bytes]\n`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
#capArtifactChunk(chunk: string, bytes: number): { chunk: string; bytes: number } | null {
|
|
926
|
+
if (bytes === 0) return null;
|
|
927
|
+
if (this.#artifactMaxBytes <= 0 || this.#artifactBytes >= this.#artifactMaxBytes) {
|
|
928
|
+
this.#artifactTruncatedBytes += bytes;
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
const room = this.#artifactMaxBytes - this.#artifactBytes;
|
|
932
|
+
if (bytes <= room) {
|
|
933
|
+
return { chunk, bytes };
|
|
934
|
+
}
|
|
935
|
+
const kept = truncateHeadBytes(chunk, room);
|
|
936
|
+
this.#artifactTruncatedBytes += bytes - kept.bytes;
|
|
937
|
+
return kept.bytes > 0 ? { chunk: kept.text, bytes: kept.bytes } : null;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
#writeArtifactTruncationNotice(): void {
|
|
941
|
+
if (this.#artifactTruncatedBytes <= 0 || this.#artifactTruncationNoticeWritten) return;
|
|
942
|
+
const notice = this.#artifactTruncationNotice(this.#artifactTruncatedBytes);
|
|
943
|
+
try {
|
|
944
|
+
if (this.#fileReady && this.#file) {
|
|
945
|
+
this.#file.sink.write(notice);
|
|
946
|
+
} else {
|
|
947
|
+
this.#queuePendingFileWrite(notice, Buffer.byteLength(notice, "utf-8"));
|
|
948
|
+
}
|
|
949
|
+
this.#artifactTruncationNoticeWritten = true;
|
|
950
|
+
} catch {
|
|
951
|
+
/* ignore */
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
910
955
|
#queuePendingFileWrite(chunk: string, bytes = Buffer.byteLength(chunk, "utf-8")): void {
|
|
911
956
|
if (!this.#pendingFileWrites) this.#pendingFileWrites = [chunk];
|
|
912
957
|
else this.#pendingFileWrites.push(chunk);
|
|
@@ -915,14 +960,17 @@ export class OutputSink {
|
|
|
915
960
|
}
|
|
916
961
|
|
|
917
962
|
#enqueueFileWrite(chunk: string, bytes: number): void {
|
|
963
|
+
const capped = this.#capArtifactChunk(chunk, bytes);
|
|
964
|
+
if (!capped) return;
|
|
965
|
+
this.#artifactBytes += capped.bytes;
|
|
918
966
|
if (!this.#fileReady || !this.#file) {
|
|
919
|
-
this.#queuePendingFileWrite(chunk, bytes);
|
|
967
|
+
this.#queuePendingFileWrite(capped.chunk, capped.bytes);
|
|
920
968
|
if (this.#willOverflow(bytes) || this.#pendingFileWriteBytes > this.#spillThreshold) this.#createFileSink();
|
|
921
969
|
return;
|
|
922
970
|
}
|
|
923
971
|
|
|
924
972
|
try {
|
|
925
|
-
this.#file.sink.write(chunk);
|
|
973
|
+
this.#file.sink.write(capped.chunk);
|
|
926
974
|
} catch {
|
|
927
975
|
try {
|
|
928
976
|
void this.#file.sink.end();
|
|
@@ -931,7 +979,7 @@ export class OutputSink {
|
|
|
931
979
|
}
|
|
932
980
|
this.#file = undefined;
|
|
933
981
|
this.#fileReady = false;
|
|
934
|
-
this.#queuePendingFileWrite(chunk, bytes);
|
|
982
|
+
this.#queuePendingFileWrite(capped.chunk, capped.bytes);
|
|
935
983
|
this.#createFileSink();
|
|
936
984
|
}
|
|
937
985
|
}
|
|
@@ -1019,6 +1067,8 @@ export class OutputSink {
|
|
|
1019
1067
|
const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
|
|
1020
1068
|
|
|
1021
1069
|
let artifactId: string | undefined;
|
|
1070
|
+
if (this.#artifactTruncatedBytes > 0) this.#createFileSink();
|
|
1071
|
+
this.#writeArtifactTruncationNotice();
|
|
1022
1072
|
if (this.#file) {
|
|
1023
1073
|
artifactId = this.#file.artifactId;
|
|
1024
1074
|
await this.#file.sink.end();
|
|
@@ -1095,6 +1145,7 @@ export class OutputSink {
|
|
|
1095
1145
|
elidedLines,
|
|
1096
1146
|
columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
|
|
1097
1147
|
columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
|
|
1148
|
+
artifactTruncatedBytes: this.#artifactTruncatedBytes > 0 ? this.#artifactTruncatedBytes : undefined,
|
|
1098
1149
|
artifactId,
|
|
1099
1150
|
};
|
|
1100
1151
|
}
|
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { ThinkingLevel } from "@gajae-code/agent-core";
|
|
4
4
|
import { type Model, modelsAreEqual } from "@gajae-code/ai";
|
|
5
5
|
import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
|
|
6
|
+
import { Spacer, Text } from "@gajae-code/tui";
|
|
6
7
|
import { setProjectDir } from "@gajae-code/utils";
|
|
7
8
|
import { jobElapsedMs } from "../async";
|
|
8
9
|
import {
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
import { extractExplicitThinkingSelector, formatModelSelectorValue, parseModelPattern } from "../config/model-resolver";
|
|
14
15
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js";
|
|
15
16
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
17
|
+
import { DynamicBorder } from "../modes/components/dynamic-border";
|
|
18
|
+
import { theme } from "../modes/theme/theme";
|
|
16
19
|
import type { InteractiveModeContext } from "../modes/types";
|
|
17
20
|
import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
|
|
18
21
|
import {
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
} from "../setup/provider-onboarding";
|
|
24
27
|
import { parseThinkingLevel } from "../thinking";
|
|
25
28
|
import { buildContextReportText } from "./helpers/context-report";
|
|
29
|
+
import { buildFastStatusReport } from "./helpers/fast-status-report";
|
|
26
30
|
import { formatDuration } from "./helpers/format";
|
|
27
31
|
import { commandConsumed, errorMessage, parseSlashCommand, usage } from "./helpers/parse";
|
|
28
32
|
import { handleSshAcp } from "./helpers/ssh";
|
|
@@ -41,6 +45,14 @@ export type { BuiltinSlashCommand, SubcommandDef } from "./types";
|
|
|
41
45
|
/** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
|
|
42
46
|
export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
|
|
43
47
|
|
|
48
|
+
function fastStatusRoleTargets(): Array<{ id: GjcModelAssignmentTargetId; label: string; isSubagentRole: boolean }> {
|
|
49
|
+
return GJC_MODEL_ASSIGNMENT_TARGET_IDS.map(id => ({
|
|
50
|
+
id,
|
|
51
|
+
label: GJC_MODEL_ASSIGNMENT_TARGETS[id].tag ?? id.toUpperCase(),
|
|
52
|
+
isSubagentRole: GJC_MODEL_ASSIGNMENT_TARGETS[id].settingsPath === "task.agentModelOverrides",
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
function parseProviderSetupSlashArgs(args: string): {
|
|
45
57
|
preset?: string;
|
|
46
58
|
compat?: string;
|
|
@@ -357,7 +369,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
357
369
|
return commandConsumed();
|
|
358
370
|
}
|
|
359
371
|
if (arg === "status") {
|
|
360
|
-
await runtime.output(
|
|
372
|
+
await runtime.output(
|
|
373
|
+
buildFastStatusReport({
|
|
374
|
+
session: runtime.session,
|
|
375
|
+
roleTargets: fastStatusRoleTargets(),
|
|
376
|
+
iconFast: theme.icon.fast,
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
361
379
|
return commandConsumed();
|
|
362
380
|
}
|
|
363
381
|
return usage("Usage: /fast [on|off|status]", runtime);
|
|
@@ -386,8 +404,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
386
404
|
return;
|
|
387
405
|
}
|
|
388
406
|
if (arg === "status") {
|
|
389
|
-
const
|
|
390
|
-
|
|
407
|
+
const report = buildFastStatusReport({
|
|
408
|
+
session: runtime.ctx.session,
|
|
409
|
+
roleTargets: fastStatusRoleTargets(),
|
|
410
|
+
iconFast: theme.icon.fast,
|
|
411
|
+
formatInactive: text => theme.fg("dim", text),
|
|
412
|
+
});
|
|
413
|
+
runtime.ctx.chatContainer.addChild(new Spacer(1));
|
|
414
|
+
runtime.ctx.chatContainer.addChild(new DynamicBorder());
|
|
415
|
+
runtime.ctx.chatContainer.addChild(new Text(report, 1, 0));
|
|
416
|
+
runtime.ctx.chatContainer.addChild(new DynamicBorder());
|
|
417
|
+
runtime.ctx.ui.requestRender();
|
|
391
418
|
runtime.ctx.editor.setText("");
|
|
392
419
|
return;
|
|
393
420
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Model } from "@gajae-code/ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single line in the `/fast status` report: a labelled model and whether fast
|
|
5
|
+
* mode is effective for it. The `fast` flag is resolved by the caller
|
|
6
|
+
* (`buildFastStatusReport`) so each row can use the correct service tier — the
|
|
7
|
+
* main session tier for the current model / `modelRoles` roles, or the subagent
|
|
8
|
+
* tier (`task.serviceTier`) for `task.agentModelOverrides` roles.
|
|
9
|
+
*/
|
|
10
|
+
export interface FastStatusRow {
|
|
11
|
+
/** Display label, e.g. "현재 모델", "DEFAULT", "EXECUTOR". */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Resolved model for this row, if any. */
|
|
14
|
+
model?: Model;
|
|
15
|
+
/** Whether fast mode is effective for this row's model. */
|
|
16
|
+
fast: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FormatFastStatusReportArgs {
|
|
20
|
+
rows: FastStatusRow[];
|
|
21
|
+
/** The active theme's fast icon token (`theme.icon.fast`). */
|
|
22
|
+
iconFast: string;
|
|
23
|
+
/** Optional decorator for inactive ("off") text, e.g. theme dim in the TUI. */
|
|
24
|
+
formatInactive?: (text: string) => string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Title line of the `/fast status` report. */
|
|
28
|
+
export const FAST_STATUS_TITLE = "Fast 모드 상태";
|
|
29
|
+
|
|
30
|
+
/** The inactive marker shown for rows where fast mode does not apply. */
|
|
31
|
+
export const FAST_STATUS_OFF = "off";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format a multiline `/fast status` report. Pure and shared by the CLI
|
|
35
|
+
* (`handle`) and TUI (`handleTui`) command branches so the two never drift.
|
|
36
|
+
* Each row's fast/off state is decided by the caller (see
|
|
37
|
+
* {@link buildFastStatusReport}) so per-row service-tier differences are honored.
|
|
38
|
+
*/
|
|
39
|
+
export function formatFastStatusReport(args: FormatFastStatusReportArgs): string {
|
|
40
|
+
const { rows, iconFast } = args;
|
|
41
|
+
const formatInactive = args.formatInactive ?? ((text: string) => text);
|
|
42
|
+
const lines: string[] = [FAST_STATUS_TITLE];
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
if (!row.model) {
|
|
45
|
+
lines.push(`${row.label}: ${formatInactive(FAST_STATUS_OFF)}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const ref = `${row.model.provider}/${row.model.id}`;
|
|
49
|
+
lines.push(`${row.label}: ${ref} ${row.fast ? iconFast : formatInactive(FAST_STATUS_OFF)}`);
|
|
50
|
+
}
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Minimal session surface needed to build the `/fast status` report. */
|
|
55
|
+
export interface FastStatusSessionLike {
|
|
56
|
+
readonly model?: Model;
|
|
57
|
+
/** Fast predicate against the main session tier (current model + `modelRoles`). */
|
|
58
|
+
isFastForProvider(provider?: string): boolean;
|
|
59
|
+
/** Fast predicate against the effective subagent tier (`task.agentModelOverrides` roles). */
|
|
60
|
+
isFastForSubagentProvider(provider?: string): boolean;
|
|
61
|
+
resolveRoleModelWithThinking(role: string): { model?: Model };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A role to enumerate in the report, with the tier source its subagent runs under. */
|
|
65
|
+
export interface FastStatusRoleTarget {
|
|
66
|
+
id: string;
|
|
67
|
+
label: string;
|
|
68
|
+
/**
|
|
69
|
+
* True for `task.agentModelOverrides` roles (executor/architect/planner/critic)
|
|
70
|
+
* that run under `task.serviceTier`; false for `modelRoles` roles (default)
|
|
71
|
+
* that run under the main session tier.
|
|
72
|
+
*/
|
|
73
|
+
isSubagentRole: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface BuildFastStatusReportArgs {
|
|
77
|
+
session: FastStatusSessionLike;
|
|
78
|
+
/** Role targets to enumerate, in display order. */
|
|
79
|
+
roleTargets: ReadonlyArray<FastStatusRoleTarget>;
|
|
80
|
+
/** The active theme's fast icon token (`theme.icon.fast`). */
|
|
81
|
+
iconFast: string;
|
|
82
|
+
/** Optional decorator for inactive ("off") text, e.g. theme dim in the TUI. */
|
|
83
|
+
formatInactive?: (text: string) => string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the `/fast status` report from a live session: the active/current model
|
|
88
|
+
* followed by each assigned role (subagent) model. Unassigned roles are skipped
|
|
89
|
+
* so the report mirrors the `/model` selector, which only badges assigned roles.
|
|
90
|
+
*
|
|
91
|
+
* Subagent roles (`task.agentModelOverrides`) are evaluated against the
|
|
92
|
+
* effective subagent tier (`task.serviceTier`), while the current model and
|
|
93
|
+
* `modelRoles` roles use the main session tier — matching where each model
|
|
94
|
+
* actually runs.
|
|
95
|
+
*/
|
|
96
|
+
export function buildFastStatusReport(args: BuildFastStatusReportArgs): string {
|
|
97
|
+
const { session, roleTargets, iconFast, formatInactive } = args;
|
|
98
|
+
const rows: FastStatusRow[] = [
|
|
99
|
+
{ label: "현재 모델", model: session.model, fast: session.isFastForProvider(session.model?.provider) },
|
|
100
|
+
];
|
|
101
|
+
for (const target of roleTargets) {
|
|
102
|
+
const resolved = session.resolveRoleModelWithThinking(target.id);
|
|
103
|
+
if (resolved.model) {
|
|
104
|
+
const fast = target.isSubagentRole
|
|
105
|
+
? session.isFastForSubagentProvider(resolved.model.provider)
|
|
106
|
+
: session.isFastForProvider(resolved.model.provider);
|
|
107
|
+
rows.push({ label: target.label, model: resolved.model, fast });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return formatFastStatusReport({ rows, iconFast, formatInactive });
|
|
111
|
+
}
|
|
@@ -315,7 +315,16 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
|
|
|
315
315
|
throw new ToolError(`Unsupported archive format: ${filePath}`);
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
-
|
|
318
|
+
// F20: cap the compressed archive read so opening a multi-GB archive cannot buffer it
|
|
319
|
+
// whole into memory. (Zip-bomb expanded-size bounding would need a streaming inflate.)
|
|
320
|
+
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
|
321
|
+
const file = Bun.file(filePath);
|
|
322
|
+
if (file.size > MAX_ARCHIVE_BYTES) {
|
|
323
|
+
throw new ToolError(
|
|
324
|
+
`Archive too large to open: ${filePath} is ${file.size} bytes (limit ${MAX_ARCHIVE_BYTES}). Extract a subset with a dedicated tool.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const bytes = await file.bytes();
|
|
319
328
|
const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
|
|
320
329
|
return new ArchiveReader(format, entries);
|
|
321
330
|
}
|
package/src/tools/bash.ts
CHANGED
|
@@ -37,8 +37,13 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
|
37
37
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
38
38
|
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
|
|
39
39
|
|
|
40
|
-
async function
|
|
40
|
+
export async function saveBashOriginalArtifactForTests(
|
|
41
|
+
session: ToolSession,
|
|
42
|
+
originalText: string,
|
|
43
|
+
): Promise<string | undefined> {
|
|
41
44
|
try {
|
|
45
|
+
const manager = session.getArtifactManager?.();
|
|
46
|
+
if (manager) return await manager.save(originalText, "bash-original");
|
|
42
47
|
const alloc = await session.allocateOutputArtifact?.("bash-original");
|
|
43
48
|
if (!alloc?.path || !alloc.id) return undefined;
|
|
44
49
|
await Bun.write(alloc.path, originalText);
|
|
@@ -375,6 +380,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
375
380
|
env: options.resolvedEnv,
|
|
376
381
|
artifactPath,
|
|
377
382
|
artifactId,
|
|
383
|
+
oneShot: true,
|
|
378
384
|
onChunk: chunk => {
|
|
379
385
|
tailBuffer.append(chunk);
|
|
380
386
|
latestText = tailBuffer.text();
|
|
@@ -387,7 +393,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
387
393
|
// path above.
|
|
388
394
|
manager.appendOutput(jobId, chunk);
|
|
389
395
|
},
|
|
390
|
-
onMinimizedSave: originalText =>
|
|
396
|
+
onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
|
|
391
397
|
});
|
|
392
398
|
const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
|
|
393
399
|
requestedTimeoutSec: options.requestedTimeoutSec,
|
|
@@ -675,6 +681,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
675
681
|
env: prepared.resolvedEnv,
|
|
676
682
|
artifactPath,
|
|
677
683
|
artifactId,
|
|
684
|
+
oneShot: true,
|
|
678
685
|
onChunk: chunk => {
|
|
679
686
|
tailBuffer.append(chunk);
|
|
680
687
|
void reportProgress(tailBuffer.text(), {
|
|
@@ -688,7 +695,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
688
695
|
cursorOffset = slice.nextOffset;
|
|
689
696
|
dispatchLines(slice.text);
|
|
690
697
|
},
|
|
691
|
-
onMinimizedSave: originalText =>
|
|
698
|
+
onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
|
|
692
699
|
});
|
|
693
700
|
flushTrailingLine();
|
|
694
701
|
this.#buildResultText(result, prepared.timeoutSec, result.output || "(no output)");
|
|
@@ -996,7 +1003,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
996
1003
|
artifactPath,
|
|
997
1004
|
artifactId,
|
|
998
1005
|
onChunk: streamTailUpdates(tailBuffer, onUpdate),
|
|
999
|
-
onMinimizedSave: originalText =>
|
|
1006
|
+
onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
|
|
1000
1007
|
});
|
|
1001
1008
|
if (result.cancelled) {
|
|
1002
1009
|
if (signal?.aborted) {
|
|
@@ -55,6 +55,8 @@ export interface TabSession {
|
|
|
55
55
|
pending: Map<string, PendingRun>;
|
|
56
56
|
dialogPolicy?: DialogPolicy;
|
|
57
57
|
kindTag: BrowserKindTag;
|
|
58
|
+
/** Session that acquired this tab; used for session-scoped teardown (F13). */
|
|
59
|
+
ownerId?: string;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export interface AcquireTabOptions {
|
|
@@ -65,6 +67,8 @@ export interface AcquireTabOptions {
|
|
|
65
67
|
signal?: AbortSignal;
|
|
66
68
|
timeoutMs: number;
|
|
67
69
|
dialogs?: DialogPolicy;
|
|
70
|
+
/** Owning session id so dispose can release only this session's tabs (F13). */
|
|
71
|
+
ownerId?: string;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
export interface AcquireTabResult {
|
|
@@ -161,6 +165,7 @@ export async function acquireTab(
|
|
|
161
165
|
pending: new Map(),
|
|
162
166
|
dialogPolicy: opts.dialogs,
|
|
163
167
|
kindTag: browser.kind.kind,
|
|
168
|
+
ownerId: opts.ownerId,
|
|
164
169
|
};
|
|
165
170
|
worker.onMessage(msg => handleTabMessage(tab, msg));
|
|
166
171
|
tabs.set(name, tab);
|
|
@@ -254,6 +259,23 @@ export async function releaseAllTabs(opts: ReleaseTabOptions = {}): Promise<numb
|
|
|
254
259
|
return count;
|
|
255
260
|
}
|
|
256
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Release only the tabs owned by `ownerId` (F13 session-scoped teardown). Tabs acquired
|
|
264
|
+
* by other sessions (or with no owner) are left untouched. No-op for a null/empty owner.
|
|
265
|
+
*/
|
|
266
|
+
export async function releaseTabsForOwner(
|
|
267
|
+
ownerId: string | null | undefined,
|
|
268
|
+
opts: ReleaseTabOptions = {},
|
|
269
|
+
): Promise<number> {
|
|
270
|
+
if (!ownerId) return 0;
|
|
271
|
+
const names = [...tabs.entries()].filter(([, tab]) => tab.ownerId === ownerId).map(([name]) => name);
|
|
272
|
+
let count = 0;
|
|
273
|
+
for (const name of names) {
|
|
274
|
+
if (await releaseTab(name, opts)) count++;
|
|
275
|
+
}
|
|
276
|
+
return count;
|
|
277
|
+
}
|
|
278
|
+
|
|
257
279
|
export async function dropHeadlessTabs(): Promise<void> {
|
|
258
280
|
const names = [...tabs.values()].filter(tab => tab.kindTag === "headless").map(tab => tab.name);
|
|
259
281
|
for (const name of names) await releaseTab(name);
|
package/src/tools/browser.ts
CHANGED
|
@@ -213,6 +213,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
|
|
213
213
|
|
|
214
214
|
const result = await untilAborted(signal, () =>
|
|
215
215
|
acquireTab(name, browser, {
|
|
216
|
+
ownerId: this.session.getSessionId?.() ?? undefined,
|
|
216
217
|
url: params.url,
|
|
217
218
|
waitUntil: params.wait_until,
|
|
218
219
|
viewport: params.viewport
|
|
@@ -381,11 +382,44 @@ function sameBrowserKind(a: BrowserKind, b: BrowserKind): boolean {
|
|
|
381
382
|
return false;
|
|
382
383
|
}
|
|
383
384
|
|
|
385
|
+
/** Max chars of a browser return value surfaced into the tool result (F22). */
|
|
386
|
+
const MAX_BROWSER_RETURN_CHARS = 256 * 1024;
|
|
387
|
+
|
|
388
|
+
const BROWSER_RETURN_BUDGET_EXCEEDED = Symbol("browser-return-budget-exceeded");
|
|
389
|
+
|
|
390
|
+
/** Hard-cap any surfaced browser return string at the byte/char limit with a notice. */
|
|
391
|
+
function capBrowserReturn(text: string): string {
|
|
392
|
+
if (text.length <= MAX_BROWSER_RETURN_CHARS) return text;
|
|
393
|
+
return `${text.slice(0, MAX_BROWSER_RETURN_CHARS)}\n\n[Browser return value truncated: ${text.length} chars exceeds the ${MAX_BROWSER_RETURN_CHARS}-char cap.]`;
|
|
394
|
+
}
|
|
395
|
+
|
|
384
396
|
function stringifyReturnValue(value: unknown): string {
|
|
385
|
-
if (typeof value === "string") return value;
|
|
397
|
+
if (typeof value === "string") return capBrowserReturn(value);
|
|
398
|
+
// F22: bound the serialization itself — the replacer tracks running size and aborts early so a
|
|
399
|
+
// huge object/array cannot build megabytes before truncation — AND hard-cap the final string,
|
|
400
|
+
// since pretty-print structural overhead (indent/braces/commas) is not counted by the budget.
|
|
401
|
+
let budget = MAX_BROWSER_RETURN_CHARS;
|
|
386
402
|
try {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
403
|
+
const text = JSON.stringify(
|
|
404
|
+
value,
|
|
405
|
+
(_key, val) => {
|
|
406
|
+
if (typeof val === "string") budget -= val.length + 4;
|
|
407
|
+
else if (typeof val === "number" || typeof val === "boolean") budget -= 8;
|
|
408
|
+
else budget -= 2;
|
|
409
|
+
if (budget < 0) throw BROWSER_RETURN_BUDGET_EXCEEDED;
|
|
410
|
+
return val;
|
|
411
|
+
},
|
|
412
|
+
2,
|
|
413
|
+
);
|
|
414
|
+
return text === undefined ? capBrowserReturn(String(value)) : capBrowserReturn(text);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error === BROWSER_RETURN_BUDGET_EXCEEDED) {
|
|
417
|
+
return `[Browser return value too large to serialize (exceeds the ${MAX_BROWSER_RETURN_CHARS}-char cap). Return a smaller or summarized value from the page script.]`;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
return capBrowserReturn(String(value));
|
|
421
|
+
} catch {
|
|
422
|
+
return "[unserializable browser return value]";
|
|
423
|
+
}
|
|
390
424
|
}
|
|
391
425
|
}
|
package/src/tools/read.ts
CHANGED
|
@@ -1270,19 +1270,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1270
1270
|
}
|
|
1271
1271
|
case "raw": {
|
|
1272
1272
|
const result = executeReadQuery(db, selector.sql);
|
|
1273
|
+
const table = renderTable(result.columns, result.rows, {
|
|
1274
|
+
totalCount: result.rows.length,
|
|
1275
|
+
offset: 0,
|
|
1276
|
+
limit: result.rows.length || DEFAULT_MAX_LINES,
|
|
1277
|
+
table: "query",
|
|
1278
|
+
dbPath: resolvedSqlitePath.absolutePath,
|
|
1279
|
+
});
|
|
1280
|
+
const body = result.truncated
|
|
1281
|
+
? `${table}\n\n[Output truncated to the first ${result.rows.length} rows; add a LIMIT clause to the query to bound or page the result.]`
|
|
1282
|
+
: table;
|
|
1273
1283
|
return toolResult<ReadToolDetails>(details)
|
|
1274
|
-
.text(
|
|
1275
|
-
prependSuffixResolutionNotice(
|
|
1276
|
-
renderTable(result.columns, result.rows, {
|
|
1277
|
-
totalCount: result.rows.length,
|
|
1278
|
-
offset: 0,
|
|
1279
|
-
limit: result.rows.length || DEFAULT_MAX_LINES,
|
|
1280
|
-
table: "query",
|
|
1281
|
-
dbPath: resolvedSqlitePath.absolutePath,
|
|
1282
|
-
}),
|
|
1283
|
-
resolvedSqlitePath.suffixResolution,
|
|
1284
|
-
),
|
|
1285
|
-
)
|
|
1284
|
+
.text(prependSuffixResolutionNotice(body, resolvedSqlitePath.suffixResolution))
|
|
1286
1285
|
.sourcePath(resolvedSqlitePath.absolutePath)
|
|
1287
1286
|
.done();
|
|
1288
1287
|
}
|