teapot-coding-agent 0.22.6 → 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}`);
@@ -677,7 +687,12 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
677
687
  "\n\n[Last conversation turns before finishing]\n" +
678
688
  d.recentContext.map((r) => r).join("\n\n");
679
689
  }
680
- parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} finished. Final report:\n${report.slice(0, 6000) || "(no summary)"}`, "harness");
690
+ parent.enqueuePrompt(
691
+ // NO truncation: the sub's report is the parent's primary input for
692
+ // deciding what to do next. Clipping it (the old 2000/6000 caps)
693
+ // silently dropped exactly the details the operator spawned the sub
694
+ // to gather. Context management is compaction's job, not the pipe's.
695
+ `[harness] Sub-agent ${ac.id} finished. Final report:\n${report || "(no summary)"}`, "harness");
681
696
  }
682
697
  else if (e.type === "error") {
683
698
  parent.enqueuePrompt(`[harness] Sub-agent ${ac.id} hit an error: ${String(e.data.message ?? "").slice(0, 500)}`, "harness");
@@ -695,12 +710,43 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
695
710
  sessionsRoot() {
696
711
  return path.join(this.config.dataDir, "sessions");
697
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
+ }
698
743
  findLatestSessionDir(agentId) {
699
- // Directory names are prefix-matched (exact, then "<id>-<suffix>"), which
700
- // is ambiguous across ids: agent "teapot" also matches "teapot-3-<hash>",
701
- // and newest-wins then handed agent teapot agent teapot-3's live session
702
- // — two agents writing one timeline. Exclude directories that belong to
703
- // 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.
704
750
  const otherIds = new Set();
705
751
  for (const a of this.config.agents) {
706
752
  if (a.id && a.id !== agentId)
@@ -710,18 +756,28 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
710
756
  if (liveId !== agentId)
711
757
  otherIds.add(liveId);
712
758
  }
759
+ // legacy fallback: ownership goes to the LONGEST id matching the name
713
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;
714
764
  for (const other of otherIds) {
715
- if (other === dir || dir.startsWith(`${other}-`))
716
- return true;
765
+ const len = matchedLen(other);
766
+ if (len > bestOther)
767
+ bestOther = len;
717
768
  }
718
- return false;
769
+ return bestOther > own; // equal-length ids cannot both match one dir
719
770
  };
720
771
  let dirs = [];
721
772
  try {
722
773
  dirs = readdirSync(this.sessionsRoot())
723
- .filter((d) => d === agentId || d.startsWith(`${agentId}-`))
724
- .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
+ })
725
781
  .filter((d) => {
726
782
  // an EMPTY chat.jsonl means the session was never used (created via
727
783
  // web, then abandoned). Reusing it would resurrect another agent's
@@ -761,7 +817,11 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
761
817
  // <agentId>-<suffix> dir when the bare name was taken). Prefix matching
762
818
  // alone could hand agent "proj-2" agent "proj"'s directory.
763
819
  const selfDir = path.join(root, agentId);
764
- 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) {
765
825
  return selfDir; // restart → continue where we left off
766
826
  }
767
827
  // one-time migration from the ≤0.5.0 flat layout (<dataDir>/<id>.jsonl)
@@ -786,8 +846,46 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
786
846
  do {
787
847
  sid = `${agentId}-${randomUUID().slice(0, 8)}`;
788
848
  } while (existsSync(path.join(root, sid)));
849
+ this.writeSessionManifest(path.join(root, sid), agentId);
789
850
  return path.join(root, sid);
790
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
+ }
791
889
  async removeAgent(id) {
792
890
  const agent = this.agents.get(id);
793
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.6",
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",