teapot-coding-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.
package/dist/master.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Master server: owns all agents and one low-frequency scheduler tick.
3
+ * Agents are in-process async loops (I/O bound only); all CPU-heavy work is
4
+ * delegated to subprocesses managed by the bash tool with hard timeouts.
5
+ */
6
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ import { Agent } from "./agent/agent.js";
10
+ import { parseSchedule, matches } from "./scheduler/cron.js";
11
+ const CONFIG_DIR = process.env.TEAPOT_CONFIG_DIR ??
12
+ path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "teapot-coding-agent");
13
+ const DATA_DIR = process.env.TEAPOT_DATA_DIR ??
14
+ process.env.XDG_DATA_HOME ??
15
+ path.join(os.homedir(), ".local", "share", "teapot-coding-agent");
16
+ const DEFAULT_CONFIG = {
17
+ port: Number(process.env.TEAPOT_PORT ?? 7788),
18
+ dataDir: DATA_DIR,
19
+ llm: {
20
+ baseUrl: process.env.TEAPOT_BASE_URL ?? "https://openrouter.ai/api/v1",
21
+ apiKey: process.env.TEAPOT_API_KEY ?? "",
22
+ model: process.env.TEAPOT_MODEL ?? "",
23
+ },
24
+ providers: {},
25
+ agents: [],
26
+ tasks: [],
27
+ };
28
+ /** Where config is looked up / stored. */
29
+ export function configDir() {
30
+ return CONFIG_DIR;
31
+ }
32
+ /**
33
+ * Resolution order:
34
+ * 1. explicit path argument (CLI)
35
+ * 2. $TEAPOT_CONFIG
36
+ * 3. ~/.config/teapot-coding-agent/config.json
37
+ * 4. ./teapot.config.json (legacy project-local)
38
+ */
39
+ export function resolveConfigPath(explicitPath) {
40
+ if (explicitPath)
41
+ return path.resolve(explicitPath);
42
+ if (process.env.TEAPOT_CONFIG)
43
+ return path.resolve(process.env.TEAPOT_CONFIG);
44
+ const xdg = path.join(CONFIG_DIR, "config.json");
45
+ if (existsSync(xdg))
46
+ return xdg;
47
+ return path.resolve("teapot.config.json");
48
+ }
49
+ let masterRawConfig = {};
50
+ export function loadConfig(configPath) {
51
+ if (!existsSync(configPath))
52
+ return DEFAULT_CONFIG;
53
+ mkdirSync(CONFIG_DIR, { recursive: true });
54
+ const user = JSON.parse(readFileSync(configPath, "utf8"));
55
+ masterRawConfig = user;
56
+ return {
57
+ ...DEFAULT_CONFIG,
58
+ ...user,
59
+ dataDir: user.dataDir ? path.resolve(user.dataDir.replace(/^~/, os.homedir())) : DEFAULT_CONFIG.dataDir,
60
+ llm: { ...DEFAULT_CONFIG.llm, ...user.llm },
61
+ providers: { ...user.providers },
62
+ };
63
+ }
64
+ /** Raw parsed user config (for lossless persistence of web edits). */
65
+ export function loadedRaw() {
66
+ return masterRawConfig;
67
+ }
68
+ export class Master {
69
+ config;
70
+ configPath;
71
+ agents = new Map();
72
+ tasks = [];
73
+ startedAt = Date.now();
74
+ constructor(config, configPath) {
75
+ this.config = config;
76
+ this.configPath = configPath;
77
+ }
78
+ /** Persist current logical config back to disk (lossless via raw user config). */
79
+ saveConfig() {
80
+ this.raw.agents = this.config.agents;
81
+ this.raw.providers = this.config.providers;
82
+ this.raw.defaultProvider = this.config.defaultProvider;
83
+ this.raw.tasks = this.config.tasks;
84
+ if (this.raw.progressIntervalMs === undefined && this.config.progressIntervalMs !== undefined)
85
+ this.raw.progressIntervalMs = this.config.progressIntervalMs;
86
+ writeFileSync(this.configPath, JSON.stringify(this.raw, null, 2) + "\n");
87
+ }
88
+ raw = loadedRaw();
89
+ /** Apply partial config edits from the web UI and persist them. */
90
+ updateConfig(patch) {
91
+ if (patch.providers)
92
+ this.config.providers = patch.providers;
93
+ if (patch.defaultProvider !== undefined)
94
+ this.config.defaultProvider = patch.defaultProvider;
95
+ if (patch.progressIntervalMs !== undefined) {
96
+ this.config.progressIntervalMs = patch.progressIntervalMs;
97
+ for (const a of this.agents.values())
98
+ a.opts.progressIntervalMs =
99
+ patch.progressIntervalMs;
100
+ }
101
+ if (patch.tasks) {
102
+ this.config.tasks = patch.tasks;
103
+ // rebuild schedule table live
104
+ this.tasks = patch.tasks.map((t) => ({
105
+ task: t,
106
+ schedule: parseSchedule(t.schedule),
107
+ lastRunMin: -1,
108
+ }));
109
+ }
110
+ this.saveConfig();
111
+ }
112
+ async start() {
113
+ mkdirSync(this.config.dataDir, { recursive: true });
114
+ for (const ac of this.config.agents) {
115
+ await this.addAgent(ac);
116
+ }
117
+ for (const t of this.config.tasks ?? []) {
118
+ this.tasks.push({
119
+ task: t,
120
+ schedule: parseSchedule(t.schedule),
121
+ lastRunMin: -1,
122
+ });
123
+ }
124
+ // single low-frequency tick for everything periodic (idle cost ≈ 0)
125
+ setInterval(() => void this.tick(), 15_000).unref();
126
+ }
127
+ /** Create an agent; optionally persist it to the config file. */
128
+ async addAgent(ac, persist = false) {
129
+ if (this.agents.has(ac.id))
130
+ throw new Error(`agent id already exists: ${ac.id}`);
131
+ // provider resolution: inline overrides > named provider > legacy llm block
132
+ const provName = ac.provider ?? this.config.defaultProvider ?? "openrouter";
133
+ const prov = this.config.providers?.[provName];
134
+ if (!prov && !ac.baseUrl && !this.config.llm.baseUrl) {
135
+ throw new Error(`agent ${ac.id}: unknown provider "${provName}" and no fallback`);
136
+ }
137
+ const llm = {
138
+ baseUrl: ac.baseUrl ?? prov?.baseUrl ?? this.config.llm.baseUrl,
139
+ apiKey: ac.apiKey ?? prov?.apiKey ?? this.config.llm.apiKey,
140
+ model: ac.model ?? prov?.model ?? this.config.llm.model,
141
+ timeoutMs: 120_000,
142
+ };
143
+ if (!llm.model)
144
+ throw new Error(`agent ${ac.id}: no model configured (set model on the agent or on its provider)`);
145
+ const logFile = path.join(this.config.dataDir, `${ac.id}.jsonl`);
146
+ const agent = new Agent({
147
+ id: ac.id,
148
+ workspace: path.resolve(ac.workspace),
149
+ llm,
150
+ logFile,
151
+ progressIntervalMs: this.config.progressIntervalMs,
152
+ autoContinue: true,
153
+ ...(this.config.contextTokenBudget ? { contextTokenBudget: this.config.contextTokenBudget } : {}),
154
+ globalSkillsDir: path.join(CONFIG_DIR, "skills"),
155
+ });
156
+ await agent.init();
157
+ this.agents.set(ac.id, agent);
158
+ if (persist) {
159
+ this.config.agents.push(ac);
160
+ this.saveConfig();
161
+ }
162
+ return agent;
163
+ }
164
+ async removeAgent(id) {
165
+ const agent = this.agents.get(id);
166
+ if (!agent)
167
+ throw new Error(`no such agent: ${id}`);
168
+ this.agents.delete(id);
169
+ await agent.dispose();
170
+ this.config.agents = this.config.agents.filter((a) => a.id !== id);
171
+ this.saveConfig();
172
+ }
173
+ /** 4 ticks/min; each tick is a few integer compares per task. */
174
+ tick() {
175
+ const now = new Date();
176
+ const minuteKey = Math.floor(now.getTime() / 60_000);
177
+ for (const t of this.tasks) {
178
+ try {
179
+ if (!matches(t.schedule, now))
180
+ continue;
181
+ if (t.lastRunMin === minuteKey)
182
+ continue; // dedupe within the same minute
183
+ t.lastRunMin = minuteKey;
184
+ const agent = this.agents.get(t.task.agent);
185
+ if (!agent)
186
+ continue;
187
+ console.log(`[teapot] scheduled task "${t.task.id}" -> agent ${t.task.agent}`);
188
+ void (async () => {
189
+ if (t.task.forked)
190
+ await agent.fork();
191
+ await agent.enqueuePrompt(t.task.prompt, `scheduler:${t.task.id}`);
192
+ if (agent.status !== "running")
193
+ agent.start(`scheduled:${t.task.id}`);
194
+ })();
195
+ }
196
+ catch (err) {
197
+ console.error(`[teapot] scheduler error (${t.task.id}):`, err.message);
198
+ }
199
+ }
200
+ }
201
+ metrics() {
202
+ const mu = process.memoryUsage();
203
+ const cu = process.cpuUsage();
204
+ return {
205
+ uptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
206
+ rssMb: +(mu.rss / 1048576).toFixed(1),
207
+ heapUsedMb: +(mu.heapUsed / 1048576).toFixed(1),
208
+ cpuMsTotal: cu.user + cu.system,
209
+ loadavg1: +(os.loadavg()[0]).toFixed(2),
210
+ agents: [...this.agents.values()].map((a) => ({
211
+ id: a.opts_id(),
212
+ turns: a.stats.turns,
213
+ toolCalls: a.stats.toolCalls,
214
+ inputTokens: a.stats.inputTokens,
215
+ outputTokens: a.stats.outputTokens,
216
+ })),
217
+ };
218
+ }
219
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Minimal 5-field cron matcher (minute hour day-of-month month day-of-week).
3
+ * Also supports shorthand "every <n>s|m|h".
4
+ * The master ticks once every 15s; matching is a handful of integer compares.
5
+ */
6
+ const BOUNDS = [
7
+ [0, 59],
8
+ [0, 23],
9
+ [1, 31],
10
+ [1, 12],
11
+ [0, 7], // 0 and 7 are both Sunday
12
+ ];
13
+ function parseField(part, min, max) {
14
+ const out = new Set();
15
+ for (const piece of part.split(",")) {
16
+ const [range, stepStr] = piece.split("/");
17
+ const step = Math.max(1, Number(stepStr ?? 1) || 1);
18
+ if (range === "*" || range === "*/" + step) {
19
+ for (let i = min; i <= max; i += step)
20
+ out.add(i);
21
+ }
22
+ else if (range.includes("-")) {
23
+ const [a, b] = range.split("-").map(Number);
24
+ if (Number.isNaN(a) || Number.isNaN(b))
25
+ throw new Error(`bad cron field: ${part}`);
26
+ for (let i = a; i <= b; i += step)
27
+ out.add(i);
28
+ }
29
+ else {
30
+ const v = Number(range);
31
+ if (Number.isNaN(v))
32
+ throw new Error(`bad cron field: ${part}`);
33
+ if (!stepStr || step === 1) {
34
+ out.add(v);
35
+ }
36
+ else {
37
+ for (let i = v; i <= max; i += step)
38
+ out.add(i);
39
+ }
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+ export function parseSchedule(spec) {
45
+ const every = spec.trim().match(/^every\s+(\d+)\s*(s|m|h)$/i);
46
+ let effective = spec.trim();
47
+ if (every) {
48
+ const n = Number(every[1]);
49
+ const unit = every[2].toLowerCase();
50
+ if (unit === "s") {
51
+ const m = Math.max(1, Math.round(n / 60));
52
+ effective = `*/${m} * * * *`;
53
+ }
54
+ else if (unit === "m") {
55
+ effective = `*/${n} * * * *`;
56
+ }
57
+ else {
58
+ effective = `0 */${n} * * *`;
59
+ }
60
+ }
61
+ const parts = effective.split(/\s+/);
62
+ if (parts.length !== 5)
63
+ throw new Error(`bad schedule: ${spec}`);
64
+ return {
65
+ raw: spec,
66
+ fields: parts.map((p, i) => parseField(p, BOUNDS[i][0], BOUNDS[i][1])),
67
+ };
68
+ }
69
+ /** Does this schedule fire at the given Date? */
70
+ export function matches(schedule, d) {
71
+ const values = [d.getMinutes(), d.getHours(), d.getDate(), d.getMonth() + 1, d.getDay()];
72
+ for (let i = 0; i < 5; i++) {
73
+ if (!schedule.fields[i].has(values[i])) {
74
+ // Sunday alias: field 5 with 7 should also match day=0
75
+ if (i === 4 && values[i] === 0 && schedule.fields[i].has(7))
76
+ continue;
77
+ return false;
78
+ }
79
+ }
80
+ return true;
81
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * REST + SSE API on Hono, plus static file serving for the web UI.
3
+ */
4
+ import { Hono } from "hono";
5
+ import { serve } from "@hono/node-server";
6
+ import { readFileSync } from "node:fs";
7
+ import { promises as fs } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { parseSchedule } from "../scheduler/cron.js";
10
+ import path from "node:path";
11
+ import { bus } from "../bus.js";
12
+ import { readEvents } from "../log/events.js";
13
+ export function buildApp(master) {
14
+ const app = new Hono();
15
+ // ---- agents ----
16
+ app.get("/api/agents", (c) => c.json({ agents: [...master.agents.values()].map((a) => a.snapshot()) }));
17
+ // create + start an agent on an arbitrary directory
18
+ app.post("/api/agents", async (c) => {
19
+ const body = await c.req.json();
20
+ if (!body.workspace?.trim())
21
+ return c.json({ error: "workspace required" }, 400);
22
+ const ws = path.resolve(body.workspace.replace(/^~/, process.env.HOME ?? "~"));
23
+ try {
24
+ const st = await fs.stat(ws);
25
+ if (!st.isDirectory())
26
+ return c.json({ error: "not a directory" }, 400);
27
+ }
28
+ catch {
29
+ return c.json({ error: `directory not found: ${ws}` }, 400);
30
+ }
31
+ const id = (body.id?.trim() || path.basename(ws)).replace(/[^\w.-]/g, "-").slice(0, 40);
32
+ try {
33
+ const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, true);
34
+ if (body.start !== false)
35
+ agent.start("created via web");
36
+ return c.json({ ok: true, agent: agent.snapshot() });
37
+ }
38
+ catch (err) {
39
+ return c.json({ error: err.message }, 400);
40
+ }
41
+ });
42
+ // remove agent (log file is kept)
43
+ app.delete("/api/agents/:id", async (c) => {
44
+ try {
45
+ await master.removeAgent(c.req.param("id"));
46
+ return c.json({ ok: true });
47
+ }
48
+ catch (err) {
49
+ return c.json({ error: err.message }, 404);
50
+ }
51
+ });
52
+ // ---- filesystem browsing (read-only, for the workspace picker) ----
53
+ app.get("/api/fs", async (c) => {
54
+ let p = c.req.query("path") || process.env.HOME || "/";
55
+ p = path.resolve(p.replace(/^~/, process.env.HOME ?? "~"));
56
+ try {
57
+ const entries = await fs.readdir(p, { withFileTypes: true });
58
+ return c.json({
59
+ path: p,
60
+ parent: path.dirname(p),
61
+ entries: entries
62
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
63
+ .slice(0, 500)
64
+ .map((e) => e.name)
65
+ .sort(),
66
+ });
67
+ }
68
+ catch {
69
+ return c.json({ error: "cannot read" }, 400);
70
+ }
71
+ });
72
+ // ---- config (view/edit from the web UI) ----
73
+ app.get("/api/config", (c) => {
74
+ const mask = (p) => Object.fromEntries(Object.entries(p ?? {}).map(([k, v]) => [
75
+ k,
76
+ { ...v, apiKey: v.apiKey ? "•••" + String(v.apiKey).slice(-4) : undefined },
77
+ ]));
78
+ return c.json({
79
+ configPath: master.configPath,
80
+ providers: mask(master.config.providers),
81
+ defaultProvider: master.config.defaultProvider,
82
+ progressIntervalMs: master.config.progressIntervalMs,
83
+ tasks: master.config.tasks,
84
+ agents: master.config.agents.map((a) => ({ id: a.id, workspace: a.workspace, provider: a.provider, model: a.model })),
85
+ });
86
+ });
87
+ app.put("/api/config", async (c) => {
88
+ const body = await c.req.json().catch(() => null);
89
+ if (!body)
90
+ return c.json({ error: "invalid JSON" }, 400);
91
+ try {
92
+ // validate schedules before applying anything
93
+ for (const t of body.tasks ?? [])
94
+ parseSchedule(t.schedule);
95
+ // keep masked keys intact: "•••1234" means "unchanged"
96
+ const prev = master.config.providers ?? {};
97
+ const providers = {};
98
+ for (const [name, p] of Object.entries(body.providers ?? {})) {
99
+ const masked = !p.apiKey || p.apiKey.startsWith("•••");
100
+ providers[name] = {
101
+ baseUrl: p.baseUrl,
102
+ apiKey: masked ? prev[name]?.apiKey : p.apiKey,
103
+ ...(p.model ? { model: p.model } : {}),
104
+ };
105
+ }
106
+ master.updateConfig({
107
+ providers,
108
+ defaultProvider: body.defaultProvider,
109
+ progressIntervalMs: body.progressIntervalMs,
110
+ tasks: body.tasks,
111
+ });
112
+ return c.json({ ok: true });
113
+ }
114
+ catch (err) {
115
+ return c.json({ error: err.message }, 400);
116
+ }
117
+ });
118
+ app.get("/api/agents/:id", (c) => {
119
+ const a = master.agents.get(c.req.param("id"));
120
+ return a ? c.json(a.snapshot()) : c.json({ error: "not found" }, 404);
121
+ });
122
+ app.post("/api/agents/:id/prompt", async (c) => {
123
+ const a = master.agents.get(c.req.param("id"));
124
+ if (!a)
125
+ return c.json({ error: "not found" }, 404);
126
+ const body = await c.req.json();
127
+ if (!body.text?.trim())
128
+ return c.json({ error: "text required" }, 400);
129
+ await a.enqueuePrompt(body.text, "user");
130
+ if (body.start !== false && a.status !== "running")
131
+ a.start("prompt");
132
+ return c.json({ ok: true });
133
+ });
134
+ app.post("/api/agents/:id/start", (c) => {
135
+ const a = master.agents.get(c.req.param("id"));
136
+ if (!a)
137
+ return c.json({ error: "not found" }, 404);
138
+ a.start("api start");
139
+ return c.json({ ok: true });
140
+ });
141
+ app.post("/api/agents/:id/stop", (c) => {
142
+ const a = master.agents.get(c.req.param("id"));
143
+ if (!a)
144
+ return c.json({ error: "not found" }, 404);
145
+ a.stop("stopped via api");
146
+ return c.json({ ok: true });
147
+ });
148
+ app.post("/api/agents/:id/goal", async (c) => {
149
+ const a = master.agents.get(c.req.param("id"));
150
+ if (!a)
151
+ return c.json({ error: "not found" }, 404);
152
+ const body = await c.req.json();
153
+ if (body.text)
154
+ await a.setGoal(body.text);
155
+ else if (body.status)
156
+ await a.setGoalStatus(body.status);
157
+ else
158
+ return c.json({ error: "text or status required" }, 400);
159
+ return c.json({ ok: true });
160
+ });
161
+ app.post("/api/agents/:id/fork", async (c) => {
162
+ const a = master.agents.get(c.req.param("id"));
163
+ if (!a)
164
+ return c.json({ error: "not found" }, 404);
165
+ const body = await c.req.json().catch(() => ({ fromEvent: null }));
166
+ const r = await a.fork(body.fromEvent ?? null);
167
+ return c.json({ ok: true, ...r });
168
+ });
169
+ // ---- event log access (human/inspect friendly) ----
170
+ app.get("/api/agents/:id/events", async (c) => {
171
+ const a = master.agents.get(c.req.param("id"));
172
+ if (!a)
173
+ return c.json({ error: "not found" }, 404);
174
+ const limit = Math.min(Number(c.req.query("limit") ?? 200), 5000);
175
+ let events = await readEvents(a.log.filePath);
176
+ const branch = c.req.query("branch");
177
+ const session = c.req.query("session");
178
+ if (branch)
179
+ events = events.filter((e) => e.branch === branch);
180
+ if (session)
181
+ events = events.filter((e) => e.session === session);
182
+ return c.json({ events: events.slice(-limit), total: events.length });
183
+ });
184
+ app.get("/api/agents/:id/branches", async (c) => {
185
+ const a = master.agents.get(c.req.param("id"));
186
+ if (!a)
187
+ return c.json({ error: "not found" }, 404);
188
+ const events = await readEvents(a.log.filePath);
189
+ const branches = new Map();
190
+ for (const e of events) {
191
+ const b = branches.get(e.branch) ?? { branch: e.branch, events: 0 };
192
+ b.events++;
193
+ if (e.type === "fork")
194
+ b.forkedFrom = e.data;
195
+ branches.set(e.branch, b);
196
+ }
197
+ return c.json({ branches: [...branches.values()] });
198
+ });
199
+ // ---- metrics / SSE ----
200
+ app.get("/api/metrics", (c) => c.json(master.metrics()));
201
+ app.get("/api/events", (c) => {
202
+ c.header("content-type", "text/event-stream");
203
+ c.header("cache-control", "no-cache");
204
+ c.header("connection", "keep-alive");
205
+ const stream = new ReadableStream({
206
+ start(controller) {
207
+ const enc = new TextEncoder();
208
+ const send = (data) => controller.enqueue(enc.encode(`data: ${JSON.stringify(data)}\n\n`));
209
+ send({ kind: "hello", agents: [...master.agents.values()].map((a) => a.snapshot()) });
210
+ const onUpdate = (ev) => send(ev);
211
+ bus.on("update", onUpdate);
212
+ // keep-alive comment every 30s so proxies don't close the stream
213
+ const ka = setInterval(() => controller.enqueue(enc.encode(": ping\n\n")), 30_000);
214
+ ;
215
+ controller._cleanup = () => {
216
+ clearInterval(ka);
217
+ bus.off("update", onUpdate);
218
+ };
219
+ },
220
+ cancel() {
221
+ /* handled in _cleanup via abort signal below */
222
+ },
223
+ });
224
+ c.req.raw.signal.addEventListener("abort", () => {
225
+ /* node-server closes the stream; cleanup runs on cancel */
226
+ });
227
+ return c.body(stream);
228
+ });
229
+ // RFC 2324 / HTCPCP compliance
230
+ app.on(["GET", "POST", "BREW"], "/brew", (c) => c.text("418 I'm a teapot \u{1FAD6}", 418));
231
+ app.on(["GET", "POST", "BREW"], "/brew/coffee", (c) => c.text("418 I'm a teapot — coffee not supported (see RFC 2324 §2.3.2)", 418));
232
+ // ---- web ui (built by vite into ./public; no bundler needed to serve) ----
233
+ const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), import.meta.url.includes("/dist/") ? "../../public" : "../../public");
234
+ const mime = {
235
+ ".html": "text/html",
236
+ ".js": "text/javascript",
237
+ ".css": "text/css",
238
+ ".svg": "image/svg+xml",
239
+ ".ico": "image/x-icon",
240
+ };
241
+ app.get("/", (c) => {
242
+ try {
243
+ return c.html(readFileSync(path.join(webRoot, "index.html"), "utf8"));
244
+ }
245
+ catch {
246
+ return c.text("web UI not built — run: pnpm build-web", 404);
247
+ }
248
+ });
249
+ app.get("/assets/*", (c) => {
250
+ const rel = c.req.path.replace("/assets/", "");
251
+ const file = path.resolve(webRoot, "assets", path.basename(rel)); // basename: no traversal
252
+ try {
253
+ return c.body(readFileSync(file), 200, {
254
+ "content-type": mime[path.extname(file)] ?? "application/octet-stream",
255
+ });
256
+ }
257
+ catch {
258
+ return c.notFound();
259
+ }
260
+ });
261
+ return app;
262
+ }
263
+ export function serveApp(app, port) {
264
+ serve({ fetch: app.fetch, port }, (info) => {
265
+ console.log(`[teapot] master listening on http://localhost:${info.port}`);
266
+ });
267
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "teapot-coding-agent",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
+ "type": "module",
6
+ "license": "AGPL-3.0-or-later",
7
+ "keywords": [
8
+ "ai",
9
+ "agent",
10
+ "coding-agent",
11
+ "llm",
12
+ "harness",
13
+ "autonomous-agents"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/akku1139/teapot.git"
18
+ },
19
+ "packageManager": "pnpm@11.3.0",
20
+ "engines": {
21
+ "node": ">=22"
22
+ },
23
+ "bin": {
24
+ "teapot": "dist/index.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "public",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.json && vite build",
33
+ "build-server": "tsc -p tsconfig.json",
34
+ "build-web": "vite build",
35
+ "start": "node dist/index.js",
36
+ "dev": "tsx src/index.ts",
37
+ "dev-web": "vite",
38
+ "test": "node --import tsx --test test/*.test.ts"
39
+ },
40
+ "dependencies": {
41
+ "@hono/node-server": "^1.14.0",
42
+ "hono": "^4.7.0",
43
+ "openai": "^5.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^24.0.0",
47
+ "solid-js": "^1.9.15",
48
+ "tsx": "^4.20.0",
49
+ "typescript": "^5.8.0",
50
+ "vite": "^8.2.2",
51
+ "vite-plugin-solid": "^2.11.14"
52
+ }
53
+ }
@@ -0,0 +1 @@
1
+ :root{--bg-darkest:#1a1c22;--bg-dark:#232630;--bg-mid:#2b2e39;--bg-light:#343845;--fg:#dcdee4;--dim:#9298a5;--line:#1d2027;--acc:#5865f2;--ok:#3ba55d;--warn:#faa81a;--err:#ed4245;--tool:#3ba0c9;font-family:gg sans,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg-dark);color:var(--fg);font-size:15px}.layout{grid-template-columns:250px 1fr 300px;height:100vh;display:grid}@media (width<=1100px){.layout{grid-template-columns:220px 1fr}.rightbar{display:none}}.sidebar{background:var(--bg-darkest);padding:10px 8px;overflow-y:auto}.sidebar h1{color:var(--fg);margin:0;padding:4px 8px 10px;font-size:14px}.agent-item{cursor:pointer;color:var(--dim);border-radius:6px;align-items:center;gap:8px;margin-bottom:2px;padding:7px 10px;display:flex}.agent-item:hover{background:var(--bg-mid)}.agent-item.sel{background:var(--bg-mid);color:var(--fg)}.dot{border-radius:50%;flex-shrink:0;width:9px;height:9px}.dot.running{background:var(--ok);box-shadow:0 0 6px var(--ok)}.dot.idle{background:var(--warn)}.dot.stopped{background:var(--dim)}.dot.error{background:var(--err);box-shadow:0 0 6px var(--err)}.sidebar .metrics{color:var(--dim);border-top:1px solid var(--line);margin-top:12px;padding:8px 10px;font-size:11px;line-height:1.7}.channel{flex-direction:column;min-width:0;display:flex}.chan-head{border-bottom:2px solid var(--line);background:var(--bg-dark);align-items:center;gap:10px;padding:10px 16px;display:flex}.chan-head .hash{color:var(--dim);font-size:20px}.chan-head .title{font-weight:700}.chan-head .sub{color:var(--dim);text-overflow:ellipsis;white-space:nowrap;margin-left:8px;font-size:12px;overflow:hidden}.badge{background:var(--bg-light);vertical-align:middle;border-radius:10px;padding:1px 8px;font-size:11px}.badge.running{color:var(--ok)}.badge.error{color:var(--err)}.badge.idle{color:var(--warn)}.badge.done{color:var(--acc)}.feed{flex:1;padding:14px 0 8px;overflow-y:auto}.msg{gap:14px;padding:3px 18px;display:flex}.msg:hover{background:#ffffff08}.msg.grouped{padding-top:0}.avatar{border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;width:38px;height:38px;margin-top:2px;font-size:17px;display:flex}.msg-body{flex:1;min-width:0}.msg-head{align-items:baseline;gap:8px;display:flex}.author{font-size:14.5px;font-weight:600}.ts{color:var(--dim);font-size:11px}.content{white-space:pre-wrap;word-break:break-word;line-height:1.45}.content p{margin:2px 0}.content pre{background:var(--bg-darkest);border-radius:6px;padding:8px;overflow-x:auto}.content code{background:var(--bg-darkest);border-radius:4px;padding:1px 4px;font-size:13px}.content h1,.content h2,.content h3,.content h4{margin:8px 0 2px;font-size:15px}.embed{border-left:3px solid var(--tool);background:var(--bg-darkest);border-radius:4px;margin-top:3px;padding:6px 10px;font-size:13.5px}.embed.fail{border-color:var(--err)}.embed .mono{white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,monospace;font-size:12.5px}.embed .meta{color:var(--dim);margin-top:3px;font-size:11px}.divider-msg{color:var(--dim);align-items:center;gap:10px;padding:4px 18px;font-size:11.5px;display:flex}.divider-msg:before,.divider-msg:after{content:"";background:var(--line);flex:1;height:1px}.divider-msg.err{color:var(--err)}.day-divider{align-items:center;gap:10px;padding:14px 18px 6px;display:flex}.day-divider:before,.day-divider:after{content:"";background:var(--bg-light);flex:1;height:1px}.day-divider span{color:var(--dim);font-size:11px}.composer{padding:0 16px 18px}.composer form{background:var(--bg-light);border-radius:10px;align-items:center;gap:8px;padding:10px 12px;display:flex}.composer input[type=text]{color:var(--fg);font:inherit;background:0 0;border:none;outline:none;flex:1}.composer button{background:var(--acc);color:#fff;cursor:pointer;border:none;border-radius:8px;padding:7px 14px;font-weight:600}.composer button:hover{opacity:.9}.composer label{color:var(--dim);white-space:nowrap;align-items:center;gap:4px;font-size:12px;display:flex}.hint{color:var(--dim);margin-top:5px;font-size:11px}.rightbar{background:var(--bg-dark);border-left:2px solid var(--line);padding:14px;font-size:13px;overflow-y:auto}.rightbar h3{text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin:14px 0 6px;font-size:11px}.rightbar h3:first-child{margin-top:0}.card{background:var(--bg-darkest);white-space:pre-wrap;word-break:break-word;border-radius:8px;max-height:200px;padding:10px;overflow-y:auto}.muted{color:var(--dim)}.branch-row{color:var(--dim);cursor:pointer;justify-content:space-between;padding:3px 0;font-size:12px;display:flex}.branch-row:hover,.branch-row.cur{color:var(--fg)}.btnrow{gap:6px;margin:8px 0;display:flex}.btnrow button{background:var(--bg-light);color:var(--fg);cursor:pointer;border:none;border-radius:6px;padding:6px 10px;font-size:13px}.btnrow button:hover{filter:brightness(1.2)}.iconbtn{background:var(--bg-light);color:var(--fg);cursor:pointer;border:none;border-radius:6px;width:26px;height:24px;font-size:13px}.iconbtn:hover{filter:brightness(1.3)}.overlay{z-index:10;background:#0009;place-items:center;display:grid;position:fixed;inset:0}.modal{background:var(--bg-mid);border-radius:10px;width:min(620px,92vw);max-height:88vh;padding:16px 18px;overflow-y:auto}.modal-head{justify-content:space-between;align-items:center;margin-bottom:12px;font-size:16px;display:flex}.modal label{color:var(--dim);flex-direction:column;gap:4px;font-size:12.5px;display:flex}.modal input[type=text],.modal input[type=number],.modal select,.modal textarea{background:var(--bg-darkest);color:var(--fg);font:inherit;border:none;border-radius:6px;outline:none;padding:7px 9px}.modal textarea{resize:vertical}.w100{width:100%}.mono{font-family:ui-monospace,Menlo,monospace;font-size:13px}.dirlist{background:var(--bg-darkest);border-radius:6px;max-height:160px;padding:4px;overflow-y:auto}.direntry{cursor:pointer;border-radius:4px;padding:4px 8px;font-size:14px}.direntry:hover{background:var(--bg-light)}