pi-rtk-optimizer 0.3.2 → 0.3.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
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.3] - 2026-03-07
11
+
12
+ ### Added
13
+ - Added rewrite bypass rules for structured `gh` output commands and non-interactive container shell sessions.
14
+ - Added dedicated runtime guard helpers and test coverage for rewrite-mode availability behavior.
15
+ - Added repository lockfile plus additional command rewriter and runtime guard tests.
16
+
17
+ ### Changed
18
+ - Updated README documentation to reflect rewrite bypass behavior, runtime guard semantics, source filtering details, and expanded development verification commands.
19
+ - Added a dedicated `typecheck` script and expanded `check` to run typecheck plus the full test suite.
20
+ - Routed `pnpm dlx` commands through the RTK proxy path instead of the generic pnpm wrapper.
21
+
22
+ ### Fixed
23
+ - Improved command tokenization so `sed` scripts, shell separators, redirects, and background operators do not break later rewrites.
24
+ - Preserved exact `read` output at the 80-line smart-truncation threshold instead of compacting boundary-sized results.
25
+ - Preserved userscript metadata blocks during source filtering.
26
+ - Limited RTK-missing command suppression to rewrite mode so suggest mode still produces guidance.
27
+
10
28
  ## [0.3.2] - 2026-03-04
11
29
 
12
30
  ### Fixed
package/README.md CHANGED
@@ -21,7 +21,9 @@
21
21
  - **Containers** — `docker`, `docker-compose`, `podman` commands
22
22
  - **Network** — `curl`, `wget` commands
23
23
  - **Package Managers** — `apt`, `brew`, `dnf`, `pacman`, `yum` commands
24
- - Runtime guard when `rtk` binary is unavailable (falls back to original commands)
24
+ - Runtime guard when `rtk` binary is unavailable (falls back to original commands only in rewrite mode)
25
+ - Safe rewrite bypasses for structured `gh` output commands and non-interactive container shell sessions
26
+ - Improved command parsing for `sed`, shell separators, and `pnpm dlx` proxy rewrites
25
27
 
26
28
  ### Output Compaction Pipeline
27
29
 
@@ -35,8 +37,8 @@ Multi-stage pipeline to reduce token consumption:
35
37
  | Git Compaction | Condenses `git status`, `git log`, `git diff` output |
36
38
  | Linter Aggregation | Summarizes linting tool output |
37
39
  | Search Grouping | Groups `grep`/`rg` results by file |
38
- | Source Code Filtering | `none`, `minimal`, or `aggressive` comment/whitespace removal |
39
- | Smart Truncation | Preserves file boundaries and important lines |
40
+ | Source Code Filtering | `none`, `minimal`, or `aggressive` comment/whitespace removal with userscript metadata preservation |
41
+ | Smart Truncation | Preserves file boundaries and important lines while keeping 80-line reads exact |
40
42
  | Hard Truncation | Final character limit enforcement |
41
43
 
42
44
  ### Interactive Settings
@@ -220,7 +222,9 @@ src/
220
222
  ├── config-store.ts # Config load/save with normalization
221
223
  ├── config-modal.ts # TUI settings modal and /rtk handler
222
224
  ├── command-rewriter.ts # Command tokenization and rewrite logic
225
+ ├── rewrite-bypass.ts # Rewrite safety bypass rules for interactive/structured commands
223
226
  ├── rewrite-rules.ts # Rewrite rule catalog
227
+ ├── runtime-guard.ts # Runtime availability guard helpers for rewrite mode
224
228
  ├── output-compactor.ts # Tool result compaction pipeline
225
229
  ├── output-metrics.ts # Savings tracking and reporting
226
230
  ├── command-completions.ts # /rtk subcommand completions
@@ -259,17 +263,20 @@ Automatic fixes applied on Windows:
259
263
  ## Development
260
264
 
261
265
  ```bash
262
- # Build (TypeScript compilation)
266
+ # Build
263
267
  npm run build
264
268
 
265
- # Lint
266
- npm run lint
269
+ # Full typecheck
270
+ npm run typecheck
267
271
 
268
272
  # Run tests
269
273
  npm run test
270
274
 
271
- # Full check (lint + test)
275
+ # Full verification
272
276
  npm run check
277
+
278
+ # Bundle sanity check
279
+ npm run build:check
273
280
  ```
274
281
 
275
282
  ## Credits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rtk-optimizer",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Pi extension that optimizes RTK command rewriting and tool output compaction for the coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -18,8 +18,9 @@
18
18
  "scripts": {
19
19
  "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
20
20
  "lint": "npm run build",
21
- "test": "bun ./src/output-compactor-test.ts",
22
- "check": "npm run lint && npm run test",
21
+ "typecheck": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json",
22
+ "test": "bun ./src/output-compactor-test.ts && bun ./src/command-rewriter-test.ts && bun ./src/runtime-guard-test.ts",
23
+ "check": "npm run lint && npm run typecheck && npm run test",
23
24
  "build:check": "bunx esbuild ./index.ts --bundle --platform=node --format=esm --outfile=./.pi-rtk-optimizer-check.mjs --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-tui && bun -e \"import { unlinkSync } from 'node:fs'; unlinkSync('./.pi-rtk-optimizer-check.mjs');\""
24
25
  },
25
26
  "keywords": [
@@ -0,0 +1,120 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import { computeRewriteDecision } from "./command-rewriter.ts";
4
+ import { DEFAULT_RTK_INTEGRATION_CONFIG, type RtkIntegrationConfig } from "./types.ts";
5
+
6
+ function runTest(name: string, testFn: () => void): void {
7
+ testFn();
8
+ console.log(`[PASS] ${name}`);
9
+ }
10
+
11
+ function cloneConfig(): RtkIntegrationConfig {
12
+ return structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG);
13
+ }
14
+
15
+ function expectedProxyExecutable(base: "pnpm"): string {
16
+ return process.platform === "win32" ? `${base}.cmd` : base;
17
+ }
18
+
19
+ runTest("pnpm dlx rewrites through RTK proxy instead of generic pnpm wrapper", () => {
20
+ const config = cloneConfig();
21
+ const decision = computeRewriteDecision("pnpm dlx create-vite@latest demo --template react-ts", config);
22
+
23
+ assert.equal(decision.changed, true);
24
+ assert.equal(decision.rule?.id, "pnpm-dlx-proxy");
25
+ assert.equal(
26
+ decision.rewrittenCommand,
27
+ `rtk proxy ${expectedProxyExecutable("pnpm")} dlx create-vite@latest demo --template react-ts`,
28
+ );
29
+ });
30
+
31
+ runTest("docker run with shell command bypasses rewrite when interactive flags are missing", () => {
32
+ const config = cloneConfig();
33
+ const decision = computeRewriteDecision("docker run ubuntu bash", config);
34
+
35
+ assert.equal(decision.changed, false);
36
+ assert.equal(decision.rewrittenCommand, "docker run ubuntu bash");
37
+ assert.equal(decision.reason, "no_match");
38
+ });
39
+
40
+ runTest("docker run with -it keeps container rewrite enabled", () => {
41
+ const config = cloneConfig();
42
+ const decision = computeRewriteDecision("docker run -it ubuntu bash", config);
43
+
44
+ assert.equal(decision.changed, true);
45
+ assert.equal(decision.rule?.id, "docker");
46
+ assert.equal(decision.rewrittenCommand, "rtk docker run -it ubuntu bash");
47
+ });
48
+
49
+ runTest("docker compose exec without -it bypasses interactive shell rewrite", () => {
50
+ const config = cloneConfig();
51
+ const decision = computeRewriteDecision("docker compose exec web bash", config);
52
+
53
+ assert.equal(decision.changed, false);
54
+ assert.equal(decision.rewrittenCommand, "docker compose exec web bash");
55
+ });
56
+
57
+ runTest("container rewrites stay enabled for scripted shells and non-shell commands", () => {
58
+ const config = cloneConfig();
59
+
60
+ const scriptedShell = computeRewriteDecision('docker run ubuntu bash -lc "echo hi"', config);
61
+ assert.equal(scriptedShell.changed, true);
62
+ assert.equal(scriptedShell.rule?.id, "docker");
63
+ assert.equal(scriptedShell.rewrittenCommand, 'rtk docker run ubuntu bash -lc "echo hi"');
64
+
65
+ const nonShell = computeRewriteDecision("docker run ubuntu python app.py", config);
66
+ assert.equal(nonShell.changed, true);
67
+ assert.equal(nonShell.rule?.id, "docker");
68
+ assert.equal(nonShell.rewrittenCommand, "rtk docker run ubuntu python app.py");
69
+ });
70
+
71
+ runTest("kubectl exec requires interactive flags before rewriting shell sessions", () => {
72
+ const config = cloneConfig();
73
+ const missingFlagsDecision = computeRewriteDecision("kubectl exec pod-123 -- bash", config);
74
+ assert.equal(missingFlagsDecision.changed, false);
75
+ assert.equal(missingFlagsDecision.rewrittenCommand, "kubectl exec pod-123 -- bash");
76
+
77
+ const interactiveDecision = computeRewriteDecision("kubectl exec -it pod-123 -- bash", config);
78
+ assert.equal(interactiveDecision.changed, true);
79
+ assert.equal(interactiveDecision.rule?.id, "kubectl");
80
+ assert.equal(interactiveDecision.rewrittenCommand, "rtk kubectl exec -it pod-123 -- bash");
81
+ });
82
+
83
+ runTest("sed scripts keep internal separators intact while later pipe segments still rewrite", () => {
84
+ const config = cloneConfig();
85
+ const decision = computeRewriteDecision("sed -e s/a/b/;d file.txt | git status", config);
86
+
87
+ assert.equal(decision.changed, true);
88
+ assert.equal(decision.rewrittenCommand, "sed -e s/a/b/;d file.txt | rtk git status");
89
+ assert.equal(decision.rule?.id, "git-any");
90
+ });
91
+
92
+ runTest("background operators rewrite both command segments without misreading redirect ampersands", () => {
93
+ const config = cloneConfig();
94
+ const backgroundDecision = computeRewriteDecision("git status & cargo test", config);
95
+ assert.equal(backgroundDecision.changed, true);
96
+ assert.equal(backgroundDecision.rewrittenCommand, "rtk git status & rtk cargo test");
97
+
98
+ const redirectDecision = computeRewriteDecision("cargo test 2>&1 | head -5", config);
99
+ assert.equal(redirectDecision.changed, true);
100
+ assert.equal(redirectDecision.rewrittenCommand, "rtk cargo test 2>&1 | head -5");
101
+ assert.equal(redirectDecision.rule?.id, "cargo-any");
102
+ });
103
+
104
+ runTest("gh structured output commands bypass RTK rewrites", () => {
105
+ const config = cloneConfig();
106
+ const structuredCommands = [
107
+ "gh pr list --json number,title",
108
+ "gh issue list --jq '.[].title'",
109
+ "gh pr view 123 --template '{{.title}}'",
110
+ ];
111
+
112
+ for (const command of structuredCommands) {
113
+ const decision = computeRewriteDecision(command, config);
114
+ assert.equal(decision.changed, false);
115
+ assert.equal(decision.rewrittenCommand, command);
116
+ assert.equal(decision.reason, "no_match");
117
+ }
118
+ });
119
+
120
+ console.log("All command-rewriter tests passed.");
@@ -1,7 +1,9 @@
1
- import type { RtkIntegrationConfig } from "./types.js";
1
+ import { shouldBypassRewriteForCommand } from "./rewrite-bypass.js";
2
2
  import { RTK_REWRITE_RULES, type RtkRewriteCategory, type RtkRewriteRule } from "./rewrite-rules.js";
3
+ import type { RtkIntegrationConfig } from "./types.js";
3
4
 
4
5
  const ENV_PREFIX_PATTERN = /^((?:[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+)*)/;
6
+ const SHELL_ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
5
7
 
6
8
  type CommandToken =
7
9
  | {
@@ -13,6 +15,14 @@ type CommandToken =
13
15
  value: string;
14
16
  };
15
17
 
18
+ type SedNextTokenMode = "none" | "defaultScript" | "expressionScript" | "fileArgument" | "inPlaceArgument";
19
+
20
+ interface SegmentParseState {
21
+ commandName?: string;
22
+ sedNextTokenMode: SedNextTokenMode;
23
+ sedScriptSeen: boolean;
24
+ }
25
+
16
26
  export interface RewriteDecision {
17
27
  changed: boolean;
18
28
  originalCommand: string;
@@ -69,6 +79,162 @@ function categoryEnabled(config: RtkIntegrationConfig, category: RtkRewriteCateg
69
79
  }
70
80
  }
71
81
 
82
+ function createSegmentParseState(): SegmentParseState {
83
+ return {
84
+ sedNextTokenMode: "none",
85
+ sedScriptSeen: false,
86
+ };
87
+ }
88
+
89
+ function normalizeShellWord(word: string): string {
90
+ const unwrapped = word.replace(/^(?:["'`])|(?:["'`])$/g, "");
91
+ const lastPathSeparator = Math.max(unwrapped.lastIndexOf("/"), unwrapped.lastIndexOf("\\"));
92
+ const basename = lastPathSeparator >= 0 ? unwrapped.slice(lastPathSeparator + 1) : unwrapped;
93
+ return basename.toLowerCase();
94
+ }
95
+
96
+ function shouldProtectSedWord(state: SegmentParseState): boolean {
97
+ return (
98
+ state.commandName === "sed" &&
99
+ !state.sedScriptSeen &&
100
+ (state.sedNextTokenMode === "defaultScript" ||
101
+ state.sedNextTokenMode === "expressionScript" ||
102
+ state.sedNextTokenMode === "inPlaceArgument")
103
+ );
104
+ }
105
+
106
+ function isQuotedEmptyToken(word: string): boolean {
107
+ return word === "''" || word === '""';
108
+ }
109
+
110
+ function looksLikeSedBackupExtension(word: string): boolean {
111
+ const normalized = word.replace(/^(?:["'`])|(?:["'`])$/g, "");
112
+ return normalized.startsWith(".") || normalized === "*";
113
+ }
114
+
115
+ function updateSegmentParseState(state: SegmentParseState, word: string): SegmentParseState {
116
+ if (!word) {
117
+ return state;
118
+ }
119
+
120
+ if (!state.commandName) {
121
+ if (SHELL_ENV_ASSIGNMENT_PATTERN.test(word)) {
122
+ return state;
123
+ }
124
+
125
+ const commandName = normalizeShellWord(word);
126
+ return {
127
+ commandName,
128
+ sedNextTokenMode: "none",
129
+ sedScriptSeen: false,
130
+ };
131
+ }
132
+
133
+ if (state.commandName !== "sed" || state.sedScriptSeen) {
134
+ return state;
135
+ }
136
+
137
+ if (state.sedNextTokenMode === "expressionScript") {
138
+ return {
139
+ ...state,
140
+ sedNextTokenMode: "none",
141
+ sedScriptSeen: true,
142
+ };
143
+ }
144
+
145
+ if (state.sedNextTokenMode === "fileArgument") {
146
+ return {
147
+ ...state,
148
+ sedNextTokenMode: "none",
149
+ };
150
+ }
151
+
152
+ if (state.sedNextTokenMode === "inPlaceArgument") {
153
+ if (isQuotedEmptyToken(word) || looksLikeSedBackupExtension(word)) {
154
+ return {
155
+ ...state,
156
+ sedNextTokenMode: "defaultScript",
157
+ };
158
+ }
159
+
160
+ return {
161
+ ...state,
162
+ sedNextTokenMode: "none",
163
+ sedScriptSeen: true,
164
+ };
165
+ }
166
+
167
+ if (state.sedNextTokenMode === "defaultScript") {
168
+ return {
169
+ ...state,
170
+ sedNextTokenMode: "none",
171
+ sedScriptSeen: true,
172
+ };
173
+ }
174
+
175
+ if (word === "--") {
176
+ return {
177
+ ...state,
178
+ sedNextTokenMode: "defaultScript",
179
+ };
180
+ }
181
+
182
+ if (word === "-e" || word === "--expression") {
183
+ return {
184
+ ...state,
185
+ sedNextTokenMode: "expressionScript",
186
+ };
187
+ }
188
+
189
+ if (word.startsWith("--expression=")) {
190
+ return {
191
+ ...state,
192
+ sedScriptSeen: true,
193
+ };
194
+ }
195
+
196
+ if (/^-[A-Za-z]*e[A-Za-z]*$/.test(word)) {
197
+ return {
198
+ ...state,
199
+ sedNextTokenMode: "expressionScript",
200
+ };
201
+ }
202
+
203
+ if (word === "-f" || word === "--file") {
204
+ return {
205
+ ...state,
206
+ sedNextTokenMode: "fileArgument",
207
+ };
208
+ }
209
+
210
+ if (word.startsWith("--file=") || /^-f.+$/.test(word)) {
211
+ return state;
212
+ }
213
+
214
+ if (word === "-i" || word === "--in-place") {
215
+ return {
216
+ ...state,
217
+ sedNextTokenMode: "inPlaceArgument",
218
+ };
219
+ }
220
+
221
+ if (word.startsWith("--in-place=") || /^-i.+$/.test(word)) {
222
+ return {
223
+ ...state,
224
+ sedNextTokenMode: "defaultScript",
225
+ };
226
+ }
227
+
228
+ if (word.startsWith("-")) {
229
+ return state;
230
+ }
231
+
232
+ return {
233
+ ...state,
234
+ sedScriptSeen: true,
235
+ };
236
+ }
237
+
72
238
  function tokenizeCommand(command: string): CommandToken[] {
73
239
  if (!command) {
74
240
  return [];
@@ -76,11 +242,47 @@ function tokenizeCommand(command: string): CommandToken[] {
76
242
 
77
243
  const tokens: CommandToken[] = [];
78
244
  let segmentStart = 0;
245
+ let segmentState = createSegmentParseState();
246
+ let currentWordStart: number | null = null;
247
+ let currentWordProtected = false;
79
248
  let quote: "'" | '"' | "`" | null = null;
80
249
  let escaped = false;
81
250
 
251
+ const finalizeWord = (endIndexExclusive: number): void => {
252
+ if (currentWordStart === null) {
253
+ return;
254
+ }
255
+
256
+ const word = command.slice(currentWordStart, endIndexExclusive);
257
+ segmentState = updateSegmentParseState(segmentState, word);
258
+ currentWordStart = null;
259
+ currentWordProtected = false;
260
+ };
261
+
262
+ const pushSeparator = (index: number, length: number): void => {
263
+ finalizeWord(index);
264
+ const segment = command.slice(segmentStart, index);
265
+ if (segment.length > 0) {
266
+ tokens.push({ type: "segment", value: segment });
267
+ }
268
+ tokens.push({ type: "separator", value: command.slice(index, index + length) });
269
+ segmentStart = index + length;
270
+ segmentState = createSegmentParseState();
271
+ };
272
+
273
+ const beginWord = (index: number): void => {
274
+ if (currentWordStart !== null) {
275
+ return;
276
+ }
277
+
278
+ currentWordStart = index;
279
+ currentWordProtected = shouldProtectSedWord(segmentState);
280
+ };
281
+
82
282
  for (let index = 0; index < command.length; index += 1) {
83
283
  const char = command[index];
284
+ const nextChar = command[index + 1] ?? "";
285
+ const prevChar = index > 0 ? command[index - 1] ?? "" : "";
84
286
 
85
287
  if (escaped) {
86
288
  escaped = false;
@@ -98,33 +300,63 @@ function tokenizeCommand(command: string): CommandToken[] {
98
300
  continue;
99
301
  }
100
302
 
101
- if (char === "'" || char === '"' || char === "`") {
102
- quote = char;
303
+ if (char === "\\") {
304
+ beginWord(index);
305
+ escaped = true;
103
306
  continue;
104
307
  }
105
308
 
106
- const nextChar = command[index + 1] ?? "";
107
- if ((char === "&" && nextChar === "&") || (char === "|" && nextChar === "|")) {
108
- const segment = command.slice(segmentStart, index);
109
- if (segment.length > 0) {
110
- tokens.push({ type: "segment", value: segment });
111
- }
112
- tokens.push({ type: "separator", value: command.slice(index, index + 2) });
113
- segmentStart = index + 2;
114
- index += 1;
309
+ if (/\s/.test(char)) {
310
+ finalizeWord(index);
115
311
  continue;
116
312
  }
117
313
 
118
- if (char === ";") {
119
- const segment = command.slice(segmentStart, index);
120
- if (segment.length > 0) {
121
- tokens.push({ type: "segment", value: segment });
314
+ if (!currentWordProtected) {
315
+ if (char === "&" && nextChar === "&") {
316
+ pushSeparator(index, 2);
317
+ index += 1;
318
+ continue;
319
+ }
320
+
321
+ if (char === "|" && nextChar === "|") {
322
+ pushSeparator(index, 2);
323
+ index += 1;
324
+ continue;
325
+ }
326
+
327
+ if (char === "|" && nextChar === "&") {
328
+ pushSeparator(index, 2);
329
+ index += 1;
330
+ continue;
331
+ }
332
+
333
+ if (char === "|" && prevChar !== ">") {
334
+ pushSeparator(index, 1);
335
+ continue;
336
+ }
337
+
338
+ if (char === "&" && nextChar !== ">" && prevChar !== ">" && prevChar !== "<") {
339
+ pushSeparator(index, 1);
340
+ continue;
341
+ }
342
+
343
+ if (char === ";") {
344
+ pushSeparator(index, 1);
345
+ continue;
122
346
  }
123
- tokens.push({ type: "separator", value: ";" });
124
- segmentStart = index + 1;
125
347
  }
348
+
349
+ if (char === "'" || char === '"' || char === "`") {
350
+ beginWord(index);
351
+ quote = char;
352
+ continue;
353
+ }
354
+
355
+ beginWord(index);
126
356
  }
127
357
 
358
+ finalizeWord(command.length);
359
+
128
360
  const tail = command.slice(segmentStart);
129
361
  if (tail.length > 0 || tokens.length === 0) {
130
362
  tokens.push({ type: "segment", value: tail });
@@ -191,6 +423,10 @@ function rewriteSingleSegmentCommand(
191
423
  }
192
424
 
193
425
  rule.matcher.lastIndex = 0;
426
+ if (shouldBypassRewriteForCommand(commandBody, rule)) {
427
+ continue;
428
+ }
429
+
194
430
  const rewrittenBody = commandBody.replace(rule.matcher, rule.replacement);
195
431
  const finalizedRewrittenBody = applyPlatformProxyCommandFixups(rewrittenBody);
196
432
  if (finalizedRewrittenBody === commandBody) {
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { registerRtkIntegrationCommand } from "./config-modal.js";
11
11
  import { EXTENSION_NAME } from "./constants.js";
12
12
  import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
13
13
  import { compactToolResult, type ToolResultCompactionMetadata } from "./output-compactor.js";
14
+ import { shouldRequireRtkAvailabilityForCommandHandling, shouldSkipCommandHandlingWhenRtkMissing } from "./runtime-guard.js";
14
15
  import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
15
16
  import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.js";
16
17
 
@@ -86,7 +87,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
86
87
  }
87
88
  };
88
89
 
89
- const refreshConfig = (ctx?: ExtensionContext | ExtensionCommandContext): void => {
90
+ const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
90
91
  const ensured = ensureConfigExists();
91
92
  if (ensured.error && ctx) {
92
93
  warnOnce(ctx, ensured.error);
@@ -95,6 +96,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
95
96
  const loaded = loadRtkIntegrationConfig();
96
97
  config = loaded.config;
97
98
  pendingLoadWarning = loaded.warning;
99
+ await refreshRuntimeStatus();
98
100
 
99
101
  if (pendingLoadWarning && ctx) {
100
102
  warnOnce(ctx, pendingLoadWarning);
@@ -162,7 +164,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
162
164
  };
163
165
 
164
166
  const ensureRuntimeStatusFresh = async (): Promise<void> => {
165
- if (!config.guardWhenRtkMissing) {
167
+ if (!shouldRequireRtkAvailabilityForCommandHandling(config)) {
166
168
  return;
167
169
  }
168
170
 
@@ -186,14 +188,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
186
188
  registerRtkIntegrationCommand(pi, controller);
187
189
 
188
190
  pi.on("session_start", async (_event, ctx) => {
189
- refreshConfig(ctx);
190
- await refreshRuntimeStatus();
191
+ await refreshConfig(ctx);
191
192
  maybeWarnRtkMissing(ctx);
192
193
  });
193
194
 
194
195
  pi.on("session_switch", async (_event, ctx) => {
195
- refreshConfig(ctx);
196
- await refreshRuntimeStatus();
196
+ await refreshConfig(ctx);
197
197
  maybeWarnRtkMissing(ctx);
198
198
  });
199
199
 
@@ -227,7 +227,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
227
227
  }
228
228
 
229
229
  await ensureRuntimeStatusFresh();
230
- if (config.guardWhenRtkMissing && !runtimeStatus.rtkAvailable) {
230
+ if (shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus)) {
231
231
  return {};
232
232
  }
233
233
 
@@ -278,9 +278,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
278
278
 
279
279
  return {
280
280
  content: outcome.content,
281
- details: outcome.metadata
282
- ? mergeCompactionDetails((event as Record<string, unknown>).details, outcome.metadata)
283
- : undefined,
281
+ details: outcome.metadata ? mergeCompactionDetails(event.details, outcome.metadata) : undefined,
284
282
  };
285
283
  } catch (error) {
286
284
  const message = error instanceof Error ? error.message : String(error);
@@ -117,4 +117,42 @@ runTest("short read output stays exact below threshold", () => {
117
117
  assert.deepEqual(result.techniques, []);
118
118
  });
119
119
 
120
+ runTest("read output stays exact at the 80-line boundary with trailing newline", () => {
121
+ const config = cloneConfig();
122
+ config.outputCompaction.smartTruncate.enabled = true;
123
+ config.outputCompaction.smartTruncate.maxLines = 40;
124
+
125
+ const content = buildReadContent(80);
126
+ const result = compactToolResult(
127
+ {
128
+ toolName: "read",
129
+ input: { path: "sample.ts" },
130
+ content: [{ type: "text", text: content }],
131
+ },
132
+ config,
133
+ );
134
+
135
+ assert.equal(result.changed, false);
136
+ assert.deepEqual(result.techniques, []);
137
+ });
138
+
139
+ runTest("read output compacts once the content exceeds the 80-line exactness threshold", () => {
140
+ const config = cloneConfig();
141
+ config.outputCompaction.smartTruncate.enabled = true;
142
+ config.outputCompaction.smartTruncate.maxLines = 40;
143
+
144
+ const content = buildReadContent(81);
145
+ const result = compactToolResult(
146
+ {
147
+ toolName: "read",
148
+ input: { path: "sample.ts" },
149
+ content: [{ type: "text", text: content }],
150
+ },
151
+ config,
152
+ );
153
+
154
+ assert.equal(result.changed, true);
155
+ assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
156
+ });
157
+
120
158
  console.log("All output-compactor tests passed.");
@@ -103,7 +103,13 @@ function countLines(text: string): number {
103
103
  if (!text) {
104
104
  return 0;
105
105
  }
106
- return text.split("\n").length;
106
+
107
+ const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
108
+ if (!normalized) {
109
+ return 1;
110
+ }
111
+
112
+ return normalized.split("\n").length;
107
113
  }
108
114
 
109
115
  function hasLossyCompaction(techniques: string[]): boolean {