diff --git a/docs/guide.md b/docs/guide.md index 3ed6e01..6ae9cf3 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -442,8 +442,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 its project directory, inside one -operator-chosen chat. +its own Telegram **forum topic**, named after the pane herdr knows it by (falling +back to its project directory), inside one operator-chosen chat. ``` /telegram topics on # topics on, auto-hosted in your paired DM; claim this session's topic @@ -454,8 +454,12 @@ operator-chosen chat. /telegram topics tidy off # keep topics after exit for re-adoption (default) ``` -- Each session **claims one topic** on start (and when that session runs `/telegram topics on` or `/telegram topics `), - named after `basename(cwd)`. A session restarted in the same directory **re-adopts** +- Each session **claims one topic** on start (and when that session runs `/telegram topics on` or `/telegram topics `). + Under herdr it is named after the pane's **agent name** — the identity you assigned + with `herdr agent start ` or `agent rename`, which is one-to-one with the + session. Otherwise it falls back to `basename(cwd)`, which is all there is outside + herdr and is ambiguous when several panes share one parent directory. A session + restarted in the same directory **re-adopts** its existing topic instead of creating a duplicate; a second live session in the same directory gets a `-` topic. Sessions already running when topics are first enabled must reload or restart before they claim one. diff --git a/src/control.test.ts b/src/control.test.ts index 71399f2..d0d7bdb 100644 --- a/src/control.test.ts +++ b/src/control.test.ts @@ -4,11 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaultAccess } from "./access"; import { type TgCallbackQuery, type TgMessage, TgError } from "./api"; -import { type ControlSpace, type RunHerdr, type TelegramCall, SpawnController, createWorktreeOmp, findSessionSpace, formatSessions, listControlSpaces, resumeOmp, sendCommandMessage, spawnOmp, validWorktreeBranch, workspaceDirectoryError } from "./control"; +import { type ControlSpace, type RunHerdr, type TelegramCall, SpawnController, agentNameForSession, createWorktreeOmp, findSessionSpace, formatSessions, listControlSpaces, resumeOmp, sendCommandMessage, spawnOmp, validWorktreeBranch, workspaceDirectoryError } from "./control"; import type { ThreadRegistry } from "./topics"; const workspaceList = (items: unknown[]): string => JSON.stringify({ result: { workspaces: items } }); const paneList = (items: unknown[]): string => JSON.stringify({ result: { panes: items } }); +const agentList = (items: unknown[]): string => JSON.stringify({ result: { agents: items } }); function snapshotRunner(calls: string[] = []): RunHerdr { return async (args) => { @@ -63,6 +64,35 @@ describe("listControlSpaces", () => { }); }); +describe("agentNameForSession", () => { + const run: RunHerdr = async (args) => { + if (args.join(" ") !== "agent list") throw new Error(`unexpected command: ${args.join(" ")}`); + return agentList([ + { name: "veltrosecurity", agent_session: { kind: "path", value: "/sessions/fleet.jsonl" } }, + { name: "", agent_session: { kind: "path", value: "/sessions/unnamed.jsonl" } }, + { agent_session: { kind: "path", value: "/sessions/nameless.jsonl" } }, + ]); + }; + + test("returns the name herdr bound to that exact session file", async () => { + expect(await agentNameForSession("/sessions/fleet.jsonl", run)).toBe("veltrosecurity"); + }); + + test("is undefined for a session herdr does not name", async () => { + // No entry, an empty name, and a missing name all mean "no deliberate identity", + // which leaves the caller on its own fallback rather than a blank topic title. + expect(await agentNameForSession("/sessions/absent.jsonl", run)).toBeUndefined(); + expect(await agentNameForSession("/sessions/unnamed.jsonl", run)).toBeUndefined(); + expect(await agentNameForSession("/sessions/nameless.jsonl", run)).toBeUndefined(); + }); + + test("rejects a malformed herdr reply rather than guessing a title", async () => { + await expect(agentNameForSession("/sessions/fleet.jsonl", async () => "not-json")).rejects.toThrow( + "invalid JSON", + ); + }); +}); + describe("findSessionSpace", () => { test("locates the hosting space by exact herdr agent session path", async () => { const run: RunHerdr = async (args) => { diff --git a/src/control.ts b/src/control.ts index 17f3162..fa4e8a0 100644 --- a/src/control.ts +++ b/src/control.ts @@ -40,7 +40,12 @@ interface PaneWire { agent_session?: unknown; } -function resultArray(raw: string, key: "workspaces" | "panes"): unknown[] { +interface AgentWire { + name?: unknown; + agent_session?: unknown; +} + +function resultArray(raw: string, key: "workspaces" | "panes" | "agents"): unknown[] { let parsed: unknown; try { parsed = JSON.parse(raw); @@ -137,6 +142,25 @@ export async function findSessionSpace(sessionFile: string, run: RunHerdr = runH return (await listControlSpaces(run)).find((space) => space.workspaceId === pane.workspace_id); } +/** + * The herdr agent name bound to one exact omp session file. + * + * A pane's agent name is the identity an operator deliberately assigned to it + * (`herdr agent start ` / `agent rename`) and it is one-to-one with the + * session, which is exactly what a per-session topic represents. `pane list` does + * not carry it; only `agent list` does. + */ +export async function agentNameForSession( + sessionFile: string, + run: RunHerdr = runHerdr, +): Promise { + const agents = resultArray(await run(["agent", "list"]), "agents") as AgentWire[]; + const hit = agents.find( + (candidate) => (candidate.agent_session as { value?: unknown } | undefined)?.value === sessionFile, + ); + return typeof hit?.name === "string" && hit.name.length > 0 ? hit.name : undefined; +} + type HerdrSpaceRef = Pick; /** Revalidate a space snapshot, create an unfocused tab, and run one shell command. */ diff --git a/src/index.ts b/src/index.ts index 842f906..b95b011 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ import { webhookConflictHint, } from "./api"; import { type BridgeHost, clearOwnerBotCommands, ensureControlTopic as ensureBridgeControlTopic, handleUpdate, parseBotCommand, syncBotCommands, tidyRemoteTopic } from "./bridge"; -import { SpawnController, findSessionSpace, listControlSpaces, sendCommandMessage } from "./control"; +import { SpawnController, agentNameForSession, findSessionSpace, listControlSpaces, sendCommandMessage } from "./control"; import { daemonAlive, daemonDisableReason, ensureDaemon, readDaemonState } from "./daemon"; import { INBOX_MAX_FILE_BYTES, pruneInbox, storeInboxFile } from "./inbox"; import { Outbound, finalAssistantText } from "./outbound"; @@ -576,6 +576,8 @@ export default function telegramExtension(pi: ExtensionAPI): void { let lockRetryTimer: NodeJS.Timeout | undefined; let ownTopic: { threadId: number; name: 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; let stopWatch: (() => void) | undefined; let stopDmWatch: (() => void) | undefined; let stopLockBeat: (() => void) | undefined; @@ -830,6 +832,14 @@ export default function telegramExtension(pi: ExtensionAPI): void { if (ownSpace || process.env.HERDR_ENV !== "1") return; const sessionFile = ctx?.sessionManager.getSessionFile(); const workspaceId = process.env.HERDR_WORKSPACE_ID; + if (sessionFile && !ownAgentName) { + // Independent of the space lookup below: the agent name is what names this + // session's topic, and it must survive a space snapshot that fails. + ownAgentName = await agentNameForSession(sessionFile).catch((err) => { + warn(`could not read this pane's herdr agent name: ${String(err)}`); + return undefined; + }); + } try { const space = (sessionFile ? await findSessionSpace(sessionFile) : undefined) ?? @@ -915,6 +925,11 @@ export default function telegramExtension(pi: ExtensionAPI): void { let name = basename(cwd); try { await captureOwnSpace(ctx); + // Prefer the name herdr knows this pane by. It is assigned deliberately and + // is one-to-one with the session, whereas `basename(cwd)` is whatever the + // parent directory happens to be called — every pane under one tree then + // claims a topic with the same useless title. + name = ownAgentName ?? name; const r = loadRegistry(warn); const sessionId = ctx?.sessionManager.getSessionId(); const sessionFile = ctx?.sessionManager.getSessionFile();