teapot-coding-agent 0.14.10 → 0.15.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.
@@ -469,7 +469,17 @@ export class Agent {
469
469
  break;
470
470
  body = stripped;
471
471
  }
472
- return { text: body, status, updatedAt: new Date().toISOString() };
472
+ // verification contract (pi-goal-x style): a `verify:` block at the tail
473
+ let verify = "";
474
+ const vm = body.match(/\nverify:\s*\n([\s\S]+)$/i) ?? body.match(/^verify:\s*(.+)$/im);
475
+ if (vm) {
476
+ verify = vm[1].trim();
477
+ body = (body.slice(0, vm.index).trimEnd());
478
+ }
479
+ const out = { text: body, status, updatedAt: new Date().toISOString() };
480
+ if (verify)
481
+ out.verify = verify;
482
+ return out;
473
483
  }
474
484
  async writeGoalFile() {
475
485
  // don't let previously-appended bookkeeping lines accumulate in the body
@@ -480,7 +490,8 @@ export class Agent {
480
490
  break;
481
491
  body = stripped;
482
492
  }
483
- await fs.writeFile(this.goalFile, `${body}\n\nstatus: ${this.goal.status}\nupdated: ${this.goal.updatedAt}\n`, "utf8");
493
+ await fs.writeFile(this.goalFile, `${body}\n\nstatus: ${this.goal.status}\nupdated: ${this.goal.updatedAt}\n` +
494
+ (this.goal.verify ? `verify:\n${this.goal.verify}\n` : ""), "utf8");
484
495
  }
485
496
  async setGoal(text) {
486
497
  this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
@@ -546,6 +557,33 @@ export class Agent {
546
557
  await this.writeGoalFile();
547
558
  await this.log.append("goal", this.currentSession, this.currentBranch, { event: "status", status });
548
559
  }
560
+ /** Set the goal's verification contract (completion requirements). */
561
+ async setGoalVerify(verify) {
562
+ this.goal = { ...this.goal, verify: verify.slice(0, 4000), updatedAt: new Date().toISOString() };
563
+ await this.writeGoalFile();
564
+ await this.log.append("goal", this.currentSession, this.currentBranch, {
565
+ event: "verify-set",
566
+ verify: verify.slice(0, 2000),
567
+ });
568
+ }
569
+ /** Record a verification-contract audit outcome (pi-goal-x style review). */
570
+ async setGoalAudit(verdict, feedback) {
571
+ this.goal = {
572
+ ...this.goal,
573
+ updatedAt: new Date().toISOString(),
574
+ audit: { verdict, feedback: feedback.slice(0, 4000), at: new Date().toISOString() },
575
+ };
576
+ if (verdict === "approved")
577
+ this.goal.status = "done";
578
+ else if (this.goal.status === "done")
579
+ this.goal.status = "active"; // reopened
580
+ await this.writeGoalFile();
581
+ await this.log.append("goal", this.currentSession, this.currentBranch, {
582
+ event: "audit",
583
+ verdict,
584
+ feedback: feedback.slice(0, 2000),
585
+ });
586
+ }
549
587
  snapshot() {
550
588
  return {
551
589
  id: this.opts.id,
@@ -554,7 +592,12 @@ export class Agent {
554
592
  workspace: this.workspace,
555
593
  session: this.currentSession,
556
594
  branch: this.currentBranch,
557
- goal: { status: this.goal.status, text: this.goal.text.slice(0, 400) },
595
+ goal: {
596
+ status: this.goal.status,
597
+ text: this.goal.text.slice(0, 400),
598
+ ...(this.goal.verify ? { verify: this.goal.verify.slice(0, 1000) } : {}),
599
+ ...(this.goal.audit ? { audit: this.goal.audit } : {}),
600
+ },
558
601
  latestProgress: this.latestProgress,
559
602
  stats: { ...this.stats },
560
603
  model: this.opts.llm.model,
@@ -595,20 +638,69 @@ export class Agent {
595
638
  this.activityChars = 0;
596
639
  this.turnsSinceProgress = 0;
597
640
  }
641
+ // stable id shared by the log event and the UI's pending echo — the UI
642
+ // flips its echo to "sent" when prompt-delivered carries this id back
643
+ const promptId = source === "user" ? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` : "";
598
644
  void this.ensureReady()
599
645
  .then(() => {
600
- this.pendingPrompts.push({ source, text });
601
- return this.log.append("prompt", this.currentSession, this.currentBranch, { source, text });
646
+ this.pendingPrompts.push({ source, text, ...(promptId ? { id: promptId } : {}) });
647
+ return this.log.append("prompt", this.currentSession, this.currentBranch, {
648
+ source,
649
+ text,
650
+ ...(promptId ? { promptId } : {}),
651
+ });
602
652
  })
603
653
  .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
604
654
  .catch(() => { });
655
+ return promptId;
605
656
  }
606
- /** Hand queued user prompts to the model at a turn boundary. */
657
+ /**
658
+ * Withdraw a still-pending user prompt (before it reaches an LLM call).
659
+ * Returns the text so the UI can put it back into the composer draft.
660
+ */
661
+ cancelPrompt(promptId) {
662
+ const i = this.pendingPrompts.findIndex((p) => p.id === promptId);
663
+ if (i === -1)
664
+ return null; // already delivered or unknown
665
+ const [p] = this.pendingPrompts.splice(i, 1);
666
+ void this.log
667
+ .append("system_note", this.currentSession, this.currentBranch, {
668
+ event: "prompt-cancelled",
669
+ promptId,
670
+ preview: p.text.slice(0, 80),
671
+ })
672
+ .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
673
+ .catch(() => { });
674
+ return p.text;
675
+ }
676
+ /**
677
+ * Hand queued user prompts to the model at a turn boundary. Each consumed
678
+ * prompt is logged as a system_note (prompt-delivered) so the UI can flip
679
+ * its pending echo to "sent" at exactly the moment the text enters an LLM
680
+ * call payload — not merely when it was logged.
681
+ */
607
682
  drainPendingPrompts() {
608
683
  for (const p of this.pendingPrompts.splice(0)) {
609
684
  this.messages.push({ role: "user", content: p.text });
685
+ if (p.source === "user") {
686
+ const id = p.id ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
687
+ this.deliveredPrompts.push(id);
688
+ // cap: UI only needs recent confirmations
689
+ if (this.deliveredPrompts.length > 64)
690
+ this.deliveredPrompts.shift();
691
+ void this.log
692
+ .append("system_note", this.currentSession, this.currentBranch, {
693
+ event: "prompt-delivered",
694
+ promptId: id,
695
+ preview: p.text.slice(0, 80),
696
+ })
697
+ .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
698
+ .catch(() => { });
699
+ }
610
700
  }
611
701
  }
702
+ /** ids of user prompts that have entered an LLM call payload (recent first-capped) */
703
+ deliveredPrompts = [];
612
704
  /** Resolves when all queued work (including a running loop) has settled. */
613
705
  settled() {
614
706
  return this.runChain.catch(() => { });
@@ -873,11 +965,20 @@ export class Agent {
873
965
  const text = String(a.text ?? "").trim();
874
966
  if (text)
875
967
  await this.setGoal(text);
968
+ // pi-goal-x-style verification contract: plain-text completion
969
+ // requirements audited before finish(goalComplete=true) is honored
970
+ if (typeof a.verify === "string" && a.verify.trim())
971
+ await this.setGoalVerify(a.verify.trim().slice(0, 4000));
876
972
  await this.answerMeta(call, text ? "goal updated" : "empty goal rejected");
877
973
  continue;
878
974
  }
879
975
  if (call.function.name === "get_goal") {
880
- await this.answerMeta(call, JSON.stringify({ goal: this.goal.text || "(none set)", status: this.goal.status }, null, 1));
976
+ await this.answerMeta(call, JSON.stringify({
977
+ goal: this.goal.text || "(none set)",
978
+ status: this.goal.status,
979
+ ...(this.goal.verify ? { verify: this.goal.verify } : {}),
980
+ ...(this.goal.audit ? { lastAudit: this.goal.audit } : {}),
981
+ }, null, 1));
881
982
  continue;
882
983
  }
883
984
  if (call.function.name === "ask_user") {
@@ -1054,8 +1155,59 @@ export class Agent {
1054
1155
  }
1055
1156
  async handleFinish(argsJson) {
1056
1157
  const args = safeParse(argsJson);
1057
- if (args.goalComplete === true)
1058
- await this.setGoalStatus("done");
1158
+ if (args.goalComplete === true && this.goal.status !== "done") {
1159
+ if (this.goal.verify?.trim()) {
1160
+ // pi-goal-x-style INDEPENDENT COMPLETION REVIEW: a separate LLM call
1161
+ // checks the verification contract against the conversation + summary
1162
+ // before "done" is honored. changes-required reopens the goal and
1163
+ // queues the auditor's feedback as the next user prompt.
1164
+ await this.log.append("goal", this.currentSession, this.currentBranch, {
1165
+ event: "audit-started",
1166
+ verify: this.goal.verify.slice(0, 2000),
1167
+ });
1168
+ try {
1169
+ const auditPrompt = `You are an independent completion AUDITOR (not the worker). Decide whether the goal is genuinely complete.\n\n` +
1170
+ `GOAL:\n${this.goal.text.slice(0, 4000)}\n\nVERIFICATION CONTRACT (must ALL hold):\n${this.goal.verify.slice(0, 2000)}\n\n` +
1171
+ `WORKER'S FINAL SUMMARY:\n${String(args.summary ?? "").slice(0, 3000)}\n\n` +
1172
+ `Conversation evidence follows. Reply with EXACTLY one line starting either\n` +
1173
+ `"APPROVED: <one-sentence justification>" or\n` +
1174
+ `"CHANGES-REQUIRED: <the specific gaps that must be addressed>".`;
1175
+ const res = await this.callLlm([
1176
+ ...this.buildMessages(),
1177
+ { role: "user", content: auditPrompt },
1178
+ ], []);
1179
+ const text = String(res.message.content ?? "");
1180
+ const approved = /^APPROVED\b/i.test(text.trim());
1181
+ const feedback = text.replace(/^(APPROVED|CHANGES-REQUIRED)\s*[:—-]?\s*/i, "").trim();
1182
+ await this.setGoalAudit(approved ? "approved" : "changes-required", feedback || "(no detail)");
1183
+ await this.log.append("message", this.currentSession, this.currentBranch, {
1184
+ role: "assistant",
1185
+ content: approved
1186
+ ? `✅ completion audit: APPROVED — ${feedback}`
1187
+ : `🔍 completion audit: CHANGES REQUIRED — ${feedback}`,
1188
+ });
1189
+ if (!approved) {
1190
+ // hand the gaps back to the worker as its next instruction
1191
+ this.pendingPrompts.push({
1192
+ source: "harness",
1193
+ text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${feedback.slice(0, 2000)}`,
1194
+ });
1195
+ }
1196
+ }
1197
+ catch (err) {
1198
+ // auditor unavailable → fail open (record it, honor the finish) so a
1199
+ // flaky provider can't trap work in an unauditable loop
1200
+ await this.log.append("system_note", this.currentSession, this.currentBranch, {
1201
+ event: "audit-failed",
1202
+ detail: String(err.message).slice(0, 300),
1203
+ });
1204
+ await this.setGoalStatus("done");
1205
+ }
1206
+ }
1207
+ else {
1208
+ await this.setGoalStatus("done");
1209
+ }
1210
+ }
1059
1211
  await this.log.append("message", this.currentSession, this.currentBranch, {
1060
1212
  role: "assistant",
1061
1213
  final: true,
@@ -1420,10 +1572,19 @@ function allToolSpecs() {
1420
1572
  type: "function",
1421
1573
  function: {
1422
1574
  name: "set_goal",
1423
- description: "Replace the harness-managed goal text. Use when the objective itself changes — not for routine updates.",
1575
+ description: "Replace the harness-managed goal text. Use when the objective itself changes — not for routine updates. " +
1576
+ "Optionally attach a verification contract (plain-text completion requirements); finish(goalComplete=true) " +
1577
+ "is then audited against it by an independent reviewer before the goal counts as done.",
1424
1578
  parameters: {
1425
1579
  type: "object",
1426
- properties: { text: { type: "string" } },
1580
+ properties: {
1581
+ text: { type: "string" },
1582
+ verify: {
1583
+ type: "string",
1584
+ description: "completion requirements that must verifiably hold, e.g. 'npm test passes with zero failures; " +
1585
+ "the new endpoint appears in README'. Omit for no audit.",
1586
+ },
1587
+ },
1427
1588
  required: ["text"],
1428
1589
  },
1429
1590
  },
@@ -1617,6 +1778,13 @@ function rebuildMessagesFrom(list) {
1617
1778
  "get_feedback", "add_feedback", "record_decision", "get_decisions",
1618
1779
  ]);
1619
1780
  const openCalls = new Map(); // real tool_call id -> name
1781
+ // callId -> the tool_call entry on its assistant message (fast arg enrichment)
1782
+ const assistantCalls = new Map();
1783
+ const rememberAssistantCalls = (m) => {
1784
+ if (Array.isArray(m.tool_calls))
1785
+ for (const t of m.tool_calls)
1786
+ assistantCalls.set(t.id, t);
1787
+ };
1620
1788
  // meta answers are now logged as regular tool_results; the legacy progress
1621
1789
  // synthesizer below must not duplicate them
1622
1790
  const loggedResults = new Set(list.filter((e) => e.type === "tool_result").map((e) => String(e.data.callId ?? "")));
@@ -1654,14 +1822,20 @@ function rebuildMessagesFrom(list) {
1654
1822
  if (!META_TOOLS.has(t.function.name))
1655
1823
  openCalls.set(t.id, t.function.name);
1656
1824
  }
1825
+ rememberAssistantCalls(m);
1657
1826
  msgs.push(m);
1658
1827
  }
1659
1828
  else if (e.type === "tool_call") {
1660
1829
  // enrich the preceding assistant tool_calls with real arguments —
1661
1830
  // prefer the provider's raw string so restored requests stay
1662
- // byte-identical (prefix caches stay warm across restarts)
1663
- const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
1664
- const tc = prev?.tool_calls?.find((t) => t.id === d.callId);
1831
+ // byte-identical (prefix caches stay warm across restarts).
1832
+ // Indexed by call id: the old [...msgs].reverse().find() scanned the
1833
+ // whole message list per call — O(n²) on multi-thousand-event logs.
1834
+ let tc = assistantCalls.get(String(d.callId ?? ""));
1835
+ if (!tc) {
1836
+ const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
1837
+ tc = prev?.tool_calls?.find((t) => t.id === d.callId);
1838
+ }
1665
1839
  if (tc) {
1666
1840
  if (typeof d.argsRaw === "string")
1667
1841
  tc.function.arguments = d.argsRaw;
@@ -16,7 +16,12 @@ function clip(s, max) {
16
16
  async function readText(p) {
17
17
  return fs.readFile(p, "utf8");
18
18
  }
19
- /** Resolve a path inside the workspace; reject escapes (incl. symlink targets). */
19
+ /** Resolve a path inside the workspace; reject escapes (incl. symlink targets).
20
+ * KNOWN LIMIT (review finding, accepted): the check-then-open pattern has a
21
+ * TOCTOU window — a symlink swapped in between safeJoin() and fs.open() could
22
+ * point outside the workspace. Closing it needs openat(2)/O_NOFOLLOW at every
23
+ * call site; the operator-facing risk is low (requires workspace write access
24
+ * already), so we document rather than rewrite all call sites. */
20
25
  export function safeJoin(cwd, p) {
21
26
  const abs = path.resolve(cwd, p);
22
27
  const rel = path.relative(cwd, abs);
@@ -37,7 +37,8 @@ export const ConfigPatchSchema = z.object({
37
37
  defaultProvider: z.string().optional(),
38
38
  progressIntervalMs: z.number().int().min(10_000, "progress interval must be ≥ 10s").optional(),
39
39
  progressMinChars: z.number().int().min(100).optional(),
40
- contextTokenBudget: z.number().int().min(1_000).optional(),
40
+ // null clears a previously pinned compact budget → back to per-model derivation
41
+ contextTokenBudget: z.number().int().min(1_000).nullable().optional(),
41
42
  contextWindowTokens: z.number().int().min(1_000).optional(),
42
43
  maxSpawnDepth: z.number().int().min(0).max(8).optional(),
43
44
  tasks: z.array(TaskSchema).optional(),
@@ -97,8 +97,12 @@ export class EventLog {
97
97
  }
98
98
  const p = new Promise((resolve, reject) => {
99
99
  this.chain = this.chain.then(() => {
100
- if (!this.stream)
100
+ if (!this.stream) {
101
+ // log closed (dispose) — resolve so callers don't await forever,
102
+ // but say so: silent drops made post-shutdown writes look logged
103
+ console.error(`[teapot] event dropped after log close (${this.agentId}): ${type}`);
101
104
  return resolve(evt);
105
+ }
102
106
  this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => err ? reject(err) : resolve(evt));
103
107
  }, () => resolve(evt));
104
108
  });
package/dist/master.js CHANGED
@@ -218,8 +218,12 @@ export class Master {
218
218
  a.opts.progressMinChars =
219
219
  patch.progressMinChars;
220
220
  }
221
- if (patch.contextTokenBudget !== undefined)
222
- this.config.contextTokenBudget = patch.contextTokenBudget;
221
+ // explicit null clears a previously saved budget → back to per-model
222
+ // derivation (75% of each agent's known window); a positive number pins it
223
+ if (patch.contextTokenBudget !== undefined) {
224
+ this.config.contextTokenBudget =
225
+ (patch.contextTokenBudget ?? 0) > 0 ? patch.contextTokenBudget : undefined;
226
+ }
223
227
  if (patch.maxSpawnDepth !== undefined)
224
228
  this.config.maxSpawnDepth = patch.maxSpawnDepth;
225
229
  if (patch.tasks) {
@@ -246,11 +250,17 @@ export class Master {
246
250
  }
247
251
  }
248
252
  for (const t of this.config.tasks ?? []) {
249
- this.tasks.push({
250
- task: t,
251
- schedule: parseSchedule(t.schedule),
252
- lastRunMin: t.lastRunMin ?? -1,
253
- });
253
+ try {
254
+ this.tasks.push({
255
+ task: t,
256
+ schedule: parseSchedule(t.schedule),
257
+ lastRunMin: t.lastRunMin ?? -1,
258
+ });
259
+ }
260
+ catch (err) {
261
+ // a hand-edited schedule typo must not stop the server from listening
262
+ console.error(`[teapot] skipping task "${t.id}": ${err.message}`);
263
+ }
254
264
  }
255
265
  // single low-frequency tick for everything periodic (idle cost ≈ 0)
256
266
  setInterval(() => void this.tick(), 15_000).unref();
@@ -397,7 +407,11 @@ export class Master {
397
407
  })),
398
408
  stop: (ids) => this.stopChildrenFor(ac.id, ids),
399
409
  message: (id, text) => this.messageChild(ac.id, id, text),
400
- wait: (ids, ms) => this.waitChildren(ac.id, ids, ms),
410
+ wait: (ids, ms) =>
411
+ // wire the parent's tool-abort signal: a user stop / dispose must
412
+ // interrupt the park immediately instead of leaving the run chain
413
+ // blocked until the full timeout (up to 1h)
414
+ this.waitChildren(ac.id, ids, ms, self.toolCtx.signal),
401
415
  };
402
416
  agent.toolCtx.subAgents = hooks;
403
417
  }
@@ -77,7 +77,17 @@ export function buildApp(master) {
77
77
  }
78
78
  };
79
79
  send({ kind: "hello", agents: [...master.agents.values()].map((a) => a.snapshot()) });
80
- onUpdate = (ev) => send(ev);
80
+ onUpdate = (raw) => {
81
+ const ev = raw;
82
+ // attach the fresh snapshot to agent-update events so clients can
83
+ // update their state WITHOUT a follow-up GET /api/agents poll
84
+ if (ev?.kind === "agent-update" && ev.agentId) {
85
+ const a = master.agents.get(ev.agentId);
86
+ send({ ...ev, snapshot: a?.snapshot() });
87
+ return;
88
+ }
89
+ send(ev);
90
+ };
81
91
  bus.on("update", onUpdate);
82
92
  // app-level liveness ping every 30s
83
93
  ka = setInterval(() => send({ kind: "ping" }), 30_000);
@@ -205,7 +215,9 @@ export function buildApp(master) {
205
215
  // collision, auto-suffix so creating ~/a/proj and ~/b/proj just works
206
216
  let id = base;
207
217
  let n = 2;
208
- while (master.agents.has(id))
218
+ // ids must be unique among RUNNING agents AND persisted config entries —
219
+ // a config-only agent (not yet loaded) would otherwise collide on addAgent
220
+ while (master.agents.has(id) || master.config.agents.some((a) => a.id === id))
209
221
  id = `${base.slice(0, 38)}-${n++}`;
210
222
  try {
211
223
  const agent = await master.addAgent({ id, workspace: ws, provider: body.provider, model: body.model }, { persist: true, fresh: true });
@@ -451,11 +463,25 @@ export function buildApp(master) {
451
463
  if (!body.text?.trim())
452
464
  return c.json({ error: "text required" }, 400);
453
465
  // returns immediately: the prompt is logged + broadcast now, delivered to
454
- // the model at the next turn boundary (never blocks on a running agent)
455
- a.enqueuePrompt(body.text, "user");
466
+ // the model at the next turn boundary (never blocks on a running agent).
467
+ // promptId ties the UI's pending echo to the later prompt-delivered note.
468
+ const promptId = a.enqueuePrompt(body.text, "user");
456
469
  if (body.start !== false && a.status !== "running")
457
470
  a.start("prompt");
458
- return c.json({ ok: true, queued: a.snapshot().pendingPrompts });
471
+ return c.json({ ok: true, promptId, queued: a.snapshot().pendingPrompts });
472
+ });
473
+ // Withdraw a still-pending prompt; returns its text for the composer draft.
474
+ app.post("/api/agents/:id/prompt/cancel", async (c) => {
475
+ const a = master.agents.get(c.req.param("id"));
476
+ if (!a)
477
+ return c.json({ error: "not found" }, 404);
478
+ const body = await c.req.json();
479
+ if (!body.promptId)
480
+ return c.json({ error: "promptId required" }, 400);
481
+ const text = a.cancelPrompt(body.promptId);
482
+ if (text === null)
483
+ return c.json({ error: "not pending (already delivered?)" }, 409);
484
+ return c.json({ ok: true, text });
459
485
  });
460
486
  app.post("/api/agents/:id/start", (c) => {
461
487
  const a = master.agents.get(c.req.param("id"));
@@ -534,6 +560,8 @@ export function buildApp(master) {
534
560
  const body = await c.req.json();
535
561
  if (body.text) {
536
562
  await a.setGoal(body.text);
563
+ if (typeof body.verify === "string" && body.verify.trim())
564
+ await a.setGoalVerify(body.verify.trim());
537
565
  // goals live behind get_goal(), so a silent save would go unnoticed —
538
566
  // queue a harness prompt unless the caller explicitly declines
539
567
  if (body.notify !== false)
@@ -701,12 +729,33 @@ export function buildApp(master) {
701
729
  return c.json({ ok: true, ...r });
702
730
  });
703
731
  // ---- event log access (human/inspect friendly) ----
732
+ // mtime-keyed parse cache: the web UI polls this endpoint every ~400ms and
733
+ // re-reading + re-parsing the WHOLE chat.jsonl each time dominated CPU on
734
+ // multi-MB sessions. The file only ever appends, so an unchanged mtime lets
735
+ // us serve the previous parse verbatim.
736
+ const eventsCache = new Map();
737
+ async function readEventsCached(filePath) {
738
+ try {
739
+ const st = await fs.stat(filePath);
740
+ const hit = eventsCache.get(filePath);
741
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
742
+ return hit.events;
743
+ const events = await readEvents(filePath);
744
+ if (eventsCache.size > 64)
745
+ eventsCache.clear();
746
+ eventsCache.set(filePath, { mtimeMs: st.mtimeMs, size: st.size, events });
747
+ return events;
748
+ }
749
+ catch {
750
+ return readEvents(filePath);
751
+ }
752
+ }
704
753
  app.get("/api/agents/:id/events", async (c) => {
705
754
  const a = master.agents.get(c.req.param("id"));
706
755
  if (!a)
707
756
  return c.json({ error: "not found" }, 404);
708
757
  const limit = Math.min(Number(c.req.query("limit") ?? 200), 5000);
709
- let events = await readEvents(a.log.filePath);
758
+ let events = await readEventsCached(a.log.filePath);
710
759
  const branch = c.req.query("branch");
711
760
  const session = c.req.query("session");
712
761
  if (branch)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.14.10",
3
+ "version": "0.15.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",