teapot-coding-agent 0.37.91 → 0.37.93

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.
@@ -7,7 +7,6 @@ import { auditPrompt, parseAuditVerdict } from "./goal-audit.js";
7
7
  import { createCatalog } from "./tool-catalog.js";
8
8
  import { resetToolCatalog, executeTool, toolSpecs, currentSkills, safeJoin, isContextOverflow, isRequestNodeLimit, hasRunningBgShells, hasRunningBgShellsFor, killBgShells } from "./tools.js";
9
9
  import { effortChoosable } from "./llm.js";
10
- import { sessionTitleFrom } from "../session-title.js";
11
10
  import { mcp } from "./mcp-registry.js";
12
11
  import { flattenToolName } from "./mcp-registry.js";
13
12
  import { metaToolSpecs } from "./meta-tools.js";
@@ -298,10 +297,6 @@ export class Agent {
298
297
  skillsStamp = "";
299
298
  lastCompactSummary;
300
299
  compactRules = [];
301
- setSessionTitle(title) {
302
- this.sessionTitle = title?.trim() || undefined;
303
- }
304
- sessionTitle;
305
300
  overflowRecoveries = 0;
306
301
  consecutiveCompactFailures = 0;
307
302
  memoryDigestText() {
@@ -747,9 +742,6 @@ export class Agent {
747
742
  const last = lineage[lineage.length - 1];
748
743
  const msgs = await rebuildMessagesFrom(lineage, this.imagesDir);
749
744
  for (const e of own) {
750
- if (this.sessionTitle === undefined && e.type === "prompt" && e.data?.source === "user") {
751
- this.sessionTitle = sessionTitleFrom(e.data?.text) ?? undefined;
752
- }
753
745
  if (e.type === "state") {
754
746
  const d = e.data;
755
747
  if (d.detail === "llm turn start")
@@ -1059,6 +1051,16 @@ export class Agent {
1059
1051
  });
1060
1052
  }
1061
1053
  snapshot() {
1054
+ /**
1055
+ * The session's name comes from ONE place — `config.sessionTitles`, via the resolver Master hands
1056
+ * down. The agent used to keep its own copy, seeded at creation and never updated when the map
1057
+ * learned the real title, so the panel showed the internal id while `config.json` had the name
1058
+ * ("config.jsonにチャットタイトルが生成されているのに表示が内部IDになってる").
1059
+ *
1060
+ * A copy that has to be PUSHED is a copy that goes stale; deriving it means there is nothing to
1061
+ * push. The session id is the fallback so the panel is never blank.
1062
+ */
1063
+ const title = this.opts.sessionTitleOf?.(this.currentSession) || this.currentSession;
1062
1064
  return {
1063
1065
  id: this.opts.id,
1064
1066
  status: this.status === "idle" && this.isLive() ? "waiting" : this.status,
@@ -1067,7 +1069,7 @@ export class Agent {
1067
1069
  workspace: this.workspace,
1068
1070
  workspaceMissing: this.isGhost,
1069
1071
  session: this.currentSession,
1070
- ...(this.sessionTitle ? { sessionTitle: this.sessionTitle } : {}),
1072
+ ...(title ? { sessionTitle: title } : {}),
1071
1073
  branch: this.currentBranch,
1072
1074
  goal: {
1073
1075
  status: this.goal.status,
@@ -1155,7 +1157,7 @@ export class Agent {
1155
1157
  })
1156
1158
  .then(() => {
1157
1159
  if (source === "user")
1158
- this.opts.onFirstUserPrompt?.(this.currentSession);
1160
+ this.opts.onFirstUserPrompt?.(this.currentSession, text);
1159
1161
  })
1160
1162
  .then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
1161
1163
  .catch(() => { });
package/dist/master.js CHANGED
@@ -282,32 +282,52 @@ export class Master {
282
282
  this.configDirty = true;
283
283
  return;
284
284
  }
285
- this.raw.agents = this.config.agents;
286
- this.raw.providers = this.config.providers;
287
- this.raw.defaultProvider = this.config.defaultProvider;
288
- this.raw.tasks = this.config.tasks;
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;
294
- if (this.config.customThemes !== undefined)
295
- this.raw.customThemes = this.config.customThemes;
296
- if (this.config.password !== undefined)
297
- this.raw.password = this.config.password;
298
- else
299
- delete this.raw.password;
300
- if (this.config.themePrefs !== undefined)
301
- this.raw.themePrefs = this.config.themePrefs;
302
- this.raw.resumeAfterUpdate = this.config.resumeAfterUpdate;
303
- if (this.config.shortContext !== undefined)
304
- this.raw.shortContext = this.config.shortContext;
305
- if (this.config.pluginsDir !== undefined)
306
- this.raw.pluginsDir = this.config.pluginsDir;
307
- if (this.raw.progressIntervalMs === undefined && this.config.progressIntervalMs !== undefined)
308
- this.raw.progressIntervalMs = this.config.progressIntervalMs;
309
- if (this.raw.progressMinChars === undefined && this.config.progressMinChars !== undefined)
310
- this.raw.progressMinChars = this.config.progressMinChars;
285
+ /**
286
+ * ONE list of the fields `config.json` mirrors from `this.config`.
287
+ *
288
+ * This used to be ad-hoc lines, and it was wrong in two ways that both read as "the setting does not
289
+ * work":
290
+ *
291
+ * - `progressIntervalMs` / `progressMinChars` were written only when `raw` did not already have
292
+ * them (`=== undefined`), so the FIRST save persisted and every later change was dropped —
293
+ * reported as "progress interval (min) が機能してなくない? 少なくともconfig.jsonには書き込まれて
294
+ * ない";
295
+ * - `contextTokenBudget`, `contextWindowTokens`, `maxSpawnDepth`, `maxTurnsPerRound`, `onError`
296
+ * and `retryDelayMs` were never written at all, so those runtime controls could not persist
297
+ * either.
298
+ *
299
+ * Undefined DELETES the key rather than leaving the old value lying in the file, so clearing a
300
+ * control in the UI actually clears it on disk.
301
+ */
302
+ const mirrored = [
303
+ "agents",
304
+ "providers",
305
+ "defaultProvider",
306
+ "tasks",
307
+ "mcpServers",
308
+ "sessionTitles",
309
+ "customThemes",
310
+ "password",
311
+ "themePrefs",
312
+ "resumeAfterUpdate",
313
+ "shortContext",
314
+ "pluginsDir",
315
+ "progressIntervalMs",
316
+ "progressMinChars",
317
+ "contextTokenBudget",
318
+ "contextWindowTokens",
319
+ "maxSpawnDepth",
320
+ "maxTurnsPerRound",
321
+ "onError",
322
+ "retryDelayMs",
323
+ ];
324
+ for (const key of mirrored) {
325
+ const value = this.config[key];
326
+ if (value === undefined)
327
+ delete this.raw[key];
328
+ else
329
+ this.raw[key] = value;
330
+ }
311
331
  this.writeConfigAtomic(JSON.stringify(this.raw, null, 2) + "\n");
312
332
  }
313
333
  deferConfigWrites = false;
@@ -421,16 +441,13 @@ export class Master {
421
441
  this.config.contextWindowTokens =
422
442
  (patch.contextWindowTokens ?? 0) > 0 ? patch.contextWindowTokens : undefined;
423
443
  }
424
- for (const k of [
425
- "contextTokenBudget", "contextWindowTokens", "maxSpawnDepth",
426
- "maxTurnsPerRound", "onError", "retryDelayMs",
427
- ]) {
428
- const v = this.config[k];
429
- if (v === undefined)
430
- delete this.raw[k];
431
- else
432
- this.raw[k] = v;
433
- }
444
+ /**
445
+ * No `this.raw[...] = v` loop here — `saveConfig()` owns the mirror now (see its `mirrored` list).
446
+ *
447
+ * There used to be one, covering six of these keys and not the other two, which is exactly how the
448
+ * runtime controls ended up in different states: `contextTokenBudget` persisted, `progressIntervalMs`
449
+ * did not. One owner, one rule — and it runs once, at the end, after `tasks` is updated too.
450
+ */
434
451
  if (patch.tasks) {
435
452
  this.config.tasks = patch.tasks;
436
453
  this.tasks = patch.tasks.map((t) => ({
@@ -631,9 +648,11 @@ export class Master {
631
648
  const agent = new Agent({
632
649
  id: ac.id,
633
650
  workspace: resolveWorkspace(ac.workspace, path.dirname(this.configPath)),
634
- onFirstUserPrompt: (sessionId) => {
635
- void this.ensureSessionTitle(ac.id, sessionId);
651
+ onFirstUserPrompt: (sessionId, firstText) => {
652
+ void this.ensureSessionTitle(ac.id, sessionId, firstText);
636
653
  },
654
+ // ONE owner for a session's name: this map. The agent derives it on every snapshot.
655
+ sessionTitleOf: (sessionId) => this.readSessionTitle(sessionId) ?? undefined,
637
656
  llm,
638
657
  sessionDir,
639
658
  // boot must touch ONLY config.json and skills; this agent's directory is opened when it is used
@@ -723,27 +742,13 @@ export class Master {
723
742
  }
724
743
  await agent.init();
725
744
  /**
726
- * Seed the title from the SESSION, so a restart shows the name the operator gave it.
745
+ * No title is seeded, and nothing is derived here.
727
746
  *
728
- * `snapshot().sessionTitle` comes from the agent's own field, and that was only ever set by
729
- * `renameSession` — so after a restart the UI fell back to the auto-generated name even though the
730
- * title was on disk. The title comes from `config.sessionTitles`, which is the one copy.
747
+ * The agent derives its name from `config.sessionTitles` on every `snapshot()` (see
748
+ * `sessionTitleOf`), so there is no copy to seed. Deriving a missing one here would read
749
+ * `chat.jsonl`, and boot must not open session directories — "起動中に読んでいいのはconfig.jsonと
750
+ * skillsだけ / セッションディレクトリはセッションがロードされるまで開かない".
731
751
  */
732
- const seedTitle = this.readSessionTitle(path.basename(sessionDir));
733
- if (seedTitle)
734
- agent.setSessionTitle(seedTitle);
735
- else {
736
- /**
737
- * NOT derived here. Deriving a missing title reads `chat.jsonl`, and boot must not open session
738
- * directories at all — "起動中に読んでいいのはconfig.jsonとskillsだけ / セッションディレクトリは
739
- * セッションがロードされるまで開かない".
740
- *
741
- * The session ID is a perfectly good fallback for the panel, and the real derivation happens
742
- * when that session is actually loaded (`ensureSessionTitle`, via the agent's first prompt).
743
- */
744
- const id = path.basename(sessionDir);
745
- agent.setSessionTitle(this.readSessionTitle(id) ?? id);
746
- }
747
752
  this.agents.set(ac.id, agent);
748
753
  if (opts.persist) {
749
754
  this.config.agents.push(ac);
@@ -1088,6 +1093,17 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1088
1093
  }
1089
1094
  this.config.sessionTitles ??= {};
1090
1095
  this.config.sessionTitles[sessionId] = title;
1096
+ /**
1097
+ * …and the LIVE agent has to be told, or the panel keeps showing the id.
1098
+ *
1099
+ * `addAgent` seeds an agent's title from this map when it is created, falling back to the session id.
1100
+ * A NEW session gets its entry written later (on the first prompt, from `chat.jsonl`), and nothing
1101
+ * pushed it back — so the agent still held the id and the panel displayed it while `config.json` had
1102
+ * the real name. Reported as "config.jsonにチャットタイトルが生成されているのに表示が内部IDになってる".
1103
+ *
1104
+ * Pushing here rather than in each caller means every writer (the derivation, a rename) is covered by
1105
+ * one rule: the map changed, so the agent that is showing it changes too.
1106
+ */
1091
1107
  this.saveConfig();
1092
1108
  }
1093
1109
  /**
@@ -1097,14 +1113,23 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1097
1113
  * per session on every call (MEASURED earlier: 0.87s for one archive toggle). Writing it down once,
1098
1114
  * where the session lives, keeps that scan off the hot path and keeps the answer in ONE place.
1099
1115
  */
1100
- async ensureSessionTitle(agentId, sessionId) {
1116
+ async ensureSessionTitle(agentId, sessionId, firstText) {
1101
1117
  const a = this.config.agents.find((x) => x.id === agentId);
1102
1118
  if (!a || a.session !== sessionId)
1103
1119
  return;
1120
+ // the map is the master: once it has a name, nothing may replace it
1104
1121
  if (this.readSessionTitle(sessionId))
1105
1122
  return;
1106
- const dir = path.join(this.sessionsRoot(), sessionId);
1107
- this.writeSessionTitle(sessionId, this.sessionTitleOf(dir) ?? sessionId);
1123
+ /**
1124
+ * A session is named from its FIRST USER MESSAGE — that is the whole point of this hook, and it must
1125
+ * keep working for a session created at runtime.
1126
+ *
1127
+ * The text arrives from the agent, which already has it, so this needs no read of `chat.jsonl`. The
1128
+ * log is only a fallback for a caller that could not supply it.
1129
+ */
1130
+ const fromText = sessionTitleFrom(firstText) ?? undefined;
1131
+ const derived = fromText ?? this.sessionTitleOf(path.join(this.sessionsRoot(), sessionId)) ?? sessionId;
1132
+ this.writeSessionTitle(sessionId, derived);
1108
1133
  }
1109
1134
  sessionTitleOf(dirPath) {
1110
1135
  let fd;
@@ -1338,7 +1363,7 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
1338
1363
  */
1339
1364
  this.writeSessionTitle(sessionId, clean);
1340
1365
  delete a.sessionTitle;
1341
- this.agents.get(agentId)?.setSessionTitle(clean);
1366
+ // no field to set: the panel derives the name from the map, so the bus nudge is the whole update
1342
1367
  bus.emit("update", { kind: "agent-update", agentId });
1343
1368
  return { title: clean };
1344
1369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.91",
3
+ "version": "0.37.93",
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",