Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <chat_id>`),
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 <chat_id>`).
Under herdr it is named after the pane's **agent name** — the identity you assigned
with `herdr agent start <name>` 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 `<name>-<pid>` topic. Sessions already running when topics are first
enabled must reload or restart before they claim one.
Expand Down
32 changes: 31 additions & 1 deletion src/control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
26 changes: 25 additions & 1 deletion src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 <name>` / `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<string | undefined> {
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<ControlSpace, "workspaceId" | "label" | "terminalIds">;

/** Revalidate a space snapshot, create an unfocused tab, and run one shell command. */
Expand Down
17 changes: 16 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) ??
Expand Down Expand Up @@ -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();
Expand Down
Loading