@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
@@ -167,19 +167,49 @@ export class EphemeralBlobStore extends BlobStore {
167
167
  }
168
168
 
169
169
  export class MemoryBlobStore extends BlobStore {
170
+ /**
171
+ * Generous byte/count LRU bound (F8). Content-addressed resident blobs are fail-closed
172
+ * on miss (callers raise/handle {@link ResidentBlobMissingError}), so evicting the
173
+ * least-recently-used entry on an extremely large session is preferable to unbounded
174
+ * RAM growth. The caps sit well above normal usage and only trip on pathological sizes.
175
+ */
176
+ static readonly #MAX_BYTES = 64 * 1024 * 1024;
177
+ static readonly #MAX_COUNT = 4096;
178
+
170
179
  #blobs = new Map<string, Buffer>();
180
+ #bytes = 0;
171
181
 
172
182
  constructor() {
173
183
  super(":memory:");
174
184
  }
175
185
 
186
+ #store(hash: string, data: Buffer): void {
187
+ const existing = this.#blobs.get(hash);
188
+ if (existing) {
189
+ this.#blobs.delete(hash);
190
+ this.#bytes -= existing.byteLength;
191
+ }
192
+ this.#blobs.set(hash, data);
193
+ this.#bytes += data.byteLength;
194
+ while (
195
+ (this.#bytes > MemoryBlobStore.#MAX_BYTES || this.#blobs.size > MemoryBlobStore.#MAX_COUNT) &&
196
+ this.#blobs.size > 1
197
+ ) {
198
+ const oldest = this.#blobs.keys().next().value;
199
+ if (oldest === undefined) break;
200
+ const evicted = this.#blobs.get(oldest);
201
+ this.#blobs.delete(oldest);
202
+ if (evicted) this.#bytes -= evicted.byteLength;
203
+ }
204
+ }
205
+
176
206
  async put(data: Buffer): Promise<BlobPutResult> {
177
207
  return this.putSync(data);
178
208
  }
179
209
 
180
210
  putSync(data: Buffer): BlobPutResult {
181
211
  const hash = new Bun.SHA256().update(data).digest("hex");
182
- this.#blobs.set(hash, Buffer.from(data));
212
+ this.#store(hash, Buffer.from(data));
183
213
  return {
184
214
  hash,
185
215
  path: `memory:${hash}`,
@@ -195,7 +225,11 @@ export class MemoryBlobStore extends BlobStore {
195
225
 
196
226
  getSync(hash: string): Buffer | null {
197
227
  const data = this.#blobs.get(hash);
198
- return data ? Buffer.from(data) : null;
228
+ if (!data) return null;
229
+ // Refresh LRU recency on hit so hot blobs survive eviction.
230
+ this.#blobs.delete(hash);
231
+ this.#blobs.set(hash, data);
232
+ return Buffer.from(data);
199
233
  }
200
234
 
201
235
  async has(hash: string): Promise<boolean> {
@@ -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 promises: Promise<void>[] = [];
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
- if (contentArray) {
906
- for (const block of contentArray) {
907
- if (isImageBlock(block) && isBlobRef(block.data)) {
908
- promises.push(
909
- resolveImageData(blobStore, block.data).then(resolved => {
910
- block.data = resolved;
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 Promise.all(promises);
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(`Fast mode is ${runtime.session.isFastModeEnabled() ? "on" : "off"}.`);
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 enabled = runtime.ctx.session.isFastModeEnabled();
390
- runtime.ctx.showStatus(`Fast mode is ${enabled ? "on" : "off"}.`);
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
- const bytes = await Bun.file(filePath).bytes();
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 saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
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 => saveBashOriginalArtifact(this.session, 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 => saveBashOriginalArtifact(this.session, 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 => saveBashOriginalArtifact(this.session, 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);
@@ -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
- return JSON.stringify(value, null, 2) ?? String(value);
388
- } catch {
389
- return String(value);
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
  }