teapot-coding-agent 0.37.74 → 0.37.76

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
@@ -714,6 +714,16 @@ export class Master {
714
714
  agent.toolCtx.subAgents = hooks;
715
715
  }
716
716
  await agent.init();
717
+ /**
718
+ * Seed the title from the SESSION, so a restart shows the name the operator gave it.
719
+ *
720
+ * `snapshot().sessionTitle` comes from the agent's own field, and that was only ever set by
721
+ * `renameSession` — so after a restart the UI fell back to the auto-generated name even though the
722
+ * title was on disk. The title is read from `session.json`, which is the one copy.
723
+ */
724
+ const seedTitle = this.readSessionTitle(path.basename(sessionDir));
725
+ if (seedTitle)
726
+ agent.setSessionTitle(seedTitle);
717
727
  this.agents.set(ac.id, agent);
718
728
  if (opts.persist) {
719
729
  this.config.agents.push(ac);
@@ -730,6 +740,9 @@ export class Master {
730
740
  if (!entry)
731
741
  return;
732
742
  entry.session = sessionId;
743
+ // The title is NOT deleted here: it now lives in the session's own manifest, so moving to another
744
+ // session simply means reading a different one. (Deleting it was how a rename reverted to the
745
+ // auto-generated name on the next switch.)
733
746
  delete entry.sessionTitle;
734
747
  delete ac.sessionTitle;
735
748
  if (this.sessionsReadOnly)
@@ -1029,18 +1042,53 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1029
1042
  const a = this.config.agents.find((x) => x.id === agentId);
1030
1043
  if (!a || a.session !== sessionId)
1031
1044
  return undefined;
1032
- const t = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
1033
- return t ? t : undefined;
1045
+ return this.readSessionTitle(sessionId) ?? undefined;
1034
1046
  }
1047
+ /** The session's own name, from its manifest. One place, so it cannot go stale in another. */
1048
+ readSessionTitle(sessionId) {
1049
+ if (!sessionId || sessionId.includes("/") || sessionId.includes(".."))
1050
+ return null;
1051
+ try {
1052
+ const raw = JSON.parse(readFileSync(path.join(this.sessionsRoot(), sessionId, "session.json"), "utf8"));
1053
+ const t = typeof raw.title === "string" ? raw.title.trim() : "";
1054
+ return t ? t : null;
1055
+ }
1056
+ catch {
1057
+ return null;
1058
+ }
1059
+ }
1060
+ writeSessionTitle(sessionId, title) {
1061
+ if (!sessionId || sessionId.includes("/") || sessionId.includes(".."))
1062
+ return;
1063
+ const file = path.join(this.sessionsRoot(), sessionId, "session.json");
1064
+ let raw = { v: 1 };
1065
+ try {
1066
+ raw = JSON.parse(readFileSync(file, "utf8"));
1067
+ }
1068
+ catch {
1069
+ }
1070
+ raw.title = title;
1071
+ try {
1072
+ writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
1073
+ }
1074
+ catch {
1075
+ }
1076
+ }
1077
+ /**
1078
+ * Derive a session's title ONCE, into the session's own manifest.
1079
+ *
1080
+ * Deriving it at read time would be worse: `sessionsOf`/`archivedAgents` would scan a `chat.jsonl`
1081
+ * per session on every call (MEASURED earlier: 0.87s for one archive toggle). Writing it down once,
1082
+ * where the session lives, keeps that scan off the hot path and keeps the answer in ONE place.
1083
+ */
1035
1084
  async ensureSessionTitle(agentId, sessionId) {
1036
1085
  const a = this.config.agents.find((x) => x.id === agentId);
1037
1086
  if (!a || a.session !== sessionId)
1038
1087
  return;
1039
- if (a.sessionTitle?.trim())
1088
+ if (this.readSessionTitle(sessionId))
1040
1089
  return;
1041
1090
  const dir = path.join(this.sessionsRoot(), sessionId);
1042
- a.sessionTitle = this.sessionTitleOf(dir) ?? sessionId;
1043
- this.saveConfig();
1091
+ this.writeSessionTitle(sessionId, this.sessionTitleOf(dir) ?? sessionId);
1044
1092
  }
1045
1093
  sessionTitleOf(dirPath) {
1046
1094
  let fd;
@@ -1263,7 +1311,16 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1263
1311
  const clean = title.replace(/\s+/g, " ").trim().slice(0, 80);
1264
1312
  if (!clean)
1265
1313
  throw new Error("a session title cannot be empty");
1266
- a.sessionTitle = clean;
1314
+ /**
1315
+ * The title belongs to the SESSION, so it is stored with the session.
1316
+ *
1317
+ * It used to live in `config.json` — a second copy of a fact the session directory already owns,
1318
+ * which is how it went stale: `recordSession()` deletes `sessionTitle` whenever the agent moves
1319
+ * session, so a rename followed by any session switch silently reverted the name to the
1320
+ * auto-generated one, and `config.json` was the only place that ever knew better.
1321
+ */
1322
+ this.writeSessionTitle(sessionId, clean);
1323
+ delete a.sessionTitle;
1267
1324
  this.saveConfig();
1268
1325
  this.agents.get(agentId)?.setSessionTitle(clean);
1269
1326
  bus.emit("update", { kind: "agent-update", agentId });
@@ -1,4 +1,4 @@
1
- import { closeSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  export const sessionTitles = {
4
4
  order: 30,
@@ -8,17 +8,48 @@ export const sessionTitles = {
8
8
  const raw = ctx.raw();
9
9
  const agents = Array.isArray(raw.agents) ? raw.agents : [];
10
10
  let changed = false;
11
+ /**
12
+ * Move the title out of `config.json` and into the session's own `session.json`.
13
+ *
14
+ * It used to be stored in `config.json` — a second copy of something the session directory owns,
15
+ * and the copy that went stale. A title already present wins over a derived one, so an operator's
16
+ * rename is never replaced by the auto-generated name.
17
+ */
11
18
  for (const a of agents) {
12
19
  const session = typeof a.session === "string" ? a.session : "";
13
20
  if (!session)
14
21
  continue;
15
- if (typeof a.sessionTitle === "string" && a.sessionTitle.trim())
16
- continue;
17
22
  if (session.includes("/") || session.includes(".."))
18
23
  continue;
19
- const title = firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl"));
20
- a.sessionTitle = title ?? session;
21
- changed = true;
24
+ const dir = path.join(ctx.dataDir, "sessions", session);
25
+ const file = path.join(dir, "session.json");
26
+ let manifest = { v: 1 };
27
+ try {
28
+ manifest = JSON.parse(readFileSync(file, "utf8"));
29
+ }
30
+ catch {
31
+ }
32
+ const already = typeof manifest.title === "string" ? manifest.title.trim() : "";
33
+ if (!already) {
34
+ const fromConfig = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
35
+ const derived = fromConfig || firstUserPromptText(path.join(dir, "chat.jsonl")) || session;
36
+ manifest.title = derived;
37
+ try {
38
+ mkdirSync(dir, { recursive: true });
39
+ writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
40
+ }
41
+ catch {
42
+ }
43
+ changed = true;
44
+ }
45
+ /**
46
+ * Only report a change when one happened: a migration that claims success every boot rewrites
47
+ * the config file forever, and re-running must be a genuine no-op (the suite asserts it).
48
+ */
49
+ if (a.sessionTitle !== undefined) {
50
+ delete a.sessionTitle;
51
+ changed = true;
52
+ }
22
53
  }
23
54
  if (changed)
24
55
  ctx.touch();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.74",
3
+ "version": "0.37.76",
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",