@gajae-code/coding-agent 0.11.2 → 0.11.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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.3] - 2026-07-19
6
+
7
+ ### Changed
8
+ - The `read` tool is now receipt-by-default: bare and unparseable reads return a bounded receipt (≤50 lines / 10 KiB) with a re-read-with-selector footer only when truncated, `:raw` stays pure verbatim up to a max(2 MiB, spill threshold) ceiling, structural summaries cap unit-granularly at 20 KiB while preserving elision and source-recovery footers, and directories are byte/line capped and never spill. Only an explicit full-content selector (`:raw` or an explicit range) with real content is spill-eligible. Subagent previews now enforce a real byte/code-point cap via per-shape render budgets plus a shape-aware artifact-eligibility tag enforced centrally in output-meta.
9
+
10
+ ### Fixed
11
+ - Workflow-state handoff no longer self-locks the active-state cache, so a same-turn skill handoff (e.g. ultragoal → ralplan) completes instead of stalling behind a lock the handoff itself still holds (#2638).
12
+ - SDK host shutdown now retries a failed broker unregister instead of short-circuiting with a stale broker-index entry, while retained startup-cleanup owner-release failures remain isolated from the red extension-error path (#2625).
13
+ - Non-TTY launches now fail fast when stdin is empty and automatically use print mode for positional prompts and `@file` inputs, preventing orphaned interactive TUI processes (#2507).
14
+
5
15
  ## [0.11.2] - 2026-07-19
6
16
 
7
17
  ### Changed
@@ -0,0 +1,23 @@
1
+ export type LaunchMode = "text" | "json" | "acp";
2
+ export interface LaunchDisposition {
3
+ autoPrint: boolean;
4
+ isInteractive: boolean;
5
+ /** Set when an interactive launch is impossible (non-TTY stdin, nothing to run). */
6
+ nonInteractiveError?: string;
7
+ }
8
+ /**
9
+ * Decides between interactive, auto-print, and fail-fast launches.
10
+ *
11
+ * A non-TTY stdin means the TUI can never receive input, so an interactive
12
+ * launch would execute the initial prompt and then block in the event loop
13
+ * forever. Prepared command-line input degrades to print mode; without any
14
+ * input to run we fail fast instead of hanging. Explicit `--print` and
15
+ * `--mode` launches are left untouched.
16
+ */
17
+ export declare function resolveLaunchDisposition(args: {
18
+ stdinIsTTY: boolean | undefined;
19
+ pipedInput: string | undefined;
20
+ hasPreparedInput: boolean;
21
+ print: boolean;
22
+ mode: LaunchMode | undefined;
23
+ }): LaunchDisposition;
@@ -662,15 +662,15 @@ export declare const SETTINGS_SCHEMA: {
662
662
  };
663
663
  readonly "tools.readArtifactSpillThreshold": {
664
664
  readonly type: "number";
665
- readonly default: 0;
665
+ readonly default: 256;
666
666
  readonly ui: {
667
667
  readonly tab: "tools";
668
- readonly label: "Experimental read-tool artifact spill threshold (KB)";
669
- readonly description: "Advanced opt-in only: combined-size cap for `read` output across all requested ranges. Above this the full output is saved as an artifact and a bounded head+tail snippet is kept inline. Live layofflabs/gpt-5.5 medium evidence on 2026-07-07 found no token savings and a lower cache-hit rate at 50 KB, so normal coding sessions should leave this Off unless intentionally measuring large-read behavior.";
668
+ readonly label: "Read artifact spill threshold (KB)";
669
+ readonly description: "Explicit large reads above this combined size are saved as an artifact with a bounded head-and-tail snippet inline. Bare reads, directories, and converted-document receipts remain inline.";
670
670
  readonly options: readonly [{
671
671
  readonly value: "0";
672
672
  readonly label: "Off";
673
- readonly description: "Default; no read-specific spill (backstop only)";
673
+ readonly description: "No read-specific spill (backstop only)";
674
674
  }, {
675
675
  readonly value: "50";
676
676
  readonly label: "50 KB";
@@ -682,7 +682,7 @@ export declare const SETTINGS_SCHEMA: {
682
682
  }, {
683
683
  readonly value: "256";
684
684
  readonly label: "256 KB";
685
- readonly description: "~64K tokens";
685
+ readonly description: "Default; ~64K tokens";
686
686
  }, {
687
687
  readonly value: "512";
688
688
  readonly label: "512 KB";
@@ -696,11 +696,11 @@ export declare const SETTINGS_SCHEMA: {
696
696
  };
697
697
  readonly "tools.fileMentionInlineBytes": {
698
698
  readonly type: "number";
699
- readonly default: 20;
699
+ readonly default: 10;
700
700
  readonly ui: {
701
701
  readonly tab: "tools";
702
702
  readonly label: "File-mention inline cap (KB)";
703
- readonly description: "Inline byte cap for auto-read `@path` file mentions, deliberately below the read-tool cap so an incidental mention injects a smaller snippet than an explicit read. The full file is still available via the read tool.";
703
+ readonly description: "Inline byte cap for auto-read `@path` file mentions, aligned with the 10 KiB bare-read receipt so incidental mentions stay within the same bounded context budget. The full file is still available via the read tool.";
704
704
  readonly options: readonly [{
705
705
  readonly value: "5";
706
706
  readonly label: "5 KB";
@@ -708,15 +708,15 @@ export declare const SETTINGS_SCHEMA: {
708
708
  }, {
709
709
  readonly value: "10";
710
710
  readonly label: "10 KB";
711
- readonly description: "~2.5K tokens";
711
+ readonly description: "Default; ~2.5K tokens";
712
712
  }, {
713
713
  readonly value: "20";
714
714
  readonly label: "20 KB";
715
- readonly description: "Default; ~5K tokens";
715
+ readonly description: "~5K tokens";
716
716
  }, {
717
717
  readonly value: "50";
718
718
  readonly label: "50 KB";
719
- readonly description: "~12.5K tokens (matches read cap)";
719
+ readonly description: "~12.5K tokens (matches bare-read receipt)";
720
720
  }];
721
721
  };
722
722
  };
@@ -2437,6 +2437,81 @@ export declare const SETTINGS_SCHEMA: {
2437
2437
  }];
2438
2438
  };
2439
2439
  };
2440
+ readonly "read.receiptBudgetLines": {
2441
+ readonly type: "number";
2442
+ readonly default: 50;
2443
+ readonly ui: {
2444
+ readonly tab: "editing";
2445
+ readonly label: "Read Receipt Line Budget";
2446
+ readonly description: "Maximum lines included in a bare read receipt before a selector footer is shown";
2447
+ readonly options: readonly [{
2448
+ readonly value: "25";
2449
+ readonly label: "25 lines";
2450
+ }, {
2451
+ readonly value: "50";
2452
+ readonly label: "50 lines";
2453
+ readonly description: "Default";
2454
+ }, {
2455
+ readonly value: "100";
2456
+ readonly label: "100 lines";
2457
+ }, {
2458
+ readonly value: "200";
2459
+ readonly label: "200 lines";
2460
+ }];
2461
+ };
2462
+ };
2463
+ readonly "read.receiptBudgetBytes": {
2464
+ readonly type: "number";
2465
+ readonly default: 10;
2466
+ readonly ui: {
2467
+ readonly tab: "editing";
2468
+ readonly label: "Read Receipt Byte Budget (KB)";
2469
+ readonly description: "Maximum UTF-8 body size for a bare read receipt before a selector footer is shown";
2470
+ readonly options: readonly [{
2471
+ readonly value: "5";
2472
+ readonly label: "5 KB";
2473
+ readonly description: "~1.25K tokens";
2474
+ }, {
2475
+ readonly value: "10";
2476
+ readonly label: "10 KB";
2477
+ readonly description: "Default; ~2.5K tokens";
2478
+ }, {
2479
+ readonly value: "20";
2480
+ readonly label: "20 KB";
2481
+ readonly description: "~5K tokens";
2482
+ }, {
2483
+ readonly value: "50";
2484
+ readonly label: "50 KB";
2485
+ readonly description: "~12.5K tokens";
2486
+ }];
2487
+ };
2488
+ };
2489
+ readonly "read.summaryMaxBytes": {
2490
+ readonly type: "number";
2491
+ readonly default: 20;
2492
+ readonly ui: {
2493
+ readonly tab: "editing";
2494
+ readonly label: "Read Summary Size Budget (KB)";
2495
+ readonly description: "Maximum UTF-8 size for a structural read summary before additional units are elided";
2496
+ readonly options: readonly [{
2497
+ readonly value: "10";
2498
+ readonly label: "10 KB";
2499
+ readonly description: "~2.5K tokens";
2500
+ }, {
2501
+ readonly value: "20";
2502
+ readonly label: "20 KB";
2503
+ readonly description: "Default; ~5K tokens";
2504
+ }, {
2505
+ readonly value: "50";
2506
+ readonly label: "50 KB";
2507
+ readonly description: "~12.5K tokens";
2508
+ }, {
2509
+ readonly value: "100";
2510
+ readonly label: "100 KB";
2511
+ readonly description: "~25K tokens";
2512
+ }];
2513
+ };
2514
+ };
2440
2515
  readonly "read.summarize.enabled": {
2441
2516
  readonly type: "boolean";
2442
2517
  readonly default: true;
@@ -118,6 +118,7 @@ export interface RunRootCommandDependencies {
118
118
  };
119
119
  initTheme?: typeof initTheme;
120
120
  readPipedInput?: typeof readPipedInput;
121
+ stdinIsTTY?: boolean;
121
122
  runStartupCredentialAutoImportIfNeeded?: typeof runStartupCredentialAutoImportIfNeeded;
122
123
  getChangelogForDisplay?: typeof getChangelogForDisplay;
123
124
  createInteractiveMode?: CreateInteractiveMode;
@@ -30,6 +30,10 @@ export declare class PromptActionAutocompleteProvider implements AutocompletePro
30
30
  items: AutocompleteItem[];
31
31
  prefix: string;
32
32
  } | null>;
33
+ getForceFileSuggestions(lines: string[], cursorLine: number, cursorCol: number): Promise<{
34
+ items: AutocompleteItem[];
35
+ prefix: string;
36
+ } | null>;
33
37
  applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string): {
34
38
  lines: string[];
35
39
  cursorLine: number;
@@ -338,6 +338,8 @@ export declare function createNotificationsExtension(api: ExtensionAPI, options?
338
338
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
339
339
  spawnedByGjc?: boolean;
340
340
  controller?: NotificationSessionController;
341
+ /** Whether this host mode can own the root SDK endpoint. Default: true. */
342
+ sdkHostModeSupported?: boolean;
341
343
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
342
344
  runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{
343
345
  replyText: string;
@@ -150,6 +150,8 @@ export interface CreateAgentSessionOptions {
150
150
  hasUI?: boolean;
151
151
  /** Whether this host mode can own a notification session endpoint. Default: true. */
152
152
  notificationHostModeSupported?: boolean;
153
+ /** Whether this host mode can own the root SDK endpoint. Default: true. */
154
+ sdkHostModeSupported?: boolean;
153
155
  /**
154
156
  * Opt-in OpenTelemetry instrumentation forwarded to the underlying Agent.
155
157
  * Passing `{}` enables the loop's GenAI-semantic-convention spans. See
@@ -27,6 +27,8 @@ export interface ReadToolDetails {
27
27
  method?: string;
28
28
  notes?: string[];
29
29
  meta?: OutputMeta;
30
+ /** Whether this explicit content read may be spilled by output metadata. */
31
+ spillEligible?: boolean;
30
32
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
31
33
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
32
34
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -98,6 +98,7 @@ export declare class SubagentTool implements AgentTool<typeof subagentSchema, Su
98
98
  constructor(session: ToolSession);
99
99
  execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<SubagentToolDetails>, _context?: AgentToolContext): Promise<AgentToolResult<SubagentToolDetails>>;
100
100
  }
101
+ export declare function capCodePointsAndBytes(text: string, maxCodePoints: number, maxBytes: number): string;
101
102
  /**
102
103
  * Canonical, value-based rendered-state signature for the `subagent` await panel.
103
104
  *
@@ -1,8 +1,8 @@
1
1
  import type { AgentMessage } from "@gajae-code/agent-core";
2
2
  /**
3
- * Default inline cap for auto-read file mentions, deliberately below the
4
- * read-tool cap (`DEFAULT_MAX_BYTES`, 50KB) so an incidental `@path` mention
5
- * injects a smaller snippet than an explicit `read`. Overridable per call.
3
+ * Default inline cap for auto-read file mentions, aligned with the read-tool's
4
+ * 10 KiB bare-read receipt so an incidental `@path` mention injects a bounded
5
+ * snippet. Overridable per call.
6
6
  */
7
7
  export declare const DEFAULT_FILE_MENTION_INLINE_BYTES: number;
8
8
  /** Extract all @filepath mentions from text */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.11.2",
4
+ "version": "0.11.3",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -53,13 +53,13 @@
53
53
  "@agentclientprotocol/sdk": "1.2.1",
54
54
  "@babel/parser": "^7.29.3",
55
55
  "@mozilla/readability": "^0.6.0",
56
- "@gajae-code/stats": "0.11.2",
57
- "@gajae-code/agent-core": "0.11.2",
58
- "@gajae-code/ai": "0.11.2",
59
- "@gajae-code/bridge-client": "0.11.2",
60
- "@gajae-code/natives": "0.11.2",
61
- "@gajae-code/tui": "0.11.2",
62
- "@gajae-code/utils": "0.11.2",
56
+ "@gajae-code/stats": "0.11.3",
57
+ "@gajae-code/agent-core": "0.11.3",
58
+ "@gajae-code/ai": "0.11.3",
59
+ "@gajae-code/bridge-client": "0.11.3",
60
+ "@gajae-code/natives": "0.11.3",
61
+ "@gajae-code/tui": "0.11.3",
62
+ "@gajae-code/utils": "0.11.3",
63
63
  "@puppeteer/browsers": "^2.13.0",
64
64
  "@types/turndown": "5.0.6",
65
65
  "@xterm/headless": "^6.0.0",
@@ -0,0 +1,42 @@
1
+ export type LaunchMode = "text" | "json" | "acp";
2
+
3
+ export interface LaunchDisposition {
4
+ autoPrint: boolean;
5
+ isInteractive: boolean;
6
+ /** Set when an interactive launch is impossible (non-TTY stdin, nothing to run). */
7
+ nonInteractiveError?: string;
8
+ }
9
+
10
+ /**
11
+ * Decides between interactive, auto-print, and fail-fast launches.
12
+ *
13
+ * A non-TTY stdin means the TUI can never receive input, so an interactive
14
+ * launch would execute the initial prompt and then block in the event loop
15
+ * forever. Prepared command-line input degrades to print mode; without any
16
+ * input to run we fail fast instead of hanging. Explicit `--print` and
17
+ * `--mode` launches are left untouched.
18
+ */
19
+ export function resolveLaunchDisposition(args: {
20
+ stdinIsTTY: boolean | undefined;
21
+ pipedInput: string | undefined;
22
+ hasPreparedInput: boolean;
23
+ print: boolean;
24
+ mode: LaunchMode | undefined;
25
+ }): LaunchDisposition {
26
+ const explicitNonInteractive = args.print || args.mode !== undefined;
27
+ const hasPreparedInput = args.pipedInput !== undefined || args.hasPreparedInput;
28
+ const hasNonTtyStdin = args.stdinIsTTY !== true;
29
+ const autoPrint = !explicitNonInteractive && hasNonTtyStdin && hasPreparedInput;
30
+ const isInteractive = !explicitNonInteractive && !autoPrint;
31
+
32
+ if (isInteractive && args.stdinIsTTY !== true) {
33
+ return {
34
+ autoPrint: false,
35
+ isInteractive: false,
36
+ nonInteractiveError:
37
+ "stdin is not a TTY and no prompt or prepared input was provided; use -p/--print (or pass a prompt or @file) for non-interactive runs.",
38
+ };
39
+ }
40
+
41
+ return { autoPrint, isInteractive };
42
+ }
@@ -669,17 +669,17 @@ export const SETTINGS_SCHEMA = {
669
669
 
670
670
  "tools.readArtifactSpillThreshold": {
671
671
  type: "number",
672
- default: 0,
672
+ default: 256,
673
673
  ui: {
674
674
  tab: "tools",
675
- label: "Experimental read-tool artifact spill threshold (KB)",
675
+ label: "Read artifact spill threshold (KB)",
676
676
  description:
677
- "Advanced opt-in only: combined-size cap for `read` output across all requested ranges. Above this the full output is saved as an artifact and a bounded head+tail snippet is kept inline. Live layofflabs/gpt-5.5 medium evidence on 2026-07-07 found no token savings and a lower cache-hit rate at 50 KB, so normal coding sessions should leave this Off unless intentionally measuring large-read behavior.",
677
+ "Explicit large reads above this combined size are saved as an artifact with a bounded head-and-tail snippet inline. Bare reads, directories, and converted-document receipts remain inline.",
678
678
  options: [
679
- { value: "0", label: "Off", description: "Default; no read-specific spill (backstop only)" },
679
+ { value: "0", label: "Off", description: "No read-specific spill (backstop only)" },
680
680
  { value: "50", label: "50 KB", description: "~12.5K tokens" },
681
681
  { value: "100", label: "100 KB", description: "~25K tokens" },
682
- { value: "256", label: "256 KB", description: "~64K tokens" },
682
+ { value: "256", label: "256 KB", description: "Default; ~64K tokens" },
683
683
  { value: "512", label: "512 KB", description: "~128K tokens" },
684
684
  { value: "1000", label: "1 MB", description: "~250K tokens" },
685
685
  ],
@@ -688,17 +688,17 @@ export const SETTINGS_SCHEMA = {
688
688
 
689
689
  "tools.fileMentionInlineBytes": {
690
690
  type: "number",
691
- default: 20,
691
+ default: 10,
692
692
  ui: {
693
693
  tab: "tools",
694
694
  label: "File-mention inline cap (KB)",
695
695
  description:
696
- "Inline byte cap for auto-read `@path` file mentions, deliberately below the read-tool cap so an incidental mention injects a smaller snippet than an explicit read. The full file is still available via the read tool.",
696
+ "Inline byte cap for auto-read `@path` file mentions, aligned with the 10 KiB bare-read receipt so incidental mentions stay within the same bounded context budget. The full file is still available via the read tool.",
697
697
  options: [
698
698
  { value: "5", label: "5 KB", description: "~1.25K tokens" },
699
- { value: "10", label: "10 KB", description: "~2.5K tokens" },
700
- { value: "20", label: "20 KB", description: "Default; ~5K tokens" },
701
- { value: "50", label: "50 KB", description: "~12.5K tokens (matches read cap)" },
699
+ { value: "10", label: "10 KB", description: "Default; ~2.5K tokens" },
700
+ { value: "20", label: "20 KB", description: "~5K tokens" },
701
+ { value: "50", label: "50 KB", description: "~12.5K tokens (matches bare-read receipt)" },
702
702
  ],
703
703
  },
704
704
  },
@@ -2016,6 +2016,51 @@ export const SETTINGS_SCHEMA = {
2016
2016
  ],
2017
2017
  },
2018
2018
  },
2019
+ "read.receiptBudgetLines": {
2020
+ type: "number",
2021
+ default: 50,
2022
+ ui: {
2023
+ tab: "editing",
2024
+ label: "Read Receipt Line Budget",
2025
+ description: "Maximum lines included in a bare read receipt before a selector footer is shown",
2026
+ options: [
2027
+ { value: "25", label: "25 lines" },
2028
+ { value: "50", label: "50 lines", description: "Default" },
2029
+ { value: "100", label: "100 lines" },
2030
+ { value: "200", label: "200 lines" },
2031
+ ],
2032
+ },
2033
+ },
2034
+ "read.receiptBudgetBytes": {
2035
+ type: "number",
2036
+ default: 10,
2037
+ ui: {
2038
+ tab: "editing",
2039
+ label: "Read Receipt Byte Budget (KB)",
2040
+ description: "Maximum UTF-8 body size for a bare read receipt before a selector footer is shown",
2041
+ options: [
2042
+ { value: "5", label: "5 KB", description: "~1.25K tokens" },
2043
+ { value: "10", label: "10 KB", description: "Default; ~2.5K tokens" },
2044
+ { value: "20", label: "20 KB", description: "~5K tokens" },
2045
+ { value: "50", label: "50 KB", description: "~12.5K tokens" },
2046
+ ],
2047
+ },
2048
+ },
2049
+ "read.summaryMaxBytes": {
2050
+ type: "number",
2051
+ default: 20,
2052
+ ui: {
2053
+ tab: "editing",
2054
+ label: "Read Summary Size Budget (KB)",
2055
+ description: "Maximum UTF-8 size for a structural read summary before additional units are elided",
2056
+ options: [
2057
+ { value: "10", label: "10 KB", description: "~2.5K tokens" },
2058
+ { value: "20", label: "20 KB", description: "Default; ~5K tokens" },
2059
+ { value: "50", label: "50 KB", description: "~12.5K tokens" },
2060
+ { value: "100", label: "100 KB", description: "~25K tokens" },
2061
+ ],
2062
+ },
2063
+ },
2019
2064
 
2020
2065
  "read.summarize.enabled": {
2021
2066
  type: "boolean",
@@ -1950,9 +1950,16 @@ async function handleHandoff(
1950
1950
  positionalSkill: string | undefined,
1951
1951
  ): Promise<StateCommandResult> {
1952
1952
  const selectors = await resolveSelectors(args, cwd, positionalSkill, "handoff");
1953
- return withWorkflowStateLock(path.relative(cwd, activeStateFile(cwd, selectors.gjcSessionId)), async () =>
1954
- handleHandoffUnlocked(args, cwd, positionalSkill),
1955
- );
1953
+ // Serialize concurrent handoffs on a dedicated sentinel lock, NOT on the
1954
+ // derived `skill-active-state.json` cache. The inner transaction
1955
+ // (applyHandoffToActiveState / syncSkillActiveState -> rebuildActiveSnapshot)
1956
+ // re-locks that cache file, and `withFileLock` is not reentrant: holding the
1957
+ // active-state lock here made the inner rebuild self-contend and fail after
1958
+ // all retries whenever `cwd === process.cwd()` (the real CLI case). Pass
1959
+ // `{ cwd }` so the sentinel resolves against the handoff cwd rather than
1960
+ // `process.cwd()`.
1961
+ const handoffLock = path.join(sessionStateDir(cwd, selectors.gjcSessionId), "handoff");
1962
+ return withWorkflowStateLock(handoffLock, async () => handleHandoffUnlocked(args, cwd, positionalSkill), { cwd });
1956
1963
  }
1957
1964
 
1958
1965
  async function handleContract(
package/src/main.ts CHANGED
@@ -23,6 +23,7 @@ import chalk from "chalk";
23
23
  import type { Args } from "./cli/args";
24
24
  import { processFileArguments } from "./cli/file-processor";
25
25
  import { buildInitialMessage } from "./cli/initial-message";
26
+ import { resolveLaunchDisposition } from "./cli/launch-disposition";
26
27
  import { runListModelsCommand } from "./cli/list-models";
27
28
  import { selectSession } from "./cli/session-picker";
28
29
  import { findConfigFile } from "./config";
@@ -282,7 +283,7 @@ export function resolveAcpStartupOptions(
282
283
  }
283
284
 
284
285
  async function readPipedInput(): Promise<string | undefined> {
285
- if (process.stdin.isTTY !== false) return undefined;
286
+ if (process.stdin.isTTY === true) return undefined;
286
287
  try {
287
288
  const text = await Bun.stdin.text();
288
289
  if (text.trim().length === 0) return undefined;
@@ -292,6 +293,30 @@ async function readPipedInput(): Promise<string | undefined> {
292
293
  }
293
294
  }
294
295
 
296
+ async function readPipedInputIfReady(): Promise<string | undefined> {
297
+ if (process.stdin.isTTY === true) return undefined;
298
+
299
+ const ready = Promise.withResolvers<void>();
300
+ const onReadable = () => ready.resolve();
301
+ const onEnd = () => ready.resolve();
302
+ const onError = () => ready.resolve();
303
+ process.stdin.once("readable", onReadable);
304
+ process.stdin.once("end", onEnd);
305
+ process.stdin.once("error", onError);
306
+ await Promise.race([ready.promise, new Promise<void>(resolve => setImmediate(resolve))]);
307
+ process.stdin.removeListener("readable", onReadable);
308
+ process.stdin.removeListener("end", onEnd);
309
+ process.stdin.removeListener("error", onError);
310
+
311
+ const chunks: Buffer[] = [];
312
+ for (let chunk = process.stdin.read(); chunk !== null; chunk = process.stdin.read()) {
313
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
314
+ }
315
+ if (chunks.length === 0) return undefined;
316
+ const text = Buffer.concat(chunks).toString("utf8");
317
+ return text.trim() ? text : undefined;
318
+ }
319
+
295
320
  export interface InteractiveModeNotify {
296
321
  kind: "warn" | "error" | "info";
297
322
  message: string;
@@ -1014,6 +1039,7 @@ export interface RunRootCommandDependencies {
1014
1039
  startupUpdate?: { check: () => Promise<string | undefined> };
1015
1040
  initTheme?: typeof initTheme;
1016
1041
  readPipedInput?: typeof readPipedInput;
1042
+ stdinIsTTY?: boolean;
1017
1043
  runStartupCredentialAutoImportIfNeeded?: typeof runStartupCredentialAutoImportIfNeeded;
1018
1044
  getChangelogForDisplay?: typeof getChangelogForDisplay;
1019
1045
  createInteractiveMode?: CreateInteractiveMode;
@@ -1164,8 +1190,16 @@ export async function runRootCommand(
1164
1190
  if (parsedArgs.noTitle || parsedArgs.mode === "acp") {
1165
1191
  Bun.env.PI_NO_TITLE = "1";
1166
1192
  }
1193
+ const hasPreparedInput = parsedArgs.messages.length > 0 || parsedArgs.fileArgs.length > 0;
1167
1194
  const { pipedInput, fileText, fileImages } = await logger.time("prepareInitialMessage", async () => {
1168
- const pipedInput = await (deps.readPipedInput ?? readPipedInput)();
1195
+ const pipedInput =
1196
+ parsedArgs.mode === "acp"
1197
+ ? undefined
1198
+ : deps.readPipedInput
1199
+ ? await deps.readPipedInput()
1200
+ : hasPreparedInput
1201
+ ? await readPipedInputIfReady()
1202
+ : await readPipedInput();
1169
1203
  if (parsedArgs.fileArgs.length === 0) {
1170
1204
  return { pipedInput, fileText: undefined, fileImages: undefined };
1171
1205
  }
@@ -1180,14 +1214,25 @@ export async function runRootCommand(
1180
1214
  fileImages,
1181
1215
  stdinContent: pipedInput,
1182
1216
  });
1183
- const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
1217
+ const disposition = resolveLaunchDisposition({
1218
+ stdinIsTTY: deps.stdinIsTTY ?? process.stdin.isTTY,
1219
+ pipedInput,
1220
+ hasPreparedInput,
1221
+ print: Boolean(parsedArgs.print),
1222
+ mode: parsedArgs.mode,
1223
+ });
1224
+ if (disposition.nonInteractiveError) {
1225
+ process.stderr.write(`${chalk.red(disposition.nonInteractiveError)}\n`);
1226
+ process.exit(1);
1227
+ }
1228
+ const autoPrint = disposition.autoPrint;
1184
1229
  const startupUpdateRoute = classifyStartupUpdateRoute(parsedArgs, autoPrint);
1185
1230
  const startupUpdate = new StartupUpdateOrchestrator(
1186
1231
  startupUpdateRoute,
1187
1232
  () => settingsInstance.get("startup.checkUpdate"),
1188
1233
  deps.startupUpdate?.check ?? (() => checkForNewVersion(VERSION)),
1189
1234
  );
1190
- const isInteractive = startupUpdateRoute === "interactive";
1235
+ const isInteractive = disposition.isInteractive;
1191
1236
  const mode = parsedArgs.mode || "text";
1192
1237
 
1193
1238
  // Initialize discovery system with settings for provider persistence
@@ -1319,6 +1364,7 @@ export async function runRootCommand(
1319
1364
  sessionOptions.modelRegistry = modelRegistry;
1320
1365
  sessionOptions.hasUI = isInteractive;
1321
1366
  sessionOptions.notificationHostModeSupported = isInteractive;
1367
+ sessionOptions.sdkHostModeSupported = isInteractive;
1322
1368
  sessionOptions.settings = settingsInstance;
1323
1369
  const hasRootStartupProfile = Boolean(settingsInstance.get("modelProfile.default") || parsedArgs.mpreset);
1324
1370
 
@@ -1351,6 +1397,30 @@ export async function runRootCommand(
1351
1397
  applyCliRuntimeApiKeyOverride(authStorage, parsedArgs.apiKey, sessionOptions.model);
1352
1398
  }
1353
1399
 
1400
+ if (
1401
+ !deps.suppressProcessExit &&
1402
+ deps.createAgentSession === undefined &&
1403
+ parsedArgs.noSession === true &&
1404
+ !parsedArgs.continue &&
1405
+ parsedArgs.resume === undefined &&
1406
+ parsedArgs.fork === undefined &&
1407
+ !isInteractive &&
1408
+ mode !== "acp" &&
1409
+ !hasRootStartupProfile &&
1410
+ !sessionOptions.model &&
1411
+ !sessionOptions.modelPattern &&
1412
+ modelRegistry.getAvailable().length === 0
1413
+ ) {
1414
+ process.stderr.write(`${chalk.red(`No models available. ${formatModelOnboardingGuidance()}`)}\n`);
1415
+ process.stderr.write(
1416
+ `${chalk.yellow(`\nAdvanced manual config remains available at ${ModelsConfigFile.path()}`)}\n`,
1417
+ );
1418
+ process.exitCode = 1;
1419
+ authStorage.close();
1420
+ stopThemeWatcher();
1421
+ await postmortem.cleanup();
1422
+ return;
1423
+ }
1354
1424
  const createAgentSessionImpl = deps.createAgentSession ?? createAgentSession;
1355
1425
  const createSession: CreateSessionForMain = async (options, context): Promise<CreateAgentSessionResult> => {
1356
1426
  const result = await logger.time("createAgentSession", createAgentSessionImpl, options);
@@ -1365,10 +1435,16 @@ export async function runRootCommand(
1365
1435
  };
1366
1436
 
1367
1437
  if (mode === "acp") {
1368
- await (deps.runAcpMode ?? (await import("./modes/acp")).runAcpMode)({
1369
- agentDir: settingsInstance.getAgentDir(),
1370
- ...(acpStartupOptions ? { startupOptions: acpStartupOptions } : {}),
1371
- });
1438
+ try {
1439
+ await (deps.runAcpMode ?? (await import("./modes/acp")).runAcpMode)({
1440
+ agentDir: settingsInstance.getAgentDir(),
1441
+ ...(acpStartupOptions ? { startupOptions: acpStartupOptions } : {}),
1442
+ });
1443
+ } finally {
1444
+ authStorage.close();
1445
+ stopThemeWatcher();
1446
+ if (!deps.suppressProcessExit) await postmortem.cleanup();
1447
+ }
1372
1448
  } else {
1373
1449
  const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager, eventBus } = await createSession(
1374
1450
  sessionOptions,
@@ -1503,20 +1579,21 @@ export async function runRootCommand(
1503
1579
  }
1504
1580
  } else {
1505
1581
  const runPrint = deps.runPrintMode ?? (await import("./modes/print-mode")).runPrintMode;
1506
- await runPrint(session, {
1507
- mode,
1508
- messages: parsedArgs.messages,
1509
- initialMessage,
1510
- initialImages,
1511
- suppressProcessExit: deps.suppressProcessExit,
1512
- });
1513
- if ($env.PI_TIMING) {
1514
- logger.printTimings();
1515
- }
1516
- stopThemeWatcher();
1517
- if (!deps.suppressProcessExit) {
1518
- const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
1519
- await postmortem.quit(exitCode);
1582
+ try {
1583
+ await runPrint(session, {
1584
+ mode,
1585
+ messages: parsedArgs.messages,
1586
+ initialMessage,
1587
+ initialImages,
1588
+ suppressProcessExit: deps.suppressProcessExit,
1589
+ });
1590
+ if ($env.PI_TIMING) {
1591
+ logger.printTimings();
1592
+ }
1593
+ } finally {
1594
+ stopThemeWatcher();
1595
+ authStorage.close();
1596
+ if (!deps.suppressProcessExit) await postmortem.cleanup();
1520
1597
  }
1521
1598
  }
1522
1599
  }
@@ -248,6 +248,13 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
248
248
 
249
249
  return this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol);
250
250
  }
251
+ getForceFileSuggestions(
252
+ lines: string[],
253
+ cursorLine: number,
254
+ cursorCol: number,
255
+ ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
256
+ return this.#baseProvider.getForceFileSuggestions(lines, cursorLine, cursorCol);
257
+ }
251
258
 
252
259
  applyCompletion(
253
260
  lines: string[],
@@ -2741,6 +2741,8 @@ export function createNotificationsExtension(
2741
2741
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
2742
2742
  spawnedByGjc?: boolean;
2743
2743
  controller?: NotificationSessionController;
2744
+ /** Whether this host mode can own the root SDK endpoint. Default: true. */
2745
+ sdkHostModeSupported?: boolean;
2744
2746
 
2745
2747
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2746
2748
  runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
@@ -2956,7 +2958,8 @@ export function createNotificationsExtension(
2956
2958
  const lifecycleRequestId = safeLifecycleRequestId(process.env.GJC_LIFECYCLE_REQUEST_ID);
2957
2959
  const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
2958
2960
  const notificationsEnabledForSession = controller.query(ctx).effectiveEnabled;
2959
- const sdkEnabledForSession = shouldHostSdk(settings, isNotificationEligibleContext(ctx));
2961
+ const sdkEnabledForSession =
2962
+ (options.sdkHostModeSupported ?? true) && shouldHostSdk(settings, isNotificationEligibleContext(ctx));
2960
2963
  const lifecycleRequired = lifecycleStartupCapability !== undefined;
2961
2964
  const failLifecycleStartup = (
2962
2965
  reason: "disabled" | "ineligible" | "failed",
@@ -102,6 +102,7 @@ export class SessionSdkHost {
102
102
  readonly reverse: ReverseLeaseRuntime;
103
103
  readonly #options: SessionSdkHostOptions;
104
104
  #started = false;
105
+ #stopPromise?: Promise<"stopped">;
105
106
  #unsubscribe?: () => void;
106
107
  #registration?: { writer: BrokerIndexWriter; generation: number };
107
108
 
@@ -154,16 +155,27 @@ export class SessionSdkHost {
154
155
  }
155
156
 
156
157
  async stop(): Promise<"stopped" | "already"> {
158
+ if (this.#stopPromise) return this.#stopPromise;
157
159
  if (!this.#started) return "already";
160
+ const stopPromise = this.#stopStartedHost();
161
+ this.#stopPromise = stopPromise;
162
+ try {
163
+ return await stopPromise;
164
+ } finally {
165
+ if (this.#stopPromise === stopPromise) this.#stopPromise = undefined;
166
+ }
167
+ }
168
+
169
+ async #stopStartedHost(): Promise<"stopped"> {
158
170
  this.#unsubscribe?.();
159
171
  this.#unsubscribe = undefined;
160
- this.#started = false;
161
172
  if (this.#registration?.writer.unregister)
162
173
  await this.#registration.writer.unregister({
163
174
  sessionId: this.#options.sessionId,
164
175
  stateRoot: this.#options.stateRoot,
165
176
  endpointGeneration: this.events.generation,
166
177
  });
178
+ this.#started = false;
167
179
  return "stopped";
168
180
  }
169
181
 
@@ -390,6 +390,8 @@ export interface CreateAgentSessionOptions {
390
390
  hasUI?: boolean;
391
391
  /** Whether this host mode can own a notification session endpoint. Default: true. */
392
392
  notificationHostModeSupported?: boolean;
393
+ /** Whether this host mode can own the root SDK endpoint. Default: true. */
394
+ sdkHostModeSupported?: boolean;
393
395
 
394
396
  /**
395
397
  * Opt-in OpenTelemetry instrumentation forwarded to the underlying Agent.
@@ -1763,7 +1765,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1763
1765
  currentAgentType: options.currentAgentType,
1764
1766
  spawnedByGjc,
1765
1767
  }) ||
1766
- shouldHostSdk(notificationCfg, isTopLevelSdkSession)
1768
+ (shouldHostSdk(notificationCfg, isTopLevelSdkSession) && (options.sdkHostModeSupported ?? true))
1767
1769
  ) {
1768
1770
  inlineExtensions.push(async api => {
1769
1771
  try {
@@ -1774,6 +1776,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1774
1776
  settings,
1775
1777
  controller: notificationSessionController,
1776
1778
  spawnedByGjc,
1779
+ sdkHostModeSupported: options.sdkHostModeSupported,
1777
1780
  runEphemeralTurn: async (promptText, signal) => {
1778
1781
  if (!session) throw new Error("Ephemeral turns are unavailable.");
1779
1782
  const { replyText } = await session.runEphemeralTurn({ promptText, signal });
@@ -605,6 +605,10 @@ async function spillLargeResultToArtifact(
605
605
  toolName: string,
606
606
  context: AgentToolContext | undefined,
607
607
  ): Promise<AgentToolResult> {
608
+ if (toolName === "read" && (result.details as { spillEligible?: boolean } | undefined)?.spillEligible !== true) {
609
+ return result;
610
+ }
611
+
608
612
  const artifactCapability = artifactCapabilityForContext(context);
609
613
  if (!artifactCapability) return result;
610
614
  const { threshold, readThreshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
@@ -709,8 +713,8 @@ async function spillLargeResultToArtifact(
709
713
  /**
710
714
  * Absolute inline-size backstop enforced after {@link spillLargeResultToArtifact}.
711
715
  *
712
- * The threshold-based spill above has escape hatches: it early-returns for the
713
- * `read` tool and for results that already carry an `artifactId` (a tool may set
716
+ * The threshold-based spill above has escape hatches: it skips ineligible `read`
717
+ * results and results that already carry an `artifactId` (a tool may set
714
718
  * partial truncation meta yet still emit oversized inline text). This backstop
715
719
  * closes those gaps: when `tools.maxInlineResultBytes` is configured (> 0), any
716
720
  * final result whose inline text exceeds the cap is force-saved to an artifact
package/src/tools/read.ts CHANGED
@@ -95,6 +95,8 @@ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx"
95
95
 
96
96
  const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
97
97
  const MAX_SUMMARY_LINES = 20_000;
98
+ const RAW_COLLECTOR_MAX_BYTES = MAX_SUMMARY_BYTES;
99
+
98
100
  /**
99
101
  * Per-line column cap for file reads. Lines wider than the value of
100
102
  * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
@@ -193,6 +195,14 @@ function formatSummaryElisionFooter(readPath: string, elidedSpans: number, elide
193
195
  const linePart = elidedLines > 0 ? `${elidedLines} ${lineWord} across ` : "";
194
196
  return `[${linePart}${elidedSpans} elided ${spanWord}; read ${readPath}:raw or a line range like ${readPath}:1-9999 for verbatim content]`;
195
197
  }
198
+
199
+ function formatReceiptFooter(readPath: string, shownLines: number, totalLines: number, shownBytes: number): string {
200
+ return `[Showing first ${shownLines} of ${totalLines} lines (~${Math.ceil(shownBytes / 1024)} KiB); re-read ${readPath}:1-${totalLines} or ${readPath}:raw for the full file]`;
201
+ }
202
+
203
+ function formatSummaryCapFooter(readPath: string, maxBytes: number, totalLines: number): string {
204
+ return `[Summary truncated at ${Math.ceil(maxBytes / 1024)} KiB; re-read ${readPath}:raw or ${readPath}:1-${totalLines} for the full source]`;
205
+ }
196
206
  const READ_CHUNK_SIZE = 8 * 1024;
197
207
 
198
208
  /**
@@ -532,6 +542,9 @@ export interface ReadToolDetails {
532
542
  method?: string;
533
543
  notes?: string[];
534
544
  meta?: OutputMeta;
545
+ /** Whether this explicit content read may be spilled by output metadata. */
546
+ spillEligible?: boolean;
547
+
535
548
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
536
549
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
537
550
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -816,10 +829,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
816
829
  options: {
817
830
  details?: ReadToolDetails;
818
831
  sourcePath?: string;
832
+ spillEligible?: boolean;
833
+ receiptPath?: string;
819
834
  sourceUrl?: string;
820
835
  sourceInternal?: string;
821
836
  entityLabel: string;
822
837
  ignoreResultLimits?: boolean;
838
+ resultByteCeiling?: number;
823
839
  raw?: boolean;
824
840
  immutable?: boolean;
825
841
  wrapUntrusted?: boolean;
@@ -871,9 +887,30 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
871
887
  }
872
888
 
873
889
  const endLine = endLineExpanded;
874
- const selectedContent = allLines.slice(startLine, endLine).join("\n");
890
+ let selectedContent = allLines.slice(startLine, endLine).join("\n");
891
+
892
+ if (options.receiptPath) {
893
+ const budgetLines = this.session.settings.get("read.receiptBudgetLines");
894
+ const budgetBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
895
+ const receiptLines: string[] = [];
896
+ let bytes = 0;
897
+ for (const line of allLines) {
898
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (receiptLines.length > 0 ? 1 : 0);
899
+ if (receiptLines.length >= budgetLines || bytes + lineBytes > budgetBytes) break;
900
+ receiptLines.push(line);
901
+ bytes += lineBytes;
902
+ }
903
+ selectedContent = receiptLines.join("\n");
904
+ }
875
905
  const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
876
- const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
906
+ const truncation = ignoreResultLimits
907
+ ? noTruncResult(selectedContent)
908
+ : truncateHead(
909
+ selectedContent,
910
+ options.resultByteCeiling === undefined
911
+ ? undefined
912
+ : { maxBytes: options.resultByteCeiling, maxLines: Number.MAX_SAFE_INTEGER },
913
+ );
877
914
 
878
915
  const shouldAddHashLines = displayMode.hashLines;
879
916
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
@@ -927,7 +964,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
927
964
  } else {
928
965
  outputText = formatText(truncation.content, startLineDisplay);
929
966
  }
967
+ if (options.receiptPath && (truncation.truncated || countTextLines(selectedContent) < totalLines)) {
968
+ outputText += `\n\n${formatReceiptFooter(options.receiptPath, countTextLines(selectedContent), totalLines, Buffer.byteLength(selectedContent, "utf-8"))}`;
969
+ }
930
970
 
971
+ details.spillEligible = options.spillEligible === true && selectedContent.length > 0;
931
972
  resultBuilder.text(options.wrapUntrusted ? wrapUntrustedContent(outputText) : outputText);
932
973
  if (truncationInfo) {
933
974
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
@@ -987,6 +1028,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
987
1028
  }
988
1029
  const finalText =
989
1030
  notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
1031
+ details.spillEligible = parts.length > 0;
1032
+
990
1033
  resultBuilder.text(finalText);
991
1034
  return resultBuilder.done();
992
1035
  }
@@ -1006,6 +1049,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1006
1049
  signal: AbortSignal | undefined,
1007
1050
  ): Promise<{
1008
1051
  outputText: string;
1052
+ contentLineCount: number;
1009
1053
  columnTruncated: number;
1010
1054
  bridgeResult?: AgentToolResult<ReadToolDetails>;
1011
1055
  }> {
@@ -1027,7 +1071,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1027
1071
  const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text");
1028
1072
  if (firstText) firstText.text = `${notice}\n${firstText.text}`;
1029
1073
  }
1030
- return { outputText: "", columnTruncated: 0, bridgeResult };
1074
+ return { outputText: "", contentLineCount: 0, columnTruncated: 0, bridgeResult };
1031
1075
  } catch (error) {
1032
1076
  logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
1033
1077
  }
@@ -1040,6 +1084,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1040
1084
  const blocks: string[] = [];
1041
1085
  const notices: string[] = [];
1042
1086
  let columnTruncated = 0;
1087
+ let contentLineCount = 0;
1043
1088
 
1044
1089
  for (const range of ranges) {
1045
1090
  const rangeStart = range.startLine - 1; // 0-indexed
@@ -1074,6 +1119,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1074
1119
  }
1075
1120
  }
1076
1121
 
1122
+ contentLineCount += collectedLines.length;
1077
1123
  if (collectedLines.length > 0) {
1078
1124
  getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, collectedLines);
1079
1125
  }
@@ -1086,7 +1132,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1086
1132
  if (notices.length > 0) {
1087
1133
  outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
1088
1134
  }
1089
- return { outputText, columnTruncated };
1135
+ return { outputText, contentLineCount, columnTruncated };
1090
1136
  }
1091
1137
 
1092
1138
  async #readArchiveDirectory(
@@ -1369,6 +1415,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1369
1415
  displayText: string;
1370
1416
  elidedSpans: number;
1371
1417
  elidedLines: number;
1418
+ capped: boolean;
1372
1419
  } {
1373
1420
  const displayMode = resolveFileDisplayMode(this.session);
1374
1421
  const shouldAddHashLines = displayMode.hashLines;
@@ -1428,17 +1475,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1428
1475
 
1429
1476
  const modelParts: string[] = [];
1430
1477
  const displayParts: string[] = [];
1478
+ const capBytes = this.session.settings.get("read.summaryMaxBytes") * 1024;
1479
+ let usedBytes = 0;
1431
1480
  let elidedSpans = 0;
1432
1481
  let elidedLines = 0;
1482
+ let capDropped = false;
1433
1483
  for (const unit of units) {
1484
+ let model: string;
1485
+ let display: string;
1486
+ let implicitElidedLines = 0;
1434
1487
  if (unit.kind === "elided") {
1435
- modelParts.push("...");
1436
- displayParts.push("...");
1437
- elidedSpans++;
1438
- elidedLines += unit.endLine - unit.startLine + 1;
1439
- continue;
1440
- }
1441
- if (unit.kind === "merged") {
1488
+ model = display = "...";
1489
+ implicitElidedLines = unit.endLine - unit.startLine + 1;
1490
+ } else if (unit.kind === "merged") {
1442
1491
  const formatted = formatMergedBraceLine(
1443
1492
  unit.startLine,
1444
1493
  unit.endLine,
@@ -1447,18 +1496,39 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1447
1496
  shouldAddHashLines,
1448
1497
  shouldAddLineNumbers,
1449
1498
  );
1450
- modelParts.push(formatted.model);
1451
- displayParts.push(formatted.display);
1499
+ model = formatted.model;
1500
+ display = formatted.display;
1501
+ implicitElidedLines = Math.max(0, unit.endLine - unit.startLine - 1);
1502
+ } else {
1503
+ model = formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers);
1504
+ display = unit.text;
1505
+ }
1506
+ const separatorBytes = modelParts.length > 0 ? 1 : 0;
1507
+ if (usedBytes + separatorBytes + Buffer.byteLength(model, "utf-8") > capBytes) {
1508
+ capDropped = true;
1452
1509
  elidedSpans++;
1453
- // Merged brace pair encloses (start+1)..(end-1) as elided.
1454
- elidedLines += Math.max(0, unit.endLine - unit.startLine - 1);
1510
+ elidedLines += unit.kind === "line" ? 1 : unit.endLine - unit.startLine + 1;
1455
1511
  continue;
1456
1512
  }
1457
- modelParts.push(formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers));
1458
- displayParts.push(unit.text);
1513
+ usedBytes += separatorBytes + Buffer.byteLength(model, "utf-8");
1514
+ modelParts.push(model);
1515
+ displayParts.push(display);
1516
+ if (unit.kind === "elided" || unit.kind === "merged") {
1517
+ elidedSpans++;
1518
+ elidedLines += implicitElidedLines;
1519
+ }
1459
1520
  }
1460
-
1461
- return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans, elidedLines };
1521
+ if (capDropped) {
1522
+ modelParts.push("…");
1523
+ displayParts.push("…");
1524
+ }
1525
+ return {
1526
+ text: modelParts.join("\n"),
1527
+ displayText: displayParts.join("\n"),
1528
+ elidedSpans,
1529
+ elidedLines,
1530
+ capped: capDropped,
1531
+ };
1462
1532
  }
1463
1533
 
1464
1534
  async execute(
@@ -1649,25 +1719,38 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1649
1719
  details: { resolvedPath: absolutePath },
1650
1720
  sourcePath: absolutePath,
1651
1721
  entityLabel: "notebook",
1722
+ spillEligible: parsed.kind !== "none",
1723
+ resultByteCeiling:
1724
+ parsed.kind !== "none"
1725
+ ? Math.max(
1726
+ RAW_COLLECTOR_MAX_BYTES,
1727
+ this.session.settings.get("tools.readArtifactSpillThreshold") * 1024,
1728
+ )
1729
+ : undefined,
1730
+ receiptPath: parsed.kind === "none" ? localReadPath : undefined,
1652
1731
  });
1653
1732
  } else if (shouldConvertWithMarkit) {
1654
- // Convert document via markit.
1655
1733
  const result = await convertFileWithMarkit(absolutePath, signal);
1656
1734
  if (result.ok) {
1657
- // Apply truncation to converted content
1658
- const truncation = truncateHead(result.content);
1659
- const outputText = truncation.content;
1660
-
1661
- details = { truncation };
1662
- sourcePath = absolutePath;
1663
- truncationInfo = { result: truncation, options: { direction: "head", startLine: 1 } };
1664
-
1665
- content = [{ type: "text", text: outputText }];
1666
- } else if (result.error) {
1667
- content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
1668
- } else {
1669
- content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
1735
+ const { offset, limit } = selToOffsetLimit(parsed);
1736
+ return this.#buildInMemoryTextResult(result.content, offset, limit, {
1737
+ details: { resolvedPath: absolutePath },
1738
+ sourcePath: absolutePath,
1739
+ entityLabel: "converted document",
1740
+ raw: isRawSelector(parsed),
1741
+ spillEligible: parsed.kind !== "none",
1742
+ resultByteCeiling:
1743
+ parsed.kind !== "none"
1744
+ ? Math.max(
1745
+ RAW_COLLECTOR_MAX_BYTES,
1746
+ this.session.settings.get("tools.readArtifactSpillThreshold") * 1024,
1747
+ )
1748
+ : undefined,
1749
+ receiptPath: parsed.kind === "none" ? localReadPath : undefined,
1750
+ });
1670
1751
  }
1752
+ content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
1753
+ details = { spillEligible: false };
1671
1754
  } else {
1672
1755
  if (
1673
1756
  parsed.kind === "none" &&
@@ -1682,8 +1765,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1682
1765
  renderedSummary.elidedSpans,
1683
1766
  renderedSummary.elidedLines,
1684
1767
  );
1685
- const modelText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
1768
+ const summaryTotalLines = Math.max(...summary.segments.map(segment => segment.endLine));
1769
+ const modelText = [
1770
+ renderedSummary.text,
1771
+ footer,
1772
+ renderedSummary.capped
1773
+ ? formatSummaryCapFooter(
1774
+ localReadPath,
1775
+ this.session.settings.get("read.summaryMaxBytes") * 1024,
1776
+ summaryTotalLines,
1777
+ )
1778
+ : "",
1779
+ ]
1780
+ .filter(Boolean)
1781
+ .join("\n\n");
1686
1782
  details = {
1783
+ spillEligible: false,
1687
1784
  displayContent: { text: renderedSummary.displayText, startLine: 1 },
1688
1785
  summary: {
1689
1786
  lines: countTextLines(renderedSummary.text),
@@ -1710,7 +1807,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1710
1807
  if (multiResult.bridgeResult) return multiResult.bridgeResult;
1711
1808
  content = [{ type: "text", text: multiResult.outputText }];
1712
1809
  sourcePath = absolutePath;
1713
- details = {};
1810
+ details = { spillEligible: multiResult.contentLineCount > 0 };
1714
1811
  if (multiResult.columnTruncated > 0) {
1715
1812
  columnTruncated = multiResult.columnTruncated;
1716
1813
  }
@@ -1750,13 +1847,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1750
1847
  const startLine = requestedStart - leadingContext;
1751
1848
  const startLineDisplay = startLine + 1;
1752
1849
 
1850
+ const isBareReceipt = parsed.kind === "none" && !content;
1851
+ const rawSelector = isRawSelector(parsed);
1753
1852
  const DEFAULT_LIMIT = this.#defaultLimit;
1754
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
1755
- const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
1756
- const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
1757
- // Scale byte budget with line limit so the configured line count actually fits.
1758
- // Assume ~512 bytes/line average; never go below the shared default.
1759
- const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1853
+ const effectiveLimit = rawSelector ? Number.MAX_SAFE_INTEGER : (limit ?? DEFAULT_LIMIT);
1854
+ const receiptBudgetBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
1855
+ const maxLinesToCollect = rawSelector
1856
+ ? Number.MAX_SAFE_INTEGER
1857
+ : isBareReceipt
1858
+ ? this.session.settings.get("read.receiptBudgetLines")
1859
+ : Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
1860
+ const selectedLineLimit = rawSelector ? null : effectiveLimit + leadingContext + trailingContext;
1861
+ const maxBytesForRead = rawSelector
1862
+ ? Math.max(
1863
+ RAW_COLLECTOR_MAX_BYTES,
1864
+ this.session.settings.get("tools.readArtifactSpillThreshold") * 1024,
1865
+ )
1866
+ : isBareReceipt
1867
+ ? receiptBudgetBytes
1868
+ : Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
1760
1869
 
1761
1870
  const streamResult = await streamLinesFromFile(
1762
1871
  absolutePath,
@@ -1793,7 +1902,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1793
1902
  // verbatim bytes for paste-back-into-tool workflows. Total byte/line
1794
1903
  // counts in `truncation` keep reflecting the source, not the trimmed
1795
1904
  // view — column truncation surfaces separately via `.limits()`.
1796
- const rawSelector = isRawSelector(parsed);
1905
+
1797
1906
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1798
1907
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1799
1908
  if (!rawSelector && !shouldAddHashLines && maxColumns > 0) {
@@ -1855,7 +1964,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1855
1964
  firstLineBytes,
1856
1965
  )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
1857
1966
  }
1858
- details = { truncation };
1967
+ details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
1968
+
1859
1969
  sourcePath = absolutePath;
1860
1970
  truncationInfo = {
1861
1971
  result: truncation,
@@ -1863,7 +1973,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1863
1973
  };
1864
1974
  } else if (truncation.truncated) {
1865
1975
  outputText = formatText(truncation.content, startLineDisplay);
1866
- details = { truncation };
1976
+ details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
1977
+
1867
1978
  sourcePath = absolutePath;
1868
1979
  truncationInfo = {
1869
1980
  result: truncation,
@@ -1875,14 +1986,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1875
1986
 
1876
1987
  outputText = formatText(truncation.content, startLineDisplay);
1877
1988
  outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
1878
- details = {};
1989
+ details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
1990
+
1879
1991
  sourcePath = absolutePath;
1880
1992
  } else {
1881
1993
  // No truncation, no user limit exceeded
1882
1994
  outputText = formatText(truncation.content, startLineDisplay);
1883
- details = {};
1995
+ details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
1996
+
1884
1997
  sourcePath = absolutePath;
1885
1998
  }
1999
+ if (isBareReceipt && (truncation.truncated || collectedLines.length < totalFileLines)) {
2000
+ outputText += `\n\n${formatReceiptFooter(localReadPath, collectedLines.length, totalFileLines, collectedBytes)}`;
2001
+ }
2002
+ if (rawSelector && truncation.truncated) {
2003
+ outputText += `\n\n[Raw read capped at ${formatBytes(maxBytesForRead)} of ${formatBytes(fileSize)}; re-read a line range to narrow the view]`;
2004
+ }
1886
2005
 
1887
2006
  if (capturedDisplayContent) {
1888
2007
  details.displayContent = capturedDisplayContent;
@@ -2109,23 +2228,23 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2109
2228
  }
2110
2229
  throwIfAborted(signal);
2111
2230
 
2112
- const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered;
2113
- const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
2114
- const details: ReadToolDetails = {
2115
- isDirectory: true,
2116
- resolvedPath: tree.rootPath,
2117
- };
2118
-
2119
- const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath);
2120
- if (tree.truncated) {
2121
- resultBuilder.limits({ resultLimit: 1 });
2122
- }
2123
- if (truncation.truncated) {
2124
- resultBuilder.truncation(truncation, { direction: "head" });
2125
- details.truncation = truncation;
2126
- }
2127
-
2128
- return resultBuilder.done();
2231
+ const receiptLines = this.session.settings.get("read.receiptBudgetLines");
2232
+ const receiptBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
2233
+ const sourceLines = tree.totalLines <= 1 ? ["(empty directory)"] : tree.rendered.split("\n");
2234
+ const bodyLines: string[] = [];
2235
+ let bodyBytes = 0;
2236
+ for (const line of sourceLines) {
2237
+ const bytes = Buffer.byteLength(line, "utf-8") + (bodyLines.length > 0 ? 1 : 0);
2238
+ if (bodyLines.length >= receiptLines || bodyBytes + bytes > receiptBytes) break;
2239
+ bodyLines.push(line);
2240
+ bodyBytes += bytes;
2241
+ }
2242
+ let output = bodyLines.join("\n");
2243
+ if (bodyLines.length < sourceLines.length || tree.truncated) {
2244
+ output += `\n\n[Listing truncated at ${bodyLines.length} lines / ${Math.ceil(bodyBytes / 1024)} KiB; read a deeper subpath (e.g. ${absolutePath}/<subdir>) to narrow the view]`;
2245
+ }
2246
+ const details: ReadToolDetails = { isDirectory: true, spillEligible: false, resolvedPath: tree.rootPath };
2247
+ return toolResult(details).text(output).sourcePath(tree.rootPath).done();
2129
2248
  }
2130
2249
  }
2131
2250
 
@@ -17,6 +17,10 @@ const MAX_LIST_LIMIT = 50;
17
17
  const RECEIPT_PREVIEW_WIDTH = 280;
18
18
  const PREVIEW_WIDTH = 2_000;
19
19
  const FULL_PREVIEW_WIDTH = 12_000;
20
+ const RECEIPT_PREVIEW_BYTES = 1_024;
21
+ const PREVIEW_BYTES = 8_192;
22
+ const FULL_PREVIEW_BYTES = 49_152;
23
+
20
24
  const STEER_QUEUED_GUIDANCE =
21
25
  "The steer message is queued for the subagent's next steering boundary and has not necessarily taken effect yet.";
22
26
 
@@ -783,7 +787,40 @@ function isSubagentJob(job: AsyncJob): boolean {
783
787
  }
784
788
 
785
789
  function sanitizeText(text: string, width: number): string {
786
- return truncateToWidth(replaceTabs(text), width, Ellipsis.Unicode);
790
+ return capCodePointsAndBytes(
791
+ truncateToWidth(replaceTabs(text), width, Ellipsis.Unicode),
792
+ width,
793
+ previewByteCap(width),
794
+ );
795
+ }
796
+
797
+ function previewByteCap(width: number): number {
798
+ return width === FULL_PREVIEW_WIDTH
799
+ ? FULL_PREVIEW_BYTES
800
+ : width === PREVIEW_WIDTH
801
+ ? PREVIEW_BYTES
802
+ : RECEIPT_PREVIEW_BYTES;
803
+ }
804
+
805
+ export function capCodePointsAndBytes(text: string, maxCodePoints: number, maxBytes: number): string {
806
+ const ellipsis = "…";
807
+ const collapsed = text.replace(/…{2,}$/u, ellipsis);
808
+ const codePoints = [...collapsed];
809
+ if (codePoints.length <= maxCodePoints && Buffer.byteLength(collapsed) <= maxBytes) return collapsed;
810
+
811
+ const source = collapsed.endsWith(ellipsis) ? collapsed.slice(0, -ellipsis.length) : collapsed;
812
+ const ellipsisBytes = Buffer.byteLength(ellipsis);
813
+ let preview = "";
814
+ let previewBytes = 0;
815
+ let previewCodePoints = 0;
816
+ for (const codePoint of source) {
817
+ const codePointBytes = Buffer.byteLength(codePoint);
818
+ if (previewCodePoints + 1 + 1 > maxCodePoints || previewBytes + codePointBytes + ellipsisBytes > maxBytes) break;
819
+ preview += codePoint;
820
+ previewBytes += codePointBytes;
821
+ previewCodePoints++;
822
+ }
823
+ return `${preview}${ellipsis}`;
787
824
  }
788
825
 
789
826
  function previewJobOutput(
@@ -799,7 +836,11 @@ function previewJobOutput(
799
836
  const width =
800
837
  verbosity === "full" ? FULL_PREVIEW_WIDTH : verbosity === "preview" ? PREVIEW_WIDTH : RECEIPT_PREVIEW_WIDTH;
801
838
  const normalized = replaceTabs(source.text);
802
- const preview = truncateToWidth(normalized, width, Ellipsis.Unicode);
839
+ const preview = capCodePointsAndBytes(
840
+ truncateToWidth(normalized, width, Ellipsis.Unicode),
841
+ width,
842
+ previewByteCap(width),
843
+ );
803
844
  return { type: source.type, preview, truncated: preview !== normalized };
804
845
  }
805
846
 
@@ -160,11 +160,11 @@ async function resolveMentionPath(
160
160
  }
161
161
 
162
162
  /**
163
- * Default inline cap for auto-read file mentions, deliberately below the
164
- * read-tool cap (`DEFAULT_MAX_BYTES`, 50KB) so an incidental `@path` mention
165
- * injects a smaller snippet than an explicit `read`. Overridable per call.
163
+ * Default inline cap for auto-read file mentions, aligned with the read-tool's
164
+ * 10 KiB bare-read receipt so an incidental `@path` mention injects a bounded
165
+ * snippet. Overridable per call.
166
166
  */
167
- export const DEFAULT_FILE_MENTION_INLINE_BYTES = 20 * 1024;
167
+ export const DEFAULT_FILE_MENTION_INLINE_BYTES = 10 * 1024;
168
168
 
169
169
  function buildTextOutput(textContent: string, maxInlineBytes?: number): { output: string; lineCount: number } {
170
170
  const cap = maxInlineBytes && maxInlineBytes > 0 ? maxInlineBytes : DEFAULT_FILE_MENTION_INLINE_BYTES;