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 +29 -8
- package/package.json +22 -3
- package/src/__tests__/version.test.ts +24 -0
- package/src/auth.ts +8 -10
- package/src/cli-plain.ts +3 -3
- package/src/cli.ts +8 -0
- package/src/commands/index.ts +18 -0
- package/src/config.ts +29 -18
- package/src/cost.ts +2 -0
- package/src/paths.ts +25 -0
- package/src/session/__tests__/session.test.ts +9 -0
- package/src/session/store.ts +4 -5
- package/src/skills.ts +9 -9
- package/src/tools/__tests__/mutating.test.ts +1 -1
- package/src/tools/bash.ts +25 -6
- package/src/tools/grep.ts +27 -21
- package/src/tui/App.tsx +73 -30
- package/src/tui/ConfirmDialog.tsx +13 -4
- package/src/tui/DiffView.tsx +17 -17
- package/src/tui/MarkdownText.tsx +25 -3
- package/src/tui/ModelPicker.tsx +8 -7
- package/src/tui/Panel.tsx +8 -4
- package/src/tui/SelectRow.tsx +40 -0
- package/src/tui/SessionList.tsx +6 -7
- package/src/tui/Spinner.tsx +43 -0
- package/src/tui/ThinkingPicker.tsx +8 -8
- package/src/tui/ToolStep.tsx +130 -53
- package/src/tui/Welcome.tsx +20 -5
- package/src/tui/__tests__/reasoning.test.ts +24 -0
- package/src/tui/__tests__/syntax.test.ts +33 -0
- package/src/tui/__tests__/tui-render.test.tsx +30 -1
- package/src/tui/placeholders.ts +22 -0
- package/src/tui/reasoning.ts +23 -0
- package/src/tui/syntax.ts +58 -0
- package/src/tui/theme.ts +13 -1
- package/src/version.ts +79 -0
- package/assets/screenshot.png +0 -0
- package/bun.lock +0 -159
- package/tsconfig.json +0 -23
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 ·
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
-
|
|
56
|
-
echo 'DEEPSEEK_API_KEY=sk-...' > .env
|
|
57
|
-
bun run interference
|
|
54
|
+
bun install -g interference-agent
|
|
55
|
+
interference
|
|
58
56
|
```
|
|
59
57
|
|
|
60
|
-
|
|
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.
|
|
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": [
|
|
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
|
-
|
|
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(
|
|
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(
|
|
27
|
-
await writeFile(
|
|
28
|
-
try { await chmod(
|
|
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(`${
|
|
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
|
|
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
|
|
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.
|
package/src/commands/index.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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: {
|
|
200
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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", () => {
|
package/src/session/store.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
"
|
|
7
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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 `
|
|
104
|
+
return `grep error: invalid regex pattern '${pattern}'`;
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
const matches: string[] = [];
|