interference-agent 0.1.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/assets/screenshot.png +0 -0
  4. package/bun.lock +159 -0
  5. package/package.json +39 -0
  6. package/src/agent/compaction.ts +114 -0
  7. package/src/agent/loop.ts +94 -0
  8. package/src/agent/prompt.ts +89 -0
  9. package/src/agent/subagent.ts +64 -0
  10. package/src/auth.ts +50 -0
  11. package/src/cli-plain.ts +274 -0
  12. package/src/cli.ts +87 -0
  13. package/src/commands/index.ts +184 -0
  14. package/src/config-file.ts +109 -0
  15. package/src/config.ts +212 -0
  16. package/src/context.ts +96 -0
  17. package/src/cost.ts +54 -0
  18. package/src/git.ts +22 -0
  19. package/src/permissions.ts +135 -0
  20. package/src/provider.ts +58 -0
  21. package/src/session/__tests__/session.test.ts +180 -0
  22. package/src/session/snapshot.ts +122 -0
  23. package/src/session/store.ts +120 -0
  24. package/src/skills.ts +177 -0
  25. package/src/tools/__tests__/mutating.test.ts +324 -0
  26. package/src/tools/__tests__/question.test.ts +53 -0
  27. package/src/tools/__tests__/todowrite.test.ts +57 -0
  28. package/src/tools/__tests__/tools.test.ts +217 -0
  29. package/src/tools/_fs.ts +12 -0
  30. package/src/tools/bash.ts +104 -0
  31. package/src/tools/edit.ts +98 -0
  32. package/src/tools/glob.ts +40 -0
  33. package/src/tools/grep.ts +187 -0
  34. package/src/tools/index.ts +21 -0
  35. package/src/tools/ls.ts +70 -0
  36. package/src/tools/question.ts +81 -0
  37. package/src/tools/read.ts +61 -0
  38. package/src/tools/registry.ts +36 -0
  39. package/src/tools/task.ts +71 -0
  40. package/src/tools/todowrite.ts +84 -0
  41. package/src/tools/webfetch.ts +111 -0
  42. package/src/tools/write.ts +51 -0
  43. package/src/tui/App.tsx +738 -0
  44. package/src/tui/ConfirmDialog.tsx +46 -0
  45. package/src/tui/DiffView.tsx +88 -0
  46. package/src/tui/MarkdownText.tsx +63 -0
  47. package/src/tui/Message.tsx +26 -0
  48. package/src/tui/ModelPicker.tsx +44 -0
  49. package/src/tui/Panel.tsx +39 -0
  50. package/src/tui/ProviderPicker.tsx +111 -0
  51. package/src/tui/QuestionDialog.tsx +64 -0
  52. package/src/tui/SessionList.tsx +72 -0
  53. package/src/tui/SlashAutocomplete.tsx +33 -0
  54. package/src/tui/StatusFooter.tsx +71 -0
  55. package/src/tui/ThinkingPicker.tsx +57 -0
  56. package/src/tui/Toast.tsx +64 -0
  57. package/src/tui/TodoList.tsx +49 -0
  58. package/src/tui/ToolStep.tsx +184 -0
  59. package/src/tui/Welcome.tsx +87 -0
  60. package/src/tui/__tests__/tui-render.test.tsx +59 -0
  61. package/src/tui/theme.ts +16 -0
  62. package/src/tui/wordmark.ts +7 -0
  63. package/tsconfig.json +23 -0
@@ -0,0 +1,64 @@
1
+ import type { ToolSet } from "ai";
2
+ import { readonlyTools, allToolsWithoutTask } from "../tools/registry.ts";
3
+
4
+ // I subagent girano in autonomia: niente todowrite (stato globale condiviso, sovrascriverebbe
5
+ // la lista del turno principale) né question (non hanno una UI per interpellare l'utente).
6
+ const { todowrite: _roTodo, question: _roQ, ...readonlyForSub } = readonlyTools;
7
+ const { todowrite: _allTodo, question: _allQ, ...allForSub } = allToolsWithoutTask;
8
+
9
+ export type SubagentType = "explore" | "general";
10
+
11
+ export interface SubagentDef {
12
+ name: string;
13
+ description: string;
14
+ systemPrompt: string;
15
+ tools: ToolSet;
16
+ }
17
+
18
+ export const SUBAGENTS: Record<SubagentType, SubagentDef> = {
19
+ explore: {
20
+ name: "explore",
21
+ description:
22
+ "Fast agent specialized for exploring codebases. Use when you need to quickly find files by patterns, " +
23
+ "search code for keywords, or answer questions about the codebase.",
24
+ systemPrompt: `You are a specialized code exploration subagent. Your purpose is to investigate the codebase and answer questions.
25
+
26
+ You have ONLY read-only tools (read, ls, glob, grep). You CANNOT write, edit, or execute commands.
27
+
28
+ Rules:
29
+ - Be fast and thorough — search broadly, then narrow down
30
+ - When you find the answer, summarize it clearly with file:line references
31
+ - Do NOT suggest edits, create files, or run commands
32
+ - If you can't find something after reasonable search, say so explicitly
33
+ - Return your findings as a concise report`,
34
+ tools: readonlyForSub,
35
+ },
36
+
37
+ general: {
38
+ name: "general",
39
+ description:
40
+ "General-purpose agent for researching complex questions and executing multi-step tasks. " +
41
+ "Has full access to all tools.",
42
+ systemPrompt: `You are a general-purpose subagent. Execute the given task using all available tools.
43
+
44
+ Rules:
45
+ - Be thorough — explore, read, write, edit, and run commands as needed
46
+ - Summarize your approach before executing
47
+ - Report results clearly with file:line references
48
+ - If you encounter errors, try to correct them before giving up
49
+ - Do NOT spawn other subagents (the task tool is not available to you)`,
50
+ tools: allForSub,
51
+ },
52
+ };
53
+
54
+ export function getSubagent(type: string): SubagentDef | null {
55
+ const t = type as SubagentType;
56
+ return SUBAGENTS[t] ?? null;
57
+ }
58
+
59
+ export function listSubagents(): { type: string; description: string }[] {
60
+ return Object.entries(SUBAGENTS).map(([type, def]) => ({
61
+ type,
62
+ description: def.description,
63
+ }));
64
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
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
+ );
8
+
9
+ interface ProviderAuth {
10
+ label: string;
11
+ envKey: string;
12
+ }
13
+
14
+ const AUTH_FILE = path.join(AUTH_DIR, "auth.json");
15
+
16
+ export async function loadAuth(): Promise<Record<string, string>> {
17
+ try {
18
+ const raw = await readFile(AUTH_FILE, "utf-8");
19
+ return JSON.parse(raw) as Record<string, string>;
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ 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 {}
29
+ }
30
+
31
+ export function applyAuthToEnv(auth: Record<string, string>, providers: Record<string, ProviderAuth>): void {
32
+ for (const [pid, def] of Object.entries(providers)) {
33
+ const key = auth[pid];
34
+ if (key && !process.env[def.envKey]) {
35
+ process.env[def.envKey] = key;
36
+ }
37
+ }
38
+ }
39
+
40
+ export async function setProviderKey(providerId: string, key: string): Promise<void> {
41
+ const auth = await loadAuth();
42
+ auth[providerId] = key;
43
+ await saveAuth(auth);
44
+ }
45
+
46
+ export async function removeProviderKey(providerId: string): Promise<void> {
47
+ const auth = await loadAuth();
48
+ delete auth[providerId];
49
+ await saveAuth(auth);
50
+ }
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env bun
2
+ import * as readline from "node:readline/promises";
3
+ import { stdin, stdout } from "node:process";
4
+ import { currentModel, currentProvider, currentMode, setMode } from "./config.ts";
5
+ import { runTurn } from "./agent/loop.ts";
6
+ import type { Chunk } from "./agent/loop.ts";
7
+ import { MissingApiKeyError } from "./provider.ts";
8
+ import { setConfirmHandler } from "./permissions.ts";
9
+ import { setAnswerHandler, type Answers } from "./tools/question.ts";
10
+ import { saveSession, loadSession, listSessions } from "./session/store.ts";
11
+ import type { Session } from "./session/store.ts";
12
+ import { nextTurn, undo, redo, finalizeSnapshots } from "./session/snapshot.ts";
13
+ import { dispatch, isSlashCommand } from "./commands/index.ts";
14
+ import { matchSkills, getCachedRegistry, loadSkillBody } from "./skills.ts";
15
+ import { shouldCompact, compactMessages, getUsagePercent } from "./agent/compaction.ts";
16
+ import { computeDiff, formatDiff } from "./tui/DiffView.tsx";
17
+ import { estimateCost, formatCost } from "./cost.ts";
18
+ import { estimateMessagesTokens } from "./agent/compaction.ts";
19
+
20
+ const DIM = "\x1b[2m";
21
+ const BOLD = "\x1b[1m";
22
+ const RED = "\x1b[31m";
23
+ const YELLOW = "\x1b[33m";
24
+ const RESET = "\x1b[0m";
25
+
26
+ export default async function plain(session: Session): Promise<void> {
27
+ const provider = currentProvider();
28
+ const mode = currentMode();
29
+ const modeLabel = mode === "plan" ? "Plan" : "Build";
30
+ const modeSymbol = mode === "plan" ? "⬡" : "⬢";
31
+
32
+ stdout.write(`\n${BOLD} interference${RESET} ${DIM}the open-source coding agent${RESET}\n`);
33
+ stdout.write(`${DIM} ______________________________${RESET}\n`);
34
+ stdout.write(`\n`);
35
+ stdout.write(
36
+ `${DIM} ${modeSymbol} ${modeLabel}${RESET} ${DIM}·${RESET} ${provider.label} ${DIM}·${RESET} ${currentModel()} ${DIM}·${RESET} ${getUsagePercent(session.messages)}% ctx ${DIM}·${RESET} ${formatCost(estimateCost(estimateMessagesTokens(session.messages)))}${RESET}\n`,
37
+ );
38
+ stdout.write(`\n`);
39
+
40
+ if (session.messages.length > 0) {
41
+ stdout.write(`${DIM} ↳ Resumed ${session.meta.id.slice(0, 12)} (${session.meta.turnCount} turns)${RESET}\n\n`);
42
+ }
43
+
44
+ stdout.write(` ${DIM}Type a message · /help · /build · /sessions · Ctrl-C${RESET}\n\n`);
45
+
46
+ const rl = readline.createInterface({ input: stdin, output: stdout });
47
+ const messages = session.messages;
48
+ let aborter: AbortController | null = null;
49
+
50
+ setConfirmHandler(async (toolName, preview) => {
51
+ stdout.write(`\n${YELLOW}${preview}${RESET}\n${YELLOW} Allow ${toolName}?${RESET} [y/N] `);
52
+ let ans: string;
53
+ try { ans = (await rl.question("")).trim().toLowerCase(); } catch { ans = "n"; }
54
+ const ok = ans === "y" || ans === "yes";
55
+ stdout.write(ok ? `${DIM} → executing…${RESET}\n` : `${DIM} → refused${RESET}\n`);
56
+ return ok;
57
+ });
58
+
59
+ setAnswerHandler(async (qs) => {
60
+ const answers: Answers = [];
61
+ for (const q of qs) {
62
+ stdout.write(`\n${BOLD}${q.header ? `[${q.header}] ` : ""}${q.question}${RESET}\n`);
63
+ q.options.forEach((o, i) => {
64
+ stdout.write(` ${i + 1}. ${o.label}${o.description ? `${DIM} — ${o.description}${RESET}` : ""}\n`);
65
+ });
66
+ const hint = q.multiple ? "numbers separated by comma (e.g. 1,3)" : "a number";
67
+ let raw: string;
68
+ try { raw = (await rl.question(`${DIM} choose ${hint} (Enter to skip): ${RESET}`)).trim(); } catch { raw = ""; }
69
+ const idxs = raw
70
+ .split(",")
71
+ .map((s) => parseInt(s.trim(), 10) - 1)
72
+ .filter((n) => Number.isInteger(n) && n >= 0 && n < q.options.length);
73
+ const picked = (q.multiple ? idxs : idxs.slice(0, 1)).map((n) => q.options[n]!.label);
74
+ answers.push(picked);
75
+ }
76
+ return answers;
77
+ });
78
+
79
+ rl.on("SIGINT", () => {
80
+ if (aborter) { aborter.abort(); aborter = null; stdout.write(`\n${DIM}[interrupted]${RESET}\n`); }
81
+ else { stdout.write("\n"); rl.close(); }
82
+ });
83
+
84
+ try {
85
+ while (true) {
86
+ let input: string;
87
+ try { input = (await rl.question(`${BOLD}›${RESET} `)).trim(); } catch { break; }
88
+ if (input.length === 0) continue;
89
+ if (input === "/exit" || input === "/quit") break;
90
+
91
+ if (isSlashCommand(input)) {
92
+ const result = await dispatch(input, {
93
+ setMode: (m) => { setMode(m); session.meta.mode = m; },
94
+ clearMessages: () => { messages.length = 0; },
95
+ doInit: async (args) => {
96
+ // /init delegates to the agent — run a turn with the init template
97
+ const template = `Generate or update the AGENTS.md file at the project root.
98
+
99
+ Follow the bundled agents-setup skill (see system prompt). Key sections:
100
+ - Project overview, stack, directory structure
101
+ - Build/test commands, code conventions
102
+ - Agent skills and triggers
103
+ - Non-negotiable rules
104
+
105
+ How to proceed:
106
+ 1. Use ls, glob, grep, and read to explore the project thoroughly
107
+ 2. Identify languages, frameworks, build system, test setup, conventions
108
+ 3. Write AGENTS.md at the project root using the write tool
109
+ 4. Confirm the file was created and summarize its contents
110
+
111
+ ${args ? `Additional context: ${args}` : ""}`;
112
+ nextTurn();
113
+ messages.push({ role: "user", content: template });
114
+ aborter = new AbortController();
115
+ try {
116
+ await consumeTurn(runTurn(messages, aborter.signal));
117
+ session.meta.turnCount++;
118
+ await finalizeSnapshots();
119
+ await saveSession(session);
120
+ return "AGENTS.md generated successfully.";
121
+ } catch (err) {
122
+ messages.pop();
123
+ return `Init failed: ${err instanceof Error ? err.message : String(err)}`;
124
+ } finally { aborter = null; }
125
+ },
126
+ doSkill: async (name, body) => {
127
+ nextTurn();
128
+ messages.push({ role: "user", content: input });
129
+ aborter = new AbortController();
130
+ try {
131
+ await consumeTurn(runTurn(messages, aborter.signal, undefined, [body]));
132
+ session.meta.turnCount++;
133
+ await finalizeSnapshots();
134
+ await saveSession(session);
135
+ return `Skill '${name}' executed.`;
136
+ } catch (err) {
137
+ messages.pop();
138
+ return `Skill failed: ${err instanceof Error ? err.message : String(err)}`;
139
+ } finally { aborter = null; }
140
+ },
141
+ doSessions: async () => {
142
+ const list = await listSessions();
143
+ if (list.length === 0) return "No sessions found.";
144
+ stdout.write(`\n${DIM}Sessions:${RESET}\n`);
145
+ for (let i = 0; i < Math.min(list.length, 15); i++) {
146
+ const s = list[i]!;
147
+ stdout.write(` ${DIM}${i + 1}.${RESET} ${s.id.slice(0, 12)} ${DIM}· ${s.mode} · ${s.turnCount}t · ${s.updatedAt.slice(0, 10)}${RESET}\n`);
148
+ }
149
+ stdout.write(`${DIM}Enter number to resume or 0 to cancel: ${RESET}`);
150
+ let choice: string;
151
+ try { choice = (await rl.question("")).trim(); } catch { return "Cancelled."; }
152
+ const idx = parseInt(choice) - 1;
153
+ if (idx >= 0 && idx < list.length && list[idx]) {
154
+ const loaded = await loadSession(list[idx]!.id);
155
+ if (loaded) {
156
+ messages.length = 0;
157
+ messages.push(...loaded.messages);
158
+ session.meta = loaded.meta;
159
+ session.messages = loaded.messages;
160
+ return `Resumed session ${list[idx]!.id.slice(0, 12)} (${loaded.meta.turnCount} turns).`;
161
+ }
162
+ return "Session not found.";
163
+ }
164
+ return "Cancelled.";
165
+ },
166
+ doRename: async (name) => {
167
+ session.meta.id = name;
168
+ await saveSession(session);
169
+ return `Session renamed to '${name}'.`;
170
+ },
171
+ });
172
+ if (result) stdout.write(`${DIM}${result}${RESET}\n\n`);
173
+ continue;
174
+ }
175
+
176
+ const matchedSkills = matchSkills(input, getCachedRegistry());
177
+ const skillBodies: string[] = [];
178
+ for (const name of matchedSkills) {
179
+ const body = await loadSkillBody(name);
180
+ if (body) skillBodies.push(body);
181
+ }
182
+ if (skillBodies.length > 0) {
183
+ stdout.write(`${DIM}Skills matched: ${matchedSkills.join(", ")}${RESET}\n`);
184
+ }
185
+
186
+ nextTurn();
187
+ messages.push({ role: "user", content: input });
188
+ aborter = new AbortController();
189
+ try {
190
+ await consumeTurn(runTurn(messages, aborter.signal, undefined, skillBodies.length > 0 ? skillBodies : undefined));
191
+ stdout.write("\n\n");
192
+ session.meta.turnCount++;
193
+ await finalizeSnapshots();
194
+ await saveSession(session);
195
+
196
+ if (shouldCompact(messages)) {
197
+ const pct = getUsagePercent(messages);
198
+ stdout.write(`${DIM}Context at ${pct}%, compacting...${RESET}\n`);
199
+ const compacted = await compactMessages(messages);
200
+ messages.length = 0;
201
+ messages.push(...compacted);
202
+ session.messages = messages;
203
+ await saveSession(session);
204
+ stdout.write(`${DIM}Compacted. ${getUsagePercent(messages)}% context used.${RESET}\n`);
205
+ }
206
+ } catch (err) {
207
+ messages.pop();
208
+ if (err instanceof MissingApiKeyError) {
209
+ stdout.write(`\n${err.message}\n\n`);
210
+ } else if (aborter === null) {
211
+ // abort
212
+ } else {
213
+ const msg = err instanceof Error ? err.message : String(err);
214
+ stdout.write(`\n${DIM}[error]${RESET} ${msg}\n\n`);
215
+ }
216
+ } finally { aborter = null; }
217
+ }
218
+ } finally { rl.close(); }
219
+ }
220
+
221
+ async function consumeTurn(chunks: AsyncGenerator<Chunk>): Promise<void> {
222
+ let sawReasoning = false;
223
+ let inText = false;
224
+ let activeTool: { name: string; args: string; input: unknown } | null = null;
225
+
226
+ for await (const chunk of chunks) {
227
+ switch (chunk.type) {
228
+ case "reasoning":
229
+ if (!sawReasoning) { stdout.write(`${DIM}┄ thinking${RESET}\n`); sawReasoning = true; }
230
+ stdout.write(`${DIM}${chunk.text}${RESET}`);
231
+ break;
232
+ case "text":
233
+ if (activeTool) { stdout.write("\n"); activeTool = null; }
234
+ if (sawReasoning && !inText) { stdout.write(`\n${DIM}┄${RESET}\n\n`); inText = true; }
235
+ else if (!inText) { inText = true; }
236
+ stdout.write(chunk.text);
237
+ break;
238
+ case "tool-call": {
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; }
241
+ else if (activeTool || !inText) { stdout.write("\n"); }
242
+ stdout.write(`${DIM}· ${chunk.toolName}${RESET}(${args})`);
243
+ activeTool = { name: chunk.toolName, args, input: chunk.input };
244
+ break;
245
+ }
246
+ case "tool-result":
247
+ if (chunk.isError) {
248
+ stdout.write(`\n${RED} → error${RESET}: ${chunk.output.slice(0, 200)}`);
249
+ } else if (activeTool && (activeTool.name === "write" || activeTool.name === "edit")) {
250
+ const input = activeTool.input as Record<string, unknown> | undefined;
251
+ let diffText = "";
252
+ if (activeTool.name === "edit" && input && typeof input.oldString === "string" && typeof input.newString === "string") {
253
+ diffText = formatDiff(computeDiff(
254
+ (input.oldString as string).split("\n"),
255
+ (input.newString as string).split("\n"),
256
+ ));
257
+ } else if (activeTool.name === "write" && input && typeof input.content === "string") {
258
+ diffText = formatDiff(computeDiff([], (input.content as string).split("\n")));
259
+ }
260
+ if (diffText) {
261
+ stdout.write(`\n${DIM} → diff:${RESET}\n${diffText}`);
262
+ } else {
263
+ const p = chunk.output.length > 120 ? chunk.output.slice(0, 120).replace(/\n/g, " ") + "…" : chunk.output.replace(/\n/g, " ");
264
+ stdout.write(`\n${DIM} →${RESET} ${p}`);
265
+ }
266
+ } else {
267
+ const p = chunk.output.length > 120 ? chunk.output.slice(0, 120).replace(/\n/g, " ") + "…" : chunk.output.replace(/\n/g, " ");
268
+ stdout.write(`\n${DIM} →${RESET} ${p}`);
269
+ }
270
+ activeTool = null;
271
+ break;
272
+ }
273
+ }
274
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bun
2
+ import { stdin, stdout } from "node:process";
3
+ import { currentModel, currentProvider } from "./config.ts";
4
+ import { MissingApiKeyError } from "./provider.ts";
5
+ import { createSession, latestSession, loadSession, saveSession, initStore } from "./session/store.ts";
6
+ import { initSnapshot } from "./session/snapshot.ts";
7
+ import { initInstructions } from "./agent/prompt.ts";
8
+ import { bootstrapSkills } from "./skills.ts";
9
+ import { initSkillCommands } from "./commands/index.ts";
10
+ import { loadConfig, applyConfig } from "./config-file.ts";
11
+ import { loadAuth, applyAuthToEnv } from "./auth.ts";
12
+ import { PROVIDERS } from "./config.ts";
13
+ import type { Session } from "./session/store.ts";
14
+
15
+ async function main(): Promise<void> {
16
+ // Titolo + nome-icona della tab del terminale (come Claude Code), solo in TTY
17
+ // (in pipe/non-TTY le sequenze OSC sporcherebbero l'output).
18
+ // OSC 1 = icon/tab name, OSC 2 = window title.
19
+ if (stdout.isTTY) {
20
+ stdout.write("\x1b]1;◉ interference\x07\x1b]2;◉ interference\x07");
21
+ }
22
+
23
+ const provider = currentProvider();
24
+
25
+ if (!process.env[provider.envKey]) {
26
+ stdout.write(`\n${new MissingApiKeyError(provider).message}\n`);
27
+ process.exit(1);
28
+ }
29
+
30
+ await initStore();
31
+ await bootstrapSkills();
32
+
33
+ const auth = await loadAuth();
34
+ applyAuthToEnv(auth, Object.fromEntries(
35
+ Object.entries(PROVIDERS).map(([pid, def]) => [pid, { label: def.label, envKey: def.envKey }])
36
+ ));
37
+
38
+ const config = await loadConfig();
39
+ if (config) applyConfig(config);
40
+
41
+ await initInstructions();
42
+ await initSkillCommands();
43
+
44
+ const args = Bun.argv.slice(2);
45
+ const resumeId = args.includes("--continue")
46
+ ? args[args.indexOf("--continue") + 1] ?? null
47
+ : null;
48
+
49
+ let session: Session;
50
+ if (resumeId) {
51
+ const s = await loadSession(resumeId);
52
+ if (!s) {
53
+ stdout.write(`Session ${resumeId} not found\n`);
54
+ process.exit(1);
55
+ }
56
+ session = s;
57
+ } else if (args.includes("--continue")) {
58
+ const s = await latestSession();
59
+ if (!s) {
60
+ stdout.write("No previous session found\n");
61
+ process.exit(1);
62
+ }
63
+ session = s;
64
+ } else {
65
+ session = createSession({
66
+ mode: "build",
67
+ provider: provider.label,
68
+ model: currentModel(),
69
+ });
70
+ }
71
+
72
+ initSnapshot(session.meta.id);
73
+
74
+ if (stdin.isTTY) {
75
+ const { default: App } = await import("./tui/App.tsx");
76
+ const { createElement } = await import("react");
77
+ const { render } = await import("ink");
78
+ const { waitUntilExit } = render(createElement(App, { session }));
79
+ await waitUntilExit;
80
+ return;
81
+ }
82
+
83
+ const { default: plain } = await import("./cli-plain.ts");
84
+ await plain(session);
85
+ }
86
+
87
+ main();
@@ -0,0 +1,184 @@
1
+ import type { AgentMode, ThinkingLevel } from "../config.ts";
2
+ import { currentProvider, currentThinking, setThinking, currentModel, setModel } from "../config.ts";
3
+ import { undo, redo } from "../session/snapshot.ts";
4
+ import { loadSkillBody, getCachedRegistry, type SkillInfo } from "../skills.ts";
5
+
6
+ export interface CommandInfo {
7
+ name: string;
8
+ description: string;
9
+ delegate?: boolean;
10
+ template?: string;
11
+ isSkill?: boolean;
12
+ }
13
+
14
+ export type CommandHandler = (
15
+ args: string,
16
+ ctx: {
17
+ setMode?: (m: AgentMode) => void;
18
+ clearMessages?: () => void;
19
+ doInit?: (args: string) => Promise<string>;
20
+ doSkill?: (name: string, body: string) => Promise<string>;
21
+ doSessions?: () => Promise<string>;
22
+ doRename?: (name: string) => Promise<string>;
23
+ doCompact?: () => Promise<string>;
24
+ },
25
+ ) => string | void | Promise<string | void>;
26
+
27
+ const registry = new Map<string, CommandInfo>();
28
+ const handlers = new Map<string, CommandHandler>();
29
+
30
+ export function register(
31
+ name: string,
32
+ description: string,
33
+ handler: CommandHandler,
34
+ opts?: { delegate?: boolean; template?: string; isSkill?: boolean },
35
+ ) {
36
+ registry.set(name, { name, description, delegate: opts?.delegate, template: opts?.template });
37
+ handlers.set(name, handler);
38
+ }
39
+
40
+ export function getCommand(name: string): CommandInfo | undefined {
41
+ return registry.get(name);
42
+ }
43
+
44
+ export function listCommands(): CommandInfo[] {
45
+ return [...registry.values()];
46
+ }
47
+
48
+ /** Comandi che matchano un filtro (nome o descrizione). Usato da CLI + autocomplete. */
49
+ export function matchCommands(filter: string): CommandInfo[] {
50
+ const f = filter.toLowerCase();
51
+ return listCommands().filter(
52
+ (c) => c.name.includes(f) || c.description.toLowerCase().includes(f),
53
+ );
54
+ }
55
+
56
+ export async function dispatch(
57
+ input: string,
58
+ ctx: Parameters<CommandHandler>[1],
59
+ ): Promise<string | null> {
60
+ const match = input.match(/^\/(\w+)(?:\s+(.*))?$/);
61
+ if (!match) return null;
62
+
63
+ const name = match[1]!;
64
+ const args = match[2] ?? "";
65
+
66
+ const handler = handlers.get(name);
67
+ if (!handler) return `Unknown command: /${name}. Type /help for available commands.`;
68
+
69
+ const result = await handler(args, ctx);
70
+ return result ?? null;
71
+ }
72
+
73
+ export function isSlashCommand(input: string): boolean {
74
+ return /^\//.test(input);
75
+ }
76
+
77
+ register("help", "Show available commands", () => {
78
+ const lines = ["Available commands:"];
79
+ for (const cmd of listCommands()) {
80
+ lines.push(` /${cmd.name.padEnd(8)} ${cmd.description}`);
81
+ }
82
+ return lines.join("\n");
83
+ });
84
+
85
+ register("clear", "Clear conversation history", (_args, ctx) => {
86
+ ctx.clearMessages?.();
87
+ return "Conversation cleared.";
88
+ });
89
+
90
+ register("plan", "Switch to Plan mode (read-only)", (_args, ctx) => {
91
+ ctx.setMode?.("plan");
92
+ return "Switched to Plan mode (read-only).";
93
+ });
94
+
95
+ register("build", "Switch to Build mode (full access)", (_args, ctx) => {
96
+ ctx.setMode?.("build");
97
+ return "Switched to Build mode (full access).";
98
+ });
99
+
100
+ register(
101
+ "init",
102
+ "Generate or update AGENTS.md for this project",
103
+ (_args, ctx) => ctx.doInit?.(_args) ?? "Init command requires agent context.",
104
+ { delegate: true },
105
+ );
106
+
107
+ register("model", "Change the model (usage: /model <model-id>)", (args, _ctx) => {
108
+ if (!args.trim()) return `Usage: /model <model-id>\nCurrent model: ${currentModel()}`;
109
+ setModel(args.trim());
110
+ return `Model set to '${args.trim()}'. Effective on next turn.`;
111
+ });
112
+
113
+ register(
114
+ "thinking",
115
+ "Set reasoning/thinking level for the current model (usage: /thinking <level>)",
116
+ (args) => {
117
+ const p = currentProvider();
118
+ const levels = p.thinkingLevels;
119
+ const arg = args.trim().toLowerCase();
120
+ if (!arg) {
121
+ return (
122
+ `Thinking: ${currentThinking()} · model: ${p.label}\n` +
123
+ `Available levels: ${levels.join(", ")}\n` +
124
+ `Usage: /thinking <level>`
125
+ );
126
+ }
127
+ if (!levels.includes(arg as ThinkingLevel)) {
128
+ return `Invalid level '${arg}' for ${p.label}. Available: ${levels.join(", ")}`;
129
+ }
130
+ setThinking(arg as ThinkingLevel);
131
+ return `Thinking set to '${arg}' (effective next turn).`;
132
+ },
133
+ );
134
+
135
+ register("undo", "Undo last file modifications", async () => {
136
+ const files = await undo();
137
+ if (files.length > 0) return `Undo: restored ${files.join(", ")}`;
138
+ return "Nothing to undo.";
139
+ });
140
+
141
+ register("redo", "Redo previously undone file modifications", async () => {
142
+ const files = await redo();
143
+ if (files.length > 0) return `Redo: restored ${files.join(", ")}`;
144
+ return "Nothing to redo.";
145
+ });
146
+
147
+ register("compact", "Compact conversation context to save tokens", (args, ctx) => {
148
+ if (ctx.doCompact) return ctx.doCompact();
149
+ return "Compaction will run at the end of this turn if context is > 90% full.";
150
+ });
151
+
152
+ register("provider", "Manage connected AI providers and API keys", () => {
153
+ return "Opening provider settings...";
154
+ });
155
+
156
+ register("sessions", "List and resume previous sessions", (_args, ctx) => {
157
+ if (ctx.doSessions) return ctx.doSessions();
158
+ return "Session list not available in this context.";
159
+ });
160
+
161
+ register("rename", "Rename the current session (usage: /rename <new-name>)", (args, ctx) => {
162
+ if (!args.trim()) return "Usage: /rename <new-name>";
163
+ if (ctx.doRename) return ctx.doRename(args.trim());
164
+ return `Session would be renamed to '${args.trim()}'.`;
165
+ });
166
+
167
+ export async function initSkillCommands(): Promise<void> {
168
+ const skills = getCachedRegistry();
169
+ for (const skill of skills) {
170
+ register(
171
+ skill.name,
172
+ skill.description,
173
+ async (args, ctx) => {
174
+ const body = await loadSkillBody(skill.name);
175
+ if (!body) return `Skill '${skill.name}' not found.`;
176
+ if (ctx.doSkill) {
177
+ return ctx.doSkill(skill.name, body);
178
+ }
179
+ return `Skill '${skill.name}' loaded.`;
180
+ },
181
+ { delegate: true, isSkill: true },
182
+ );
183
+ }
184
+ }