teapot-coding-agent 0.4.0 → 0.6.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/README.md CHANGED
@@ -13,6 +13,11 @@ tab, and any number of long-running agents.
13
13
  (`frontend/`, built to `public/`): agents as channels, events flowing as chat
14
14
  messages, tool calls as compact embeds. Markdown is rendered by a hand-written,
15
15
  XSS-safe renderer (`frontend/md.js`)
16
+ - **Integrated terminal** — humans get an interactive shell (xterm.js over
17
+ WebSocket) inside the selected agent's workspace: inspect what the agent
18
+ did, run tests, fix things alongside it. Zero native dependencies — the PTY
19
+ comes from util-linux `script` when available (colors, line editing,
20
+ ctrl+c), with a plain-pipe fallback elsewhere.
16
21
  - **Human-readable persistence** — append-only JSONL event logs you can read
17
22
  with `cat` / `jq`; goal & memory as plain Markdown files in git
18
23
 
@@ -105,13 +110,34 @@ master (Hono server, src/master.ts + src/server/api.ts)
105
110
  never hard-coded.
106
111
  - **Tools** (`src/agent/tools.ts`): provider-agnostic JSON-schema function
107
112
  specs — `read_file`, `write_file`, `edit_file`, `list_dir`, `bash` (git goes
108
- through bash), plus meta tools `finish` / `report_progress`. Paths are
109
- confined to the workspace; bash runs detached in its own process group and
110
- the whole group is SIGKILLed on timeout.
111
- - **Goal / knowledge**: `GOAL.md` (goal + status), `AGENTS.md` (project
112
- knowledge), `MEMORY.md` (agent notes) live in each workspace as normal git-
113
- editable Markdown. The goal file is the source of truth; the harness re-reads
114
- it on restart.
113
+ through bash), plus meta tools `finish` / `report_progress` / `set_goal` /
114
+ `set_memory`. Paths are confined to the workspace; bash runs detached in its
115
+ own process group and the whole group is SIGKILLed on timeout.
116
+ - **Session storage** everything teapot manages lives under
117
+ `<dataDir>/sessions/<sid>/` (`chat.jsonl`, `goal.md`, `memory.md`), so agent
118
+ workspaces stay clean. Each incarnation gets a fresh `<agentId>-<uuid>`
119
+ directory (no history leaks across projects); restarts reuse the latest one.
120
+ Legacy layouts are migrated automatically.
121
+ - **Goal / knowledge** — goal + memory are harness-managed and injected into
122
+ the prompt every turn (agents update them via `set_goal` / `set_memory`);
123
+ `AGENTS.md` is optional project knowledge in the workspace root that is
124
+ injected when present. Nothing is seeded into your project anymore.
125
+
126
+ ### Web UI
127
+
128
+ - **Sessions as channels** — chat feed with live-streamed LLM output (💭
129
+ reasoning collapsible), tool calls/results as expandable embeds, progress
130
+ reports and state changes as dividers
131
+ - **Deep links** — every session has a URL (`http://localhost:7788/session/<id>`);
132
+ the last open session is remembered
133
+ - **Details panel** (`d`) — session info, model switcher (provider select +
134
+ OpenAI-compatible `GET /models` autocomplete; applies to the running
135
+ session from the next turn), controls, goal editor, progress, runtime stats
136
+ - **Terminal** (`t`) — interactive shell in the agent's workspace for humans,
137
+ rendered with xterm.js over WebSocket
138
+ - **Keyboard** — `↑`/`↓` switch sessions · `/` focus composer · `t` terminal ·
139
+ `d` panel · `esc` interrupt a running agent
140
+ - Realtime updates flow over WebSocket (`/api/ws`) with auto-reconnect
115
141
 
116
142
  ### Agent Skills
117
143
 
@@ -141,13 +167,14 @@ description: Steps to cut a release safely
141
167
 
142
168
  ### Session log format (JSONL)
143
169
 
144
- One file per agent (`dataDir/<agent>.jsonl`); every conversation *including
145
- forks* lives in the same interleaved stream:
170
+ One file per session (`<dataDir>/sessions/<sid>/chat.jsonl`, sid =
171
+ `<agentId>-<uuid8>`); every conversation *including forks* lives in the same
172
+ interleaved stream:
146
173
 
147
174
  ```json
148
- {"v":1,"id":"e27","seq":27,"ts":"…","agent":"alpha","session":"sess-alpha-main",
175
+ {"v":1,"id":"e27","seq":27,"ts":"…","agent":"alpha","session":"alpha-9f3c21ab",
149
176
  "branch":"br032sl","parent":"e26","type":"fork",
150
- "data":{"fromSession":"sess-alpha-main","fromBranch":"br0","fromEvent":"e26","newBranch":"br032sl"}}
177
+ "data":{"fromSession":"alpha-9f3c21ab","fromBranch":"br0","fromEvent":"e26","newBranch":"br032sl"}}
151
178
  ```
152
179
 
153
180
  - every event carries `session`, `branch`, `parent` (previous event on the same branch), monotonic `seq`
@@ -14,12 +14,17 @@ import { executeTool, toolSpecs, currentSkills } from "./tools.js";
14
14
  import { bus } from "../bus.js";
15
15
  const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
16
16
 
17
- ## Persistent context files (human-readable, git-tracked)
18
- - AGENTS.md : project knowledge/conventions written for agents (read it first)
19
- - GOAL.md : your current long-term goal and its status
20
- - MEMORY.md : durable notes you write for yourself
17
+ ## Goal (managed by the harness)
18
+ The current goal is injected into this prompt by the harness. Change it with
19
+ set_goal(text) when the objective itself changes; call finish(goalComplete=true)
20
+ only when the goal is fully achieved.
21
21
 
22
- Keep these files updated with edit_file/write_file. They survive restarts.
22
+ ## Persistent context (all optional create only if useful)
23
+ - AGENTS.md (workspace root): project knowledge/conventions. When present, the
24
+ harness injects it into this prompt; keep it current with edit_file/write_file.
25
+ - memory.md: your durable notes, harness-managed — update them with
26
+ set_memory(content); they are injected into future prompts.
27
+ - skills/: reusable playbooks via load_skill / save_skill.
23
28
 
24
29
  ## Rules
25
30
  - Work step by step with tools. Verify results (run tests/builds) before claiming progress.
@@ -66,7 +71,7 @@ export class Agent {
66
71
  provider: "",
67
72
  ...opts,
68
73
  };
69
- this.log = new EventLog(opts.logFile, opts.id);
74
+ this.log = new EventLog(path.join(opts.sessionDir, "chat.jsonl"), opts.id);
70
75
  this.skillRoots = [
71
76
  { dir: path.join(opts.workspace, "skills"), source: "workspace" },
72
77
  ...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
@@ -77,7 +82,8 @@ export class Agent {
77
82
  maxOutputBytes: 60_000,
78
83
  skillRoots: this.skillRoots,
79
84
  };
80
- this.mainSession = `sess-${opts.id}-main`;
85
+ // the session id IS the directory name — one directory per incarnation
86
+ this.mainSession = path.basename(opts.sessionDir);
81
87
  this.currentSession = this.mainSession;
82
88
  }
83
89
  skillRoots;
@@ -124,18 +130,49 @@ export class Agent {
124
130
  async init() {
125
131
  await this.log.load();
126
132
  await fs.mkdir(this.workspace, { recursive: true });
127
- // seed persistent context files if missing
128
- await this.seed("AGENTS.md", "# Project knowledge\n\n(Describe conventions, build commands, and gotchas here.)\n");
129
- await this.seed("MEMORY.md", "# Memory\n");
130
- const goalText = await this.readGoalFile();
131
- if (goalText !== null)
132
- this.goal = this.parseGoalFile(goalText);
133
- else
134
- await this.writeGoalFile();
133
+ // goal lives next to the session log (dataDir), NOT in the workspace —
134
+ // migrate a legacy workspace GOAL.md once, then never touch the workspace
135
+ await this.migrateGoalFromWorkspace();
136
+ const stored = await this.readGoalStore();
137
+ this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
135
138
  if (this.opts.restoreSession)
136
139
  await this.restoreFromLog();
137
140
  await this.refreshSkills();
138
141
  }
142
+ /** harness-managed files inside the session directory */
143
+ get goalFile() {
144
+ return path.join(this.opts.sessionDir, "goal.md");
145
+ }
146
+ get memoryFile() {
147
+ return path.join(this.opts.sessionDir, "memory.md");
148
+ }
149
+ async readGoalStoreRaw() {
150
+ return fs.readFile(this.goalFile, "utf8").catch(() => null);
151
+ }
152
+ async readGoalStore() {
153
+ const raw = await this.readGoalStoreRaw();
154
+ return raw === null ? null : this.parseGoalFile(raw);
155
+ }
156
+ /** One-time import of a pre-0.6.0 workspace GOAL.md; content is preserved. */
157
+ async migrateGoalFromWorkspace() {
158
+ const legacy = path.join(this.workspace, "GOAL.md");
159
+ let wsText;
160
+ try {
161
+ wsText = await fs.readFile(legacy, "utf8");
162
+ }
163
+ catch {
164
+ return; // nothing to migrate
165
+ }
166
+ const existing = await this.readGoalStoreRaw();
167
+ if (existing === null)
168
+ await fs.writeFile(this.goalFile, wsText, "utf8");
169
+ await fs.rm(legacy).catch(() => { });
170
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
171
+ event: "goal-migrated",
172
+ from: "GOAL.md",
173
+ to: this.goalFile,
174
+ });
175
+ }
139
176
  /**
140
177
  * Rebuild the in-memory conversation from the JSONL event log so a restart
141
178
  * continues where the agent left off instead of starting blank.
@@ -237,24 +274,20 @@ export class Agent {
237
274
  });
238
275
  }
239
276
  }
240
- async seed(file, content) {
241
- const p = path.join(this.workspace, file);
242
- try {
243
- await fs.access(p);
244
- }
245
- catch {
246
- await fs.writeFile(p, content, "utf8");
247
- }
248
- }
249
- readGoalFile() {
250
- return fs.readFile(path.join(this.workspace, "GOAL.md"), "utf8").catch(() => null);
251
- }
252
277
  parseGoalFile(text) {
253
278
  // humans and agents may append their own status lines — latest wins
254
279
  const all = [...text.matchAll(/status:\s*(\w+)/gi)];
255
280
  const last = all[all.length - 1]?.[1];
256
281
  const status = last === "done" ? "done" : last === "paused" ? "paused" : "active";
257
- return { text: text.trim(), status, updatedAt: new Date().toISOString() };
282
+ // keep bookkeeping lines out of the injected goal text
283
+ let body = text.trim();
284
+ for (let i = 0; i < 4; i++) {
285
+ const stripped = body.replace(/\n+(?:status|updated):[^\n]*$/i, "").trimEnd();
286
+ if (stripped === body)
287
+ break;
288
+ body = stripped;
289
+ }
290
+ return { text: body, status, updatedAt: new Date().toISOString() };
258
291
  }
259
292
  async writeGoalFile() {
260
293
  // don't let previously-appended bookkeeping lines accumulate in the body
@@ -265,7 +298,7 @@ export class Agent {
265
298
  break;
266
299
  body = stripped;
267
300
  }
268
- await fs.writeFile(path.join(this.workspace, "GOAL.md"), `${body}\n\nstatus: ${this.goal.status}\nupdated: ${this.goal.updatedAt}\n`, "utf8");
301
+ await fs.writeFile(this.goalFile, `${body}\n\nstatus: ${this.goal.status}\nupdated: ${this.goal.updatedAt}\n`, "utf8");
269
302
  }
270
303
  async setGoal(text) {
271
304
  this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
@@ -290,6 +323,7 @@ export class Agent {
290
323
  stats: { ...this.stats },
291
324
  model: this.opts.llm.model,
292
325
  provider: this.opts.provider,
326
+ sessionDir: this.opts.sessionDir,
293
327
  };
294
328
  }
295
329
  /** Queue a user prompt; wakes the loop if needed. Returns immediately. */
@@ -354,7 +388,7 @@ export class Agent {
354
388
  await this.sleepInterruptible(this.opts.continueDelayMs);
355
389
  if (this.stopRequested)
356
390
  break;
357
- const nudge = "Continue working toward the goal in GOAL.md. If you are blocked, explain why briefly.";
391
+ const nudge = "Continue working toward the current goal. If you are blocked, explain why briefly.";
358
392
  await this.log.append("prompt", this.currentSession, this.currentBranch, {
359
393
  source: "harness",
360
394
  text: nudge,
@@ -445,7 +479,7 @@ export class Agent {
445
479
  turn: ++this.stats.turns,
446
480
  });
447
481
  // stream the assistant reply live to connected clients
448
- const res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
482
+ const res = await this.llmCall(await this.buildMessages(), allToolSpecs(), (s) => {
449
483
  bus.emit("update", {
450
484
  kind: "llm-delta",
451
485
  agentId: this.opts.id,
@@ -491,6 +525,29 @@ export class Agent {
491
525
  });
492
526
  continue;
493
527
  }
528
+ if (call.function.name === "set_goal") {
529
+ const a = safeParse(call.function.arguments);
530
+ const text = String(a.text ?? "").trim();
531
+ if (text)
532
+ await this.setGoal(text);
533
+ this.messages.push({
534
+ role: "tool",
535
+ tool_call_id: call.id,
536
+ content: text ? "goal updated" : "empty goal rejected",
537
+ });
538
+ continue;
539
+ }
540
+ if (call.function.name === "set_memory") {
541
+ const a = safeParse(call.function.arguments);
542
+ const content = String(a.content ?? "").slice(0, 32_000);
543
+ await fs.writeFile(this.memoryFile, content, "utf8");
544
+ this.messages.push({
545
+ role: "tool",
546
+ tool_call_id: call.id,
547
+ content: "memory saved (injected into future prompts)",
548
+ });
549
+ continue;
550
+ }
494
551
  await this.log.append("tool_call", this.currentSession, this.currentBranch, {
495
552
  callId: call.id,
496
553
  name: call.function.name,
@@ -517,11 +574,34 @@ export class Agent {
517
574
  }
518
575
  throw new Error("runaway detection: too many turns in one round (>200)");
519
576
  }
520
- buildMessages() {
577
+ /** AGENTS.md is optional project knowledge — injected when present. */
578
+ agentsMdCache = null;
579
+ async readAgentsMd() {
580
+ try {
581
+ const p = path.join(this.workspace, "AGENTS.md");
582
+ const st = await fs.stat(p);
583
+ if (this.agentsMdCache?.mtimeMs === st.mtimeMs)
584
+ return this.agentsMdCache.text;
585
+ const text = await fs.readFile(p, "utf8");
586
+ this.agentsMdCache = { mtimeMs: st.mtimeMs, text };
587
+ return text;
588
+ }
589
+ catch {
590
+ this.agentsMdCache = null;
591
+ return "";
592
+ }
593
+ }
594
+ async buildMessages() {
521
595
  const sys = [SYSTEM_TEMPLATE];
522
596
  if (this.goal.text) {
523
597
  sys.push(`## Current goal (${this.goal.status})\n${this.goal.text}`);
524
598
  }
599
+ const agentsMd = await this.readAgentsMd();
600
+ if (agentsMd.trim())
601
+ sys.push(`## Project knowledge (AGENTS.md)\n${clipText(agentsMd, 8000)}`);
602
+ const memory = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
603
+ if (memory.trim())
604
+ sys.push(`## Your notes (memory.md)\n${clipText(memory, 4000)}`);
525
605
  sys.push(this.skillsListing());
526
606
  const hasSystem = this.messages[0]?.role === "system";
527
607
  const head = [{ role: "system", content: sys.join("\n\n") }];
@@ -549,7 +629,7 @@ export class Agent {
549
629
  text: request,
550
630
  });
551
631
  this.messages.push({ role: "user", content: request });
552
- const res = await this.llmCall(this.buildMessages(), []); // no tools: pure report
632
+ const res = await this.llmCall(await this.buildMessages(), []); // no tools: pure report
553
633
  await this.recordProgress(JSON.stringify({ freeform: res.message.content }));
554
634
  await this.log.append("message", this.currentSession, this.currentBranch, {
555
635
  role: "assistant",
@@ -627,7 +707,7 @@ export class Agent {
627
707
  {
628
708
  role: "user",
629
709
  content: `[harness] Context was compacted: ${oldCount} earlier messages were summarized. ` +
630
- "Persistent files (GOAL.md / AGENTS.md / MEMORY.md) are still on disk re-read them when needed.\n\n" +
710
+ "Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
631
711
  `## Summary of earlier conversation\n${summary}`,
632
712
  },
633
713
  ...this.messages.slice(cut),
@@ -723,7 +803,7 @@ export class Agent {
723
803
  this.currentBranch = branch;
724
804
  }
725
805
  }
726
- /** workspace tools + agent-meta tools (finish / report_progress) */
806
+ /** workspace tools + agent-meta tools (finish / report_progress / set_goal / set_memory) */
727
807
  function allToolSpecs() {
728
808
  return [
729
809
  ...toolSpecs(),
@@ -731,7 +811,7 @@ function allToolSpecs() {
731
811
  type: "function",
732
812
  function: {
733
813
  name: "finish",
734
- description: "End the current round. Call with goalComplete=true only when the goal in GOAL.md is fully achieved.",
814
+ description: "End the current round. Call with goalComplete=true only when the current goal (shown in your prompt) is fully achieved.",
735
815
  parameters: {
736
816
  type: "object",
737
817
  properties: {
@@ -759,8 +839,35 @@ function allToolSpecs() {
759
839
  },
760
840
  },
761
841
  },
842
+ {
843
+ type: "function",
844
+ function: {
845
+ name: "set_goal",
846
+ description: "Replace the harness-managed goal text (it is injected into your prompt). Use when the objective itself changes — not for routine updates.",
847
+ parameters: {
848
+ type: "object",
849
+ properties: { text: { type: "string" } },
850
+ required: ["text"],
851
+ },
852
+ },
853
+ },
854
+ {
855
+ type: "function",
856
+ function: {
857
+ name: "set_memory",
858
+ description: "Overwrite your durable session notes (memory.md, injected into future prompts). Keep them terse: decisions, gotchas, where you left off.",
859
+ parameters: {
860
+ type: "object",
861
+ properties: { content: { type: "string" } },
862
+ required: ["content"],
863
+ },
864
+ },
865
+ },
762
866
  ];
763
867
  }
868
+ function clipText(s, max) {
869
+ return s.length <= max ? s : s.slice(0, max) + `\n… [truncated, ${s.length} chars total]`;
870
+ }
764
871
  function str(v) {
765
872
  return typeof v === "string" ? v : "";
766
873
  }
package/dist/master.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Agents are in-process async loops (I/O bound only); all CPU-heavy work is
4
4
  * delegated to subprocesses managed by the bash tool with hard timeouts.
5
5
  */
6
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, renameSync } from "node:fs";
7
+ import { randomUUID } from "node:crypto";
7
8
  import path from "node:path";
8
9
  import os from "node:os";
9
10
  import { Agent } from "./agent/agent.js";
@@ -192,8 +193,14 @@ export class Master {
192
193
  // single low-frequency tick for everything periodic (idle cost ≈ 0)
193
194
  setInterval(() => void this.tick(), 15_000).unref();
194
195
  }
195
- /** Create an agent; optionally persist it to the config file. */
196
- async addAgent(ac, persist = false) {
196
+ /**
197
+ * Create an agent; optionally persist it to the config file.
198
+ * Each incarnation gets its own session directory under
199
+ * <dataDir>/sessions/<agentId>-<uuid>/ (chat.jsonl, goal.md, memory.md).
200
+ * Restarts reuse the latest existing session; fresh creations never touch
201
+ * an older one's history.
202
+ */
203
+ async addAgent(ac, opts = {}) {
197
204
  if (this.agents.has(ac.id))
198
205
  throw new Error(`agent id already exists: ${ac.id}`);
199
206
  // provider resolution: inline overrides > named provider > legacy llm block
@@ -210,12 +217,13 @@ export class Master {
210
217
  };
211
218
  if (!llm.model)
212
219
  throw new Error(`agent ${ac.id}: no model configured (set model on the agent or on its provider)`);
213
- const logFile = path.join(this.config.dataDir, `${ac.id}.jsonl`);
220
+ const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
221
+ await mkdirSync(sessionDir, { recursive: true });
214
222
  const agent = new Agent({
215
223
  id: ac.id,
216
224
  workspace: path.resolve(ac.workspace),
217
225
  llm,
218
- logFile,
226
+ sessionDir,
219
227
  progressIntervalMs: this.config.progressIntervalMs,
220
228
  autoContinue: true,
221
229
  ...(this.config.contextTokenBudget ? { contextTokenBudget: this.config.contextTokenBudget } : {}),
@@ -225,12 +233,68 @@ export class Master {
225
233
  agent.log.onEvent = (e) => printAgentEvent(e);
226
234
  await agent.init();
227
235
  this.agents.set(ac.id, agent);
228
- if (persist) {
236
+ if (opts.persist) {
229
237
  this.config.agents.push(ac);
230
238
  this.saveConfig();
231
239
  }
232
240
  return agent;
233
241
  }
242
+ /** sessions root + helpers */
243
+ sessionsRoot() {
244
+ return path.join(this.config.dataDir, "sessions");
245
+ }
246
+ findLatestSessionDir(agentId) {
247
+ let dirs = [];
248
+ try {
249
+ dirs = readdirSync(this.sessionsRoot())
250
+ .filter((d) => d === agentId || d.startsWith(`${agentId}-`))
251
+ .sort((a, b) => {
252
+ // newest chat.jsonl wins
253
+ const ma = this.sessionMtime(path.join(this.sessionsRoot(), a));
254
+ const mb = this.sessionMtime(path.join(this.sessionsRoot(), b));
255
+ return mb - ma;
256
+ });
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ return dirs[0] ? path.join(this.sessionsRoot(), dirs[0]) : null;
262
+ }
263
+ sessionMtime(dir) {
264
+ try {
265
+ return statSync(path.join(dir, "chat.jsonl")).mtimeMs;
266
+ }
267
+ catch {
268
+ return 0;
269
+ }
270
+ }
271
+ resolveSessionDir(agentId, fresh) {
272
+ const root = this.sessionsRoot();
273
+ mkdirSync(root, { recursive: true });
274
+ // one-time migration from the ≤0.5.0 flat layout (<dataDir>/<id>.jsonl)
275
+ if (!fresh) {
276
+ const legacy = path.join(this.config.dataDir, `${agentId}.jsonl`);
277
+ if (existsSync(legacy)) {
278
+ const target = path.join(root, agentId);
279
+ if (!existsSync(target)) {
280
+ mkdirSync(target, { recursive: true });
281
+ renameSync(legacy, path.join(target, "chat.jsonl"));
282
+ console.log(`[teapot] migrated session storage: ${agentId}.jsonl → sessions/${agentId}/chat.jsonl`);
283
+ }
284
+ }
285
+ }
286
+ if (!fresh) {
287
+ const existing = this.findLatestSessionDir(agentId);
288
+ if (existing)
289
+ return existing; // restart → continue where we left off
290
+ }
291
+ // fresh incarnation: guaranteed-unique directory
292
+ let sid = "";
293
+ do {
294
+ sid = `${agentId}-${randomUUID().slice(0, 8)}`;
295
+ } while (existsSync(path.join(root, sid)));
296
+ return path.join(root, sid);
297
+ }
234
298
  async removeAgent(id) {
235
299
  const agent = this.agents.get(id);
236
300
  if (!agent)
@@ -4,7 +4,8 @@
4
4
  import { Hono } from "hono";
5
5
  import { serve, upgradeWebSocket } from "@hono/node-server";
6
6
  import { WebSocketServer } from "ws";
7
- import { readFileSync } from "node:fs";
7
+ import { readFileSync, existsSync } from "node:fs";
8
+ import { spawn } from "node:child_process";
8
9
  import { promises as fs } from "node:fs";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { parseSchedule } from "../scheduler/cron.js";
@@ -52,6 +53,78 @@ export function buildApp(master) {
52
53
  },
53
54
  };
54
55
  }));
56
+ // ---- human terminal: interactive shell in the agent's workspace ----
57
+ // Uses util-linux `script` as a zero-dependency PTY when available (colors,
58
+ // line editing, ctrl+c); falls back to plain pipes otherwise.
59
+ app.get("/api/agents/:id/term", upgradeWebSocket((c) => {
60
+ const agentId = c.req.param("id") ?? "";
61
+ let child = null;
62
+ const cleanup = () => {
63
+ if (!child)
64
+ return;
65
+ try {
66
+ child.kill("SIGHUP");
67
+ }
68
+ catch {
69
+ /* already gone */
70
+ }
71
+ child = null;
72
+ };
73
+ return {
74
+ onOpen(_evt, ws) {
75
+ const agent = master.agents.get(agentId);
76
+ const send = (d) => {
77
+ try {
78
+ ws.send(JSON.stringify(d));
79
+ }
80
+ catch {
81
+ /* client gone */
82
+ }
83
+ };
84
+ if (!agent) {
85
+ send({ kind: "exit", error: `no such agent: ${agentId}` });
86
+ return;
87
+ }
88
+ const shell = process.env.SHELL || "/bin/bash";
89
+ const hasScript = existsSync("/usr/bin/script");
90
+ // script's pty reports a 0x0 winsize, so shells fall back to these
91
+ const env = { ...process.env, TERM: "xterm-256color", COLUMNS: "100", LINES: "30" };
92
+ child = hasScript
93
+ ? spawn("script", ["-qec", shell, "/dev/null"], { cwd: agent.workspace, env })
94
+ : spawn(shell, [], { cwd: agent.workspace, env: { ...env, TERM: "dumb" } });
95
+ console.log(`[teapot] ⌨ terminal open: ${agentId} @ ${agent.workspace} (${hasScript ? "pty" : "pipe"})`);
96
+ child.stdout?.on("data", (b) => send({ kind: "data", data: b.toString("utf8") }));
97
+ child.stderr?.on("data", (b) => send({ kind: "data", data: b.toString("utf8") }));
98
+ child.on("close", (code) => {
99
+ send({ kind: "exit", code });
100
+ console.log(`[teapot] ⌨ terminal exit: ${agentId} (${code ?? "signal"})`);
101
+ child = null;
102
+ });
103
+ },
104
+ onMessage(evt) {
105
+ if (!child?.stdin?.writable)
106
+ return;
107
+ let m;
108
+ try {
109
+ m = JSON.parse(String(evt.data));
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ if (m.kind === "input")
115
+ child.stdin.write(String(m.data ?? ""));
116
+ else if (m.kind === "resize") {
117
+ const r = Number(m.rows) | 0;
118
+ const cl = Number(m.cols) | 0;
119
+ if (r > 0 && cl > 0)
120
+ child.stdin.write(`stty rows ${r} cols ${cl} >/dev/null 2>&1\n`);
121
+ }
122
+ },
123
+ onClose() {
124
+ cleanup();
125
+ },
126
+ };
127
+ }));
55
128
  // ---- agents ----
56
129
  app.get("/api/agents", (c) => c.json({ agents: [...master.agents.values()].map((a) => a.snapshot()) }));
57
130
  // create + start an agent on an arbitrary directory
@@ -70,7 +143,7 @@ export function buildApp(master) {
70
143
  }
71
144
  const id = (body.id?.trim() || path.basename(ws)).replace(/[^\w.-]/g, "-").slice(0, 40);
72
145
  try {
73
- const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, true);
146
+ const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, { persist: true, fresh: true });
74
147
  if (body.start !== false)
75
148
  agent.start("created via web");
76
149
  return c.json({ ok: true, agent: agent.snapshot() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -46,6 +46,8 @@
46
46
  "devDependencies": {
47
47
  "@types/node": "^24.0.0",
48
48
  "@types/ws": "^8.18.1",
49
+ "@xterm/addon-fit": "^0.11.0",
50
+ "@xterm/xterm": "^6.0.0",
49
51
  "solid-js": "^1.9.15",
50
52
  "typescript": "^5.8.0",
51
53
  "vite": "^8.2.2",
@@ -0,0 +1 @@
1
+ var e=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core;(this._terminal.rows!==e.rows||this._terminal.cols!==e.cols)&&(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let e=this._terminal._core._renderService.dimensions;if(e.css.cell.width===0||e.css.cell.height===0)return;let t=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,n=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(n.getPropertyValue(`height`)),i=Math.max(0,parseInt(n.getPropertyValue(`width`))),a=window.getComputedStyle(this._terminal.element),o={top:parseInt(a.getPropertyValue(`padding-top`)),bottom:parseInt(a.getPropertyValue(`padding-bottom`)),right:parseInt(a.getPropertyValue(`padding-right`)),left:parseInt(a.getPropertyValue(`padding-left`))},s=o.top+o.bottom,c=o.right+o.left,l=r-s,u=i-c-t;return{cols:Math.max(2,Math.floor(u/e.css.cell.width)),rows:Math.max(1,Math.floor(l/e.css.cell.height))}}};export{e as FitAddon};