chisle 3.6.1 → 3.8.0

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.
@@ -55,7 +55,7 @@
55
55
  const fs = require('fs');
56
56
  const path = require('path');
57
57
  const crypto = require('crypto');
58
- const { getClaudeDir, getCopilotDir, getOpencodeDir, readFlag } = require('./chisle-config');
58
+ const { getClaudeDir, getCopilotDir, getOpencodeStateDir, readFlag, getDefaultMode } = require('./chisle-config');
59
59
 
60
60
  // One threshold. Env overrides all.
61
61
  // These are the values the old 'full' level used — the default,
@@ -84,7 +84,10 @@ const SAFE_TOOLS_COPILOT = ['bash', 'powershell', 'grep', 'glob', 'web_fetch', '
84
84
  // NEVER_COMPRESS above — not merely absent from the allowlist.
85
85
  const NEVER_COMPRESS_COPILOT = ['view', 'create', 'edit', 'str_replace', 'insert'];
86
86
 
87
- const SALVAGE_RE = /\b(error|err!|fail(ed|ure|ing)?|exception|traceback|panic|fatal|denied|refused|timed?[ _-]?out|assert(ion)?|segfault|npe|undefined reference|cannot find|not found|warning)\b/i;
87
+ // Letter/digit boundaries, not \b: `_` is a word char, so \b missed severities
88
+ // inside codes like FATAL_MEMORY_PROBE / APP_ERROR_CODE (#38), and `err!` then
89
+ // a space has no trailing \b at all.
90
+ const SALVAGE_RE = /(?<![A-Za-z0-9])(?:err!|(?:error|fail(ed|ure|ing)?|exception|traceback|panic|fatal|denied|refused|timed?[ _-]?out|assert(ion)?|segfault|npe|undefined reference|cannot find|not found|warning)(?![A-Za-z0-9]))/i;
88
91
  const MAX_SALVAGED = 12; // error lines rescued from the elided middle
89
92
  const MAX_SALVAGE_LINE = 300; // per-line char cap on salvaged lines
90
93
 
@@ -214,7 +217,7 @@ function compressForOpencode(toolName, text, opts) {
214
217
  if (process.env.CHISLE_COMPRESS === '0') return null;
215
218
  if (!opencodeToolAllowed(toolName)) return null;
216
219
  if (typeof text !== 'string' || !text) return null;
217
- const stateDir = opts.stateDir || process.env.CHISLE_STATE_DIR || getOpencodeDir();
220
+ const stateDir = opts.stateDir || getOpencodeStateDir();
218
221
  const limits = limitsFor();
219
222
  const dup = dedupCheck(toolName, text, opts.sessionId, opts.callId, stateDir, true);
220
223
  if (dup != null && dup.length < text.length) return boundForOpencode(dup, null, limits.maxChars);
@@ -327,54 +330,96 @@ function duplicateMarker(toolName, text, reference = 'previous') {
327
330
  const DEDUP_SESSION_KEEP = 20;
328
331
  const DEDUP_DIR = 'chisle-dedup';
329
332
 
330
- function readDedupFile(p) {
333
+ // `root` (the user-owned state root) may itself be a symlink (e.g. dotfiles
334
+ // managed with GNU stow), so it is resolved with stat — but every subdirectory
335
+ // beneath it and the target file are verified with lstat and must not be links.
336
+ function secureDirectory(dir, root) {
337
+ try {
338
+ dir = path.resolve(dir);
339
+ root = root ? path.resolve(root) : dir;
340
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
341
+ let cur = dir;
342
+ while (cur !== root && cur.length > 1) {
343
+ const st = fs.lstatSync(cur);
344
+ if (!st.isDirectory() || st.isSymbolicLink()) return false;
345
+ const parent = path.dirname(cur);
346
+ if (parent === cur) break;
347
+ cur = parent;
348
+ }
349
+ const st = fs.statSync(root);
350
+ return st.isDirectory();
351
+ } catch (e) { return false; }
352
+ }
353
+
354
+ function safeReadText(p) {
331
355
  let st;
332
356
  try { st = fs.lstatSync(p); }
333
- catch (e) { return { state: null, unsafe: e.code !== 'ENOENT' }; }
334
- if (st.isSymbolicLink() || !st.isFile()) return { state: null, unsafe: true };
357
+ catch (e) { return { text: null, unsafe: e.code !== 'ENOENT' }; }
358
+ if (st.isSymbolicLink() || !st.isFile()) return { text: null, unsafe: true };
335
359
  const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
336
360
  let fd;
337
- let raw;
338
361
  try {
339
362
  fd = fs.openSync(p, fs.constants.O_RDONLY | noFollow);
340
- raw = fs.readFileSync(fd, 'utf8');
363
+ return { text: fs.readFileSync(fd, 'utf8'), unsafe: false };
341
364
  } catch (e) {
342
- return { state: null, unsafe: true };
365
+ return { text: null, unsafe: true };
343
366
  } finally {
344
367
  if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
345
368
  }
346
- try { return { state: JSON.parse(raw) || null, unsafe: false }; }
347
- catch (e) { return { state: null, unsafe: false }; }
348
369
  }
349
370
 
350
- function dedupStateDir(stateDir) {
351
- const dir = path.join(stateDir || getClaudeDir(), DEDUP_DIR);
352
- try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (e) { return null; }
371
+ function safeAtomicWrite(p, content, root) {
372
+ const dir = path.dirname(p);
373
+ if (!secureDirectory(dir, root)) return false;
353
374
  try {
354
- const st = fs.lstatSync(dir);
355
- return st.isDirectory() && !st.isSymbolicLink() ? dir : null;
356
- } catch (e) { return null; }
357
- }
358
-
359
- function writeDedupState(p, state) {
375
+ const st = fs.lstatSync(p);
376
+ if (st.isSymbolicLink() || !st.isFile()) return false;
377
+ } catch (e) {
378
+ if (e.code !== 'ENOENT') return false;
379
+ }
360
380
  const tmp = p + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
361
381
  const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
362
382
  const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow;
363
383
  let fd;
364
384
  try {
365
385
  fd = fs.openSync(tmp, flags, 0o600);
366
- fs.writeFileSync(fd, JSON.stringify(state));
386
+ fs.writeFileSync(fd, content);
367
387
  try { fs.fchmodSync(fd, 0o600); } catch (e) {}
368
388
  fs.closeSync(fd);
369
389
  fd = undefined;
390
+ try {
391
+ const st = fs.lstatSync(p);
392
+ if (st.isSymbolicLink() || !st.isFile()) return false;
393
+ } catch (e) {
394
+ if (e.code !== 'ENOENT') return false;
395
+ }
370
396
  fs.renameSync(tmp, p);
397
+ return true;
371
398
  } catch (e) {
372
- if (fd !== undefined) { try { fs.closeSync(fd); } catch (closeError) {} }
373
- try { fs.unlinkSync(tmp); } catch (unlinkError) {}
374
- throw e;
399
+ return false;
400
+ } finally {
401
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
402
+ try { fs.unlinkSync(tmp); } catch (e) {}
375
403
  }
376
404
  }
377
405
 
406
+ function readDedupFile(p) {
407
+ const { text, unsafe } = safeReadText(p);
408
+ if (text == null) return { state: null, unsafe };
409
+ try { return { state: JSON.parse(text) || null, unsafe: false }; }
410
+ catch (e) { return { state: null, unsafe: false }; }
411
+ }
412
+
413
+ function dedupStateDir(stateDir) {
414
+ const root = stateDir || getClaudeDir();
415
+ const dir = path.join(root, DEDUP_DIR);
416
+ return secureDirectory(dir, root) ? dir : null;
417
+ }
418
+
419
+ function writeDedupState(p, state, root) {
420
+ if (!safeAtomicWrite(p, JSON.stringify(state), root)) throw new Error('unsafe dedup state path');
421
+ }
422
+
378
423
  function pruneDedupSessions(dir) {
379
424
  try {
380
425
  const files = fs.readdirSync(dir)
@@ -394,7 +439,7 @@ function pruneDedupSessions(dir) {
394
439
 
395
440
  // `stateDir` defaults to getClaudeDir() so existing Claude/Pi callers keep
396
441
  // their prior storage path. Copilot passes getCopilotDir(); OpenCode passes
397
- // getOpencodeDir() and opts into per-session files for interleaved sessions.
442
+ // getOpencodeStateDir() and opts into per-session files for interleaved sessions.
398
443
  function dedupCheck(toolName, text, sessionId, toolUseId, stateDir, perSession) {
399
444
  if (process.env.CHISLE_COMPRESS_DEDUP === '0') return null;
400
445
  if (!sessionId || typeof sessionId !== 'string') return null;
@@ -430,7 +475,7 @@ function dedupCheck(toolName, text, sessionId, toolUseId, stateDir, perSession)
430
475
  const dup = rec.hash === hash && !sameCall;
431
476
  state.tools[toolName] = { hash, id: toolUseId || rec.id || null };
432
477
  if (perSession) {
433
- writeDedupState(p, state);
478
+ writeDedupState(p, state, stateDir || getClaudeDir());
434
479
  pruneDedupSessions(dir);
435
480
  } else {
436
481
  fs.mkdirSync(dir, { recursive: true });
@@ -466,7 +511,12 @@ function pruneSpill(dir) {
466
511
  try {
467
512
  const files = fs.readdirSync(dir)
468
513
  .filter(f => f.endsWith('.txt'))
469
- .map(f => { const p = path.join(dir, f); return { p, t: fs.statSync(p).mtimeMs }; })
514
+ .map(f => {
515
+ const p = path.join(dir, f);
516
+ const st = fs.lstatSync(p);
517
+ return st.isFile() && !st.isSymbolicLink() ? { p, t: st.mtimeMs } : null;
518
+ })
519
+ .filter(Boolean)
470
520
  .sort((a, b) => b.t - a.t);
471
521
  for (const f of files.slice(SPILL_KEEP)) { try { fs.unlinkSync(f.p); } catch (e) {} }
472
522
  } catch (e) {}
@@ -475,12 +525,18 @@ function pruneSpill(dir) {
475
525
  function spill(text, toolName, stateDir) {
476
526
  if (process.env.CHISLE_COMPRESS_SPILL === '0') return null;
477
527
  try {
528
+ const root = stateDir || getClaudeDir();
478
529
  const dir = spillDir(stateDir);
479
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
530
+ if (!secureDirectory(dir, root)) return null;
480
531
  const hash = crypto.createHash('sha256').update(text).digest('hex').slice(0, 12);
481
532
  const safeTool = String(toolName || 'tool').replace(/[^A-Za-z0-9_-]/g, '') || 'tool';
482
533
  const file = path.join(dir, `${safeTool}-${hash}.txt`);
483
- if (!fs.existsSync(file)) fs.writeFileSync(file, text, { mode: 0o600 });
534
+ // Same hash → same content (atomic writes never leave a partial file), so
535
+ // an existing regular file is reused. Re-reading to compare would also
536
+ // fail on lone surrogates, which UTF-8 cannot round-trip.
537
+ let st = null;
538
+ try { st = fs.lstatSync(file); } catch (e) { if (e.code !== 'ENOENT') return null; }
539
+ if (st ? st.isSymbolicLink() || !st.isFile() : !safeAtomicWrite(file, text, root)) return null;
484
540
  pruneSpill(dir);
485
541
  return file;
486
542
  } catch (e) { return null; }
@@ -525,20 +581,19 @@ function compress(text, limits, spillPath) {
525
581
  // count — stats only, never worth a lock file.
526
582
  function recordSavings(saved, stateDir) {
527
583
  try {
528
- const dir = stateDir || getClaudeDir();
529
- const p = path.join(dir, '.chisle-compress-stats.json');
530
- try { if (fs.lstatSync(p).isSymbolicLink()) return; } catch (e) { if (e.code !== 'ENOENT') return; }
584
+ const root = stateDir || getClaudeDir();
585
+ if (!secureDirectory(root, root)) return;
586
+ const p = path.join(root, '.chisle-compress-stats.json');
587
+ const current = safeReadText(p);
588
+ if (current.unsafe) return;
531
589
  let stats = { savedChars: 0, events: 0 };
532
590
  try {
533
- const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
591
+ const parsed = JSON.parse(current.text);
534
592
  if (parsed && Number.isFinite(parsed.savedChars) && Number.isFinite(parsed.events)) stats = parsed;
535
593
  } catch (e) {}
536
594
  stats.savedChars += saved;
537
595
  stats.events += 1;
538
- fs.mkdirSync(dir, { recursive: true });
539
- const tmp = p + '.' + process.pid + '.tmp';
540
- fs.writeFileSync(tmp, JSON.stringify(stats), { mode: 0o600 });
541
- fs.renameSync(tmp, p);
596
+ safeAtomicWrite(p, JSON.stringify(stats), root);
542
597
  } catch (e) {}
543
598
  }
544
599
 
@@ -627,9 +682,10 @@ function main() {
627
682
 
628
683
  // Copilot has no /chisle mode-toggle mechanism (no UserPromptSubmit
629
684
  // hook wired for it yet, so there is nothing to flip a flag file with):
630
- // treat it as always-on, matching the always-on ruleset it already
631
- // gets. Claude/Pi keep reading the real flag file exactly as before.
632
- const mode = copilot ? 'on' : readFlag(path.join(getClaudeDir(), '.chisle-active'));
685
+ // it follows the default mode (CHISLE_DEFAULT_MODE or the shared
686
+ // config, 'on' when unset), like OpenCode. Claude/Pi keep reading the
687
+ // real flag file exactly as before.
688
+ const mode = copilot ? getDefaultMode() : readFlag(path.join(getClaudeDir(), '.chisle-active'));
633
689
 
634
690
  const updated = processPayload(payload, mode);
635
691
  if (updated == null) return;
@@ -41,11 +41,18 @@ function getClaudeDir() {
41
41
  // OpenCode's global config dir. The compressor plugin keeps its spill/dedup/
42
42
  // stats here (as Copilot uses getCopilotDir), so recovery files sit beside
43
43
  // OpenCode's own config instead of leaking into ~/.claude.
44
- function getOpencodeDir() {
45
- if (process.env.XDG_CONFIG_HOME) {
46
- return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
47
- }
48
- return path.join(os.homedir(), '.config', 'opencode');
44
+ function getOpencodeDir(opts) {
45
+ const options = opts || {};
46
+ const env = options.env || process.env;
47
+ const home = options.home || os.homedir();
48
+ if (env.OPENCODE_CONFIG_DIR) return env.OPENCODE_CONFIG_DIR;
49
+ if (env.XDG_CONFIG_HOME) return path.join(env.XDG_CONFIG_HOME, 'opencode');
50
+ return path.join(home, '.config', 'opencode');
51
+ }
52
+
53
+ function getOpencodeStateDir(opts) {
54
+ const env = (opts && opts.env) || process.env;
55
+ return env.CHISLE_STATE_DIR || getOpencodeDir(opts);
49
56
  }
50
57
 
51
58
  // GitHub Copilot CLI's own user-level state directory (mirrors the
@@ -350,7 +357,7 @@ function readFlag(flagPath) {
350
357
  }
351
358
 
352
359
  module.exports = {
353
- LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir, getCopilotDir, getOpencodeDir,
360
+ LEGACY_MODES, legacySetting, getDefaultMode, getClaudeDir, getCopilotDir, getOpencodeDir, getOpencodeStateDir, getConfigDir,
354
361
  VALID_MODES, safeWriteFlag, readFlag,
355
362
  PROSE_HEADINGS, CODE_HEADINGS, getSections, filterSections,
356
363
  getActiveOutputStyle, explicitSections, resolveSections,
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ // chisle — stdio MCP proxy: tool-output compression for any MCP host.
3
+ //
4
+ // Most agents cannot rewrite a tool result from a hook (Windsurf, Cline,
5
+ // Kiro, Gemini, Qwen; Cursor only for MCP), but every one of them launches
6
+ // stdio MCP servers from a command line it does not inspect. Wrapping that
7
+ // command puts Chisle between host and server:
8
+ //
9
+ // node chisle-mcp-proxy.js -- <original command> [args...]
10
+ //
11
+ // Bytes pass through untouched in both directions, except the JSON-RPC
12
+ // response to a `tools/call` request: its `content[]` text blocks go through
13
+ // the same scrub + elide core as every other harness (head/tail kept, error
14
+ // lines salvaged, full original spilled to disk). Everything else — init,
15
+ // tools/list, notifications, resources, malformed lines — is forwarded
16
+ // byte-identical. `npx chisle mcp wrap` rewrites host configs to use it.
17
+ //
18
+ // Deliberately absent:
19
+ // - dedup markers: one server process can outlive a chat and serve the
20
+ // next one, whose context never saw the earlier copy.
21
+ // - read/edit-shaped tools (read_file, get_file_contents, edit_file, ...):
22
+ // their output feeds exact-match edits. Skipped by name.
23
+ // - structuredContent: left as-is; hosts that forward it get it unchanged.
24
+ //
25
+ // Never breaks the server: any compressor error forwards the original line.
26
+ // Tunables: CHISLE_COMPRESS=0, CHISLE_DEFAULT_MODE=off, CHISLE_COMPRESS_MAX_CHARS
27
+ // (+ HEAD/TAIL_LINES), CHISLE_COMPRESS_SPILL=0, CHISLE_STATE_DIR (spill + stats,
28
+ // default ~/.config/chisle).
29
+
30
+ 'use strict';
31
+
32
+ const cp = require('child_process');
33
+ const { StringDecoder } = require('string_decoder');
34
+ const core = require('./chisle-compress-output');
35
+ const { getDefaultMode, getConfigDir } = require('./chisle-config');
36
+
37
+ const IS_WIN = process.platform === 'win32';
38
+
39
+ // Tool names whose output may feed a later exact-match edit, or is file
40
+ // content the model quotes back. Matched on the raw MCP tool name.
41
+ const UNSAFE_MCP_RE = /read|view|file|edit|write|patch|replace|insert/i;
42
+ const MAX_PENDING_LINE = 16 * 1024 * 1024; // drop observation of absurd lines
43
+
44
+ function mcpToolAllowed(name) {
45
+ return typeof name === 'string' && !!name && !UNSAFE_MCP_RE.test(name);
46
+ }
47
+
48
+ function stateDir() {
49
+ return process.env.CHISLE_STATE_DIR || getConfigDir();
50
+ }
51
+
52
+ // Compress one tools/call result in place. Returns chars saved (0 = untouched).
53
+ function compressResult(result, toolName) {
54
+ if (!result || !Array.isArray(result.content)) return 0;
55
+ const limits = core.limitsFor();
56
+ let saved = 0;
57
+ for (const block of result.content) {
58
+ if (!block || block.type !== 'text' || typeof block.text !== 'string') continue;
59
+ if (block.text.includes('[chisle:')) continue; // already compressed upstream
60
+ const updated = core.transform(block.text, limits, 'mcp-' + toolName, stateDir());
61
+ if (updated != null && updated.length < block.text.length) {
62
+ saved += block.text.length - updated.length;
63
+ block.text = updated;
64
+ }
65
+ }
66
+ return saved;
67
+ }
68
+
69
+ // One server→host line. Returns the line to forward (original when untouched).
70
+ function rewriteLine(line, pending) {
71
+ if (!pending.size || line.indexOf('"result"') === -1) return line;
72
+ let msg;
73
+ try { msg = JSON.parse(line); } catch (e) { return line; }
74
+ const msgs = Array.isArray(msg) ? msg : [msg];
75
+ let saved = 0;
76
+ for (const m of msgs) {
77
+ if (!m || m.id == null || !('result' in m)) continue;
78
+ const key = JSON.stringify(m.id);
79
+ const tool = pending.get(key);
80
+ if (tool === undefined) continue;
81
+ pending.delete(key);
82
+ if (!mcpToolAllowed(tool)) continue;
83
+ saved += compressResult(m.result, tool);
84
+ }
85
+ if (!saved) return line;
86
+ core.recordSavings(saved, stateDir());
87
+ return JSON.stringify(msg);
88
+ }
89
+
90
+ // One host→server line: remember which request ids are tool calls.
91
+ function observeRequest(line, pending) {
92
+ if (line.indexOf('tools/call') === -1 && line.indexOf('notifications/cancelled') === -1) return;
93
+ let msg;
94
+ try { msg = JSON.parse(line); } catch (e) { return; }
95
+ for (const m of Array.isArray(msg) ? msg : [msg]) {
96
+ if (m && m.method === 'tools/call' && m.id != null) {
97
+ pending.set(JSON.stringify(m.id), m.params && typeof m.params.name === 'string' ? m.params.name : '');
98
+ }
99
+ // A cancelled call never gets a result; forget it.
100
+ if (m && m.method === 'notifications/cancelled' && m.params && m.params.requestId != null) {
101
+ pending.delete(JSON.stringify(m.params.requestId));
102
+ }
103
+ }
104
+ }
105
+
106
+ // Splits a byte stream into lines, calling onLine(lineWithoutNewline, eol).
107
+ function lineSplitter(onLine) {
108
+ let buf = '';
109
+ return {
110
+ push(chunk) {
111
+ buf += chunk;
112
+ let i;
113
+ while ((i = buf.indexOf('\n')) !== -1) {
114
+ const raw = buf.slice(0, i);
115
+ buf = buf.slice(i + 1);
116
+ const cr = raw.endsWith('\r');
117
+ onLine(cr ? raw.slice(0, -1) : raw, cr ? '\r\n' : '\n');
118
+ }
119
+ },
120
+ rest() { const r = buf; buf = ''; return r; },
121
+ get size() { return buf.length; },
122
+ };
123
+ }
124
+
125
+ function quoteWin(a) {
126
+ if (a === '' || /[\s"&|<>^()%!]/.test(a)) return '"' + String(a).replace(/\\(?=\\*"|$)/g, '\\\\').replace(/"/g, '\\"') + '"';
127
+ return a;
128
+ }
129
+
130
+ function main(argv) {
131
+ const sep = argv.indexOf('--');
132
+ const cmd = sep === -1 ? argv : argv.slice(sep + 1);
133
+ if (!cmd.length) {
134
+ process.stderr.write('usage: chisle-mcp-proxy -- <command> [args...]\n');
135
+ process.exit(2);
136
+ }
137
+
138
+ // Windows MCP commands are usually npx/uvx shims (.cmd), which Node refuses
139
+ // to spawn without a shell; elsewhere spawn directly, no shell parsing.
140
+ const child = IS_WIN
141
+ ? cp.spawn(cmd.map(quoteWin).join(' '), [], { stdio: ['pipe', 'pipe', 'inherit'], shell: true, windowsHide: true })
142
+ : cp.spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'inherit'] });
143
+
144
+ const enabled = process.env.CHISLE_COMPRESS !== '0' && getDefaultMode() !== 'off';
145
+ const pending = new Map();
146
+
147
+ child.on('error', (e) => {
148
+ process.stderr.write('chisle-mcp-proxy: cannot start ' + cmd[0] + ': ' + e.message + '\n');
149
+ process.exit(127);
150
+ });
151
+ child.stdin.on('error', () => {}); // server gone; exit handler reports it
152
+ process.stdout.on('error', () => { try { child.kill(); } catch (e) {} process.exit(0); }); // host gone
153
+
154
+ // Host → server: forward raw, observe tool calls on the side.
155
+ const reqLines = lineSplitter(line => observeRequest(line, pending));
156
+ const decoder = new StringDecoder('utf8');
157
+ process.stdin.on('data', (chunk) => {
158
+ child.stdin.write(chunk);
159
+ if (!enabled) return;
160
+ reqLines.push(decoder.write(chunk));
161
+ if (reqLines.size > MAX_PENDING_LINE) reqLines.rest();
162
+ });
163
+ process.stdin.on('end', () => child.stdin.end());
164
+
165
+ // Server → host: rewrite only tool-call responses. Paused while the host is
166
+ // slow to read so a chatty server cannot balloon this process.
167
+ if (!enabled) {
168
+ child.stdout.pipe(process.stdout);
169
+ } else {
170
+ const resLines = lineSplitter((line, eol) => {
171
+ let out = line;
172
+ try { out = rewriteLine(line, pending); } catch (e) { out = line; }
173
+ if (!process.stdout.write(out + eol)) {
174
+ child.stdout.pause();
175
+ process.stdout.once('drain', () => child.stdout.resume());
176
+ }
177
+ });
178
+ child.stdout.setEncoding('utf8');
179
+ child.stdout.on('data', chunk => resLines.push(chunk));
180
+ child.stdout.on('end', () => { const r = resLines.rest(); if (r) process.stdout.write(r); });
181
+ }
182
+
183
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
184
+ process.on(sig, () => { try { child.kill(sig); } catch (e) {} });
185
+ }
186
+
187
+ child.on('close', (code, signal) => {
188
+ const status = code != null ? code : (signal ? 128 + (require('os').constants.signals[signal] || 1) : 1);
189
+ process.stdout.write('', () => process.exit(status));
190
+ });
191
+ }
192
+
193
+ if (require.main === module) main(process.argv.slice(2));
194
+
195
+ module.exports = { mcpToolAllowed, compressResult, rewriteLine, observeRequest, lineSplitter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chisle",
3
- "version": "3.6.1",
3
+ "version": "3.8.0",
4
4
  "description": "Maximum-efficiency dev mode for AI coding agents. Zero-fluff prose, YAGNI-first code, and safe tool-output compression.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -17,6 +17,7 @@
17
17
  "cursor",
18
18
  "codex",
19
19
  "copilot",
20
+ "antigravity",
20
21
  "opencode",
21
22
  "hermes-agent",
22
23
  "hermes",
@@ -93,11 +94,13 @@
93
94
  ".windsurf/",
94
95
  ".clinerules/",
95
96
  ".kiro/",
97
+ ".agents/",
96
98
  ".github/copilot-instructions.md",
97
99
  ".claude-plugin/",
98
100
  ".opencode/",
99
101
  "README.md",
100
- "LICENSE"
102
+ "LICENSE",
103
+ "NOTICE"
101
104
  ],
102
105
  "publishConfig": {
103
106
  "access": "public",
@@ -44,7 +44,7 @@ Ladder runs after understanding the problem, never instead. Bug fix = root cause
44
44
 
45
45
  ## Thinking Is Billed Too
46
46
 
47
- Reasoning costs the same as output. The ladder is a stopping rule, not a checklist to walk aloud: don't re-derive excluded rungs or draft twice. Obvious fix → give it. Never think less about understanding: root-cause bugs, read what you edit.
47
+ Reasoning costs the same as output. Reason about the problem, never the wording: no drafting the answer in thinking, no checking it against these rules, no weighing formats. Obvious answer → write it straight away. Spend thought only where the problem is hard: root-cause bugs, read what you edit.
48
48
 
49
49
  ## Code Rules
50
50