teapot-coding-agent 0.37.75 → 0.37.77

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
@@ -287,6 +287,10 @@ export class Master {
287
287
  this.raw.defaultProvider = this.config.defaultProvider;
288
288
  this.raw.tasks = this.config.tasks;
289
289
  this.raw.mcpServers = this.config.mcpServers;
290
+ if (this.config.sessionTitles !== undefined)
291
+ this.raw.sessionTitles = this.config.sessionTitles;
292
+ else
293
+ delete this.raw.sessionTitles;
290
294
  if (this.config.customThemes !== undefined)
291
295
  this.raw.customThemes = this.config.customThemes;
292
296
  if (this.config.password !== undefined)
@@ -714,6 +718,28 @@ export class Master {
714
718
  agent.toolCtx.subAgents = hooks;
715
719
  }
716
720
  await agent.init();
721
+ /**
722
+ * Seed the title from the SESSION, so a restart shows the name the operator gave it.
723
+ *
724
+ * `snapshot().sessionTitle` comes from the agent's own field, and that was only ever set by
725
+ * `renameSession` — so after a restart the UI fell back to the auto-generated name even though the
726
+ * title was on disk. The title comes from `config.sessionTitles`, which is the one copy.
727
+ */
728
+ const seedTitle = this.readSessionTitle(path.basename(sessionDir));
729
+ if (seedTitle)
730
+ agent.setSessionTitle(seedTitle);
731
+ else {
732
+ /**
733
+ * Derive a missing title at BOOT, once, and write it down.
734
+ *
735
+ * Waiting for the session's next user prompt means an existing session shows no name until the
736
+ * operator types into it, and every left-panel read has to fall back. Doing it here keeps the
737
+ * derivation to one pass per session and off the read path.
738
+ */
739
+ const id = path.basename(sessionDir);
740
+ this.writeSessionTitle(id, this.sessionTitleOf(sessionDir) ?? id);
741
+ agent.setSessionTitle(this.readSessionTitle(id) ?? id);
742
+ }
717
743
  this.agents.set(ac.id, agent);
718
744
  if (opts.persist) {
719
745
  this.config.agents.push(ac);
@@ -730,6 +756,9 @@ export class Master {
730
756
  if (!entry)
731
757
  return;
732
758
  entry.session = sessionId;
759
+ // The title is NOT deleted here: it now lives in the session's own manifest, so moving to another
760
+ // session simply means reading a different one. (Deleting it was how a rename reverted to the
761
+ // auto-generated name on the next switch.)
733
762
  delete entry.sessionTitle;
734
763
  delete ac.sessionTitle;
735
764
  if (this.sessionsReadOnly)
@@ -1029,18 +1058,49 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1029
1058
  const a = this.config.agents.find((x) => x.id === agentId);
1030
1059
  if (!a || a.session !== sessionId)
1031
1060
  return undefined;
1032
- const t = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
1033
- return t ? t : undefined;
1061
+ return this.readSessionTitle(sessionId) ?? undefined;
1034
1062
  }
1063
+ /** The session's own name, from its manifest. One place, so it cannot go stale in another. */
1064
+ readSessionTitle(sessionId) {
1065
+ if (!sessionId)
1066
+ return null;
1067
+ const t = this.config.sessionTitles?.[sessionId];
1068
+ return typeof t === "string" && t.trim() ? t.trim() : null;
1069
+ }
1070
+ writeSessionTitle(sessionId, title) {
1071
+ if (!sessionId)
1072
+ return;
1073
+ /**
1074
+ * Never write when the data dir was overridden by `TEAPOT_DATA_DIR`.
1075
+ *
1076
+ * In that mode the sessions being resolved are NOT the ones this config describes, so recording
1077
+ * anything about them — a pointer, or now a title — corrupts a config the operator did not ask to
1078
+ * change (#143). The title is still derived in memory so the UI shows something.
1079
+ */
1080
+ if (this.sessionsReadOnly) {
1081
+ this.config.sessionTitles ??= {};
1082
+ this.config.sessionTitles[sessionId] = title;
1083
+ return;
1084
+ }
1085
+ this.config.sessionTitles ??= {};
1086
+ this.config.sessionTitles[sessionId] = title;
1087
+ this.saveConfig();
1088
+ }
1089
+ /**
1090
+ * Derive a session's title ONCE, into the session's own manifest.
1091
+ *
1092
+ * Deriving it at read time would be worse: `sessionsOf`/`archivedAgents` would scan a `chat.jsonl`
1093
+ * per session on every call (MEASURED earlier: 0.87s for one archive toggle). Writing it down once,
1094
+ * where the session lives, keeps that scan off the hot path and keeps the answer in ONE place.
1095
+ */
1035
1096
  async ensureSessionTitle(agentId, sessionId) {
1036
1097
  const a = this.config.agents.find((x) => x.id === agentId);
1037
1098
  if (!a || a.session !== sessionId)
1038
1099
  return;
1039
- if (a.sessionTitle?.trim())
1100
+ if (this.readSessionTitle(sessionId))
1040
1101
  return;
1041
1102
  const dir = path.join(this.sessionsRoot(), sessionId);
1042
- a.sessionTitle = this.sessionTitleOf(dir) ?? sessionId;
1043
- this.saveConfig();
1103
+ this.writeSessionTitle(sessionId, this.sessionTitleOf(dir) ?? sessionId);
1044
1104
  }
1045
1105
  sessionTitleOf(dirPath) {
1046
1106
  let fd;
@@ -1215,7 +1275,8 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1215
1275
  size = st.size;
1216
1276
  }
1217
1277
  catch { }
1218
- const title = owner ? this.titleFor(owner, d) : undefined;
1278
+ // from the config map — no second per-directory read
1279
+ const title = owner ? this.readSessionTitle(d) ?? undefined : undefined;
1219
1280
  out.push({ id: d, agentId: owner, mtimeMs: m, sizeBytes: size, ...(title ? { title } : {}) });
1220
1281
  }
1221
1282
  return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -1263,8 +1324,16 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1263
1324
  const clean = title.replace(/\s+/g, " ").trim().slice(0, 80);
1264
1325
  if (!clean)
1265
1326
  throw new Error("a session title cannot be empty");
1266
- a.sessionTitle = clean;
1267
- this.saveConfig();
1327
+ /**
1328
+ * The title is keyed by SESSION, not by agent.
1329
+ *
1330
+ * As `agents[].sessionTitle` it was per AGENT, so `recordSession()` had to delete it whenever the
1331
+ * agent moved session — which is what reverted a rename — and a session no agent points at could not
1332
+ * keep a name at all. The map fixes both AND keeps the left panel to one config read: storing it in
1333
+ * `<session>/session.json` instead was MEASURED at 13.2ms for 838 sessions versus 1.2ms here.
1334
+ */
1335
+ this.writeSessionTitle(sessionId, clean);
1336
+ delete a.sessionTitle;
1268
1337
  this.agents.get(agentId)?.setSessionTitle(clean);
1269
1338
  bus.emit("update", { kind: "agent-update", agentId });
1270
1339
  return { title: clean };
@@ -7,19 +7,48 @@ export const sessionTitles = {
7
7
  run(ctx) {
8
8
  const raw = ctx.raw();
9
9
  const agents = Array.isArray(raw.agents) ? raw.agents : [];
10
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
10
11
  let changed = false;
12
+ /**
13
+ * Move the title out of `config.json` and into the session's own `session.json`.
14
+ *
15
+ * It used to be stored in `config.json` — a second copy of something the session directory owns,
16
+ * and the copy that went stale. A title already present wins over a derived one, so an operator's
17
+ * rename is never replaced by the auto-generated name.
18
+ */
19
+ /**
20
+ * Move `agents[].sessionTitle` into the `sessionTitles` MAP, keyed by session id.
21
+ *
22
+ * Per-agent was the wrong key: an agent moves between sessions, so the field had to be cleared on
23
+ * every switch (which reverted a rename), and a session no agent points at could not keep a name.
24
+ * A map also keeps the left panel to ONE config read instead of a `session.json` per directory —
25
+ * MEASURED: 838 sessions = 13.2ms of directory reads vs 1.2ms for the config file.
26
+ */
27
+ const titles = (raw.sessionTitles = isRecord(raw.sessionTitles) ? raw.sessionTitles : {});
11
28
  for (const a of agents) {
12
29
  const session = typeof a.session === "string" ? a.session : "";
13
- if (!session)
14
- continue;
15
- if (typeof a.sessionTitle === "string" && a.sessionTitle.trim())
16
- continue;
17
- if (session.includes("/") || session.includes(".."))
18
- continue;
19
- const title = firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl"));
20
- a.sessionTitle = title ?? session;
21
- changed = true;
30
+ const legacy = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
31
+ if (session && !session.includes("/") && !session.includes("..")) {
32
+ const already = typeof titles[session] === "string" ? String(titles[session]).trim() : "";
33
+ if (!already) {
34
+ const derived = legacy ||
35
+ firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl")) ||
36
+ session;
37
+ titles[session] = derived;
38
+ changed = true;
39
+ }
40
+ }
41
+ /**
42
+ * Only report a change when one happened: a migration that claims success every boot rewrites
43
+ * the config file forever, and re-running must be a genuine no-op (the suite asserts it).
44
+ */
45
+ if (a.sessionTitle !== undefined) {
46
+ delete a.sessionTitle;
47
+ changed = true;
48
+ }
22
49
  }
50
+ if (Object.keys(titles).length === 0 && raw.sessionTitles !== undefined)
51
+ delete raw.sessionTitles;
23
52
  if (changed)
24
53
  ctx.touch();
25
54
  return changed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.75",
3
+ "version": "0.37.77",
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",