teapot-coding-agent 0.22.7 → 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
@@ -380,6 +380,9 @@ export class Master {
380
380
  }
381
381
  const sessionDir = this.resolveSessionDir(ac.id, opts.fresh === true);
382
382
  await mkdirSync(sessionDir, { recursive: true });
383
+ // every session dir this master hands out carries an ownership manifest —
384
+ // legacy dirs get backfilled on their first restart under the new code
385
+ this.writeSessionManifest(sessionDir, ac.id);
383
386
  const agent = new Agent({
384
387
  id: ac.id,
385
388
  workspace: path.resolve(ac.workspace),
@@ -639,12 +642,19 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
639
642
  }
640
643
  }
641
644
  async messageChild(parentId, childId, text) {
642
- const cfg = this.config.agents.find((a) => a.id === childId);
645
+ // childId is normally the live agent id — but an internal session id
646
+ // (sessions/<id>, the stable timeline handle) also resolves, so callers
647
+ // holding only a session reference can still reach the owning agent.
648
+ let targetId = childId;
649
+ const owned = this.sessionById(childId);
650
+ if (owned)
651
+ targetId = owned.agentId;
652
+ const cfg = this.config.agents.find((a) => a.id === targetId);
643
653
  if (!cfg || cfg.parent !== parentId)
644
654
  throw new Error(`not your sub-agent: ${childId}`);
645
- const child = this.agents.get(childId);
655
+ const child = this.agents.get(targetId);
646
656
  if (!child)
647
- throw new Error(`sub-agent not running: ${childId}`);
657
+ throw new Error(`sub-agent not running: ${targetId}`);
648
658
  child.enqueuePrompt(`[harness] Message from @${parentId}:\n\n${text}`, "harness");
649
659
  if (child.status !== "running")
650
660
  child.start(`message from ${parentId}`);
@@ -700,51 +710,92 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
700
710
  sessionsRoot() {
701
711
  return path.join(this.config.dataDir, "sessions");
702
712
  }
713
+ /**
714
+ * Session-dir ownership manifest (sessions/<dir>/session.json). Ownership
715
+ * used to be GUESSED from the directory name's prefix, which is ambiguous:
716
+ * agent "teapot" matches "teapot-3-<hash>", and sibling ids where one is a
717
+ * prefix of the other ("prismrv-root-sub-review" vs
718
+ * "…-review-kernel") excluded each other's own dirs — every restart then
719
+ * generated a fresh empty session and history read as zero. The owner is
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.
723
+ */
724
+ readSessionOwner(dirName) {
725
+ try {
726
+ const raw = JSON.parse(readFileSync(path.join(this.sessionsRoot(), dirName, "session.json"), "utf8"));
727
+ return typeof raw.agentId === "string" ? raw.agentId : null;
728
+ }
729
+ catch {
730
+ return null; // legacy dir or unreadable manifest → fall back to names
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
+ }
751
+ writeSessionManifest(dirPath, agentId) {
752
+ try {
753
+ const file = path.join(dirPath, "session.json");
754
+ if (existsSync(file))
755
+ return; // never clobber an existing manifest
756
+ writeFileSync(file, `${JSON.stringify({ v: 1, agentId }, null, 2)}\n`);
757
+ }
758
+ catch {
759
+ /* best-effort metadata */
760
+ }
761
+ }
703
762
  findLatestSessionDir(agentId) {
704
- // Directory names are prefix-matched (exact, then "<id>-<suffix>"), which
705
- // is ambiguous across ids: agent "teapot" also matches "teapot-3-<hash>",
706
- // and newest-wins then handed agent teapot agent teapot-3's live session
707
- // — two agents writing one timeline. Exclude directories that belong to
708
- // ANOTHER configured agent id (or another live agent) before picking.
709
- const otherIds = new Set();
710
- for (const a of this.config.agents) {
711
- if (a.id && a.id !== agentId)
712
- otherIds.add(a.id);
713
- }
714
- for (const [liveId] of this.agents) {
715
- if (liveId !== agentId)
716
- otherIds.add(liveId);
717
- }
718
- const belongsToOther = (dir) => {
719
- for (const other of otherIds) {
720
- if (other === dir || dir.startsWith(`${other}-`))
721
- return true;
722
- }
723
- return false;
724
- };
763
+ // Directory names alone are ambiguous: agent "teapot" prefix-matches
764
+ // "teapot-3-<hash>", and sibling ids where one is a prefix of the other
765
+ // ("prismrv-root-sub-review" vs "…-review-kernel") used to exclude each
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);
725
774
  let dirs = [];
726
775
  try {
727
- dirs = readdirSync(this.sessionsRoot())
728
- .filter((d) => d === agentId || d.startsWith(`${agentId}-`))
729
- .filter((d) => !belongsToOther(d))
730
- .filter((d) => {
731
- // an EMPTY chat.jsonl means the session was never used (created via
732
- // web, then abandoned). Reusing it would resurrect another agent's
733
- // timeline under this id, and two incarnations could even land in
734
- // 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;
735
789
  try {
736
- return statSync(path.join(this.sessionsRoot(), d, "chat.jsonl")).size > 0;
737
- }
738
- catch {
739
- return false;
790
+ size = statSync(path.join(dirPath, "chat.jsonl")).size;
740
791
  }
741
- })
742
- .sort((a, b) => {
743
- // newest chat.jsonl wins
744
- const ma = this.sessionMtime(path.join(this.sessionsRoot(), a));
745
- const mb = this.sessionMtime(path.join(this.sessionsRoot(), b));
746
- return mb - ma;
747
- });
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);
748
799
  }
749
800
  catch {
750
801
  return null;
@@ -763,10 +814,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
763
814
  const root = this.sessionsRoot();
764
815
  mkdirSync(root, { recursive: true });
765
816
  // exact-restart match first: <agentId> itself (or a previously generated
766
- // <agentId>-<suffix> dir when the bare name was taken). Prefix matching
767
- // 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.
768
820
  const selfDir = path.join(root, agentId);
769
- if (!fresh && statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
821
+ const selfOwner = this.readSessionOwner(agentId);
822
+ const selfUsable = selfOwner === null || selfOwner === agentId;
823
+ if (!fresh &&
824
+ selfUsable &&
825
+ statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
770
826
  return selfDir; // restart → continue where we left off
771
827
  }
772
828
  // one-time migration from the ≤0.5.0 flat layout (<dataDir>/<id>.jsonl)
@@ -791,8 +847,81 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
791
847
  do {
792
848
  sid = `${agentId}-${randomUUID().slice(0, 8)}`;
793
849
  } while (existsSync(path.join(root, sid)));
850
+ this.writeSessionManifest(path.join(root, sid), agentId);
794
851
  return path.join(root, sid);
795
852
  }
853
+ /**
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.
857
+ */
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.
865
+ let names = [];
866
+ try {
867
+ names = readdirSync(this.sessionsRoot());
868
+ }
869
+ catch {
870
+ return [];
871
+ }
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 }));
902
+ }
903
+ /** resolve an internal session id to (ownerAgentId, absolute dir) — null when it doesn't exist */
904
+ sessionById(sessionId) {
905
+ // internal ids are directory names: refuse anything path-like up front
906
+ if (!sessionId || sessionId.includes("/") || sessionId.includes("\\") || sessionId.startsWith("."))
907
+ return null;
908
+ const root = this.sessionsRoot();
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);
913
+ if (!owner)
914
+ return null;
915
+ try {
916
+ const st = statSync(path.join(root, sessionId));
917
+ if (!st.isDirectory())
918
+ return null;
919
+ }
920
+ catch {
921
+ return null;
922
+ }
923
+ return { agentId: owner, dir: path.join(root, sessionId) };
924
+ }
796
925
  async removeAgent(id) {
797
926
  const agent = this.agents.get(id);
798
927
  if (!agent)
@@ -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)
@@ -923,13 +906,21 @@ export function buildApp(master) {
923
906
  if (!a)
924
907
  return c.json({ error: "not found" }, 404);
925
908
  const limit = Math.min(Number(c.req.query("limit") ?? 200), 5000);
926
- let events = await readEventsCached(a.log.filePath);
909
+ let filePath = a.log.filePath;
910
+ // ?session=<internalId> reads ANY session owned by this agent (past
911
+ // incarnations too) — the internal id, not the agent id, is the stable
912
+ // timeline handle. Ownership is enforced via the manifest.
913
+ const sessionId = c.req.query("session");
914
+ if (sessionId) {
915
+ const rec = master.sessionById(sessionId);
916
+ if (!rec || rec.agentId !== c.req.param("id"))
917
+ return c.json({ error: "not found" }, 404);
918
+ filePath = path.join(rec.dir, "chat.jsonl");
919
+ }
920
+ let events = await readEventsCached(filePath);
927
921
  const branch = c.req.query("branch");
928
- const session = c.req.query("session");
929
922
  if (branch)
930
923
  events = events.filter((e) => e.branch === branch);
931
- if (session)
932
- events = events.filter((e) => e.session === session);
933
924
  // cursor pagination for older pages: everything strictly BEFORE this id
934
925
  const before = c.req.query("before");
935
926
  if (before) {
@@ -939,6 +930,22 @@ export function buildApp(master) {
939
930
  }
940
931
  return c.json({ events: events.slice(-limit), total: events.length });
941
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() }));
937
+ // list the internal session ids (= stable timeline handles) an agent owns,
938
+ // newest first — the web UI routes /session/<internalId> by these
939
+ app.get("/api/agents/:id/sessions", (c) => {
940
+ const id = c.req.param("id");
941
+ // resolvable when live, configured, OR still owning sessions on disk
942
+ // (a stopped/lazy agent must keep its timeline reachable)
943
+ const known = master.agents.has(id) || master.config.agents.some((a) => a.id === id);
944
+ const owned = master.sessionsOf(id);
945
+ if (!known && owned.length === 0)
946
+ return c.json({ error: "not found" }, 404);
947
+ return c.json({ sessions: owned });
948
+ });
942
949
  app.get("/api/agents/:id/branches", async (c) => {
943
950
  const a = master.agents.get(c.req.param("id"));
944
951
  if (!a)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.22.7",
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",