teapot-coding-agent 0.22.8 → 0.22.9

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 CHANGED
@@ -717,8 +717,9 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
717
717
  * prefix of the other ("prismrv-root-sub-review" vs
718
718
  * "…-review-kernel") excluded each other's own dirs — every restart then
719
719
  * generated a fresh empty session and history read as zero. The owner is
720
- * now recorded explicitly at creation time; prefix matching only remains
721
- * as a fallback for legacy dirs without a manifest.
720
+ * now recorded explicitly at creation time; legacy dirs without a manifest
721
+ * fall back to longest-prefix inference and get one backfilled on first
722
+ * touch.
722
723
  */
723
724
  readSessionOwner(dirName) {
724
725
  try {
@@ -729,6 +730,24 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
729
730
  return null; // legacy dir or unreadable manifest → fall back to names
730
731
  }
731
732
  }
733
+ /**
734
+ * Owner of a session dir — manifest first; legacy dirs are inferred from
735
+ * the LONGEST configured/live id whose "<id>" or "<id>-<rest>" shape the
736
+ * name matches. Returns {owner, hadManifest} so callers can backfill.
737
+ */
738
+ inferSessionOwner(dirName, candidateIds) {
739
+ const fromManifest = this.readSessionOwner(dirName);
740
+ if (fromManifest)
741
+ return { owner: fromManifest, hadManifest: true };
742
+ let best = null;
743
+ for (const id of candidateIds) {
744
+ if (!id)
745
+ continue;
746
+ if ((id === dirName || dirName.startsWith(`${id}-`)) && (!best || id.length > best.len))
747
+ best = { id, len: id.length };
748
+ }
749
+ return { owner: best?.id ?? null, hadManifest: false };
750
+ }
732
751
  writeSessionManifest(dirPath, agentId) {
733
752
  try {
734
753
  const file = path.join(dirPath, "session.json");
@@ -744,58 +763,39 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
744
763
  // Directory names alone are ambiguous: agent "teapot" prefix-matches
745
764
  // "teapot-3-<hash>", and sibling ids where one is a prefix of the other
746
765
  // ("prismrv-root-sub-review" vs "…-review-kernel") used to exclude each
747
- // other's own dirs — every restart then generated a fresh empty session
748
- // and history read as zero. The manifest written at creation time
749
- // decides; only legacy dirs without one fall back to name matching.
750
- const otherIds = new Set();
751
- for (const a of this.config.agents) {
752
- if (a.id && a.id !== agentId)
753
- otherIds.add(a.id);
754
- }
755
- for (const [liveId] of this.agents) {
756
- if (liveId !== agentId)
757
- otherIds.add(liveId);
758
- }
759
- // legacy fallback: ownership goes to the LONGEST id matching the name
760
- const belongsToOther = (dir) => {
761
- const matchedLen = (id) => id === dir || dir.startsWith(`${id}-`) ? id.length : 0;
762
- const own = matchedLen(agentId); // always > 0: caller pre-filtered by id
763
- let bestOther = 0;
764
- for (const other of otherIds) {
765
- const len = matchedLen(other);
766
- if (len > bestOther)
767
- bestOther = len;
768
- }
769
- return bestOther > own; // equal-length ids cannot both match one dir
770
- };
766
+ // other's own dirs. Ownership comes from the manifest; legacy dirs are
767
+ // inferred by longest matching id.
768
+ const candidateIds = new Set([agentId]); // the agent itself owns its bare-name dir
769
+ for (const a of this.config.agents)
770
+ if (a.id)
771
+ candidateIds.add(a.id);
772
+ for (const [liveId] of this.agents)
773
+ candidateIds.add(liveId);
771
774
  let dirs = [];
772
775
  try {
773
- dirs = readdirSync(this.sessionsRoot())
774
- .filter((d) => {
775
- const owner = this.readSessionOwner(d);
776
- if (owner)
777
- return owner === agentId; // manifest decides
778
- // legacy dir without a manifest: name-prefix + longest-match rule
779
- return (d === agentId || d.startsWith(`${agentId}-`)) && !belongsToOther(d);
780
- })
781
- .filter((d) => {
782
- // an EMPTY chat.jsonl means the session was never used (created via
783
- // web, then abandoned). Reusing it would resurrect another agent's
784
- // timeline under this id, and two incarnations could even land in
785
- // one directory. Never treat empty logs as existing history.
776
+ const withHistory = [];
777
+ const empty = [];
778
+ for (const d of readdirSync(this.sessionsRoot())) {
779
+ const { owner } = this.inferSessionOwner(d, candidateIds);
780
+ if (owner !== agentId)
781
+ continue;
782
+ const dirPath = path.join(this.sessionsRoot(), d);
783
+ const m = this.sessionMtime(dirPath);
784
+ // an EMPTY chat.jsonl means the session was never really used — but it
785
+ // must not SHADOW a real history dir: an incarnation's state events
786
+ // keep refreshing the empty log's mtime, and newest-wins would then
787
+ // pick the empty one forever ("timeline of zero" on every restart).
788
+ let size = 0;
786
789
  try {
787
- return statSync(path.join(this.sessionsRoot(), d, "chat.jsonl")).size > 0;
790
+ size = statSync(path.join(dirPath, "chat.jsonl")).size;
788
791
  }
789
- catch {
790
- return false;
791
- }
792
- })
793
- .sort((a, b) => {
794
- // newest chat.jsonl wins
795
- const ma = this.sessionMtime(path.join(this.sessionsRoot(), a));
796
- const mb = this.sessionMtime(path.join(this.sessionsRoot(), b));
797
- return mb - ma;
798
- });
792
+ catch { /* no log yet */ }
793
+ (size > 0 ? withHistory : empty).push({ d, m });
794
+ }
795
+ const pick = (list) => list.sort((a, b) => b.m - a.m)[0]?.d;
796
+ // prefer a dir that actually carries history; fall back to the newest
797
+ // empty one ONLY when no history exists under this id at all
798
+ dirs = [pick(withHistory) ?? pick(empty)].filter((x) => !!x);
799
799
  }
800
800
  catch {
801
801
  return null;
@@ -814,14 +814,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
814
814
  const root = this.sessionsRoot();
815
815
  mkdirSync(root, { recursive: true });
816
816
  // exact-restart match first: <agentId> itself (or a previously generated
817
- // <agentId>-<suffix> dir when the bare name was taken). Prefix matching
818
- // alone could hand agent "proj-2" agent "proj"'s directory.
817
+ // <agentId>-<suffix> dir when the bare name was taken) — but only when it
818
+ // actually carries history. An empty log here must not shadow the real
819
+ // one found by findLatestSessionDir below.
819
820
  const selfDir = path.join(root, agentId);
820
- // the manifest on the bare-name dir wins: another agent may own it now
821
- // (ids are recycled across incarnations). No manifest → legacy behavior.
822
821
  const selfOwner = this.readSessionOwner(agentId);
823
822
  const selfUsable = selfOwner === null || selfOwner === agentId;
824
- if (!fresh && selfUsable && statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
823
+ if (!fresh &&
824
+ selfUsable &&
825
+ statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
825
826
  return selfDir; // restart → continue where we left off
826
827
  }
827
828
  // one-time migration from the ≤0.5.0 flat layout (<dataDir>/<id>.jsonl)
@@ -850,11 +851,17 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
850
851
  return path.join(root, sid);
851
852
  }
852
853
  /**
853
- * Internal session ids (= directory names) owned by an agent, newest log
854
- * first. These are the stable handles the web UI routes by — agent ids can
855
- * be recycled across incarnations, internal session ids cannot.
854
+ * All session dirs on disk with their inferred/manifested owner, newest
855
+ * log first. One readdir powers every "list sessions" need — the UI calls
856
+ * the aggregated endpoint instead of fanning out one request per agent.
856
857
  */
857
- sessionsOf(agentId) {
858
+ allSessions() {
859
+ const candidateIds = new Set(this.config.agents.map((a) => a.id).filter(Boolean));
860
+ for (const [liveId] of this.agents)
861
+ candidateIds.add(liveId);
862
+ // dir names themselves are candidate owners too: "s" owns legacy "s",
863
+ // "teapot-3-3c0048d7" is best matched by the longest configured id —
864
+ // inference needs the id set only, so nothing extra to add here.
858
865
  let names = [];
859
866
  try {
860
867
  names = readdirSync(this.sessionsRoot());
@@ -862,10 +869,36 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
862
869
  catch {
863
870
  return [];
864
871
  }
865
- return names
866
- .filter((d) => this.readSessionOwner(d) === agentId)
867
- .map((d) => ({ id: d, mtimeMs: this.sessionMtime(path.join(this.sessionsRoot(), d)) }))
868
- .sort((a, b) => b.mtimeMs - a.mtimeMs);
872
+ const out = [];
873
+ for (const d of names) {
874
+ const dirPath = path.join(this.sessionsRoot(), d);
875
+ const { owner, hadManifest } = this.inferSessionOwner(d, candidateIds);
876
+ if (!owner)
877
+ continue; // orphan dir (unknown owner) — not addressable
878
+ // backfill legacy dirs so the inference result becomes durable
879
+ if (!hadManifest)
880
+ this.writeSessionManifest(dirPath, owner);
881
+ let m = 0;
882
+ let size = 0;
883
+ try {
884
+ const st = statSync(path.join(dirPath, "chat.jsonl"));
885
+ m = st.mtimeMs;
886
+ size = st.size;
887
+ }
888
+ catch { /* no log yet — still listable */ }
889
+ out.push({ id: d, agentId: owner, mtimeMs: m, sizeBytes: size });
890
+ }
891
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
892
+ }
893
+ /**
894
+ * Internal session ids (= directory names) owned by an agent, newest log
895
+ * first. These are the stable handles the web UI routes by — agent ids can
896
+ * be recycled across incarnations, internal session ids cannot.
897
+ */
898
+ sessionsOf(agentId) {
899
+ return this.allSessions()
900
+ .filter((s) => s.agentId === agentId)
901
+ .map(({ id, mtimeMs, sizeBytes }) => ({ id, mtimeMs, sizeBytes }));
869
902
  }
870
903
  /** resolve an internal session id to (ownerAgentId, absolute dir) — null when it doesn't exist */
871
904
  sessionById(sessionId) {
@@ -873,7 +906,10 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
873
906
  if (!sessionId || sessionId.includes("/") || sessionId.includes("\\") || sessionId.startsWith("."))
874
907
  return null;
875
908
  const root = this.sessionsRoot();
876
- const owner = this.readSessionOwner(sessionId);
909
+ const candidateIds = new Set(this.config.agents.map((a) => a.id).filter(Boolean));
910
+ for (const [liveId] of this.agents)
911
+ candidateIds.add(liveId);
912
+ const { owner } = this.inferSessionOwner(sessionId, candidateIds);
877
913
  if (!owner)
878
914
  return null;
879
915
  try {
@@ -871,23 +871,6 @@ export function buildApp(master) {
871
871
  return c.json({ ok: true });
872
872
  });
873
873
  // edit a previously-sent prompt: forks there, optionally summarizes the tail
874
- app.post("/api/agents/:id/edit-prompt", async (c) => {
875
- const a = master.agents.get(c.req.param("id"));
876
- if (!a)
877
- return c.json({ error: "not found" }, 404);
878
- const body = await c.req
879
- .json()
880
- .catch(() => null);
881
- if (!body?.eventId || !body.text?.trim())
882
- return c.json({ error: "eventId and text required" }, 400);
883
- try {
884
- const r = await a.editPromptAt(body.eventId, body.text, body.tail === "summarize" ? "summarize" : "discard");
885
- return c.json({ ok: true, ...r });
886
- }
887
- catch (err) {
888
- return c.json({ error: err.message }, 409);
889
- }
890
- });
891
874
  app.post("/api/agents/:id/fork", async (c) => {
892
875
  const a = master.agents.get(c.req.param("id"));
893
876
  if (!a)
@@ -947,6 +930,10 @@ export function buildApp(master) {
947
930
  }
948
931
  return c.json({ events: events.slice(-limit), total: events.length });
949
932
  });
933
+ // ONE readdir-backed snapshot of every session on disk, owner included.
934
+ // The UI fetches this once per agent-set change instead of fanning out one
935
+ // request per agent — /api/agents/:id/sessions stays for compatibility.
936
+ app.get("/api/sessions", (c) => c.json({ sessions: master.allSessions() }));
950
937
  // list the internal session ids (= stable timeline handles) an agent owns,
951
938
  // newest first — the web UI routes /session/<internalId> by these
952
939
  app.get("/api/agents/:id/sessions", (c) => {
@@ -954,9 +941,10 @@ export function buildApp(master) {
954
941
  // resolvable when live, configured, OR still owning sessions on disk
955
942
  // (a stopped/lazy agent must keep its timeline reachable)
956
943
  const known = master.agents.has(id) || master.config.agents.some((a) => a.id === id);
957
- if (!known && master.sessionsOf(id).length === 0)
944
+ const owned = master.sessionsOf(id);
945
+ if (!known && owned.length === 0)
958
946
  return c.json({ error: "not found" }, 404);
959
- return c.json({ sessions: master.sessionsOf(id) });
947
+ return c.json({ sessions: owned });
960
948
  });
961
949
  app.get("/api/agents/:id/branches", async (c) => {
962
950
  const a = master.agents.get(c.req.param("id"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.22.8",
3
+ "version": "0.22.9",
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",