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 +38 -11
- package/dist/agent/agent.js +143 -36
- package/dist/master.js +70 -6
- package/dist/server/api.js +75 -2
- package/package.json +3 -1
- package/public/assets/addon-fit-DIOBYJe3.js +1 -0
- package/public/assets/index-C4RhCPYd.js +8 -0
- package/public/assets/index-DIs0rfrk.css +1 -0
- package/public/assets/xterm-C3BHN0de.js +16 -0
- package/public/index.html +2 -2
- package/public/assets/index-CHlf4zTW.css +0 -1
- package/public/assets/index-CN4J99YT.js +0 -8
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
|
|
109
|
-
confined to the workspace; bash runs detached in its
|
|
110
|
-
the whole group is SIGKILLed on timeout.
|
|
111
|
-
- **
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
145
|
-
forks* lives in the same
|
|
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":"
|
|
175
|
+
{"v":1,"id":"e27","seq":27,"ts":"…","agent":"alpha","session":"alpha-9f3c21ab",
|
|
149
176
|
"branch":"br032sl","parent":"e26","type":"fork",
|
|
150
|
-
"data":{"fromSession":"
|
|
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`
|
package/dist/agent/agent.js
CHANGED
|
@@ -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
|
-
##
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
await this.
|
|
130
|
-
const
|
|
131
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
/**
|
|
196
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/dist/server/api.js
CHANGED
|
@@ -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.
|
|
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};
|