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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ The bot creates:
Restart any omp sessions that were already running before you enabled topics so
they can claim their own topic.

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
closed and reopened on re-adoption (group host) when it exits. Sweep leftovers from
Expand Down Expand Up @@ -126,6 +149,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 <name>` 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.
Expand Down
40 changes: 36 additions & 4 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,29 @@ 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 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 |
|---|---|
| `/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. |
Expand All @@ -191,11 +211,19 @@ in omp control and leaves a short redirect notice in the originating topic.
| `/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 <name>` | 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. |
| `/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
Expand Down Expand Up @@ -608,8 +636,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
Expand All @@ -618,10 +646,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
/telegram topics names space # herdr/space/folder titles, no ongoing sync (default)
/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 <chat_id>`).
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 <name>` 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
Expand Down
3 changes: 3 additions & 0 deletions src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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;
/**
Expand Down Expand Up @@ -321,6 +323,7 @@ export function loadAccess(warn?: (msg: string) => void): Access {
notifyChat: parsed.notifyChat,
topicsChat: parsed.topicsChat,
topicsTidy: parsed.topicsTidy,
topicsNames: parsed.topicsNames === "session" ? "session" : "space",
controlThreadId: parsed.controlThreadId,
notifyMode,
};
Expand Down
124 changes: 124 additions & 0 deletions src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +77,129 @@ function keyboardOf(call: { payload: Record<string, unknown> } | 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 () => {
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]);
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 <name>");
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 <name> 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 <name> inside a live omp session topic.");
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 <name> 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"]);
});

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<TgMessage>();
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({
Expand Down
23 changes: 18 additions & 5 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> — 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" },
Expand Down Expand Up @@ -189,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) ||
Expand Down Expand Up @@ -726,7 +728,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 <name> inside a live omp session topic." : `Run /${parsed.name} inside a session topic.` : "Pair this DM locally before using session commands.",
false,
);
}
Expand Down Expand Up @@ -841,6 +843,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
Expand Down Expand Up @@ -901,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."
: "No saved omp session is attached to this topic.";
: parsed?.name === "rename"
? "Use /rename <name> 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,
Expand Down
Loading
Loading