@stfade/pi-read-delegator 1.0.11 → 1.0.13

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.
@@ -0,0 +1,420 @@
1
+ /**
2
+ * bash-filter.ts — Bash command classification and Reader forwarding
3
+ *
4
+ * Classifies shell commands as read-only (delegate to Reader subagent),
5
+ * write (execute directly), or ambiguous (prompt user).
6
+ */
7
+ // ---------------------------------------------------------------------------
8
+ // Command lists
9
+ // ---------------------------------------------------------------------------
10
+ /**
11
+ * Commands that ONLY read and should be forwarded to the Reader subagent.
12
+ *
13
+ * Covers three shells:
14
+ * - bash/sh (Linux/macOS/WSL/Git Bash)
15
+ * - PowerShell (Windows)
16
+ * - cmd.exe (Windows)
17
+ *
18
+ * All comparisons are case-insensitive (isReadSegment lowercases the argv).
19
+ *
20
+ * Context-dependent:
21
+ * - sed without -i is read-only (stream editor → stdout)
22
+ * - awk is read-only (pattern scanning / processing language)
23
+ */
24
+ const READ_COMMANDS = new Set([
25
+ // ── bash / sh (Linux, macOS, WSL, Git Bash) ──
26
+ "cat",
27
+ "grep",
28
+ "find",
29
+ "ls",
30
+ "head",
31
+ "tail",
32
+ "less",
33
+ "wc",
34
+ "nl",
35
+ "more",
36
+ "bat",
37
+ "rg",
38
+ "fd",
39
+ "awk",
40
+ "du",
41
+ "df",
42
+ "stat",
43
+ "file",
44
+ "which",
45
+ "where",
46
+ "type",
47
+ "dir",
48
+ "sort",
49
+ "uniq",
50
+ "cut",
51
+ "tr",
52
+ "diff",
53
+ "cmp",
54
+ "comm",
55
+ "od",
56
+ "hexdump",
57
+ "xxd",
58
+ // ── PowerShell (Windows) ──
59
+ "get-content",
60
+ "select-string",
61
+ "get-childitem",
62
+ "get-itemproperty",
63
+ "get-item",
64
+ "test-path",
65
+ "get-alias",
66
+ "get-command",
67
+ "measure-object",
68
+ "compare-object",
69
+ "where-object",
70
+ "select-object",
71
+ "format-list",
72
+ "format-table",
73
+ "get-service",
74
+ "get-process",
75
+ "get-eventlog",
76
+ "get-history",
77
+ "get-variable",
78
+ "get-psdrive",
79
+ "get-psprovider",
80
+ // ── cmd.exe (Windows) ──
81
+ "findstr",
82
+ "comp",
83
+ "fc",
84
+ "tree",
85
+ ]);
86
+ /**
87
+ * Commands that write to the filesystem and should execute directly.
88
+ * sed and tee are context-dependent — handled specially in isWriteCommand.
89
+ *
90
+ * Covers bash/sh, PowerShell, and cmd.exe writable commands.
91
+ */
92
+ const WRITE_COMMANDS = new Set([
93
+ // ── bash / sh ──
94
+ "mkdir",
95
+ "touch",
96
+ "echo",
97
+ "rm",
98
+ "mv",
99
+ "cp",
100
+ "chmod",
101
+ "chown",
102
+ "ln",
103
+ "rmdir",
104
+ // ── cross-platform build tools ──
105
+ "npm",
106
+ "pnpm",
107
+ "yarn",
108
+ "pip",
109
+ "cargo",
110
+ "go",
111
+ "npx",
112
+ "node",
113
+ "python",
114
+ "python3",
115
+ "git",
116
+ "docker",
117
+ "kubectl",
118
+ "tsc",
119
+ "make",
120
+ "cmake",
121
+ "dotnet",
122
+ "rustc",
123
+ "gcc",
124
+ "g++",
125
+ // ── PowerShell write commands ──
126
+ "set-content",
127
+ "add-content",
128
+ "new-item",
129
+ "remove-item",
130
+ "copy-item",
131
+ "move-item",
132
+ "rename-item",
133
+ "out-file",
134
+ "export-csv",
135
+ "export-clixml",
136
+ "start-process",
137
+ "invoke-expression",
138
+ "invoke-webrequest",
139
+ // ── cmd.exe write commands ──
140
+ "del",
141
+ "erase",
142
+ "rename",
143
+ "copy",
144
+ "xcopy",
145
+ "robocopy",
146
+ "move",
147
+ "md",
148
+ "rd",
149
+ "attrib",
150
+ "icacls",
151
+ "cacls",
152
+ "setx",
153
+ "reg",
154
+ ]);
155
+ // ---------------------------------------------------------------------------
156
+ // Public API
157
+ // ---------------------------------------------------------------------------
158
+ /**
159
+ * Shell operators that separate distinct commands in a pipeline or chain.
160
+ * We check each segment independently — if ANY segment starts with a read
161
+ * command, the whole command is treated as containing a read.
162
+ */
163
+ const SHELL_SEPARATORS = /(?:&&|\|\||[;|&])(?=(?:[^"']*["'][^"']*["'])*[^"']*$)/;
164
+ /**
165
+ * Output redirect operators. These turn a read command into a write —
166
+ * e.g. `grep pattern file > out.txt` writes output to a file. We still
167
+ * block these because the actual operation (grep/find/cat) is a read.
168
+ */
169
+ const REDIRECT_WRITE = /\b>>?\b/;
170
+ /**
171
+ * Split a command string by shell separators (&&, ||, ;, |, &) and return
172
+ * the list of segment strings. Respects quoting so separators inside quotes
173
+ * are not treated as actual shell separators.
174
+ */
175
+ export function splitShellSegments(command) {
176
+ const segments = [];
177
+ let current = "";
178
+ let inSingle = false;
179
+ let inDouble = false;
180
+ for (let i = 0; i < command.length; i++) {
181
+ const ch = command[i];
182
+ if (inSingle) {
183
+ current += ch;
184
+ if (ch === "'")
185
+ inSingle = false;
186
+ }
187
+ else if (inDouble) {
188
+ current += ch;
189
+ if (ch === '"')
190
+ inDouble = false;
191
+ else if (ch === "\\" && i + 1 < command.length) {
192
+ current += command[++i];
193
+ }
194
+ }
195
+ else if (ch === "'") {
196
+ current += ch;
197
+ inSingle = true;
198
+ }
199
+ else if (ch === '"') {
200
+ current += ch;
201
+ inDouble = true;
202
+ }
203
+ else if (ch === "|") {
204
+ // Peek ahead for ||
205
+ if (i + 1 < command.length && command[i + 1] === "|") {
206
+ // || separator
207
+ segments.push(current.trim());
208
+ current = "";
209
+ i++; // skip second |
210
+ }
211
+ else {
212
+ // Single | pipe
213
+ segments.push(current.trim());
214
+ current = "";
215
+ }
216
+ }
217
+ else if (ch === "&") {
218
+ if (i + 1 < command.length && command[i + 1] === "&") {
219
+ // && separator
220
+ segments.push(current.trim());
221
+ current = "";
222
+ i++; // skip second &
223
+ }
224
+ else {
225
+ // Single & background
226
+ segments.push(current.trim());
227
+ current = "";
228
+ }
229
+ }
230
+ else if (ch === ";") {
231
+ segments.push(current.trim());
232
+ current = "";
233
+ }
234
+ else {
235
+ current += ch;
236
+ }
237
+ }
238
+ // Flush remaining
239
+ const remainder = current.trim();
240
+ if (remainder.length > 0) {
241
+ segments.push(remainder);
242
+ }
243
+ return segments;
244
+ }
245
+ /**
246
+ * Check if a single segment (no pipes/chains) is a read command.
247
+ */
248
+ function isReadSegment(segment) {
249
+ const argv = parseArgv(segment);
250
+ if (argv.length === 0)
251
+ return false;
252
+ const cmd = argv[0].toLowerCase();
253
+ // sed without -i is read-only
254
+ if (cmd === "sed" || cmd === "sed.exe") {
255
+ return !hasInlineFlag(argv);
256
+ }
257
+ return READ_COMMANDS.has(cmd);
258
+ }
259
+ /**
260
+ * Determine if a bash command is read-only and should be forwarded to Reader.
261
+ *
262
+ * Splits on shell separators (&&, ||, ;, |, &) and checks EACH segment.
263
+ * If ANY segment starts with a read command, the full command is blocked.
264
+ *
265
+ * Examples:
266
+ * isReadCommand("cat file") → true
267
+ * isReadCommand("echo hello && cat file") → true (cat in chain)
268
+ * isReadCommand("npm test") → false
269
+ * isReadCommand("grep x | head -5") → true (pipeline)
270
+ */
271
+ export function isReadCommand(command) {
272
+ const segments = splitShellSegments(command);
273
+ for (const seg of segments) {
274
+ if (isReadSegment(seg))
275
+ return true;
276
+ }
277
+ return false;
278
+ }
279
+ /**
280
+ * Determine if a bash command modifies the filesystem and should run directly.
281
+ *
282
+ * Rules:
283
+ * - If any segment's first word is in WRITE_COMMANDS → true
284
+ * - sed with -i flag → true (in-place edit)
285
+ * - Command contains > or >> redirect → true (writes to file)
286
+ * - Command contains tee → true (writes to file)
287
+ */
288
+ export function isWriteCommand(command) {
289
+ const segments = splitShellSegments(command);
290
+ for (const seg of segments) {
291
+ const argv = parseArgv(seg);
292
+ if (argv.length === 0)
293
+ continue;
294
+ const cmd = argv[0].toLowerCase();
295
+ if (cmd === "sed" || cmd === "sed.exe") {
296
+ if (hasInlineFlag(argv))
297
+ return true;
298
+ continue;
299
+ }
300
+ if (WRITE_COMMANDS.has(cmd))
301
+ return true;
302
+ // tee always writes
303
+ if (cmd === "tee" || cmd === "tee.exe")
304
+ return true;
305
+ }
306
+ // Output redirects anywhere in the full command = write
307
+ if (REDIRECT_WRITE.test(command))
308
+ return true;
309
+ return false;
310
+ }
311
+ /**
312
+ * Wrap a shell command into a Reader subagent task.
313
+ *
314
+ * Returns a formatted string instructing the Reader to execute and report
315
+ * minimal results.
316
+ */
317
+ export function wrapForReader(command) {
318
+ return [
319
+ "Execute this shell command and return ONLY the essential result.",
320
+ "Max 5 lines or a single number. Never dump full file contents.",
321
+ `Command: ${command}`,
322
+ ].join("\n");
323
+ }
324
+ /**
325
+ * Wrap a generic task (non-bash) into a Reader subagent task.
326
+ */
327
+ export function wrapTaskForReader(task) {
328
+ return [
329
+ "Execute this task and return ONLY the essential result.",
330
+ "Max 5 lines or a single number. Never dump full file contents.",
331
+ `Task: ${task}`,
332
+ ].join("\n");
333
+ }
334
+ // ---------------------------------------------------------------------------
335
+ // Internals
336
+ // ---------------------------------------------------------------------------
337
+ /**
338
+ * Parse a command string into argv tokens, respecting single/double quotes.
339
+ *
340
+ * This is a simplified parser — edge cases like escaped quotes inside
341
+ * opposite-quoted strings are handled on a best-effort basis.
342
+ */
343
+ function parseArgv(command) {
344
+ const tokens = [];
345
+ let current = "";
346
+ let inSingle = false;
347
+ let inDouble = false;
348
+ for (let i = 0; i < command.length; i++) {
349
+ const ch = command[i];
350
+ if (inSingle) {
351
+ if (ch === "'") {
352
+ inSingle = false;
353
+ }
354
+ else {
355
+ current += ch;
356
+ }
357
+ }
358
+ else if (inDouble) {
359
+ if (ch === '"') {
360
+ inDouble = false;
361
+ }
362
+ else if (ch === "\\" && i + 1 < command.length) {
363
+ // Simple escape handling inside double quotes
364
+ const next = command[i + 1];
365
+ if (next === '"' || next === "\\" || next === "$" || next === "`") {
366
+ current += next;
367
+ i++;
368
+ }
369
+ else {
370
+ current += ch;
371
+ }
372
+ }
373
+ else {
374
+ current += ch;
375
+ }
376
+ }
377
+ else {
378
+ if (ch === "'") {
379
+ inSingle = true;
380
+ }
381
+ else if (ch === '"') {
382
+ inDouble = true;
383
+ }
384
+ else if (ch === " " || ch === "\t") {
385
+ if (current.length > 0) {
386
+ tokens.push(current);
387
+ current = "";
388
+ }
389
+ }
390
+ else {
391
+ current += ch;
392
+ }
393
+ }
394
+ }
395
+ // Flush remaining token
396
+ if (current.length > 0) {
397
+ tokens.push(current);
398
+ }
399
+ return tokens;
400
+ }
401
+ /**
402
+ * Check whether `sed` has the -i (in-place) flag.
403
+ */
404
+ function hasInlineFlag(argv) {
405
+ for (let i = 1; i < argv.length; i++) {
406
+ const arg = argv[i];
407
+ // -i, -i.bak, --in-place, --in-place=.bak
408
+ if (arg === "-i" || arg.startsWith("-i.") || arg === "--in-place") {
409
+ return true;
410
+ }
411
+ if (arg.startsWith("--in-place=")) {
412
+ return true;
413
+ }
414
+ // Stop at the expression (s/.../.../ or -e '...') — flags after that
415
+ // might apply to the expression, not sed itself. In practice, -i always
416
+ // comes before the expression.
417
+ }
418
+ return false;
419
+ }
420
+ //# sourceMappingURL=bash-filter.js.map
@@ -9,8 +9,8 @@ export interface ReadDelegatorConfig {
9
9
  enabled: boolean;
10
10
  reader_subagent_name: string;
11
11
  blocked_tools: string[];
12
- allowed_bash_write_commands: string[];
13
12
  orchestrator_prompt: string;
13
+ reader_model: string;
14
14
  language: string;
15
15
  }
16
16
  /**
@@ -1,4 +1,3 @@
1
- "use strict";
2
1
  /**
3
2
  * config.ts — Configuration loader for pi-read-delegator
4
3
  *
@@ -6,45 +5,10 @@
6
5
  * If the config file doesn't exist, it creates one with defaults.
7
6
  * If the config file is corrupted, it overwrites with defaults and logs a warning.
8
7
  */
9
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- var desc = Object.getOwnPropertyDescriptor(m, k);
12
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
- desc = { enumerable: true, get: function() { return m[k]; } };
14
- }
15
- Object.defineProperty(o, k2, desc);
16
- }) : (function(o, m, k, k2) {
17
- if (k2 === undefined) k2 = k;
18
- o[k2] = m[k];
19
- }));
20
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
- Object.defineProperty(o, "default", { enumerable: true, value: v });
22
- }) : function(o, v) {
23
- o["default"] = v;
24
- });
25
- var __importStar = (this && this.__importStar) || (function () {
26
- var ownKeys = function(o) {
27
- ownKeys = Object.getOwnPropertyNames || function (o) {
28
- var ar = [];
29
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
- return ar;
31
- };
32
- return ownKeys(o);
33
- };
34
- return function (mod) {
35
- if (mod && mod.__esModule) return mod;
36
- var result = {};
37
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
- __setModuleDefault(result, mod);
39
- return result;
40
- };
41
- })();
42
- Object.defineProperty(exports, "__esModule", { value: true });
43
- exports.loadConfig = loadConfig;
44
- exports.saveConfig = saveConfig;
45
- const fs = __importStar(require("fs"));
46
- const os = __importStar(require("os"));
47
- const path = __importStar(require("path"));
8
+ import * as fs from "fs";
9
+ import * as os from "os";
10
+ import * as path from "path";
11
+ import { rawLog, rawWarn, rawError } from "./ui";
48
12
  // ---------------------------------------------------------------------------
49
13
  // Defaults
50
14
  // ---------------------------------------------------------------------------
@@ -52,16 +16,32 @@ const DEFAULT_CONFIG = {
52
16
  enabled: true,
53
17
  reader_subagent_name: "reader",
54
18
  blocked_tools: ["read", "grep", "find", "ls"],
55
- allowed_bash_write_commands: [
56
- "mkdir",
57
- "echo",
58
- "touch",
59
- "sed",
60
- "rm",
61
- "mv",
62
- "cp",
63
- ],
64
- orchestrator_prompt: "You are an orchestrator. For any file reading, searching, or listing operation, you MUST use the subagent tool with subagent='reader'. Do not use read/grep/find/ls yourself. If you need to run a shell command that only reads (like cat, grep, find, ls), also delegate it to the reader subagent.",
19
+ orchestrator_prompt: [
20
+ "## Reader Subagent Protocol",
21
+ "",
22
+ "Your `read`,`grep`,`find`,`ls` tools are BLOCKED. Shell read commands (cat, grep, type, Get-Content, etc.) are also blocked.",
23
+ "",
24
+ "### How to delegate",
25
+ 'Use: `subagent(agent="reader", task="<format>")`',
26
+ "Format: Action: {read|grep|find|ls} Target: {file|dir} Detail: {what,be specific}",
27
+ "",
28
+ "### Graduated reading (use this order)",
29
+ "1. **Find first**: `find src/ *.ts` → locate the relevant file.",
30
+ "2. **Grep next**: `grep functionName in src/file.ts` → locate the exact spot.",
31
+ "3. **Read last**: `read src/file.ts lines 42-80` → get only the needed section.",
32
+ "Never read an entire file unless you truly need all of it.",
33
+ "",
34
+ "### Cache awareness",
35
+ "You have previously-read file content in your context window. Before delegating, check if you already have what you need.",
36
+ "Re-reading the same file wastes tokens — reuse cached content.",
37
+ "",
38
+ "### Reader output format (no headers, no fluff)",
39
+ "grep: file:line content | read: N: line | find/ls: bare list",
40
+ "Large grep/find results: reader returns count-line first, then top matches.",
41
+ "The reader auto-skips imports, node_modules, binaries — you get clean data.",
42
+ 'If you get "(no matches)" or an error, adjust and retry.',
43
+ ].join("\n"),
44
+ reader_model: "lmstudio/nvidia/nemotron-3-nano-4b",
65
45
  language: "auto",
66
46
  };
67
47
  // ---------------------------------------------------------------------------
@@ -87,7 +67,7 @@ function configFilePath() {
87
67
  * - If the file is corrupted, overwrite with defaults, log a warning, return defaults.
88
68
  * - Otherwise parse and return the typed config.
89
69
  */
90
- function loadConfig() {
70
+ export function loadConfig() {
91
71
  const filePath = configFilePath();
92
72
  try {
93
73
  if (!fs.existsSync(filePath)) {
@@ -104,7 +84,7 @@ function loadConfig() {
104
84
  }
105
85
  catch (err) {
106
86
  // File is missing, unreadable, or invalid JSON → overwrite with defaults
107
- console.warn(`[pi-read-delegator] Corrupted config file at ${filePath}. Overwriting with defaults. Error: ${err}`);
87
+ rawWarn(`Corrupted config file at ${filePath}. Overwriting with defaults. Error: ${err}`);
108
88
  try {
109
89
  ensureDir(path.dirname(filePath));
110
90
  fs.writeFileSync(filePath, JSON.stringify(DEFAULT_CONFIG, null, 2), "utf-8");
@@ -120,17 +100,17 @@ function loadConfig() {
120
100
  * @param config The config object to persist
121
101
  * @param options.silent If true, suppress console output
122
102
  */
123
- function saveConfig(config, options) {
103
+ export function saveConfig(config, options) {
124
104
  const filePath = configFilePath();
125
105
  ensureDir(path.dirname(filePath));
126
106
  try {
127
107
  fs.writeFileSync(filePath, JSON.stringify(config, null, 2), "utf-8");
128
108
  if (!options?.silent) {
129
- console.log(`[pi-read-delegator] Config saved to ${filePath}`);
109
+ rawLog(`Config saved to ${filePath}`);
130
110
  }
131
111
  }
132
112
  catch (err) {
133
- console.error(`[pi-read-delegator] Failed to save config: ${err}`);
113
+ rawError(`Failed to save config: ${err}`);
134
114
  throw err;
135
115
  }
136
116
  }
@@ -151,12 +131,12 @@ function mergeDefaults(partial, defaults) {
151
131
  blocked_tools: Array.isArray(p.blocked_tools)
152
132
  ? p.blocked_tools
153
133
  : defaults.blocked_tools,
154
- allowed_bash_write_commands: Array.isArray(p.allowed_bash_write_commands)
155
- ? p.allowed_bash_write_commands
156
- : defaults.allowed_bash_write_commands,
157
134
  orchestrator_prompt: typeof p.orchestrator_prompt === "string"
158
135
  ? p.orchestrator_prompt
159
136
  : defaults.orchestrator_prompt,
137
+ reader_model: typeof p.reader_model === "string"
138
+ ? p.reader_model
139
+ : defaults.reader_model,
160
140
  language: typeof p.language === "string" ? p.language : defaults.language,
161
141
  };
162
142
  }