teapot-coding-agent 0.37.76 → 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)
@@ -719,11 +723,23 @@ export class Master {
719
723
  *
720
724
  * `snapshot().sessionTitle` comes from the agent's own field, and that was only ever set by
721
725
  * `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.
726
+ * title was on disk. The title comes from `config.sessionTitles`, which is the one copy.
723
727
  */
724
728
  const seedTitle = this.readSessionTitle(path.basename(sessionDir));
725
729
  if (seedTitle)
726
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
+ }
727
743
  this.agents.set(ac.id, agent);
728
744
  if (opts.persist) {
729
745
  this.config.agents.push(ac);
@@ -1046,33 +1062,29 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1046
1062
  }
1047
1063
  /** The session's own name, from its manifest. One place, so it cannot go stale in another. */
1048
1064
  readSessionTitle(sessionId) {
1049
- if (!sessionId || sessionId.includes("/") || sessionId.includes(".."))
1065
+ if (!sessionId)
1050
1066
  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
- }
1067
+ const t = this.config.sessionTitles?.[sessionId];
1068
+ return typeof t === "string" && t.trim() ? t.trim() : null;
1059
1069
  }
1060
1070
  writeSessionTitle(sessionId, title) {
1061
- if (!sessionId || sessionId.includes("/") || sessionId.includes(".."))
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;
1062
1083
  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
1084
  }
1085
+ this.config.sessionTitles ??= {};
1086
+ this.config.sessionTitles[sessionId] = title;
1087
+ this.saveConfig();
1076
1088
  }
1077
1089
  /**
1078
1090
  * Derive a session's title ONCE, into the session's own manifest.
@@ -1263,7 +1275,8 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1263
1275
  size = st.size;
1264
1276
  }
1265
1277
  catch { }
1266
- 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;
1267
1280
  out.push({ id: d, agentId: owner, mtimeMs: m, sizeBytes: size, ...(title ? { title } : {}) });
1268
1281
  }
1269
1282
  return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -1312,16 +1325,15 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1312
1325
  if (!clean)
1313
1326
  throw new Error("a session title cannot be empty");
1314
1327
  /**
1315
- * The title belongs to the SESSION, so it is stored with the session.
1328
+ * The title is keyed by SESSION, not by agent.
1316
1329
  *
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.
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.
1321
1334
  */
1322
1335
  this.writeSessionTitle(sessionId, clean);
1323
1336
  delete a.sessionTitle;
1324
- this.saveConfig();
1325
1337
  this.agents.get(agentId)?.setSessionTitle(clean);
1326
1338
  bus.emit("update", { kind: "agent-update", agentId });
1327
1339
  return { title: clean };
@@ -1,4 +1,4 @@
1
- import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
1
+ import { closeSync, openSync, readSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  export const sessionTitles = {
4
4
  order: 30,
@@ -7,6 +7,7 @@ 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;
11
12
  /**
12
13
  * Move the title out of `config.json` and into the session's own `session.json`.
@@ -15,32 +16,27 @@ export const sessionTitles = {
15
16
  * and the copy that went stale. A title already present wins over a derived one, so an operator's
16
17
  * rename is never replaced by the auto-generated name.
17
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 : {});
18
28
  for (const a of agents) {
19
29
  const session = typeof a.session === "string" ? a.session : "";
20
- if (!session)
21
- continue;
22
- if (session.includes("/") || session.includes(".."))
23
- continue;
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`);
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;
40
39
  }
41
- catch {
42
- }
43
- changed = true;
44
40
  }
45
41
  /**
46
42
  * Only report a change when one happened: a migration that claims success every boot rewrites
@@ -51,6 +47,8 @@ export const sessionTitles = {
51
47
  changed = true;
52
48
  }
53
49
  }
50
+ if (Object.keys(titles).length === 0 && raw.sessionTitles !== undefined)
51
+ delete raw.sessionTitles;
54
52
  if (changed)
55
53
  ctx.touch();
56
54
  return changed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.76",
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",