interference-agent 0.1.0 → 0.2.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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <p align="center"><strong>The open-source coding agent that lives in your terminal.</strong></p>
4
4
 
5
5
  <p align="center">
6
- TypeScript + Bun · Plan / Build modes · 9 tools · permissions · sessions with undo · 38 skills · subagents · TUI
6
+ TypeScript + Bun · Plan / Build modes · 11 tools · permissions · sessions with undo · extensible skills · subagents · TUI
7
7
  </p>
8
8
 
9
9
  ---
@@ -14,10 +14,10 @@ permissions and a read-only **Plan** mode so nothing happens without your say-so
14
14
 
15
15
  ## Features
16
16
 
17
- - **9 tools**: `read` · `ls` · `glob` · `grep` · `webfetch` · `write` · `edit` · `bash` · `task` (subagent)
17
+ - **11 tools**: `read` · `ls` · `glob` · `grep` · `webfetch` · `write` · `edit` · `bash` · `todowrite` · `question` · `task` (subagent)
18
18
  - **Plan & Build** modes — explore read-only, switch to full access when ready
19
19
  - **Permissioned by design** — allow / ask / deny enforced in code, not in the prompt; dangerous commands auto-blocked (`rm -rf`, `sudo`, `curl | sh`)
20
- - **38 skills** — auto-detected by keyword matching, or invoked explicitly via `/skill-name`; full Agent Skills format support
20
+ - **Extensible skills** — Agent Skills format (SKILL.md); auto-detected by keyword matching, or invoked via `/skill-name`; 3 skills bundled, user-extensible
21
21
  - **Subagents** — delegate complex tasks to isolated agents (`explore` for read-only, `general` for full access)
22
22
  - **Atomic edit** — unique-match string replacement with `replaceAll` support
23
23
  - **Safe bash** — timeout, output truncation, exit code, dangerous-command deny list
@@ -51,13 +51,34 @@ permissions and a read-only **Plan** mode so nothing happens without your say-so
51
51
  ## Quickstart
52
52
 
53
53
  ```bash
54
- bun install
55
- # Set API key in .env (Bun auto-loads it)
56
- echo 'DEEPSEEK_API_KEY=sk-...' > .env
57
- bun run interference
54
+ bun install -g interference-agent
55
+ interference
58
56
  ```
59
57
 
60
- > Requires **Bun 1.3+** (Node ≥22 / React ≥19.2 for the Ink TUI).
58
+ On first run, use `/provider` to add your API keys. They're saved in `~/.interference/auth.json`.
59
+
60
+ interference stores its state in `~/.interference/` — sessions, skills, snapshots, and auth — just like Claude Code uses `~/.claude/`.
61
+
62
+ > Requires **Bun 1.3+**.
63
+
64
+ ## Updating
65
+
66
+ ```bash
67
+ bun install -g interference-agent@latest # or: npm i -g interference-agent@latest
68
+ ```
69
+
70
+ interference checks npm for new versions and shows a discreet notice when one is available; run `/update` from inside the app to upgrade.
71
+
72
+ ## Releasing (maintainers)
73
+
74
+ Releases publish to npm automatically on tag push:
75
+
76
+ ```bash
77
+ npm version minor # runs typecheck+test, bumps, tags
78
+ git push --follow-tags # the `publish` GitHub Action publishes to npm
79
+ ```
80
+
81
+ Requires the `NPM_TOKEN` repo secret. See `CHANGELOG.md`.
61
82
 
62
83
  ## Screenshot
63
84
 
package/package.json CHANGED
@@ -1,23 +1,42 @@
1
1
  {
2
2
  "name": "interference-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.3",
4
4
  "description": "The open-source coding agent that lives in your terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "ricciviero",
8
8
  "repository": "github:ricciviero/interference",
9
9
  "homepage": "https://github.com/ricciviero/interference#readme",
10
- "keywords": ["ai", "coding-agent", "terminal", "cli", "bun", "typescript", "llm", "open-source"],
10
+ "keywords": [
11
+ "ai",
12
+ "coding-agent",
13
+ "terminal",
14
+ "cli",
15
+ "bun",
16
+ "typescript",
17
+ "llm",
18
+ "open-source"
19
+ ],
11
20
  "bin": {
12
21
  "interference": "./src/cli.ts"
13
22
  },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "bun": ">=1.3.0"
30
+ },
14
31
  "scripts": {
15
32
  "interference": "bun run src/cli.ts",
16
33
  "start": "bun run src/cli.ts",
17
34
  "dev": "bun run --watch src/cli.ts",
18
35
  "typecheck": "bunx tsc --noEmit",
19
36
  "build": "bun build src/cli.ts --compile --minify --sourcemap --outfile dist/interference",
20
- "test": "bun test ./src"
37
+ "test": "bun test ./src",
38
+ "preversion": "bun run typecheck && bun run test",
39
+ "postversion": "git push && git push --tags"
21
40
  },
22
41
  "dependencies": {
23
42
  "@ai-sdk/anthropic": "^4.0.1",
@@ -0,0 +1,24 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { isNewer, CURRENT_VERSION } from "../version.ts";
3
+ import { dispatch } from "../commands/index.ts";
4
+
5
+ describe("version / update (iter 28)", () => {
6
+ test("isNewer compares semver correctly", () => {
7
+ expect(isNewer("0.2.0", "0.1.0")).toBe(true);
8
+ expect(isNewer("1.0.0", "0.9.9")).toBe(true);
9
+ expect(isNewer("0.1.1", "0.1.0")).toBe(true);
10
+ expect(isNewer("0.1.0", "0.1.0")).toBe(false);
11
+ expect(isNewer("0.1.0", "0.2.0")).toBe(false);
12
+ expect(isNewer("v0.3.0", "0.2.9")).toBe(true); // tollera prefisso v
13
+ });
14
+
15
+ test("CURRENT_VERSION matches package.json", async () => {
16
+ const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json();
17
+ expect(CURRENT_VERSION).toBe(pkg.version);
18
+ });
19
+
20
+ test("/version command returns the version", async () => {
21
+ const out = await dispatch("/version", {});
22
+ expect(out).toBe(`interference v${CURRENT_VERSION}`);
23
+ });
24
+ });
package/src/auth.ts CHANGED
@@ -1,21 +1,19 @@
1
1
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
-
4
- const AUTH_DIR = path.join(
5
- process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
6
- ".interference",
7
- );
3
+ import { interferenceDir } from "./paths.ts";
8
4
 
9
5
  interface ProviderAuth {
10
6
  label: string;
11
7
  envKey: string;
12
8
  }
13
9
 
14
- const AUTH_FILE = path.join(AUTH_DIR, "auth.json");
10
+ // Risolti a runtime (non all'import) così INTERFERENCE_HOME isola i test.
11
+ const authDir = (): string => interferenceDir();
12
+ const authFile = (): string => path.join(authDir(), "auth.json");
15
13
 
16
14
  export async function loadAuth(): Promise<Record<string, string>> {
17
15
  try {
18
- const raw = await readFile(AUTH_FILE, "utf-8");
16
+ const raw = await readFile(authFile(), "utf-8");
19
17
  return JSON.parse(raw) as Record<string, string>;
20
18
  } catch {
21
19
  return {};
@@ -23,9 +21,9 @@ export async function loadAuth(): Promise<Record<string, string>> {
23
21
  }
24
22
 
25
23
  export async function saveAuth(auth: Record<string, string>): Promise<void> {
26
- await mkdir(AUTH_DIR, { recursive: true });
27
- await writeFile(AUTH_FILE, JSON.stringify(auth, null, 2));
28
- try { await chmod(AUTH_FILE, 0o600); } catch {}
24
+ await mkdir(authDir(), { recursive: true });
25
+ await writeFile(authFile(), JSON.stringify(auth, null, 2));
26
+ try { await chmod(authFile(), 0o600); } catch {}
29
27
  }
30
28
 
31
29
  export function applyAuthToEnv(auth: Record<string, string>, providers: Record<string, ProviderAuth>): void {
package/src/cli-plain.ts CHANGED
@@ -226,18 +226,18 @@ async function consumeTurn(chunks: AsyncGenerator<Chunk>): Promise<void> {
226
226
  for await (const chunk of chunks) {
227
227
  switch (chunk.type) {
228
228
  case "reasoning":
229
- if (!sawReasoning) { stdout.write(`${DIM} thinking${RESET}\n`); sawReasoning = true; }
229
+ if (!sawReasoning) { stdout.write(`${YELLOW} Thinking${RESET}\n`); sawReasoning = true; }
230
230
  stdout.write(`${DIM}${chunk.text}${RESET}`);
231
231
  break;
232
232
  case "text":
233
233
  if (activeTool) { stdout.write("\n"); activeTool = null; }
234
- if (sawReasoning && !inText) { stdout.write(`\n${DIM}┄${RESET}\n\n`); inText = true; }
234
+ if (sawReasoning && !inText) { stdout.write(`\n\n`); inText = true; }
235
235
  else if (!inText) { inText = true; }
236
236
  stdout.write(chunk.text);
237
237
  break;
238
238
  case "tool-call": {
239
239
  const args = typeof chunk.input === "string" ? chunk.input : JSON.stringify(chunk.input);
240
- if (sawReasoning && !inText) { stdout.write(`\n${DIM}┄${RESET}\n\n`); inText = true; }
240
+ if (sawReasoning && !inText) { stdout.write(`\n\n`); inText = true; }
241
241
  else if (activeTool || !inText) { stdout.write("\n"); }
242
242
  stdout.write(`${DIM}· ${chunk.toolName}${RESET}(${args})`);
243
243
  activeTool = { name: chunk.toolName, args, input: chunk.input };
package/src/cli.ts CHANGED
@@ -11,8 +11,16 @@ import { loadConfig, applyConfig } from "./config-file.ts";
11
11
  import { loadAuth, applyAuthToEnv } from "./auth.ts";
12
12
  import { PROVIDERS } from "./config.ts";
13
13
  import type { Session } from "./session/store.ts";
14
+ import { CURRENT_VERSION } from "./version.ts";
14
15
 
15
16
  async function main(): Promise<void> {
17
+ // --version / -v: stampa la versione ed esce (prima di tutto).
18
+ const cliArgs = Bun.argv.slice(2);
19
+ if (cliArgs.includes("--version") || cliArgs.includes("-v")) {
20
+ stdout.write(`${CURRENT_VERSION}\n`);
21
+ return;
22
+ }
23
+
16
24
  // Titolo + nome-icona della tab del terminale (come Claude Code), solo in TTY
17
25
  // (in pipe/non-TTY le sequenze OSC sporcherebbero l'output).
18
26
  // OSC 1 = icon/tab name, OSC 2 = window title.
@@ -2,6 +2,7 @@ import type { AgentMode, ThinkingLevel } from "../config.ts";
2
2
  import { currentProvider, currentThinking, setThinking, currentModel, setModel } from "../config.ts";
3
3
  import { undo, redo } from "../session/snapshot.ts";
4
4
  import { loadSkillBody, getCachedRegistry, type SkillInfo } from "../skills.ts";
5
+ import { CURRENT_VERSION } from "../version.ts";
5
6
 
6
7
  export interface CommandInfo {
7
8
  name: string;
@@ -74,6 +75,23 @@ export function isSlashCommand(input: string): boolean {
74
75
  return /^\//.test(input);
75
76
  }
76
77
 
78
+ register("version", "Show the interference version", () => {
79
+ return `interference v${CURRENT_VERSION}`;
80
+ });
81
+
82
+ register("update", "Update interference to the latest version", async () => {
83
+ const proc = Bun.spawn(["npm", "i", "-g", "interference-agent@latest"], {
84
+ stdout: "pipe",
85
+ stderr: "pipe",
86
+ });
87
+ const code = await proc.exited;
88
+ if (code === 0) {
89
+ return "Updated to the latest version. Restart interference to use it.";
90
+ }
91
+ const err = (await new Response(proc.stderr).text()).trim().slice(0, 300);
92
+ return `Update failed (exit ${code}).${err ? `\n${err}` : ""}\nTry manually: npm i -g interference-agent@latest`;
93
+ });
94
+
77
95
  register("help", "Show available commands", () => {
78
96
  const lines = ["Available commands:"];
79
97
  for (const cmd of listCommands()) {
package/src/config.ts CHANGED
@@ -70,6 +70,7 @@ export const PROVIDERS: Record<ProviderId, ProviderDef> = {
70
70
  thinkingLevels: ["off", "low", "medium", "high", "max"],
71
71
  defaultThinking: "high",
72
72
  models: [
73
+ { id: "claude-sonnet-5", label: "Claude Sonnet 5 (1M ctx)" },
73
74
  { id: "claude-opus-4-8", label: "Claude Opus 4.8 (1M ctx)" },
74
75
  { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (1M ctx)" },
75
76
  ],
@@ -90,14 +91,16 @@ export const PROVIDERS: Record<ProviderId, ProviderDef> = {
90
91
  kimi: {
91
92
  label: "Moonshot Kimi",
92
93
  envKey: "KIMI_API_KEY",
93
- defaultModel: "kimi-k2.7",
94
+ defaultModel: "kimi-k2.7-code",
94
95
  kind: "openai-compatible",
95
96
  contextLimit: 1_000_000,
96
97
  baseURL: "https://api.moonshot.ai/v1",
97
98
  thinkingLevels: ["off", "max"],
98
99
  defaultThinking: "max",
99
100
  models: [
100
- { id: "kimi-k2.7", label: "Kimi K2.7 (1M ctx)" },
101
+ { id: "kimi-k2.7-code", label: "Kimi K2.7 Code (thinking always on)" },
102
+ { id: "kimi-k2.6", label: "Kimi K2.6 (thinking opzionale)" },
103
+ { id: "kimi-k2.5", label: "Kimi K2.5" },
101
104
  ],
102
105
  },
103
106
  };
@@ -171,19 +174,13 @@ export interface ReasoningConfig {
171
174
  maxOutputTokens?: number;
172
175
  }
173
176
 
174
- // Anthropic: livello → budget token del thinking (maxOutputTokens deve superarlo).
175
- const ANTHROPIC_BUDGET: Record<Exclude<ThinkingLevel, "off">, number> = {
176
- low: 8_000,
177
- medium: 16_000,
178
- high: 32_000,
179
- max: 60_000,
180
- };
181
-
182
177
  /** Traduce il livello di thinking corrente nelle opzioni del provider attivo. */
183
178
  export function reasoningConfig(): ReasoningConfig {
184
179
  const level = currentThinking();
185
180
 
186
- switch (config.provider) {
181
+ // Provider EFFETTIVO: rispetta l'override runtime (`/provider`, `/model`),
182
+ // non solo `config.provider` (statico da env all'avvio).
183
+ switch (_providerOverride ?? config.provider) {
187
184
  case "deepseek":
188
185
  if (level === "off") {
189
186
  return { providerOptions: { deepseek: { thinking: { type: "disabled" } } } };
@@ -193,11 +190,18 @@ export function reasoningConfig(): ReasoningConfig {
193
190
  };
194
191
 
195
192
  case "anthropic": {
196
- if (level === "off") return {};
197
- const budget = ANTHROPIC_BUDGET[level];
193
+ // Modelli Anthropic moderni (Sonnet 5, Opus 4.8/4.7, Fable 5) NON supportano
194
+ // `thinking.type: "enabled"` (→ HTTP 400): si usa adaptive thinking + effort.
195
+ // `off` → solo effort basso (niente adaptive): il modello resta veloce/economico.
196
+ // (Senza inviare effort, il default lato server è `high` → "off" non spegnerebbe nulla.)
197
+ if (level === "off") {
198
+ return { providerOptions: { anthropic: { effort: "low" } } };
199
+ }
200
+ // display:"summarized" rende visibile il ragionamento (di default è omesso).
198
201
  return {
199
- providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: budget } } },
200
- maxOutputTokens: budget + 8_000,
202
+ providerOptions: {
203
+ anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: level },
204
+ },
201
205
  };
202
206
  }
203
207
 
@@ -205,8 +209,15 @@ export function reasoningConfig(): ReasoningConfig {
205
209
  case "glm":
206
210
  return { extraBody: { thinking: { type: level === "off" ? "disabled" : "enabled" } } };
207
211
 
208
- case "kimi":
209
- if (level === "off") return { extraBody: { thinking: { type: "disabled" } } };
210
- return { extraBody: { thinking: { type: "enabled", keep: "all" } } };
212
+ case "kimi": {
213
+ // Reasoning Moonshot (campo `reasoning_content`, letto nativamente da @ai-sdk/openai-compatible).
214
+ // max_tokens 16000 richiesto: la somma reasoning+content non deve superare max_tokens.
215
+ // I modelli `*-code` hanno il thinking SEMPRE ON e NON accettano il param `thinking`.
216
+ const alwaysOn = currentModel().includes("k2.7-code");
217
+ if (alwaysOn) return { maxOutputTokens: 16_000 };
218
+ if (level === "off")
219
+ return { extraBody: { thinking: { type: "disabled" } }, maxOutputTokens: 16_000 };
220
+ return { extraBody: { thinking: { type: "enabled", keep: "all" } }, maxOutputTokens: 16_000 };
221
+ }
211
222
  }
212
223
  }
package/src/cost.ts CHANGED
@@ -6,6 +6,8 @@ interface Pricing {
6
6
  }
7
7
 
8
8
  const PRICING: Record<string, Pricing> = {
9
+ // Sonnet 5: standard $3/$15 (intro $2/$10 fino al 2026-08-31).
10
+ "claude-sonnet-5": { inputPer1M: 3.0, outputPer1M: 15.0 },
9
11
  "deepseek-v4-pro": { inputPer1M: 2.0, outputPer1M: 8.0 },
10
12
  "deepseek-v4-flash": { inputPer1M: 0.27, outputPer1M: 1.10 },
11
13
  "gpt-5.5": { inputPer1M: 5.0, outputPer1M: 30.0 },
package/src/paths.ts ADDED
@@ -0,0 +1,25 @@
1
+ import * as path from "node:path";
2
+
3
+ /**
4
+ * Home base dei dati di interference (`~/.interference`).
5
+ *
6
+ * `INTERFERENCE_HOME` ridireziona l'intero store dell'app: usato dai test per
7
+ * isolarsi dalla directory reale dell'utente, così nessun test possa scrivere
8
+ * o cancellare dati veri (sessioni, snapshot, credenziali, skill, cache update).
9
+ *
10
+ * Tutti i consumatori di `~/.interference` DEVONO passare da qui — non
11
+ * ricalcolare la home a mano (vedi store/skills/auth/version).
12
+ */
13
+ export function interferenceHome(): string {
14
+ return (
15
+ process.env.INTERFERENCE_HOME ??
16
+ process.env.HOME ??
17
+ process.env.USERPROFILE ??
18
+ "/tmp"
19
+ );
20
+ }
21
+
22
+ /** Path dentro `~/.interference` (o la home reindirizzata da INTERFERENCE_HOME). */
23
+ export function interferenceDir(...segments: string[]): string {
24
+ return path.join(interferenceHome(), ".interference", ...segments);
25
+ }
@@ -26,7 +26,14 @@ import type { ModelMessage } from "ai";
26
26
 
27
27
  const TMP = path.join(process.cwd(), ".test-tmp-session");
28
28
 
29
+ // ISOLAMENTO STORE: reindirizza ~/.interference verso TMP così i test su
30
+ // saveSession/cleanupSessions/deleteSession NON tocchino mai le sessioni reali
31
+ // dell'utente. Senza questo override, `cleanupSessions(2)` cancellerebbe le
32
+ // chat vere salvate in ~/.interference/<hash>/sessions.
33
+ const PREV_HOME = process.env.INTERFERENCE_HOME;
34
+
29
35
  beforeAll(async () => {
36
+ process.env.INTERFERENCE_HOME = TMP;
30
37
  await rm(TMP, { recursive: true, force: true });
31
38
  await mkdir(TMP, { recursive: true });
32
39
  await writeFile(path.join(TMP, "a.txt"), "original a\n");
@@ -35,6 +42,8 @@ beforeAll(async () => {
35
42
 
36
43
  afterAll(async () => {
37
44
  await rm(TMP, { recursive: true, force: true });
45
+ if (PREV_HOME === undefined) delete process.env.INTERFERENCE_HOME;
46
+ else process.env.INTERFERENCE_HOME = PREV_HOME;
38
47
  });
39
48
 
40
49
  describe("session store", () => {
@@ -3,9 +3,11 @@ import * as path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import type { ModelMessage } from "ai";
5
5
  import type { Todo } from "../tools/todowrite.ts";
6
+ import { interferenceDir } from "../paths.ts";
6
7
 
7
8
  export interface SessionMeta {
8
9
  id: string;
10
+ title?: string; // nome leggibile (auto dal primo messaggio o via /rename)
9
11
  workspace: string;
10
12
  startedAt: string;
11
13
  updatedAt: string;
@@ -26,11 +28,8 @@ function projectDir(): string {
26
28
  .update(process.cwd())
27
29
  .digest("hex")
28
30
  .slice(0, 12);
29
- return path.join(
30
- process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
31
- ".interference",
32
- hash,
33
- );
31
+ // Home reindirizzabile via INTERFERENCE_HOME (isolamento test) — vedi paths.ts.
32
+ return interferenceDir(hash);
34
33
  }
35
34
 
36
35
  function sessionsDir(): string {
package/src/skills.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { readFile, readdir, mkdir, writeFile } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import { interferenceDir } from "./paths.ts";
3
4
 
4
- export const SKILLS_DIR = path.join(
5
- process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
6
- ".interference",
7
- "skills",
8
- );
5
+ /** Directory delle skill (`~/.interference/skills`, reindirizzabile in test). */
6
+ export function skillsDir(): string {
7
+ return interferenceDir("skills");
8
+ }
9
9
 
10
10
  const BUNDLED_SKILLS: Record<string, string> = {
11
11
  "agents-setup": `---
@@ -90,10 +90,10 @@ export async function loadSkillRegistry(): Promise<SkillInfo[]> {
90
90
  if (registryCache) return registryCache;
91
91
  const list: SkillInfo[] = [];
92
92
  try {
93
- const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
93
+ const entries = await readdir(skillsDir(), { withFileTypes: true });
94
94
  for (const entry of entries) {
95
95
  if (!entry.isDirectory()) continue;
96
- const skillFile = path.join(SKILLS_DIR, entry.name, "SKILL.md");
96
+ const skillFile = path.join(skillsDir(), entry.name, "SKILL.md");
97
97
  try {
98
98
  const content = await readFile(skillFile, "utf-8");
99
99
  const info = parseSkillFrontmatter(content);
@@ -119,7 +119,7 @@ function parseSkillFrontmatter(content: string): SkillInfo | null {
119
119
  }
120
120
 
121
121
  export async function loadSkillBody(name: string): Promise<string | null> {
122
- const skillFile = path.join(SKILLS_DIR, name, "SKILL.md");
122
+ const skillFile = path.join(skillsDir(), name, "SKILL.md");
123
123
  try {
124
124
  return await readFile(skillFile, "utf-8");
125
125
  } catch {
@@ -165,7 +165,7 @@ const STOPWORDS = new Set([
165
165
 
166
166
  export async function bootstrapSkills(): Promise<void> {
167
167
  for (const [name, content] of Object.entries(BUNDLED_SKILLS)) {
168
- const dir = path.join(SKILLS_DIR, name);
168
+ const dir = path.join(skillsDir(), name);
169
169
  try { await mkdir(dir, { recursive: true }); } catch {}
170
170
  const fp = path.join(dir, "SKILL.md");
171
171
  try {
@@ -311,7 +311,7 @@ describe("bash tool", () => {
311
311
  test("timeout kills process", async () => {
312
312
  setRules([{ tool: "bash", decision: "allow" }]);
313
313
  const out = await call<string>(bash, { command: "sleep 5", timeout: 500 });
314
- expect(out).toContain("exit code");
314
+ expect(out).toContain("timed out");
315
315
  });
316
316
 
317
317
  test("ask with confirmation", async () => {
package/src/tools/bash.ts CHANGED
@@ -39,16 +39,35 @@ export const bash = tool({
39
39
  cwd: process.cwd(),
40
40
  stdout: "pipe",
41
41
  stderr: "pipe",
42
- timeout: ms,
43
- killSignal: "SIGTERM",
44
42
  });
45
43
 
46
- const [stdout, stderr] = await Promise.all([
47
- readStream(proc.stdout, OUTPUT_CAP),
48
- readStream(proc.stderr, OUTPUT_CAP),
44
+ // Esecuzione (lettura stream + exit) in gara col timeout. Allo scadere
45
+ // uccidiamo il processo e ritorniamo SUBITO senza aspettare l'EOF: un figlio
46
+ // orfano (es. `sleep`) può tenere la pipe aperta dopo la morte della shell e
47
+ // bloccherebbe la lettura. (L'opzione `timeout` di Bun.spawn non è affidabile
48
+ // su tutti i runner.)
49
+ const finished = (async () => {
50
+ const [stdout, stderr] = await Promise.all([
51
+ readStream(proc.stdout, OUTPUT_CAP),
52
+ readStream(proc.stderr, OUTPUT_CAP),
53
+ ]);
54
+ const exitCode = await proc.exited;
55
+ return { stdout, stderr, exitCode };
56
+ })().catch(() => ({ stdout: "", stderr: "", exitCode: 1 }));
57
+
58
+ const result = await Promise.race([
59
+ finished,
60
+ new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), ms)),
49
61
  ]);
50
62
 
51
- const exitCode = await proc.exited;
63
+ if (result === "timeout") {
64
+ try {
65
+ proc.kill(9);
66
+ } catch {}
67
+ return `Command timed out after ${ms}ms and was killed.`;
68
+ }
69
+
70
+ const { stdout, stderr, exitCode } = result;
52
71
 
53
72
  let output = "";
54
73
  if (stdout.length > 0) output += stdout;
package/src/tools/grep.ts CHANGED
@@ -58,29 +58,35 @@ async function tryRg(
58
58
  args.push("--", pattern);
59
59
  args.push(cwd);
60
60
 
61
- const proc = Bun.spawn(["rg", ...args], {
62
- stdout: "pipe",
63
- stderr: "pipe",
64
- });
65
- const out = await proc.stdout.text();
66
- const err = await proc.stderr.text();
67
- await proc.exited;
68
-
69
- if (proc.exitCode === 0) {
70
- const cwdName = path.relative(process.cwd(), cwd) || ".";
71
- return formatMatches(out, pattern, cwdName);
72
- }
61
+ // ripgrep può non essere installato: `Bun.spawn(["rg"])` lancia ENOENT prima
62
+ // ancora di poter leggere stderr → wrappiamo tutto e torniamo null (→ fallback JS).
63
+ try {
64
+ const proc = Bun.spawn(["rg", ...args], {
65
+ stdout: "pipe",
66
+ stderr: "pipe",
67
+ });
68
+ const out = await proc.stdout.text();
69
+ const err = await proc.stderr.text();
70
+ await proc.exited;
71
+
72
+ if (proc.exitCode === 0) {
73
+ const cwdName = path.relative(process.cwd(), cwd) || ".";
74
+ return formatMatches(out, pattern, cwdName);
75
+ }
73
76
 
74
- if (proc.exitCode === 1) {
75
- const cwdName = path.relative(process.cwd(), cwd) || ".";
76
- return `No matches for '${pattern}' in ${cwdName}`;
77
- }
77
+ if (proc.exitCode === 1) {
78
+ const cwdName = path.relative(process.cwd(), cwd) || ".";
79
+ return `No matches for '${pattern}' in ${cwdName}`;
80
+ }
78
81
 
79
- if (err.includes("command not found") || err.includes("No such file")) {
80
- return null; // rg not available, fallback to JS
81
- }
82
+ if (err.includes("command not found") || err.includes("No such file")) {
83
+ return null; // rg non disponibile fallback JS
84
+ }
82
85
 
83
- return `grep error (exit ${proc.exitCode}): ${err || "unknown error"}`;
86
+ return `grep error (exit ${proc.exitCode}): ${err || "unknown error"}`;
87
+ } catch {
88
+ return null; // rg non installato (ENOENT) → fallback JS
89
+ }
84
90
  }
85
91
 
86
92
  async function jsGrep(
@@ -95,7 +101,7 @@ async function jsGrep(
95
101
  try {
96
102
  regex = new RegExp(pattern, flags);
97
103
  } catch {
98
- return `Invalid regex pattern: ${pattern}`;
104
+ return `grep error: invalid regex pattern '${pattern}'`;
99
105
  }
100
106
 
101
107
  const matches: string[] = [];