Skip to content

Commit ccdd226

Browse files
feat: build terminal-based and api-driven ai agent
1 parent 2b61477 commit ccdd226

8 files changed

Lines changed: 769 additions & 23 deletions

File tree

src/commands/registry.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,15 @@ export async function dispatchCommand(
225225
h.updateConfig({ model: arg });
226226
return { type: "ok", message: `model set to ${arg}` };
227227

228+
case "theme": {
229+
const next = (arg || "").trim().toLowerCase();
230+
if (next === "light" || next === "dark") {
231+
h.updateConfig({ theme: next as "light" | "dark" });
232+
return { type: "ok", message: `Theme set to ${next}` };
233+
}
234+
return { type: "ok", message: `Theme: ${h.config.theme || "dark"}` };
235+
}
236+
228237
case "yolo": {
229238
const next = !(ctx.getYolo?.() ?? h.config.autoApprove);
230239
h.setAutoApprove(next);

src/config/load.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,11 @@ export function loadConfig(overrides: Partial<ArrowConfig> = {}): ArrowConfig {
385385
swarm,
386386
contextBudgetChars:
387387
overrides.contextBudgetChars ?? file.contextBudgetChars ?? 120_000,
388+
theme: overrides.theme ?? file.theme ?? "dark",
389+
shellSafetyLevel: overrides.shellSafetyLevel ?? file.shellSafetyLevel ?? "normal",
390+
fileConfirmationPolicy: overrides.fileConfirmationPolicy ?? file.fileConfirmationPolicy ?? "destructive",
391+
webSearchToggle: overrides.webSearchToggle ?? file.webSearchToggle ?? true,
392+
telemetryOptIn: overrides.telemetryOptIn ?? file.telemetryOptIn ?? false,
388393
};
389394
}
390395

@@ -424,6 +429,11 @@ export function saveSettings(cfg: Partial<ArrowConfig>): void {
424429
systemExtra: cfg.systemExtra,
425430
swarm: cfg.swarm,
426431
contextBudgetChars: cfg.contextBudgetChars,
432+
theme: cfg.theme,
433+
shellSafetyLevel: cfg.shellSafetyLevel,
434+
fileConfirmationPolicy: cfg.fileConfirmationPolicy,
435+
webSearchToggle: cfg.webSearchToggle,
436+
telemetryOptIn: cfg.telemetryOptIn,
427437
};
428438
writeFileSync(SETTINGS_PATH, stringifyYaml(safe), "utf8");
429439
saveConfig(safe as Partial<ArrowConfig>);

src/config/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ export interface ArrowConfig {
7979
swarm: SwarmConfig;
8080
/** Global context budget (chars) for main agents */
8181
contextBudgetChars: number;
82+
theme?: "dark" | "light";
83+
shellSafetyLevel?: "safe" | "normal" | "low";
84+
fileConfirmationPolicy?: "always" | "never" | "destructive";
85+
webSearchToggle?: boolean;
86+
telemetryOptIn?: boolean;
8287
}
8388

8489
export interface AgentLogLine {

src/core/server.ts

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { existsSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
2+
import { join, dirname } from "node:path";
3+
import { spawn } from "node:child_process";
4+
import type { ArrowConfig } from "../config/types";
5+
import type { Harness } from "./harness";
6+
import { IGNORE_DIRS } from "../tools/workspace";
7+
8+
export async function startServer(cfg: ArrowConfig, harness: Harness) {
9+
const port = process.env.ARROWCODE_PORT ? Number(process.env.ARROWCODE_PORT) : 3000;
10+
11+
console.log(`\n=============================================`);
12+
console.log(`🚀 Starting ArrowCode API Server on port ${port}...`);
13+
console.log(`=============================================\n`);
14+
15+
const server = Bun.serve({
16+
port,
17+
async fetch(req) {
18+
const url = new URL(req.url);
19+
20+
// CORS Headers
21+
const headers = {
22+
"Access-Control-Allow-Origin": "*",
23+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, DELETE",
24+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
25+
};
26+
27+
if (req.method === "OPTIONS") {
28+
return new Response("OK", { status: 200, headers });
29+
}
30+
31+
try {
32+
// GET /api/files
33+
if (url.pathname === "/api/files" && req.method === "GET") {
34+
const files: string[] = [];
35+
const walk = (dir: string) => {
36+
let entries: string[];
37+
try {
38+
entries = readdirSync(dir);
39+
} catch {
40+
return;
41+
}
42+
for (const name of entries) {
43+
if (IGNORE_DIRS.has(name)) continue;
44+
const full = join(dir, name);
45+
let st;
46+
try {
47+
st = statSync(full);
48+
} catch {
49+
continue;
50+
}
51+
if (st.isDirectory()) {
52+
walk(full);
53+
} else {
54+
files.push(harness.workspace.rel(full));
55+
}
56+
}
57+
};
58+
walk(harness.workspace.root);
59+
return Response.json({ success: true, files }, { headers });
60+
}
61+
62+
// GET /api/status
63+
if (url.pathname === "/api/status" && req.method === "GET") {
64+
return Response.json({
65+
success: true,
66+
phase: harness.phase,
67+
cycle: harness.cycle,
68+
metrics: harness.metrics,
69+
activeSessionId: harness.sessions.active?.meta.id || null,
70+
}, { headers });
71+
}
72+
73+
// POST /api/query (Streaming SSE)
74+
if (url.pathname === "/api/query" && req.method === "POST") {
75+
const body = await req.json();
76+
const { prompt } = body as { prompt: string };
77+
if (!prompt) {
78+
return Response.json({ error: "Missing prompt" }, { status: 400, headers });
79+
}
80+
81+
let unsubscribe: (() => void) | null = null;
82+
const stream = new ReadableStream({
83+
start(controller) {
84+
unsubscribe = harness.events.on((event) => {
85+
try {
86+
controller.enqueue(`data: ${JSON.stringify(event)}\n\n`);
87+
} catch {
88+
// Client disconnected or stream closed
89+
}
90+
});
91+
92+
// Trigger query in background
93+
void harness.chatDirect(prompt).then(() => {
94+
try {
95+
controller.enqueue(`data: ${JSON.stringify({ type: "stream_done" })}\n\n`);
96+
} catch {
97+
/* ignore */
98+
}
99+
});
100+
},
101+
cancel() {
102+
if (unsubscribe) unsubscribe();
103+
}
104+
});
105+
106+
return new Response(stream, {
107+
headers: {
108+
...headers,
109+
"Content-Type": "text/event-stream",
110+
"Cache-Control": "no-cache",
111+
"Connection": "keep-alive",
112+
}
113+
});
114+
}
115+
116+
// POST /api/edit
117+
if (url.pathname === "/api/edit" && req.method === "POST") {
118+
const body = await req.json();
119+
const { path, content } = body as { path: string; content: string };
120+
if (!path || content === undefined) {
121+
return Response.json({ error: "Missing path or content" }, { status: 400, headers });
122+
}
123+
124+
try {
125+
const absPath = harness.workspace.resolve(path);
126+
mkdirSync(dirname(absPath), { recursive: true });
127+
writeFileSync(absPath, content, "utf8");
128+
harness.files.record("write", path, "api-edit", content);
129+
return Response.json({ success: true, message: `Wrote ${path} successfully.` }, { headers });
130+
} catch (err: any) {
131+
return Response.json({ success: false, error: err.message }, { status: 500, headers });
132+
}
133+
}
134+
135+
// POST /api/run (Streaming SSE)
136+
if (url.pathname === "/api/run" && req.method === "POST") {
137+
const body = await req.json();
138+
const { command } = body as { command: string };
139+
if (!command) {
140+
return Response.json({ error: "Missing command" }, { status: 400, headers });
141+
}
142+
143+
const blocked = [
144+
/rm\s+-rf\s+\/($|\s)/,
145+
/mkfs\./,
146+
/dd\s+if=.*of=\/dev\//,
147+
];
148+
for (const b of blocked) {
149+
if (b.test(command)) {
150+
return Response.json({ error: "Dangerous shell command blocked." }, { status: 403, headers });
151+
}
152+
}
153+
154+
const stream = new ReadableStream({
155+
start(controller) {
156+
const child = spawn(command, {
157+
shell: true,
158+
cwd: harness.workspace.root,
159+
env: { ...process.env, TERM: "dumb" }
160+
});
161+
162+
child.stdout?.on("data", (data) => {
163+
try {
164+
controller.enqueue(`data: ${JSON.stringify({ type: "stdout", text: data.toString() })}\n\n`);
165+
} catch {
166+
/* ignore */
167+
}
168+
});
169+
170+
child.stderr?.on("data", (data) => {
171+
try {
172+
controller.enqueue(`data: ${JSON.stringify({ type: "stderr", text: data.toString() })}\n\n`);
173+
} catch {
174+
/* ignore */
175+
}
176+
});
177+
178+
child.on("close", (code) => {
179+
try {
180+
controller.enqueue(`data: ${JSON.stringify({ type: "close", code })}\n\n`);
181+
controller.close();
182+
} catch {
183+
/* ignore */
184+
}
185+
});
186+
}
187+
});
188+
189+
return new Response(stream, {
190+
headers: {
191+
...headers,
192+
"Content-Type": "text/event-stream",
193+
"Cache-Control": "no-cache",
194+
"Connection": "keep-alive",
195+
}
196+
});
197+
}
198+
199+
return Response.json({ error: "Not Found" }, { status: 404, headers });
200+
} catch (err: any) {
201+
return Response.json({ error: err.message }, { status: 500, headers });
202+
}
203+
}
204+
});
205+
206+
console.log(`Server is listening on http://localhost:${port}`);
207+
return server;
208+
}

0 commit comments

Comments
 (0)