teapot-coding-agent 0.22.7 → 0.22.8

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,12 +710,43 @@ 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; prefix matching only remains
721
+ * as a fallback for legacy dirs without a manifest.
722
+ */
723
+ readSessionOwner(dirName) {
724
+ try {
725
+ const raw = JSON.parse(readFileSync(path.join(this.sessionsRoot(), dirName, "session.json"), "utf8"));
726
+ return typeof raw.agentId === "string" ? raw.agentId : null;
727
+ }
728
+ catch {
729
+ return null; // legacy dir or unreadable manifest → fall back to names
730
+ }
731
+ }
732
+ writeSessionManifest(dirPath, agentId) {
733
+ try {
734
+ const file = path.join(dirPath, "session.json");
735
+ if (existsSync(file))
736
+ return; // never clobber an existing manifest
737
+ writeFileSync(file, `${JSON.stringify({ v: 1, agentId }, null, 2)}\n`);
738
+ }
739
+ catch {
740
+ /* best-effort metadata */
741
+ }
742
+ }
703
743
  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.
744
+ // Directory names alone are ambiguous: agent "teapot" prefix-matches
745
+ // "teapot-3-<hash>", and sibling ids where one is a prefix of the other
746
+ // ("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.
709
750
  const otherIds = new Set();
710
751
  for (const a of this.config.agents) {
711
752
  if (a.id && a.id !== agentId)
@@ -715,18 +756,28 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
715
756
  if (liveId !== agentId)
716
757
  otherIds.add(liveId);
717
758
  }
759
+ // legacy fallback: ownership goes to the LONGEST id matching the name
718
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;
719
764
  for (const other of otherIds) {
720
- if (other === dir || dir.startsWith(`${other}-`))
721
- return true;
765
+ const len = matchedLen(other);
766
+ if (len > bestOther)
767
+ bestOther = len;
722
768
  }
723
- return false;
769
+ return bestOther > own; // equal-length ids cannot both match one dir
724
770
  };
725
771
  let dirs = [];
726
772
  try {
727
773
  dirs = readdirSync(this.sessionsRoot())
728
- .filter((d) => d === agentId || d.startsWith(`${agentId}-`))
729
- .filter((d) => !belongsToOther(d))
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
+ })
730
781
  .filter((d) => {
731
782
  // an EMPTY chat.jsonl means the session was never used (created via
732
783
  // web, then abandoned). Reusing it would resurrect another agent's
@@ -766,7 +817,11 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
766
817
  // <agentId>-<suffix> dir when the bare name was taken). Prefix matching
767
818
  // alone could hand agent "proj-2" agent "proj"'s directory.
768
819
  const selfDir = path.join(root, agentId);
769
- if (!fresh && statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
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
+ const selfOwner = this.readSessionOwner(agentId);
823
+ const selfUsable = selfOwner === null || selfOwner === agentId;
824
+ if (!fresh && selfUsable && statSync(path.join(selfDir, "chat.jsonl"), { throwIfNoEntry: false })?.size) {
770
825
  return selfDir; // restart → continue where we left off
771
826
  }
772
827
  // one-time migration from the ≤0.5.0 flat layout (<dataDir>/<id>.jsonl)
@@ -791,8 +846,46 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
791
846
  do {
792
847
  sid = `${agentId}-${randomUUID().slice(0, 8)}`;
793
848
  } while (existsSync(path.join(root, sid)));
849
+ this.writeSessionManifest(path.join(root, sid), agentId);
794
850
  return path.join(root, sid);
795
851
  }
852
+ /**
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.
856
+ */
857
+ sessionsOf(agentId) {
858
+ let names = [];
859
+ try {
860
+ names = readdirSync(this.sessionsRoot());
861
+ }
862
+ catch {
863
+ return [];
864
+ }
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);
869
+ }
870
+ /** resolve an internal session id to (ownerAgentId, absolute dir) — null when it doesn't exist */
871
+ sessionById(sessionId) {
872
+ // internal ids are directory names: refuse anything path-like up front
873
+ if (!sessionId || sessionId.includes("/") || sessionId.includes("\\") || sessionId.startsWith("."))
874
+ return null;
875
+ const root = this.sessionsRoot();
876
+ const owner = this.readSessionOwner(sessionId);
877
+ if (!owner)
878
+ return null;
879
+ try {
880
+ const st = statSync(path.join(root, sessionId));
881
+ if (!st.isDirectory())
882
+ return null;
883
+ }
884
+ catch {
885
+ return null;
886
+ }
887
+ return { agentId: owner, dir: path.join(root, sessionId) };
888
+ }
796
889
  async removeAgent(id) {
797
890
  const agent = this.agents.get(id);
798
891
  if (!agent)
@@ -923,13 +923,21 @@ export function buildApp(master) {
923
923
  if (!a)
924
924
  return c.json({ error: "not found" }, 404);
925
925
  const limit = Math.min(Number(c.req.query("limit") ?? 200), 5000);
926
- let events = await readEventsCached(a.log.filePath);
926
+ let filePath = a.log.filePath;
927
+ // ?session=<internalId> reads ANY session owned by this agent (past
928
+ // incarnations too) — the internal id, not the agent id, is the stable
929
+ // timeline handle. Ownership is enforced via the manifest.
930
+ const sessionId = c.req.query("session");
931
+ if (sessionId) {
932
+ const rec = master.sessionById(sessionId);
933
+ if (!rec || rec.agentId !== c.req.param("id"))
934
+ return c.json({ error: "not found" }, 404);
935
+ filePath = path.join(rec.dir, "chat.jsonl");
936
+ }
937
+ let events = await readEventsCached(filePath);
927
938
  const branch = c.req.query("branch");
928
- const session = c.req.query("session");
929
939
  if (branch)
930
940
  events = events.filter((e) => e.branch === branch);
931
- if (session)
932
- events = events.filter((e) => e.session === session);
933
941
  // cursor pagination for older pages: everything strictly BEFORE this id
934
942
  const before = c.req.query("before");
935
943
  if (before) {
@@ -939,6 +947,17 @@ export function buildApp(master) {
939
947
  }
940
948
  return c.json({ events: events.slice(-limit), total: events.length });
941
949
  });
950
+ // list the internal session ids (= stable timeline handles) an agent owns,
951
+ // newest first — the web UI routes /session/<internalId> by these
952
+ app.get("/api/agents/:id/sessions", (c) => {
953
+ const id = c.req.param("id");
954
+ // resolvable when live, configured, OR still owning sessions on disk
955
+ // (a stopped/lazy agent must keep its timeline reachable)
956
+ const known = master.agents.has(id) || master.config.agents.some((a) => a.id === id);
957
+ if (!known && master.sessionsOf(id).length === 0)
958
+ return c.json({ error: "not found" }, 404);
959
+ return c.json({ sessions: master.sessionsOf(id) });
960
+ });
942
961
  app.get("/api/agents/:id/branches", async (c) => {
943
962
  const a = master.agents.get(c.req.param("id"));
944
963
  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.8",
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",