From dfb70574a5b9f99d9da6b91f8eaa3d4549d1267b Mon Sep 17 00:00:00 2001 From: Blueness Date: Sun, 11 Oct 2026 09:06:52 +0800 Subject: [PATCH 1/3] Follow omp session names in Telegram topics --- README.md | 18 +++++ docs/guide.md | 26 ++++++- src/access.ts | 3 + src/index.test.ts | 5 +- src/index.ts | 70 +++++++++++++++--- src/index.wiring.test.ts | 98 ++++++++++++++++++++++++- src/topic-name.test.ts | 142 ++++++++++++++++++++++++++++++++++++ src/topic-name.ts | 150 +++++++++++++++++++++++++++++++++++++++ src/topics.ts | 37 +++++----- 9 files changed, 516 insertions(+), 33 deletions(-) create mode 100644 src/topic-name.test.ts create mode 100644 src/topic-name.ts diff --git a/README.md b/README.md index c5823a1..c0e42e9 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,24 @@ The bot creates: Restart any omp sessions that were already running before you enabled topics so they can claim their own topic. +By default (`/telegram topics names session`), topic names follow the omp session +name, including auto-titles and local `/rename`. +Without a session name, the herdr agent name, herdr space label, then directory +name are used. Whitespace is collapsed and `TOPIC_NAME_MAX = 32` code points +caps the display name (including `…`); truncation prefers a word boundary after +at least 20 code points and never splits an emoji. Live-name collisions use a +`-pid` suffix within the same cap. Names synchronize after adoption, on every +`agent_end`, and within about 30 seconds of an idle rename. Rename failures +warn once per name and retry on a later check without blocking topic routing. +The registry format and saved session/herdr resume identities are unchanged. + +Set `/telegram topics names space` to restore the 0.14.0 herdr agent, space label, +or folder-derived name once, at the next check. After restoration, space mode +does no further topic edits or sync interval work. A settings-file watcher reacts +to mode switches in all live owning sessions, without a restart. Returning to +`session` starts synchronization again. `/telegram topics names [status]` shows +the current mode. + Topics persist and are re-adopted on restart. To tidy them automatically instead, run `/telegram topics tidy on` — each session's topic is deleted (DM host) or closed and reopened on re-adoption (group host) when it exits. Sweep leftovers from diff --git a/docs/guide.md b/docs/guide.md index 5242d30..10c400b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -179,6 +179,22 @@ topic. Use it for `/spawn`, `/sessions`, `/cleanup`, `/status`, `/help`, and `/thinking`, and `/retry`. A global command entered elsewhere posts its result in omp control and leaves a short redirect notice in the originating topic. +Topic names follow the omp session name by default, falling back to the herdr +agent name, space label, then directory. Names collapse whitespace and are capped +at 32 code points including `…`, preferring a word boundary after 20 code points +and preserving CJK and emoji graphemes. Live collisions use the existing `-pid` +suffix within the cap. Names sync after claim/adoption, on `agent_end`, and via +an unref'd 30-second comparison. Failed edits warn once per name and retry; +`TOPIC_NOT_MODIFIED` is success. Routing and saved resume identities are unchanged. + +`/telegram topics names session` is the default. Use +`/telegram topics names space` to restore each owned topic's 0.14.0 herdr agent, +space label, or folder-derived name once at the next check. Once restored, space +mode makes no further edits and runs no sync interval. A settings-file watcher +reacts to mode switches in every live owning session without a restart. +Switching back to `session` resumes synchronization. `topics names [status]` +shows the current mode, also included in topics status beside tidy. + | Command | Effect | |---|---| | `/spawn [space]` | List open herdr spaces with inline buttons, or confirm an exact label. A space with live omp sessions requires confirmation before starting another. | @@ -608,8 +624,8 @@ Session shutdown and agent completion clear pending timers. When several omp sessions share one bot, a single chat can't tell them apart: the poll-lock holder receives every message and its replies land in the chat's main view, so you can't address a specific session. **Topics mode** gives each session -its own Telegram **forum topic**, named after the pane herdr knows it by (falling -back to its project directory), inside one operator-chosen chat. +its own Telegram **forum topic**, following the omp session name by default or +the herdr agent/space/folder name in space mode, inside one operator-chosen chat. ``` /telegram topics on # topics on, auto-hosted in your paired DM; claim this session's topic @@ -618,10 +634,14 @@ back to its project directory), inside one operator-chosen chat. /telegram topics # show the topics chat, this session's topic, and DM forum-topic mode /telegram topics tidy on # tidy this session's topic on clean exit (delete in DM host, close in group host) /telegram topics tidy off # keep topics after exit for re-adoption (default) +/telegram topics names session # follow omp session names (default) +/telegram topics names space # restore the herdr/space/folder title once; disable ongoing sync +/telegram topics names status # show the current naming mode ``` - Each session **claims one topic** on start (and when that session runs `/telegram topics on` or `/telegram topics `). - The title is the strongest identity available, in this order: the pane's **herdr agent + In session mode the omp session name comes first. The fallback, and space-mode + title, is the strongest identity available, in this order: the pane's **herdr agent name** (what you assigned with `herdr agent start ` or `agent rename`, one-to-one with the session), then the pane's **herdr space label**, then `basename(cwd)`. The space is consulted because the agent lookup is best-effort — it reads herdr over a diff --git a/src/access.ts b/src/access.ts index 82f3b42..f054a41 100644 --- a/src/access.ts +++ b/src/access.ts @@ -90,6 +90,8 @@ export type Access = { topicsChat?: string; /** Tidy this session's topic on clean exit: delete it in a DM host, close it in a forum supergroup. Set via /telegram topics tidy. */ topicsTidy?: boolean; + /** Topic naming: session (default) follows omp names; space uses legacy herdr/folder titles. */ + topicsNames?: "session" | "space"; /** Persistent owner-DM topic used for bridge/herdr control commands. */ controlThreadId?: number; /** @@ -321,6 +323,7 @@ export function loadAccess(warn?: (msg: string) => void): Access { notifyChat: parsed.notifyChat, topicsChat: parsed.topicsChat, topicsTidy: parsed.topicsTidy, + topicsNames: parsed.topicsNames === "space" ? "space" : "session", controlThreadId: parsed.controlThreadId, notifyMode, }; diff --git a/src/index.test.ts b/src/index.test.ts index d4a6bb1..dfbd3df 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -164,9 +164,10 @@ describe("/telegram argument completions", () => { }); test("completes nested subcommands past the first token", () => { - expect(labels("topics ")).toEqual(["on", "off", "status", "tidy"]); + expect(labels("topics ")).toEqual(["on", "off", "status", "tidy", "names"]); expect(labels("topics ti")).toEqual(["tidy"]); expect(labels("topics tidy ")).toEqual(["on", "off", "status"]); + expect(labels("topics names ")).toEqual(["session", "space", "status"]); expect(labels("daemon ")).toEqual(["status", "restart", "stop"]); expect(labels("policy ")).toEqual(["pairing", "allowlist", "disabled"]); expect(labels("notify ")).toEqual(["off", "away", "always", "status", "clear"]); @@ -175,6 +176,8 @@ describe("/telegram argument completions", () => { test("completes set keys and their enum values", () => { expect(labels("set ")).toContain("replyToMode"); + expect(labels("set ")).not.toContain("topicNames"); + expect(labels("set ")).not.toContain("topicsNames"); const setKey = (telegramArgumentCompletions("set ") ?? []).find((i) => i.label === "replyToMode"); expect(setKey?.description).toBe("thread replies: off | first | all"); expect(labels("set replyToMode ")).toEqual(["off", "first", "all"]); diff --git a/src/index.ts b/src/index.ts index 7762ad2..f3285cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,7 @@ import { SETTINGS_GRAMMAR, SETTING_HELP } from "./settings"; import { DeliveryReporter } from "./delivery"; import { SessionCardController, publishSessionStatus, removeSessionStatus, type SessionStatusSnapshot } from "./session-status"; import { RunLifecycle } from "./run-lifecycle"; +import { availableTopicName, topicName, TopicNameSync } from "./topic-name"; interface SendParams { @@ -133,7 +134,7 @@ const TELEGRAM_ARGS: CompletionNode = { set: SETTINGS_GRAMMAR, notify: { off: null, away: null, always: null, status: null, clear: null }, own: { status: null, clear: null }, - topics: { on: null, off: null, status: null, tidy: { on: null, off: null, status: null } }, + topics: { on: null, off: null, status: null, tidy: { on: null, off: null, status: null }, names: { session: null, space: null, status: null } }, }; const SUBCOMMANDS = Object.keys(TELEGRAM_ARGS); @@ -501,6 +502,8 @@ export default function telegramExtension(pi: ExtensionAPI): void { let lastCtx: ExtensionContext | undefined; let lockRetryTimer: NodeJS.Timeout | undefined; let ownTopic: { threadId: number; name: string } | undefined; + const topicNameSync = new TopicNameSync(); + let syncedTopicName: string | undefined; let ownSpace: { workspaceId: string; label: string; terminalIds: string[] } | undefined; /** herdr agent name for this session, when herdr named this pane. */ let ownAgentName: string | undefined; @@ -607,6 +610,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { getToken: () => token || resolveToken(), onAccessChanged: (current) => { access = current; + checkTopicName(); ensureDaemon(warn); }, onTokenChanged: (nextToken, me) => { @@ -804,6 +808,8 @@ export default function telegramExtension(pi: ExtensionAPI): void { } async function stopBot(): Promise { + topicNameSync.stop(); + syncedTopicName = undefined; stopLockBeat?.(); stopLockBeat = undefined; stopDmWatch?.(); @@ -881,6 +887,35 @@ export default function telegramExtension(pi: ExtensionAPI): void { }; } + function desiredTopicName(): string { + return topicName(pi.getSessionName(), sessionTopicTitle(ownAgentName, ownSpace?.label, process.cwd())); + } + + /** Never hold up routing or agent_end for an optional Telegram title edit. */ + async function syncTopicName(force = false): Promise { + const current = loadAccess(warn); + const mode = current.topicsNames ?? "session"; + if (ownTopic) topicNameSync.configure(mode, checkTopicName); + if (!ownTopic || !current.topicsChat || !token || (isDmChat(current.topicsChat) && botHasTopics === false)) return; + const desired = mode === "session" ? desiredTopicName() : sessionTopicTitle(ownAgentName, ownSpace?.label, process.cwd()); + if (mode === "session" && !force && desired === syncedTopicName) return; + const topic = ownTopic; + const chatId = current.topicsChat; + if (!chatId || (isDmChat(chatId) && botHasTopics === false)) return; + if (await topicNameSync.check(chatId, topic, desired, (method, payload) => tg(token, method, payload), log)) { + if (ownTopic === topic && access.topicsChat === chatId) syncedTopicName = desired; + } + } + + function checkTopicName(): void { + void syncTopicName().catch((err) => warn(`topic name check failed: ${String(err)}`)); + } + + function startTopicNameSync(): void { + topicNameSync.watchSettings(checkTopicName, log); + syncedTopicName = undefined; + checkTopicName(); + } /** Consume DMs spooled to this session while it owns dm-owner.json. */ function startDmWatch(): void { stopDmWatch?.(); @@ -1028,9 +1063,8 @@ export default function telegramExtension(pi: ExtensionAPI): void { let name = basename(cwd); try { await captureOwnSpace(ctx); - // Identity beats the directory name; see `sessionTopicTitle` for why the - // herdr space is consulted before falling back to `basename(cwd)`. - name = sessionTopicTitle(ownAgentName, ownSpace?.label, cwd); + const sessionNames = loadAccess(warn).topicsNames !== "space"; + name = sessionNames ? desiredTopicName() : sessionTopicTitle(ownAgentName, ownSpace?.label, cwd); const r = loadRegistry(warn); const sessionId = ctx?.sessionManager.getSessionId(); const sessionFile = ctx?.sessionManager.getSessionFile(); @@ -1060,10 +1094,11 @@ export default function telegramExtension(pi: ExtensionAPI): void { releaseThread(threadId, process.pid, warn); warn(`saved topic #${threadId} no longer exists in Telegram; creating a replacement`); } - } else if (existing) { + } else if (existing && !sessionNames) { name = `${name}-${process.pid}`; } if (!ownTopic) { + if (sessionNames) name = availableTopicName(desiredTopicName(), loadRegistry(warn)); const topic = await tg<{ message_thread_id: number }>(token, "createForumTopic", { chat_id: access.topicsChat, name }); claimThread(access.topicsChat, topic.message_thread_id, threadEntry(ctx, cwd, name), warn); ownTopic = { threadId: topic.message_thread_id, name }; @@ -1076,6 +1111,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { stopWatch = watchRoute(ownTopic.threadId, (message) => processLocal(message), log, undefined, (message, state, detail) => state === "accepted" ? undefined : deliveries.report(message, state, detail)); log.info(`[telegram] topic #${ownTopic.threadId} (${ownTopic.name}) claimed in chat ${access.topicsChat}`); + startTopicNameSync(); } catch (err) { warn(`could not claim topic: ${String(err)}`); ctx?.ui.notify(`telegram: topic claim failed — ${err instanceof TgError ? `${err.code} ${err.message}` : String(err)} (running untopiced)`, "warning"); @@ -1653,6 +1689,20 @@ export default function telegramExtension(pi: ExtensionAPI): void { const v = arg.trim(); let a = loadAccess(warn); access = a; + if (/^names(\s|$)/.test(v)) { + const sub = v.slice(5).trim().toLowerCase(); + if (sub === "session" || sub === "space") { + access = a = updateAccess((current) => { current.topicsNames = sub; }, warn); + syncedTopicName = undefined; + checkTopicName(); + ctx.ui.notify(`telegram: topics names ${sub}`, "info"); + } else if (sub === "" || sub === "status") { + ctx.ui.notify(`telegram: topics names ${a.topicsNames ?? "session"}`, "info"); + } else { + ctx.ui.notify("usage: /telegram topics names session | space", "warning"); + } + return; + } if (/^tidy(\s|$)/.test(v)) { const sub = v.slice(4).trim().toLowerCase(); if (sub === "on") { @@ -1669,6 +1719,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { return; } if (v === "off") { + topicNameSync.stop(); stopWatch?.(); stopWatch = undefined; if (a.topicsTidy && ownTopic && a.topicsChat && token) { @@ -1721,9 +1772,9 @@ export default function telegramExtension(pi: ExtensionAPI): void { return; } const owned = ownTopic ? ` · this session: #${ownTopic.threadId} (${ownTopic.name})` : ""; - if (!a.topicsChat) return ctx.ui.notify("usage: /telegram topics on | | off | tidy on|off", "info"); + if (!a.topicsChat) return ctx.ui.notify("usage: /telegram topics on | | off | tidy on|off | names session|space", "info"); const dmMode = isDmChat(a.topicsChat) ? ` · DM forum-topic mode: ${botHasTopics === undefined ? "unknown" : botHasTopics ? "on" : "off"}` : ""; - ctx.ui.notify(`telegram: topicsChat = ${a.topicsChat}${owned}${dmMode} · tidy: ${a.topicsTidy ? "on" : "off"} · control: ${a.controlThreadId != null ? `#${a.controlThreadId}` : "not attached"}`, "info"); + ctx.ui.notify(`telegram: topicsChat = ${a.topicsChat}${owned}${dmMode} · tidy: ${a.topicsTidy ? "on" : "off"} · names: ${a.topicsNames ?? "session"} · control: ${a.controlThreadId != null ? `#${a.controlThreadId}` : "not attached"}`, "info"); } // ---- registrations ------------------------------------------------------ @@ -2374,7 +2425,9 @@ export default function telegramExtension(pi: ExtensionAPI): void { await runLifecycle.fallbackApplied(event); }); pi.on("agent_end", async (event, ctx) => { - if (isTaskSubagent(ctx.hasUI, pi.getActiveTools()) || event.willContinue) return; + if (isTaskSubagent(ctx.hasUI, pi.getActiveTools())) return; + void syncTopicName(true).catch((err) => warn(`topic name check failed: ${String(err)}`)); + if (event.willContinue) return; lastCtx = ctx; for (const pending of pendingApprovals.values()) clearTimeout(pending.timer); pendingApprovals.clear(); @@ -2420,6 +2473,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { // bridge. Its cleanup would release the parent's poll lock, repoint the DM // owner at the ended child, and drop the parent's session status (#83). if (isTaskSubagent(ctx.hasUI, pi.getActiveTools())) return; + topicNameSync.stop(); poller.stop(); stopWatch?.(); stopDmWatch?.(); diff --git a/src/index.wiring.test.ts b/src/index.wiring.test.ts index c09df35..9f1b5d2 100644 --- a/src/index.wiring.test.ts +++ b/src/index.wiring.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { type Access, defaultAccess, loadAccess } from "./access"; +import { type Access, defaultAccess, loadAccess, saveAccess } from "./access"; import telegramExtension from "./index"; import { claimDmOwner, loadDmOwner, loadRegistry, saveRegistry } from "./topics"; @@ -21,6 +21,8 @@ type Harness = { handlers: Map; setActiveCalls: string[][]; active: () => string[]; + sessionName: () => string | undefined; + rename: (name: string) => void; }; // A structural fake ExtensionAPI that captures registrations and tool-set @@ -32,6 +34,7 @@ function harness(initialTools: string[], activateRegisteredTools = false): Harne const handlers = new Map(); const setActiveCalls: string[][] = []; let active = [...initialTools]; + let sessionName: string | undefined; // Every `T.Xxx(...)` access returns a callable that yields the same stand-in, // so schema construction at registration time never throws. const anyType: unknown = new Proxy(() => anyType, { get: () => () => anyType }); @@ -72,11 +75,13 @@ function harness(initialTools: string[], activateRegisteredTools = false): Harne setModel: async () => true, getThinkingLevel: () => undefined, setThinkingLevel: () => {}, + getSessionName: () => sessionName, + setSessionName: (name: string) => { sessionName = name; }, }; // Structural stand-in for the injected API; the extension only touches the // members mocked above during registration and the handlers under test. telegramExtension(pi as unknown as ExtensionAPI); - return { tools, commands, handlers, setActiveCalls, active: () => active }; + return { tools, commands, handlers, setActiveCalls, active: () => active, sessionName: () => sessionName, rename: (name) => { sessionName = name; } }; } const previousStateDir = process.env.OMP_TELEGRAM_STATE_DIR; @@ -131,6 +136,95 @@ async function startBridge(h: Harness): Promise { ); } +describe("topic-name settings wiring", () => { + test.each(["session", "space"] as const)("%s mode controls creation and edits; enabling session works without restart", async (mode) => { + writeAccess({ enabled: true, allowFrom: ["42"], topicsChat: "42", controlThreadId: 98, topicsNames: mode }); + const h = harness(["read"]); + h.rename("Session title"); + const calls: { method: string; body: Record }[] = []; + const edited = Promise.withResolvers>(); + const previousFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const method = String(input).split("/").pop()!; + const body = JSON.parse(String(init?.body ?? "{}")); + calls.push({ method, body }); + if (method === "editForumTopic") edited.resolve(body); + const result = method === "createForumTopic" ? { message_thread_id: 99 } : { message_id: 7 }; + return new Response(JSON.stringify({ ok: true, result }), { status: 200 }); + }) as typeof fetch; + try { + await startBridge(h); + expect(calls.find((call) => call.method === "createForumTopic")?.body.name).toBe( + mode === "session" ? "Session title" : basename(process.cwd()), + ); + h.rename("Renamed session"); + await h.handlers.get("agent_end")?.[0]?.({ type: "agent_end", willContinue: true }, {}); + if (mode === "space") { + expect(calls.filter((call) => call.method === "editForumTopic")).toEqual([]); + await h.commands.get("telegram")!.handler("topics names session", { ui: { notify() {} } }); + } + expect(await edited.promise).toMatchObject({ chat_id: "42", message_thread_id: 99, name: "Renamed session" }); + } finally { + await h.handlers.get("session_shutdown")?.[0]?.({ type: "session_shutdown" }, {}); + globalThis.fetch = previousFetch; + } + }); + test("mode changes from another session restore space once and wake an idle space session", async () => { + writeAccess({ enabled: true, allowFrom: ["42"], topicsChat: "42", controlThreadId: 98 }); + const h = harness(["read"]); + h.rename("Session title"); + const restored = Promise.withResolvers>(); + const resumed = Promise.withResolvers>(); + const edits: Record[] = []; + const previousFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const method = String(input).split("/").pop()!; + const body = JSON.parse(String(init?.body ?? "{}")); + if (method === "editForumTopic") { + edits.push(body); + if (body.name === basename(process.cwd())) restored.resolve(body); + else resumed.resolve(body); + } + const result = method === "createForumTopic" ? { message_thread_id: 99 } : { message_id: 7 }; + return new Response(JSON.stringify({ ok: true, result }), { status: 200 }); + }) as typeof fetch; + try { + await startBridge(h); + saveAccess({ ...loadAccess(), topicsNames: "space" }); + expect((await restored.promise).name).toBe(basename(process.cwd())); + h.rename("Changed while in space"); + await h.handlers.get("agent_end")?.[0]?.({ type: "agent_end", willContinue: true }, {}); + saveAccess({ ...loadAccess(), topicsNames: "session" }); + expect((await resumed.promise).name).toBe("Changed while in space"); + expect(edits.map((edit) => edit.name)).toEqual([basename(process.cwd()), "Changed while in space"]); + } finally { + await h.handlers.get("session_shutdown")?.[0]?.({ type: "session_shutdown" }, {}); + globalThis.fetch = previousFetch; + } + }); + + test("topics names validates and persists the mode, reports it beside tidy, and rejects the retired set key", async () => { + writeAccess({ topicsChat: "42" }); + const h = harness(["read"]); + const notices: string[] = []; + const ctx = { ui: { notify: (text: string) => { notices.push(text); } } }; + await h.commands.get("telegram")!.handler("topics names", ctx); + expect(notices.at(-1)).toBe("telegram: topics names session"); + await h.commands.get("telegram")!.handler("topics names space", ctx); + expect(loadAccess().topicsNames).toBe("space"); + await h.commands.get("telegram")!.handler("topics status", ctx); + expect(notices.at(-1)).toContain("tidy: off · names: space"); + await h.commands.get("telegram")!.handler("topics names project", ctx); + expect(notices.at(-1)).toBe("usage: /telegram topics names session | space"); + expect(loadAccess().topicsNames).toBe("space"); + await h.commands.get("telegram")!.handler("set topicNames session", ctx); + expect(notices.at(-1)).toContain('set: unknown key "topicNames"'); + expect(loadAccess().topicsNames).toBe("space"); + await h.commands.get("telegram")!.handler("topics names session", ctx); + expect(loadAccess().topicsNames).toBe("session"); + }); +}); + describe("extension wiring", () => { test.each(["on", "off"])("%s preserves settings changed by another session", async (command) => { writeAccess({ enabled: false, allowFrom: ["42"] }); diff --git a/src/topic-name.test.ts b/src/topic-name.test.ts new file mode 100644 index 0000000..ae6a2ff --- /dev/null +++ b/src/topic-name.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { defaultAccess, loadAccess, saveAccess } from "./access"; +import { type Logger, TgError } from "./api"; +import { availableTopicName, TOPIC_NAME_MAX, topicName, TopicNameSync } from "./topic-name"; +import { loadRegistry, saveRegistry } from "./topics"; + +const previousStateDir = process.env.OMP_TELEGRAM_STATE_DIR; +let dir: string; +let sync: TopicNameSync; +let calls: Array<{ method: string; payload: Record }>; +let warnings: string[]; +let log: Logger; +const callTelegram = async (method: string, payload: Record) => { calls.push({ method, payload }); }; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "omp-tg-topic-name-")); + process.env.OMP_TELEGRAM_STATE_DIR = dir; + sync = new TopicNameSync(); + calls = []; + warnings = []; + log = { debug: () => {}, info: () => {}, error: () => {}, warn: (text) => { warnings.push(text); } }; + saveRegistry({ version: 1, chatId: "42", threads: { + "7": { pid: process.pid, cwd: "/project", name: "old", claimedAt: 1, sessionId: "session-a", sessionFile: "/session.jsonl" }, + } }); +}); +afterEach(() => { + sync.stop(); + if (previousStateDir === undefined) delete process.env.OMP_TELEGRAM_STATE_DIR; + else process.env.OMP_TELEGRAM_STATE_DIR = previousStateDir; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("topic name normalization", () => { + test("uses the session name, collapses whitespace, and falls back for blank names", () => { + expect(topicName(" session\n name ", "project")).toBe("session name"); + expect(topicName(" ", "project")).toBe("project"); + expect(topicName(undefined, "project")).toBe("project"); + }); + test("prefers a word boundary after at least 20 code points", () => { + expect(topicName("abcdefghijklmnopqrst hello world again", "")).toBe("abcdefghijklmnopqrst hello…"); + expect(topicName("short " + "x".repeat(40), "")).toBe("short " + "x".repeat(25) + "…"); + }); + test("caps CJK and single-code-point emoji without splitting them", () => { + expect(topicName("中".repeat(40), "")).toBe("中".repeat(31) + "…"); + expect(topicName("😀".repeat(40), "")).toBe("😀".repeat(31) + "…"); + }); + test("preserves multi-code-point emoji graphemes", () => { + const family = "👨‍👩‍👧‍👦"; + expect(topicName("a".repeat(28) + family + " more", "")).toBe("a".repeat(28) + "…"); + expect(topicName(family.repeat(6), "")).toBe(family.repeat(4) + "…"); + }); + test("leaves exactly 32 code points untouched", () => { + expect(topicName("a".repeat(TOPIC_NAME_MAX), "")).toBe("a".repeat(TOPIC_NAME_MAX)); + expect(topicName("😀".repeat(TOPIC_NAME_MAX), "")).toBe("😀".repeat(TOPIC_NAME_MAX)); + }); +}); + +describe("topic name synchronization", () => { + test("renames a changed topic and preserves routing identities; unchanged names do not call Telegram", async () => { + const topic = { threadId: 7, name: "old" }; + const before = loadRegistry().threads["7"]; + expect(await sync.check("42", topic, "new", callTelegram, log)).toBe(true); + expect(calls).toEqual([{ method: "editForumTopic", payload: { chat_id: "42", message_thread_id: 7, name: "new" } }]); + expect(loadRegistry().threads["7"]).toEqual({ ...before, name: "new" }); + expect(topic.name).toBe("new"); + expect(await sync.check("42", topic, "new", callTelegram, log)).toBe(true); + expect(calls).toHaveLength(1); + }); + test("treats TOPIC_NOT_MODIFIED as success", async () => { + const topic = { threadId: 7, name: "old" }; + expect(await sync.check("42", topic, "new", async () => { throw new TgError("Bad Request: TOPIC_NOT_MODIFIED", 400); }, log)).toBe(true); + expect(topic.name).toBe("new"); + expect(loadRegistry().threads["7"].name).toBe("new"); + expect(warnings).toEqual([]); + }); + test("warns once and retries a failed name until success", async () => { + const topic = { threadId: 7, name: "old" }; + const fail = async () => { throw new TgError("Bad Request: unavailable", 400); }; + expect(await sync.check("42", topic, "new", fail, log)).toBe(false); + expect(await sync.check("42", topic, "new", fail, log)).toBe(false); + expect(warnings).toHaveLength(1); + expect(loadRegistry().threads["7"].name).toBe("old"); + expect(await sync.check("42", topic, "new", callTelegram, log)).toBe(true); + expect(topic.name).toBe("new"); + }); + test("suffixes a live collision inside the cap", async () => { + const desired = "😀".repeat(32); + const registry = loadRegistry(); + registry.threads["8"] = { pid: process.pid, cwd: "/other", name: desired, claimedAt: 1 }; + saveRegistry(registry); + const expected = availableTopicName(desired, registry, 7); + expect(expected.endsWith(`-${process.pid}`)).toBe(true); + expect(Array.from(expected).length).toBeLessThanOrEqual(32); + const topic = { threadId: 7, name: "old" }; + expect(await sync.check("42", topic, desired, callTelegram, log)).toBe(true); + expect(topic.name).toBe(expected); + expect(await sync.check("42", topic, desired, callTelegram, log)).toBe(true); + expect(calls).toHaveLength(1); + }); + test("never edits a thread owned by another process", async () => { + expect(await sync.check("99", { threadId: 7, name: "old" }, "new", callTelegram, log)).toBe(false); + expect(calls).toEqual([]); + }); + test("space mode restores the legacy title once, then stops edits and the interval", async () => { + const topic = { threadId: 7, name: "old" }; + sync.configure("session", () => {}); + expect(await sync.check("42", topic, "Session title", callTelegram, log)).toBe(true); + sync.configure("space", () => {}); + expect(sync.running).toBe(false); + const legacy = "legacy space name ".repeat(3).trim(); + expect(await sync.check("42", topic, legacy, callTelegram, log)).toBe(true); + expect(topic.name).toBe(legacy); + expect(await sync.check("42", topic, "later space name", callTelegram, log)).toBe(false); + expect(calls).toHaveLength(2); + sync.configure("session", () => {}); + expect(sync.running).toBe(true); + expect(await sync.check("42", topic, "New session", callTelegram, log)).toBe(true); + sync.configure("space", () => {}); + expect(await sync.check("42", topic, legacy, callTelegram, log)).toBe(true); + expect(calls).toHaveLength(4); + expect(sync.running).toBe(false); + }); + test("a failed space restoration retries at the next check", async () => { + const topic = { threadId: 7, name: "old" }; + sync.configure("space", () => {}); + expect(await sync.check("42", topic, "space", async () => { throw new TgError("unavailable", 400); }, log)).toBe(false); + expect(await sync.check("42", topic, "space", callTelegram, log)).toBe(true); + expect(topic.name).toBe("space"); + expect(sync.running).toBe(false); + }); + test("persists both naming modes, defaulting to session", () => { + saveAccess(defaultAccess()); + expect(loadAccess().topicsNames).toBe("session"); + for (const mode of ["space", "session"] as const) { + saveAccess({ ...loadAccess(), topicsNames: mode }); + expect(loadAccess().topicsNames).toBe(mode); + } + }); +}); diff --git a/src/topic-name.ts b/src/topic-name.ts new file mode 100644 index 0000000..b19130a --- /dev/null +++ b/src/topic-name.ts @@ -0,0 +1,150 @@ +import { type FSWatcher, watch } from "node:fs"; +import { stateDir } from "./access"; +import { type Logger, TgError, withRateLimit } from "./api"; +import { isAlive, loadRegistry, renameThread, type ThreadRegistry } from "./topics"; + +/** Display cap, including the ellipsis and any live-owner suffix (Telegram allows 128). */ +export const TOPIC_NAME_MAX = 32; +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +function prefix(value: string, max: number): string { + let result = ""; + let length = 0; + for (const { segment } of graphemes.segment(value)) { + const size = Array.from(segment).length; + if (length + size > max) break; + result += segment; + length += size; + } + return result; +} + +/** Normalize before comparing, so long session titles do not cause repeated edits. */ +export function topicName(sessionName: string | undefined, fallback: string): string { + const name = (sessionName?.trim() || fallback).replace(/\s+/gu, " ").trim(); + if (Array.from(name).length <= TOPIC_NAME_MAX) return name; + let shortened = prefix(name, TOPIC_NAME_MAX - 1); + const space = shortened.lastIndexOf(" "); + if (space >= 0 && Array.from(shortened.slice(0, space)).length >= 20) shortened = shortened.slice(0, space); + return `${shortened.trimEnd()}…`; +} + +/** Keep the existing -pid convention without exceeding the display/Telegram cap. */ +export function availableTopicName( + desired: string, + registry: ThreadRegistry, + threadId?: number, + pid = process.pid, + alive: (pid: number) => boolean = isAlive, + maxLength = TOPIC_NAME_MAX, +): string { + const occupied = (name: string): boolean => Object.entries(registry.threads).some( + ([id, entry]) => Number(id) !== threadId && entry.name === name && alive(entry.pid), + ); + if (!occupied(desired)) return desired; + for (let attempt = 1; ; attempt++) { + const suffix = `-${pid}${attempt === 1 ? "" : `-${attempt}`}`; + const base = Number.isFinite(maxLength) ? prefix(desired, maxLength - Array.from(suffix).length).trimEnd() : desired; + const name = `${base}${suffix}`; + if (!occupied(name)) return name; + } +} + +export interface NamedTopic { + threadId: number; + name: string; +} + +/** Detached from routing; one request at a time and one warning per failed name. */ +export class TopicNameSync { + #pending = false; + #warned = new Set(); + #timer: NodeJS.Timeout | undefined; + #mode: "session" | "space" = "session"; + #spaceSettled = false; + #settingsWatcher: FSWatcher | undefined; + #check: (() => void) | undefined; + + get running(): boolean { + return this.#timer !== undefined; + } + + configure(mode: "session" | "space", check: () => void): void { + if (mode !== this.#mode) this.#spaceSettled = false; + this.#mode = mode; + this.#check = check; + if (mode === "space") { + clearInterval(this.#timer); + this.#timer = undefined; + } else if (!this.#timer) { + this.#timer = setInterval(check, 30_000); + this.#timer.unref(); + } + } + + /** Atomic access.json replacements must wake idle space-mode sessions too. */ + watchSettings(check: () => void, log: Logger): void { + if (this.#settingsWatcher) return; + try { + this.#settingsWatcher = watch(stateDir(), { persistent: false }, (_event, name) => { + if (name?.toString() === "access.json") check(); + }); + this.#settingsWatcher.on("error", (err) => log.warn(`[telegram] topic names settings watch failed: ${String(err)}`)); + } catch (err) { + log.warn(`[telegram] topic names settings watch failed: ${String(err)}`); + } + } + + stop(): void { + clearInterval(this.#timer); + this.#timer = undefined; + this.#settingsWatcher?.close(); + this.#settingsWatcher = undefined; + this.#spaceSettled = false; + } + + async check( + chatId: string | undefined, + topic: NamedTopic | undefined, + desired: string, + callTelegram: (method: string, payload: Record) => Promise, + log: Logger, + ): Promise { + if ((this.#mode === "space" && this.#spaceSettled) || !chatId || !topic || this.#pending) return false; + const mode = this.#mode; + this.#pending = true; + let name = desired; + try { + // The cheap in-memory check is done by the caller. Only changed names and + // retries read the registry, and only the still-owned thread is editable. + const registry = loadRegistry(); + const entry = registry.threads[String(topic.threadId)]; + if (registry.chatId !== chatId || entry?.pid !== process.pid) return false; + name = availableTopicName(desired, registry, topic.threadId, process.pid, isAlive, mode === "space" ? Infinity : TOPIC_NAME_MAX); + if (entry.name !== name) { + try { + await withRateLimit(() => callTelegram("editForumTopic", { + chat_id: chatId, + message_thread_id: topic.threadId, + name, + }), { log }); + } catch (err) { + if (!(err instanceof TgError && /TOPIC_NOT_MODIFIED/i.test(err.message))) throw err; + } + if (!renameThread(chatId, topic.threadId, process.pid, name)) return false; + } + topic.name = name; + if (mode === "space" && this.#mode === mode) this.#spaceSettled = true; + return true; + } catch (err) { + if (!this.#warned.has(name)) { + this.#warned.add(name); + log.warn(`[telegram] could not rename topic to ${JSON.stringify(name)}; retrying at the next check: ${String(err)}`); + } + return false; + } finally { + this.#pending = false; + if (this.#mode !== mode) this.#check?.(); + } + } +} diff --git a/src/topics.ts b/src/topics.ts index 81e6848..884d383 100644 --- a/src/topics.ts +++ b/src/topics.ts @@ -53,25 +53,9 @@ export const ROUTED_TTL_MS = 600_000; export const DM_ROUTE_KEY = "dm" as const; /** - * The title a newly created session topic gets, strongest identity first. - * - * 1. **herdr agent name** — operator-assigned and one-to-one with the session, - * which is exactly what a per-session topic represents. - * 2. **herdr space label** — equally one-to-one with the pane, and captured by - * a *different* call than the agent name, so it still answers when that - * lookup comes back empty. - * 3. **`basename(cwd)`** — the last resort, and the reason the first two exist: - * every pane under one directory tree claims the same useless title. - * - * The middle rung is not belt-and-braces. The agent lookup reads herdr over a - * socket and swallows its own failure, and `tidy` closes a topic on exit so the - * title is re-derived on every restart rather than once. On a fleet whose panes - * share a parent directory — `~/.omp/conductor/…` for two projects, say — a - * single missed lookup is enough to retitle a live project's topic after the - * shared directory, which is how two projects end up both called "conductor". - * - * Blank is treated as absent throughout: Telegram rejects an empty topic name, - * and a space with no custom name must not consume the fallback chain. + * Backward-compatible fallback when omp has not named the session yet. + * Prefer the herdr agent name, then space label, then the directory name. + * Blank identities do not consume the fallback chain. */ export function sessionTopicTitle( agentName: string | undefined, @@ -141,6 +125,21 @@ export function claimThread(chatId: string, threadId: number, entry: ThreadEntry }, warn); } +/** Rename only a still-owned claim, preserving all routing and herdr identities. */ +export function renameThread(chatId: string, threadId: number, pid: number, name: string): boolean { + return withRegistryLock(() => { + const registry = loadRegistry(); + const entry = registry.threads[String(threadId)]; + if (registry.chatId !== chatId || entry?.pid !== pid) return false; + if (Object.entries(registry.threads).some( + ([id, other]) => Number(id) !== threadId && other.name === name && isAlive(other.pid), + )) return false; + entry.name = name; + saveRegistry(registry); + return true; + }); +} + /** * Drop a claim only if `pid` still owns it. Dead entries are kept to preserve * exact session → thread identity and same-cwd legacy claims for re-adoption From ea1ae9627853b38636d0f13a7ef336af5cac11da Mon Sep 17 00:00:00 2001 From: Blueness Date: Sun, 11 Oct 2026 09:06:52 +0800 Subject: [PATCH 2/3] Add owner-only Telegram session rename command --- README.md | 13 ++++- docs/guide.md | 12 ++++- src/bridge.test.ts | 101 +++++++++++++++++++++++++++++++++++++++ src/bridge.ts | 16 +++++-- src/index.ts | 13 ++++- src/index.wiring.test.ts | 36 +++++++++++++- src/session-rename.ts | 35 ++++++++++++++ 7 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 src/session-rename.ts diff --git a/README.md b/README.md index c0e42e9..cfbdefd 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,13 @@ or folder-derived name once, at the next check. After restoration, space mode does no further topic edits or sync interval work. A settings-file watcher reacts to mode switches in all live owning sessions, without a restart. Returning to `session` starts synchronization again. `/telegram topics names [status]` shows -the current mode. +the current mode. `/rename` still changes the omp session in space mode, but its +reply says the topic keeps the space name and points to the session-mode command. + +After updating this plugin, locally exit/resume already-running sessions to load +the new command. An old daemon can forward `/rename` to an updated live session; +run `/telegram daemon restart` locally to refresh its command menu, help, and +control/unowned-topic usage handling. Topics persist and are re-adopted on restart. To tidy them automatically instead, run `/telegram topics tidy on` — each session's topic is deleted (DM host) or @@ -144,6 +150,11 @@ Inside an owner-DM session topic, the paired owner can: - Use `/stop` to stop its current task. - Use `/compact [focus]` to compact that session's context. - Use `/model` and `/thinking` to change that session with inline pickers. +- Use `/rename ` to persist this session's full name and, in `session` mode, + update its topic. In `space` mode the topic keeps its space name. + Whitespace is collapsed; only the topic display name is capped at 32 code + points. Bare `/rename` shows the current name and usage without changing it. + It is owner-only in a session topic, not a group or **omp control** command. - Use `/retry` to resend failed reply parts. `/retry uncertain` also resends parts that Telegram may already have accepted, so it can duplicate a message. diff --git a/docs/guide.md b/docs/guide.md index 10c400b..d70fb44 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -176,7 +176,7 @@ full menu is scoped to the paired owner's DM. With owner-DM topics enabled, the bridge creates one persistent **omp control** topic. Use it for `/spawn`, `/sessions`, `/cleanup`, `/status`, `/help`, and `/whoami`. Session topics accept `/session`, `/stop`, `/compact`, `/model`, -`/thinking`, and `/retry`. A global command entered elsewhere posts its result +`/thinking`, `/rename`, and `/retry`. A global command entered elsewhere posts its result in omp control and leaves a short redirect notice in the originating topic. Topic names follow the omp session name by default, falling back to the herdr @@ -194,6 +194,8 @@ mode makes no further edits and runs no sync interval. A settings-file watcher reacts to mode switches in every live owning session without a restart. Switching back to `session` resumes synchronization. `topics names [status]` shows the current mode, also included in topics status beside tidy. +`/rename` still changes the omp session in space mode; its reply says the topic +keeps the space name and points to `/telegram topics names session`. | Command | Effect | |---|---| @@ -207,11 +209,19 @@ shows the current mode, also included in topics status beside tidy. | `/compact [focus]` | Compact the owning session's context while it is idle. Optional text focuses the summary. | | `/model [provider/id]` | Show a paged model picker, or switch directly to an authenticated model specification. | | `/thinking [level]` | Show a thinking-level picker, or set `inherit`, `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`. | +| `/rename ` | Persist this session's full name, with whitespace collapsed, and synchronize its topic in `session` mode (`TOPIC_NAME_MAX = 32` code points, including `…`). In `space` mode, keep the space title and show `/telegram topics names session` in the reply. Bare `/rename` shows the current name and usage. Use it in the paired owner's session topic; control topics and groups cannot rename sessions. Stale topics follow the same auto-resume convention as `/model` and `/thinking`. | | `/retry [uncertain]` | Retry failed automatic reply parts for this owner's DM session topic. `uncertain` includes parts Telegram may already have accepted. | | `/status` | Show the paired owner, bridge state, topics state, live omp count, and topic-owner count. | | `/help` | Show the owner command summary. | | `/whoami` | Show Telegram chat and user IDs. | +Both the poller/daemon and session extension parse commands through the shared +bridge. The owning session calls `pi.setSessionName` for `/rename`, never submits +the command as an agent prompt. After an upgrade, locally exit/resume existing +sessions; an old daemon still forwards commands to updated live sessions, but +run `/telegram daemon restart` locally to load the new menu/help and +control/unowned-topic usage responses. + A session card shows the session state, ID, directory, model, thinking level, context use, pending-message state, and last activity. Refresh updates the card. Stop, Model, Thinking, and Compact route the matching session command to the diff --git a/src/bridge.test.ts b/src/bridge.test.ts index 49403f9..28bf9a2 100644 --- a/src/bridge.test.ts +++ b/src/bridge.test.ts @@ -9,6 +9,7 @@ import { type TelegramCall, SpawnController } from "./control"; import { TelegramPromptController } from "./prompts"; import { sessionCardCallbackId } from "./session-status"; import { DM_ROUTE_KEY, type ThreadEntry, claimDmOwner, classifyStale, loadRegistry, saveRegistry, watchRoute } from "./topics"; +import { handleSessionRename } from "./session-rename"; const previousStateDir = process.env.OMP_TELEGRAM_STATE_DIR; let dir: string; @@ -76,6 +77,106 @@ function keyboardOf(call: { payload: Record } | undefined): Inl return rows as InlineButton[][]; // known Bot API shape in these tests } +describe("/rename", () => { + let name: string | undefined; + let renamed: string[]; + let syncChecks: number; + + beforeEach(() => { + name = "Current session"; + renamed = []; + syncChecks = 0; + saveRegistry({ + version: 1, + chatId: "42", + threads: { "7": { pid: process.pid, cwd: "/project", name: "project", claimedAt: 1 } }, + }); + }); + + function renameHost(): BridgeHost { + return makeHost({ + isDaemon: false, + selfPid: process.pid, + ownThreadId: () => 7, + handleSessionCommand: async (msg, parsed) => { + if (parsed.name !== "rename") return false; + await handleSessionRename(msg, parsed.args, { + access: loadAccess(), + ownThreadId: 7, + hasContext: true, + getSessionName: () => name, + setSessionName: (value) => { name = value; renamed.push(value); }, + syncTopicName: () => { syncChecks++; }, + reply: async (text) => { calls.push({ method: "sendMessage", payload: { text } }); }, + }); + return true; + }, + }); + } + + test("the paired owner renames its session, preserving the full normalized name", async () => { + const fullName = "a".repeat(40) + " new name"; + await handleUpdate(renameHost(), { update_id: 1, message: message(`/rename ${fullName.replace(/ /g, " ")} `, 7) }); + expect(renamed).toEqual([fullName]); + expect(syncChecks).toBe(1); + expect(calls.at(-1)?.payload.text).toBe(`Session renamed: ${fullName}`); + }); + + test("bare /rename shows the current name and usage without mutation", async () => { + await handleUpdate(renameHost(), { update_id: 2, message: message("/rename", 7) }); + expect(calls.at(-1)?.payload.text).toBe("Current session name: Current session\nUsage: /rename "); + expect(renamed).toEqual([]); + expect(syncChecks).toBe(0); + }); + + test("a non-owner cannot rename or obtain the session name", async () => { + await handleUpdate(renameHost(), { update_id: 3, message: { ...message("/rename hacked", 7), from: { id: 99 } } }); + expect(renamed).toEqual([]); + expect(calls).toEqual([]); + }); + + test("the control topic and untopiced DM show usage only", async () => { + for (const topic of [99, undefined]) { + await handleUpdate(renameHost(), { update_id: 4, message: message("/rename wrong", topic) }); + expect(calls.at(-1)?.payload.text).toBe("Use /rename inside a live omp session topic."); + } + expect(renamed).toEqual([]); + expect(syncChecks).toBe(0); + }); + + test("an unowned topic shows usage only", async () => { + await handleUpdate(renameHost(), { update_id: 5, message: message("/rename wrong", 8) }); + expect(calls.at(-1)?.payload.text).toBe("Use /rename inside a live omp session topic."); + expect(renamed).toEqual([]); + }); + + test("accepts the @botname suffix", async () => { + await handleUpdate(renameHost(), { update_id: 6, message: message("/rename@omp_bot New name", 7) }); + expect(renamed).toEqual(["New name"]); + }); + + test("space mode still renames the session and explains how to follow session names", async () => { + saveAccess({ ...loadAccess(), topicsNames: "space" }); + await handleUpdate(renameHost(), { update_id: 8, message: message("/rename New name", 7) }); + expect(renamed).toEqual(["New name"]); + expect(calls.at(-1)?.payload.text).toBe("Session renamed: New name\nTopic keeps the space name. Use /telegram topics names session to follow session names."); + expect(loadRegistry().threads["7"].name).toBe("project"); + }); + + test("a daemon forwards /rename unchanged to the live owning session", async () => { + const msg = message("/rename@omp_bot New name", 7); + await handleUpdate(makeHost(), { update_id: 9, message: msg }); + const delivered = Promise.withResolvers(); + const stop = watchRoute(7, (routed) => { delivered.resolve(routed); }, log); + try { + expect((await delivered.promise).text).toBe(msg.text); + expect(calls).toEqual([]); + } finally { + stop(); + } + }); +}); + describe("shared bridge routing", () => { test("forwards a live foreign topic through the filesystem spool", async () => { saveRegistry({ diff --git a/src/bridge.ts b/src/bridge.ts index 389affc..0376134 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -84,6 +84,7 @@ const COMMANDS: CommandSpec[] = [ { command: "compact", description: "Compact this session's context", scope: "session", help: ["/compact [focus] — compact this session’s context"] }, { command: "model", description: "Show or change this session's model", scope: "session", help: ["/model [provider/id] — show or change this session’s model"] }, { command: "thinking", description: "Show or change thinking level", scope: "session", help: ["/thinking [level] — show or change thinking level"] }, + { command: "rename", description: "Rename this omp session", scope: "session", help: ["/rename — rename this session; without a name, show its name and usage"] }, { command: "retry", description: "Retry failed Telegram delivery", scope: "session", help: ["/retry — retry failed outbound messages for this session"] }, { command: "status", description: "Bridge and session health", scope: "global", help: ["/status — bridge and session health"] }, { command: "help", description: "Show available commands", scope: "global" }, @@ -726,7 +727,7 @@ async function handleDaemonSessionCommand(host: BridgeHost, access: Access, msg: host, access, msg, - owner ? `Run /${parsed.name} inside a session topic.` : "Pair this DM locally before using session commands.", + owner ? parsed.name === "rename" ? "Use /rename inside a live omp session topic." : `Run /${parsed.name} inside a session topic.` : "Pair this DM locally before using session commands.", false, ); } @@ -841,6 +842,15 @@ export async function handleUpdate( (await handleGlobalCommand(host, msg, parsed)) ) return; + // A rename has no target in the control topic or an untopiced DM, even if a + // DM-owner session is pinned. Do not forward it there as a normal prompt. + if (parsed?.name === "rename" && msg.chat.type === "private" && + (msg.is_topic_message !== true || msg.message_thread_id == null || + String(msg.chat.id) !== access.topicsChat || msg.message_thread_id === access.controlThreadId)) { + await handleDaemonSessionCommand(host, access, msg, parsed); + return; + } + const aliveFn = host.alive ?? isAlive; const registry = loadRegistry(host.warn); const route = access.topicsChat @@ -902,8 +912,8 @@ export async function handleUpdate( parsed?.name === "stop" ? "No live omp session owns this topic, so there is nothing to stop." : entry - ? "No live omp session owns this topic. Resume it locally once to refresh its auto-resume identity." - : "No saved omp session is attached to this topic."; + ? `No live omp session owns this topic. Resume it locally once to refresh its auto-resume identity.${parsed?.name === "rename" ? " Use /rename inside its session topic." : ""}` + : parsed?.name === "rename" ? "Use /rename inside a live omp session topic." : "No saved omp session is attached to this topic."; await host.callTelegram("sendMessage", { chat_id: String(msg.chat.id), message_thread_id: route.threadId, diff --git a/src/index.ts b/src/index.ts index f3285cd..2929de0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ import { DeliveryReporter } from "./delivery"; import { SessionCardController, publishSessionStatus, removeSessionStatus, type SessionStatusSnapshot } from "./session-status"; import { RunLifecycle } from "./run-lifecycle"; import { availableTopicName, topicName, TopicNameSync } from "./topic-name"; +import { handleSessionRename } from "./session-rename"; interface SendParams { @@ -1325,7 +1326,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { options: { cardAction?: boolean } = {}, ): Promise { const { name: cmd, args } = parsed; - if (cmd !== "stop" && cmd !== "compact" && cmd !== "model" && cmd !== "thinking" && cmd !== "retry") return false; + if (cmd !== "stop" && cmd !== "compact" && cmd !== "model" && cmd !== "thinking" && cmd !== "retry" && cmd !== "rename") return false; access = loadAccess(warn); if (access.dmPolicy === "disabled") return true; @@ -1377,6 +1378,16 @@ export default function telegramExtension(pi: ExtensionAPI): void { if (!owner) await commandReply(msg, "Pair this DM locally before using session commands.", false); else if (!ctx) await commandReply(msg, "Run /model inside the omp session topic you want to change.", false); else startModelChange(msg, args, ctx); + } else if (cmd === "rename") { + await handleSessionRename(msg, args, { + access, + ownThreadId: ownTopic?.threadId, + hasContext: sessionContextFor(msg) != null, + getSessionName: () => pi.getSessionName(), + setSessionName: (name) => pi.setSessionName(name), + syncTopicName: checkTopicName, + reply: (text) => commandReply(msg, text, false), + }); } else { const ctx = owner ? sessionContextFor(msg) : undefined; if (!owner) await commandReply(msg, "Pair this DM locally before using session commands.", false); diff --git a/src/index.wiring.test.ts b/src/index.wiring.test.ts index 9f1b5d2..3c5faa5 100644 --- a/src/index.wiring.test.ts +++ b/src/index.wiring.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { type Access, defaultAccess, loadAccess, saveAccess } from "./access"; import telegramExtension from "./index"; -import { claimDmOwner, loadDmOwner, loadRegistry, saveRegistry } from "./topics"; +import { claimDmOwner, loadDmOwner, loadRegistry, saveRegistry, writeRouted } from "./topics"; type EventHandler = (event: unknown, ctx: unknown) => unknown; type CommandHandler = (args: string, ctx: unknown) => unknown; @@ -225,6 +225,40 @@ describe("topic-name settings wiring", () => { }); }); +describe("session rename wiring", () => { + test.each(["session", "space"] as const)("/rename in %s mode calls the real session API", async (mode) => { + writeAccess({ enabled: true, allowFrom: ["42"], topicsChat: "42", controlThreadId: 98, topicsNames: mode }); + const h = harness(["read"]); + const replied = Promise.withResolvers(); + const calls: { method: string; body: Record }[] = []; + const previousFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const method = String(input).split("/").pop()!; + const body = JSON.parse(String(init?.body ?? "{}")); + calls.push({ method, body }); + if (method === "sendMessage" && String(body.text).startsWith("Session renamed:")) replied.resolve(String(body.text)); + const result = method === "createForumTopic" ? { message_thread_id: 99 } : { message_id: 7 }; + return new Response(JSON.stringify({ ok: true, result }), { status: 200 }); + }) as typeof fetch; + try { + await startBridge(h); + calls.length = 0; + writeRouted(99, { + message_id: 1, date: 1, from: { id: 42 }, chat: { id: 42, type: "private" }, + is_topic_message: true, message_thread_id: 99, text: "/rename@omp_bot New session name", + }); + expect(await replied.promise).toBe( + `Session renamed: New session name${mode === "space" ? "\nTopic keeps the space name. Use /telegram topics names session to follow session names." : ""}`, + ); + expect(h.sessionName()).toBe("New session name"); + if (mode === "space") expect(calls.filter((call) => call.method === "editForumTopic")).toEqual([]); + } finally { + await h.handlers.get("session_shutdown")?.[0]?.({ type: "session_shutdown" }, {}); + globalThis.fetch = previousFetch; + } + }); +}); + describe("extension wiring", () => { test.each(["on", "off"])("%s preserves settings changed by another session", async (command) => { writeAccess({ enabled: false, allowFrom: ["42"] }); diff --git a/src/session-rename.ts b/src/session-rename.ts new file mode 100644 index 0000000..6d84920 --- /dev/null +++ b/src/session-rename.ts @@ -0,0 +1,35 @@ +import { isPairedOwnerDm, type Access } from "./access"; +import type { TgMessage } from "./api"; + +export interface SessionRenameHost { + access: Access; + ownThreadId?: number; + hasContext: boolean; + getSessionName(): string | undefined; + setSessionName(name: string): void | Promise; + syncTopicName(): void; + reply(text: string): Promise; +} + +/** Rename only the paired owner's live session topic, never an agent prompt. */ +export async function handleSessionRename(msg: TgMessage, args: string, host: SessionRenameHost): Promise { + if (!isPairedOwnerDm(String(msg.from?.id ?? ""), String(msg.chat.id), msg.chat.type, host.access)) return; + if (!host.hasContext || msg.is_topic_message !== true || host.ownThreadId == null || + msg.message_thread_id !== host.ownThreadId || String(msg.chat.id) !== host.access.topicsChat || + msg.message_thread_id === host.access.controlThreadId) { + await host.reply("Use /rename inside the omp session topic you want to rename."); + return; + } + const name = args.trim().replace(/\s+/gu, " "); + if (!name) { + await host.reply(`Current session name: ${host.getSessionName()?.trim() || "(unnamed)"}\nUsage: /rename `); + return; + } + try { + await host.setSessionName(name); + host.syncTopicName(); + await host.reply(`Session renamed: ${name}${host.access.topicsNames === "space" ? "\nTopic keeps the space name. Use /telegram topics names session to follow session names." : ""}`); + } catch (err) { + await host.reply(`Could not rename this session: ${err instanceof Error ? err.message : String(err)}`); + } +} From ca1568f0e3ead1ef6ec682a83724cc8c24aa1d49 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Wed, 14 Oct 2026 22:47:11 +0100 Subject: [PATCH 3/3] Default topic names to space; keep /rename off dead topics - topicsNames defaults to space, so upgrades keep herdr/space/folder titles; /telegram topics names session opts in to omp session names - /rename in an unowned topic replies with usage instead of auto-resuming the saved session just to rename it - Space mode keeps the 30s check until its one restoration lands, so an idle session retries a failed edit instead of waiting for agent_end - Collision suffixes reuse the word-boundary truncation (with the ellipsis) instead of a raw prefix cut - Drop the duplicate topicsChat/botHasTopics guard in syncTopicName - Restore the sessionTopicTitle rationale comment; docs follow the new default and say "bridge" instead of "plugin" --- README.md | 45 ++++++++++++++++++++-------------------- docs/guide.md | 42 +++++++++++++++++++------------------ src/access.ts | 4 ++-- src/bridge.test.ts | 23 ++++++++++++++++++++ src/bridge.ts | 11 ++++++---- src/index.test.ts | 1 + src/index.ts | 9 ++++---- src/index.wiring.test.ts | 18 ++++++++-------- src/session-rename.ts | 2 +- src/topic-name.test.ts | 16 ++++++++++---- src/topic-name.ts | 37 +++++++++++++++++++++------------ src/topics.ts | 23 +++++++++++++++++--- 12 files changed, 147 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index cfbdefd..82bfdae 100644 --- a/README.md +++ b/README.md @@ -90,29 +90,28 @@ The bot creates: Restart any omp sessions that were already running before you enabled topics so they can claim their own topic. -By default (`/telegram topics names session`), topic names follow the omp session -name, including auto-titles and local `/rename`. -Without a session name, the herdr agent name, herdr space label, then directory -name are used. Whitespace is collapsed and `TOPIC_NAME_MAX = 32` code points -caps the display name (including `…`); truncation prefers a word boundary after -at least 20 code points and never splits an emoji. Live-name collisions use a -`-pid` suffix within the same cap. Names synchronize after adoption, on every -`agent_end`, and within about 30 seconds of an idle rename. Rename failures -warn once per name and retry on a later check without blocking topic routing. -The registry format and saved session/herdr resume identities are unchanged. - -Set `/telegram topics names space` to restore the 0.14.0 herdr agent, space label, -or folder-derived name once, at the next check. After restoration, space mode -does no further topic edits or sync interval work. A settings-file watcher reacts -to mode switches in all live owning sessions, without a restart. Returning to -`session` starts synchronization again. `/telegram topics names [status]` shows -the current mode. `/rename` still changes the omp session in space mode, but its -reply says the topic keeps the space name and points to the session-mode command. - -After updating this plugin, locally exit/resume already-running sessions to load -the new command. An old daemon can forward `/rename` to an updated live session; -run `/telegram daemon restart` locally to refresh its command menu, help, and -control/unowned-topic usage handling. +Topic names use the herdr agent name, then the herdr space label, then the +directory name. That's the default, `/telegram topics names space`. + +Run `/telegram topics names session` to name topics after the omp session instead, +including omp's auto-titles and `/rename`. A session without a name falls back to +the space name. Whitespace is collapsed and `TOPIC_NAME_MAX = 32` code points caps +the display name, counting `…` and any `-pid` collision suffix. Truncation prefers +a word boundary after at least 20 code points and never splits an emoji. Names +sync after a topic is claimed, on every `agent_end`, and within about 30 seconds +of an idle rename. A failed rename warns once per name and retries on a later +check without blocking routing. The registry format and saved session/herdr +resume identities don't change. + +Switching back to `space` puts each topic's space name back once, then stops +editing topics. Live sessions pick up a mode switch without a restart. +`/telegram topics names [status]` shows the current mode. `/rename` still renames +the omp session in space mode, and its reply says the topic keeps the space name. + +After updating the bridge, exit and resume sessions that were already running so +they load `/rename`. An old daemon still forwards `/rename` to updated sessions; +run `/telegram daemon restart` to refresh its command menu, help, and usage +replies in control and unowned topics. Topics persist and are re-adopted on restart. To tidy them automatically instead, run `/telegram topics tidy on` — each session's topic is deleted (DM host) or diff --git a/docs/guide.md b/docs/guide.md index d70fb44..98b37a1 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -179,23 +179,25 @@ topic. Use it for `/spawn`, `/sessions`, `/cleanup`, `/status`, `/help`, and `/thinking`, `/rename`, and `/retry`. A global command entered elsewhere posts its result in omp control and leaves a short redirect notice in the originating topic. -Topic names follow the omp session name by default, falling back to the herdr -agent name, space label, then directory. Names collapse whitespace and are capped -at 32 code points including `…`, preferring a word boundary after 20 code points -and preserving CJK and emoji graphemes. Live collisions use the existing `-pid` -suffix within the cap. Names sync after claim/adoption, on `agent_end`, and via -an unref'd 30-second comparison. Failed edits warn once per name and retry; -`TOPIC_NOT_MODIFIED` is success. Routing and saved resume identities are unchanged. - -`/telegram topics names session` is the default. Use -`/telegram topics names space` to restore each owned topic's 0.14.0 herdr agent, -space label, or folder-derived name once at the next check. Once restored, space -mode makes no further edits and runs no sync interval. A settings-file watcher -reacts to mode switches in every live owning session without a restart. -Switching back to `session` resumes synchronization. `topics names [status]` -shows the current mode, also included in topics status beside tidy. -`/rename` still changes the omp session in space mode; its reply says the topic -keeps the space name and points to `/telegram topics names session`. +Topic names use the herdr agent name, then space label, then directory. That's +the default, `/telegram topics names space`. + +`/telegram topics names session` names topics after the omp session instead, +falling back to the space name while the session is unnamed. Names collapse +whitespace and are capped at 32 code points, counting `…` and any `-pid` collision +suffix. Truncation prefers a word boundary after 20 code points and keeps CJK and +emoji graphemes whole. Names sync after claim/adoption, on `agent_end`, and via an +unref'd 30-second comparison. Failed edits warn once per name and retry; +`TOPIC_NOT_MODIFIED` counts as success. Routing and saved resume identities don't +change. + +Switching back to `space` restores each owned topic's space name once, retrying +on the 30-second check until it lands. After that, space mode makes no edits and +runs no sync interval. A settings-file watcher lets every live owning session +react to a mode switch without a restart. `topics names [status]` shows the +current mode, which also appears in topics status beside tidy. `/rename` still +changes the omp session in space mode; its reply says the topic keeps the space +name and points to `/telegram topics names session`. | Command | Effect | |---|---| @@ -209,7 +211,7 @@ keeps the space name and points to `/telegram topics names session`. | `/compact [focus]` | Compact the owning session's context while it is idle. Optional text focuses the summary. | | `/model [provider/id]` | Show a paged model picker, or switch directly to an authenticated model specification. | | `/thinking [level]` | Show a thinking-level picker, or set `inherit`, `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`. | -| `/rename ` | Persist this session's full name, with whitespace collapsed, and synchronize its topic in `session` mode (`TOPIC_NAME_MAX = 32` code points, including `…`). In `space` mode, keep the space title and show `/telegram topics names session` in the reply. Bare `/rename` shows the current name and usage. Use it in the paired owner's session topic; control topics and groups cannot rename sessions. Stale topics follow the same auto-resume convention as `/model` and `/thinking`. | +| `/rename ` | Persist this session's full name, with whitespace collapsed, and synchronize its topic in `session` mode (`TOPIC_NAME_MAX = 32` code points, including `…`). In `space` mode, keep the space title and show `/telegram topics names session` in the reply. Bare `/rename` shows the current name and usage. Use it in the paired owner's live session topic; control topics and groups cannot rename sessions, and a stale topic replies with usage instead of auto-resuming. | | `/retry [uncertain]` | Retry failed automatic reply parts for this owner's DM session topic. `uncertain` includes parts Telegram may already have accepted. | | `/status` | Show the paired owner, bridge state, topics state, live omp count, and topic-owner count. | | `/help` | Show the owner command summary. | @@ -644,8 +646,8 @@ the herdr agent/space/folder name in space mode, inside one operator-chosen chat /telegram topics # show the topics chat, this session's topic, and DM forum-topic mode /telegram topics tidy on # tidy this session's topic on clean exit (delete in DM host, close in group host) /telegram topics tidy off # keep topics after exit for re-adoption (default) -/telegram topics names session # follow omp session names (default) -/telegram topics names space # restore the herdr/space/folder title once; disable ongoing sync +/telegram topics names session # follow omp session names +/telegram topics names space # herdr/space/folder titles, no ongoing sync (default) /telegram topics names status # show the current naming mode ``` diff --git a/src/access.ts b/src/access.ts index f054a41..9af5745 100644 --- a/src/access.ts +++ b/src/access.ts @@ -90,7 +90,7 @@ export type Access = { topicsChat?: string; /** Tidy this session's topic on clean exit: delete it in a DM host, close it in a forum supergroup. Set via /telegram topics tidy. */ topicsTidy?: boolean; - /** Topic naming: session (default) follows omp names; space uses legacy herdr/folder titles. */ + /** Topic naming: space (default) keeps herdr/folder titles; session follows omp session names. */ topicsNames?: "session" | "space"; /** Persistent owner-DM topic used for bridge/herdr control commands. */ controlThreadId?: number; @@ -323,7 +323,7 @@ export function loadAccess(warn?: (msg: string) => void): Access { notifyChat: parsed.notifyChat, topicsChat: parsed.topicsChat, topicsTidy: parsed.topicsTidy, - topicsNames: parsed.topicsNames === "space" ? "space" : "session", + topicsNames: parsed.topicsNames === "session" ? "session" : "space", controlThreadId: parsed.controlThreadId, notifyMode, }; diff --git a/src/bridge.test.ts b/src/bridge.test.ts index 28bf9a2..151d29f 100644 --- a/src/bridge.test.ts +++ b/src/bridge.test.ts @@ -115,6 +115,7 @@ describe("/rename", () => { } test("the paired owner renames its session, preserving the full normalized name", async () => { + saveAccess({ ...loadAccess(), topicsNames: "session" }); const fullName = "a".repeat(40) + " new name"; await handleUpdate(renameHost(), { update_id: 1, message: message(`/rename ${fullName.replace(/ /g, " ")} `, 7) }); expect(renamed).toEqual([fullName]); @@ -150,6 +151,28 @@ describe("/rename", () => { expect(renamed).toEqual([]); }); + test("a resumable dead topic shows usage instead of starting the session", async () => { + const registry = loadRegistry(); + registry.threads["8"] = { + pid: 2_000_000_000, + cwd: "/tmp/project", + name: "project", + claimedAt: 1, + sessionFile: "/tmp/session.jsonl", + workspaceId: "workspace-1", + workspaceLabel: "project", + workspaceTerminalIds: ["pane-1"], + }; + saveRegistry(registry); + const resumed: number[] = []; + await handleUpdate(makeHost({ resumeTopic: async (_msg, threadId) => void resumed.push(threadId) }), { + update_id: 10, + message: message("/rename wrong", 8), + }); + expect(resumed).toEqual([]); + expect(calls.at(-1)?.payload.text).toBe("Use /rename inside a live omp session topic."); + }); + test("accepts the @botname suffix", async () => { await handleUpdate(renameHost(), { update_id: 6, message: message("/rename@omp_bot New name", 7) }); expect(renamed).toEqual(["New name"]); diff --git a/src/bridge.ts b/src/bridge.ts index 0376134..7bbf0ea 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -190,7 +190,8 @@ export function canAutoResumeTopic( entry: ThreadEntry | undefined, commandName?: string, ): entry is ThreadEntry { - if (commandName === "stop" || !entry) return false; + // A rename names a live session; it must not start one. + if (commandName === "stop" || commandName === "rename" || !entry) return false; if (!isPairedOwnerDm(String(msg.from?.id ?? ""), String(msg.chat.id), msg.chat.type, access)) return false; const hasSession = (typeof entry.sessionFile === "string" && entry.sessionFile.length > 0) || @@ -911,9 +912,11 @@ export async function handleUpdate( const text = parsed?.name === "stop" ? "No live omp session owns this topic, so there is nothing to stop." - : entry - ? `No live omp session owns this topic. Resume it locally once to refresh its auto-resume identity.${parsed?.name === "rename" ? " Use /rename inside its session topic." : ""}` - : parsed?.name === "rename" ? "Use /rename inside a live omp session topic." : "No saved omp session is attached to this topic."; + : parsed?.name === "rename" + ? "Use /rename inside a live omp session topic." + : entry + ? "No live omp session owns this topic. Resume it locally once to refresh its auto-resume identity." + : "No saved omp session is attached to this topic."; await host.callTelegram("sendMessage", { chat_id: String(msg.chat.id), message_thread_id: route.threadId, diff --git a/src/index.test.ts b/src/index.test.ts index dfbd3df..288fc92 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -137,6 +137,7 @@ describe("stale topic auto-resume policy", () => { test("allows only normal messages from the paired owner DM with complete resume identity", () => { expect(canAutoResumeTopic(ownerMessage, access, entry)).toBe(true); expect(canAutoResumeTopic(ownerMessage, access, entry, "stop")).toBe(false); + expect(canAutoResumeTopic(ownerMessage, access, entry, "rename")).toBe(false); expect(canAutoResumeTopic({ ...ownerMessage, from: { id: 99 } }, access, entry)).toBe(false); expect(canAutoResumeTopic({ ...ownerMessage, chat: { id: -1001, type: "supergroup" } }, access, entry)).toBe(false); expect(canAutoResumeTopic(ownerMessage, access, { ...entry, workspaceTerminalIds: undefined })).toBe(false); diff --git a/src/index.ts b/src/index.ts index 2929de0..82ae50f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -895,14 +895,13 @@ export default function telegramExtension(pi: ExtensionAPI): void { /** Never hold up routing or agent_end for an optional Telegram title edit. */ async function syncTopicName(force = false): Promise { const current = loadAccess(warn); - const mode = current.topicsNames ?? "session"; + const mode = current.topicsNames ?? "space"; if (ownTopic) topicNameSync.configure(mode, checkTopicName); if (!ownTopic || !current.topicsChat || !token || (isDmChat(current.topicsChat) && botHasTopics === false)) return; const desired = mode === "session" ? desiredTopicName() : sessionTopicTitle(ownAgentName, ownSpace?.label, process.cwd()); if (mode === "session" && !force && desired === syncedTopicName) return; const topic = ownTopic; const chatId = current.topicsChat; - if (!chatId || (isDmChat(chatId) && botHasTopics === false)) return; if (await topicNameSync.check(chatId, topic, desired, (method, payload) => tg(token, method, payload), log)) { if (ownTopic === topic && access.topicsChat === chatId) syncedTopicName = desired; } @@ -1064,7 +1063,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { let name = basename(cwd); try { await captureOwnSpace(ctx); - const sessionNames = loadAccess(warn).topicsNames !== "space"; + const sessionNames = loadAccess(warn).topicsNames === "session"; name = sessionNames ? desiredTopicName() : sessionTopicTitle(ownAgentName, ownSpace?.label, cwd); const r = loadRegistry(warn); const sessionId = ctx?.sessionManager.getSessionId(); @@ -1708,7 +1707,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { checkTopicName(); ctx.ui.notify(`telegram: topics names ${sub}`, "info"); } else if (sub === "" || sub === "status") { - ctx.ui.notify(`telegram: topics names ${a.topicsNames ?? "session"}`, "info"); + ctx.ui.notify(`telegram: topics names ${a.topicsNames ?? "space"}`, "info"); } else { ctx.ui.notify("usage: /telegram topics names session | space", "warning"); } @@ -1785,7 +1784,7 @@ export default function telegramExtension(pi: ExtensionAPI): void { const owned = ownTopic ? ` · this session: #${ownTopic.threadId} (${ownTopic.name})` : ""; if (!a.topicsChat) return ctx.ui.notify("usage: /telegram topics on | | off | tidy on|off | names session|space", "info"); const dmMode = isDmChat(a.topicsChat) ? ` · DM forum-topic mode: ${botHasTopics === undefined ? "unknown" : botHasTopics ? "on" : "off"}` : ""; - ctx.ui.notify(`telegram: topicsChat = ${a.topicsChat}${owned}${dmMode} · tidy: ${a.topicsTidy ? "on" : "off"} · names: ${a.topicsNames ?? "session"} · control: ${a.controlThreadId != null ? `#${a.controlThreadId}` : "not attached"}`, "info"); + ctx.ui.notify(`telegram: topicsChat = ${a.topicsChat}${owned}${dmMode} · tidy: ${a.topicsTidy ? "on" : "off"} · names: ${a.topicsNames ?? "space"} · control: ${a.controlThreadId != null ? `#${a.controlThreadId}` : "not attached"}`, "info"); } // ---- registrations ------------------------------------------------------ diff --git a/src/index.wiring.test.ts b/src/index.wiring.test.ts index 3c5faa5..cfb9f55 100644 --- a/src/index.wiring.test.ts +++ b/src/index.wiring.test.ts @@ -170,7 +170,7 @@ describe("topic-name settings wiring", () => { } }); test("mode changes from another session restore space once and wake an idle space session", async () => { - writeAccess({ enabled: true, allowFrom: ["42"], topicsChat: "42", controlThreadId: 98 }); + writeAccess({ enabled: true, allowFrom: ["42"], topicsChat: "42", controlThreadId: 98, topicsNames: "session" }); const h = harness(["read"]); h.rename("Session title"); const restored = Promise.withResolvers>(); @@ -209,19 +209,19 @@ describe("topic-name settings wiring", () => { const notices: string[] = []; const ctx = { ui: { notify: (text: string) => { notices.push(text); } } }; await h.commands.get("telegram")!.handler("topics names", ctx); - expect(notices.at(-1)).toBe("telegram: topics names session"); - await h.commands.get("telegram")!.handler("topics names space", ctx); - expect(loadAccess().topicsNames).toBe("space"); + expect(notices.at(-1)).toBe("telegram: topics names space"); + await h.commands.get("telegram")!.handler("topics names session", ctx); + expect(loadAccess().topicsNames).toBe("session"); await h.commands.get("telegram")!.handler("topics status", ctx); - expect(notices.at(-1)).toContain("tidy: off · names: space"); + expect(notices.at(-1)).toContain("tidy: off · names: session"); await h.commands.get("telegram")!.handler("topics names project", ctx); expect(notices.at(-1)).toBe("usage: /telegram topics names session | space"); - expect(loadAccess().topicsNames).toBe("space"); - await h.commands.get("telegram")!.handler("set topicNames session", ctx); + expect(loadAccess().topicsNames).toBe("session"); + await h.commands.get("telegram")!.handler("set topicNames space", ctx); expect(notices.at(-1)).toContain('set: unknown key "topicNames"'); - expect(loadAccess().topicsNames).toBe("space"); - await h.commands.get("telegram")!.handler("topics names session", ctx); expect(loadAccess().topicsNames).toBe("session"); + await h.commands.get("telegram")!.handler("topics names space", ctx); + expect(loadAccess().topicsNames).toBe("space"); }); }); diff --git a/src/session-rename.ts b/src/session-rename.ts index 6d84920..419fed4 100644 --- a/src/session-rename.ts +++ b/src/session-rename.ts @@ -28,7 +28,7 @@ export async function handleSessionRename(msg: TgMessage, args: string, host: Se try { await host.setSessionName(name); host.syncTopicName(); - await host.reply(`Session renamed: ${name}${host.access.topicsNames === "space" ? "\nTopic keeps the space name. Use /telegram topics names session to follow session names." : ""}`); + await host.reply(`Session renamed: ${name}${host.access.topicsNames !== "session" ? "\nTopic keeps the space name. Use /telegram topics names session to follow session names." : ""}`); } catch (err) { await host.reply(`Could not rename this session: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/src/topic-name.test.ts b/src/topic-name.test.ts index ae6a2ff..3b1a5a1 100644 --- a/src/topic-name.test.ts +++ b/src/topic-name.test.ts @@ -100,6 +100,12 @@ describe("topic name synchronization", () => { expect(await sync.check("42", topic, desired, callTelegram, log)).toBe(true); expect(calls).toHaveLength(1); }); + test("a collision suffix keeps the word-boundary truncation", () => { + const desired = "abcdefghijklmnopqrst hello world"; + const registry = loadRegistry(); + registry.threads["8"] = { pid: process.pid, cwd: "/other", name: desired, claimedAt: 1 }; + expect(availableTopicName(desired, registry, 7, 123456, () => true)).toBe("abcdefghijklmnopqrst…-123456"); + }); test("never edits a thread owned by another process", async () => { expect(await sync.check("99", { threadId: 7, name: "old" }, "new", callTelegram, log)).toBe(false); expect(calls).toEqual([]); @@ -109,10 +115,10 @@ describe("topic name synchronization", () => { sync.configure("session", () => {}); expect(await sync.check("42", topic, "Session title", callTelegram, log)).toBe(true); sync.configure("space", () => {}); - expect(sync.running).toBe(false); const legacy = "legacy space name ".repeat(3).trim(); expect(await sync.check("42", topic, legacy, callTelegram, log)).toBe(true); expect(topic.name).toBe(legacy); + expect(sync.running).toBe(false); expect(await sync.check("42", topic, "later space name", callTelegram, log)).toBe(false); expect(calls).toHaveLength(2); sync.configure("session", () => {}); @@ -123,17 +129,19 @@ describe("topic name synchronization", () => { expect(calls).toHaveLength(4); expect(sync.running).toBe(false); }); - test("a failed space restoration retries at the next check", async () => { + test("a failed space restoration keeps the interval until a retry lands", async () => { const topic = { threadId: 7, name: "old" }; sync.configure("space", () => {}); + expect(sync.running).toBe(true); expect(await sync.check("42", topic, "space", async () => { throw new TgError("unavailable", 400); }, log)).toBe(false); + expect(sync.running).toBe(true); expect(await sync.check("42", topic, "space", callTelegram, log)).toBe(true); expect(topic.name).toBe("space"); expect(sync.running).toBe(false); }); - test("persists both naming modes, defaulting to session", () => { + test("persists both naming modes, defaulting to space", () => { saveAccess(defaultAccess()); - expect(loadAccess().topicsNames).toBe("session"); + expect(loadAccess().topicsNames).toBe("space"); for (const mode of ["space", "session"] as const) { saveAccess({ ...loadAccess(), topicsNames: mode }); expect(loadAccess().topicsNames).toBe(mode); diff --git a/src/topic-name.ts b/src/topic-name.ts index b19130a..5a9c116 100644 --- a/src/topic-name.ts +++ b/src/topic-name.ts @@ -19,16 +19,20 @@ function prefix(value: string, max: number): string { return result; } -/** Normalize before comparing, so long session titles do not cause repeated edits. */ -export function topicName(sessionName: string | undefined, fallback: string): string { - const name = (sessionName?.trim() || fallback).replace(/\s+/gu, " ").trim(); - if (Array.from(name).length <= TOPIC_NAME_MAX) return name; - let shortened = prefix(name, TOPIC_NAME_MAX - 1); +/** Cap at `max` code points, ending in `…`; prefer a word boundary after at least 20. */ +function fit(name: string, max: number): string { + if (Array.from(name).length <= max) return name; + let shortened = prefix(name, max - 1); const space = shortened.lastIndexOf(" "); if (space >= 0 && Array.from(shortened.slice(0, space)).length >= 20) shortened = shortened.slice(0, space); return `${shortened.trimEnd()}…`; } +/** Normalize before comparing, so long session titles do not cause repeated edits. */ +export function topicName(sessionName: string | undefined, fallback: string): string { + return fit((sessionName?.trim() || fallback).replace(/\s+/gu, " ").trim(), TOPIC_NAME_MAX); +} + /** Keep the existing -pid convention without exceeding the display/Telegram cap. */ export function availableTopicName( desired: string, @@ -44,7 +48,7 @@ export function availableTopicName( if (!occupied(desired)) return desired; for (let attempt = 1; ; attempt++) { const suffix = `-${pid}${attempt === 1 ? "" : `-${attempt}`}`; - const base = Number.isFinite(maxLength) ? prefix(desired, maxLength - Array.from(suffix).length).trimEnd() : desired; + const base = Number.isFinite(maxLength) ? fit(desired, maxLength - Array.from(suffix).length) : desired; const name = `${base}${suffix}`; if (!occupied(name)) return name; } @@ -73,15 +77,20 @@ export class TopicNameSync { if (mode !== this.#mode) this.#spaceSettled = false; this.#mode = mode; this.#check = check; - if (mode === "space") { - clearInterval(this.#timer); - this.#timer = undefined; - } else if (!this.#timer) { + // Space mode keeps polling until its one restoration lands, so an idle + // session still retries a failed edit. + if (mode === "space" && this.#spaceSettled) this.#stopTimer(); + else if (!this.#timer) { this.#timer = setInterval(check, 30_000); this.#timer.unref(); } } + #stopTimer(): void { + clearInterval(this.#timer); + this.#timer = undefined; + } + /** Atomic access.json replacements must wake idle space-mode sessions too. */ watchSettings(check: () => void, log: Logger): void { if (this.#settingsWatcher) return; @@ -96,8 +105,7 @@ export class TopicNameSync { } stop(): void { - clearInterval(this.#timer); - this.#timer = undefined; + this.#stopTimer(); this.#settingsWatcher?.close(); this.#settingsWatcher = undefined; this.#spaceSettled = false; @@ -134,7 +142,10 @@ export class TopicNameSync { if (!renameThread(chatId, topic.threadId, process.pid, name)) return false; } topic.name = name; - if (mode === "space" && this.#mode === mode) this.#spaceSettled = true; + if (mode === "space" && this.#mode === mode) { + this.#spaceSettled = true; + this.#stopTimer(); + } return true; } catch (err) { if (!this.#warned.has(name)) { diff --git a/src/topics.ts b/src/topics.ts index 884d383..cec5db4 100644 --- a/src/topics.ts +++ b/src/topics.ts @@ -53,9 +53,26 @@ export const ROUTED_TTL_MS = 600_000; export const DM_ROUTE_KEY = "dm" as const; /** - * Backward-compatible fallback when omp has not named the session yet. - * Prefer the herdr agent name, then space label, then the directory name. - * Blank identities do not consume the fallback chain. + * The space-mode topic title, and the session-mode fallback while omp has not + * named the session yet. Strongest identity first. + * + * 1. **herdr agent name** — operator-assigned and one-to-one with the session, + * which is exactly what a per-session topic represents. + * 2. **herdr space label** — equally one-to-one with the pane, and captured by + * a *different* call than the agent name, so it still answers when that + * lookup comes back empty. + * 3. **`basename(cwd)`** — the last resort, and the reason the first two exist: + * every pane under one directory tree claims the same useless title. + * + * The middle rung is not belt-and-braces. The agent lookup reads herdr over a + * socket and swallows its own failure, and `tidy` closes a topic on exit so the + * title is re-derived on every restart rather than once. On a fleet whose panes + * share a parent directory — `~/.omp/conductor/…` for two projects, say — a + * single missed lookup is enough to retitle a live project's topic after the + * shared directory, which is how two projects end up both called "conductor". + * + * Blank is treated as absent throughout: Telegram rejects an empty topic name, + * and a space with no custom name must not consume the fallback chain. */ export function sessionTopicTitle( agentName: string | undefined,