@soffits/dsh-telegram-bot 0.4.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MAINTAINING.md CHANGED
@@ -10,7 +10,7 @@ This repository is a maintained fork of [Boobaw/dsh-telegram-bot](https://github
10
10
  - Session titles are `Telegram-<person>-<DSH title>` (`plannedTitle` in `src/topics.js`): DSH has no title hook, so `syncTopicTitle` re-appends each generated `session/title` with the prefix and the same source (automatic titling keeps running), and `startTopicTitle` calls `sessionTitle.refresh` at the first model request, because the `Telegram-<person>-<topic name>` placeholder is a user title that stops automatic titling. `<person>` comes from `userNames` / `userNamesFile`, else the chat's Telegram name (`chatNames` in the state file, learned from messages or `getChat`). Each session record keeps the label its title carries (`titlePerson`), so a changed name still finds the old prefix. `migrateSessionTitles` brings every bound session to the current form once the bot connects, reading titles from the log first and opening only sessions whose title changes. DSH's session header keeps only its own fields (dsh-session copies `cwd`, `parentSession`, `agentPreset` and the rest by name), so the source and person are not in session meta; DSH 0.2.2 has experimental plugin records (`appendPluginRecord` in dsh-session) that could carry them in the log later. Topic renames record a `telegram-topic` notice as a `user/message` (`recordNote`): session search indexes only message, tool and todo events, not titles or queued inbox items.
11
11
  - Deleted topics are forgotten as soon as any Bot API call reports them gone (`forgetGoneTopic`).
12
12
  - Chat-wide commands sent in All are answered in All; the topic Telegram made for them is deleted.
13
- - Sender labels: each Telegram prompt names who wrote it.
13
+ - Sender labels: each Telegram prompt names who wrote it, in a text line for the model and as `source.principal` for readers (`src/sender.js`). DSH writes `principal` itself only for non-owner callers of its session controller, and the bot hands text prompts to that controller, which builds the message, so the bot remembers the sender by request id and records it on the message in an `agent/pre-step` listener (DSH lets that hook replace a step's messages); messages the bot builds itself get it directly. Sends recorded in a bound session carry the sender (job, session or label) the same way.
14
14
  - Voice messages, audio and round videos: downloaded, transcribed (DSH speech-to-text or a configured command) and passed with their file path.
15
15
  - Messages sent during a running turn are steered into it (`src/steer.js`): text through `sessionController.prompt({ mode: 'steer' })`, the Web UI's own call, and prompts with stored attachments through `agent.steer`, the method that mode uses. DSH keeps the turn going while its next-step inbox holds input and starts a new turn when input is left after `turn/end`, so a message that lands as the turn ends is still answered; `chat.steerFollow` shows that turn in the topic even with `mirrorTurns` off, and is cleared at the first `turn/end` that leaves the inbox empty. Prompts reach DSH in order (`admitInOrder`). `busyMessages: queue` keeps upstream's bot-side queue.
16
16
  - Stickers: received ones are attached as images (thumbnail or first frame for animated and video stickers) and kept in a library (`src/stickers.js`) with the agent's meaning notes; the `telegram_sticker` tool finds, views, notes, loads sets and sends them through `sendToTopic`.
@@ -18,6 +18,7 @@ This repository is a maintained fork of [Boobaw/dsh-telegram-bot](https://github
18
18
  - Models (`src/models.js`): the session's agent owns its model (`agent.model.current()`); the bot keeps no copy, because a copy drifted on fallback switches and Web UI picks and was carried into `/new`. Sessions are created and resumed through `sessionController.create` / `resolveAgent` (the Web UI's own composition); the raw `agents` registry is only the headless fallback. The picker reads `sessionController.modelCatalog()` and applies picks with `sessionController.selectModel`, which also saves the default, as a Web pick does. `/new` starts the new session on the previous session's current model.
19
19
  - Lifecycle notices go to a chat fixed by config (`controlChatOf` in `src/utils.js`: `controlChat`, else the first allowed user's private chat). Upstream moves its control chat to whoever wrote last and stores it in `topics.json`; the fork dropped that file.
20
20
  - Final replies keep only the answer (`src/final-text.js`): text written between tool calls streams as a live draft, but at the turn's end the reply is rebuilt from the session's durable messages, keeping the last text block of each message that called no tool. Mirrored turns use the same rule.
21
+ - Subagent activity (`src/agents.js`): `subagent/start` and `subagent/end` (global listeners) drive one status message per topic turn, found by following `session.header.parentSession` from the child up to a bound session. A board is keyed by the topic's `turnEpoch` and opens only while the topic shows a turn (`busy` and not `done`); a nested child joins the board that lists its nearest ancestor. Edits are coalesced (1.5 s) and serialized per board, a running board ticks once a minute but edits only when the text changes, and a board whose children are all done is frozen. The description is the parent catalog's `label` (the delegation's `description`), read again at each edit until found. `/agents` walks `subagents.listChildren` (durable catalogs, bounded reads) and reads each child's `subagentTiming` and `modelSelection` projections and its log for the last `send_message` or tool-free answer.
21
22
  - Reply, quote and forward context: the agent sees what a message replies to, quotes or forwards, and forwards sent together become one prompt.
22
23
 
23
24
  Upstream changes are merged in, not rebased, so the history of both lines stays intact.
@@ -36,4 +37,4 @@ git push origin main
36
37
 
37
38
  Conflicts usually land in `src/app.js` (most of the bridge lives there), `README.md` and the `name` and `version` lines of `package.json`. Keep the fork's own name and version: it is published on npm as `@soffits/dsh-telegram-bot` with its own semver line (0.4.0 was the first release, on top of upstream 0.3.0), so bump the minor version for features and upstream merges and the patch version for fixes, and name the merged upstream version in the merge or release commit. Make sure `client.js` still matches `src/client.js` after the merge (`cmp client.js src/client.js`).
38
39
 
39
- Fork-only code is kept in separate files where that is natural (`src/topics.js`, `src/outbox.js`, `src/message-context.js`, `src/sent-log.js`, `src/voice.js`, `src/steer.js`, `src/models.js`, `src/final-text.js`, `bin/dsh-telegram-send.js`, `test/fake-dsh.js`, `test/fork-features.test.js`, `test/message-context.test.js`, `test/sent-log.test.js`, `test/steer.test.js`, `test/models.test.js`, `test/final-text.test.js`, `test/voice.test.js`), so most upstream edits apply without touching it.
40
+ Fork-only code is kept in separate files where that is natural (`src/topics.js`, `src/outbox.js`, `src/message-context.js`, `src/sent-log.js`, `src/voice.js`, `src/steer.js`, `src/sender.js`, `src/models.js`, `src/final-text.js`, `src/agents.js`, `bin/dsh-telegram-send.js`, `test/fake-dsh.js`, `test/fork-features.test.js`, `test/message-context.test.js`, `test/sent-log.test.js`, `test/steer.test.js`, `test/sender.test.js`, `test/models.test.js`, `test/final-text.test.js`, `test/agents.test.js`, `test/voice.test.js`), so most upstream edits apply without touching it.
package/README.md CHANGED
@@ -25,7 +25,7 @@ BotFather: **Bot Settings → Threaded mode → Enable**. Leave **Disallow users
25
25
  - One Telegram topic per DSH session; Web and Telegram use the same session.
26
26
  - You create the topics. A new topic asks which workspace to use, then whether to start a new session or continue an existing one from that workspace. The message that opened the topic runs once the session is chosen.
27
27
  - Topic names are yours: the bot never renames a topic after the session title. `/rename` renames it on request.
28
- - A Telegram session is titled `Telegram-<person>-<what the conversation is about>` in DSH, for example `Telegram-Livia-Fix empty Telegram sessions`, so the sidebar shows where each session comes from and whose chat it is. `<person>` is the name set for the chat's user in `userNames` or `userNamesFile`, else the user's Telegram first name; in a group it is the group's title. The last part is DSH's own title (its title model, or the first words of the first message); until it exists the topic name stands in for it. Sessions with titles from earlier versions (`<topic name> · <task>`, a bare topic name) are moved to this form when the bot starts. A title someone types for the session is left alone.
28
+ - A Telegram session is titled `Telegram-<person>-<what the conversation is about>` in DSH, for example `Telegram-Ana-Plan a weekend trip`, so the sidebar shows where each session comes from and whose chat it is. `<person>` is the name set for the chat's user in `userNames` or `userNamesFile`, else the user's Telegram first name; in a group it is the group's title. The last part is DSH's own title (its title model, or the first words of the first message); until it exists the topic name stands in for it. Sessions with titles from earlier versions (`<topic name> · <task>`, a bare topic name) are moved to this form when the bot starts. A title someone types for the session is left alone.
29
29
  - Renaming a topic also records a short note in its session, such as `Telegram topic renamed: "Old" -> "New" (thread 123)`, so searching earlier sessions finds it under either name.
30
30
  - A session the bot creates for a topic is never handed out as a blank session. DSH's **New conversation** reuses a session that has not started a turn yet; the bot therefore starts one empty turn as soon as it creates a session, and stops it before any model request. Without this, a topic session waiting for its first message could be picked up by the Web UI, and that Web chat would be mirrored into the topic.
31
31
  - Deleted topics clean themselves up: when Telegram answers any call for a topic with "message thread not found", `TOPIC_ID_INVALID` or `TOPIC_DELETED`, the bot drops that topic's session binding and name and logs it. The session stays in DSH. A closed topic keeps its binding, since it can be reopened.
@@ -37,15 +37,24 @@ BotFather: **Bot Settings → Threaded mode → Enable**. Leave **Disallow users
37
37
  - Automatic goal rounds of a topic's session are shown in the topic like a Telegram turn, with thinking, tool calls and the final answer, also when **Show turns from other clients** is off. Their approvals and questions are answered in the topic.
38
38
  - While a turn runs in a topic's session (started from Telegram or shown from another client), that topic shows "typing…". Each topic shows its own; it stops when the turn ends, fails or is stopped.
39
39
  - Reasoning and tool calls are shown as temporary messages and removed after the answer. Each tool-call entry is capped at two lines.
40
+ - Subagents show in the topic. When a turn the topic shows starts subagents, one quiet status message lists them, each with its description, model and running time, and is edited in place as they start and finish (✓ done, ✗ failed with the reason). Changes that land within about 1.5 seconds share one edit, and running times are refreshed at most once a minute, which keeps well inside Telegram's edit limits. Subagents started by subagents are listed under their parent. Once all of them are done the message stays as it is, and the next subagent opens a new one. For example:
41
+
42
+ ```
43
+ 🤖 3 subagents · 1 running
44
+ ✓ Compare flight prices · claude-opus-5-5 · 2m 14s
45
+ ↳ ✓ Check baggage rules · claude-haiku-4-5 · 41s
46
+ ⏳ Find a hotel near the venue · deepseek-v4-flash · 3m
47
+ ```
48
+ - `/agents` lists the session's running and most recent subagents (up to eight, nested ones included), with their state, model, time, when they started and the beginning of their last report (the last message they sent their parent, or their final answer). It reads DSH's saved sessions, so it also covers subagents from before a restart; one cut off by a restart shows as interrupted.
40
49
  - Inline controls for model, reasoning effort, permission mode and context usage. The model picker offers every provider and model of the Web UI's model picker (provider first, then its models, with paging) and switches the session the way the Web UI does, which also makes the pick the default for new sessions. The panel always shows the model the session runs now; `/new` starts the next session on that model.
41
50
  - Tool approvals with **Deny** / **Allow** buttons.
42
51
  - `ask_user_question` support: options, multi-select, custom answers and plan review.
43
52
  - Documents and photos sent in a session topic are saved as DSH attachments and passed to the agent.
44
53
  - Voice messages, audio files and round video messages are saved as files and transcribed; the agent gets the transcript and the file's path, so it can check a doubtful transcript against the recording. See [Voice messages](#voice-messages).
45
- - Every prompt from Telegram starts with a line naming the sender, such as `[Telegram message from Ana Silva (@ana, user id 123)]`, so the agent knows who is writing when several allowed users share the bot. Prompts typed in the Web UI are not labelled.
54
+ - Every prompt from Telegram starts with a line naming the sender, such as `[Telegram message from Ana Silva (@ana, user id 123)]`, so the agent knows who is writing when several allowed users share the bot. Prompts typed in the Web UI are not labelled. The sender is also recorded as data on the prompt's source as `principal`, the field DSH uses for the caller behind a user message: `{ kind: 'telegram-user', channel: 'telegram', id: '123', name: 'Ana Silva', username: 'ana', chat: { id, thread, name } }`, where `chat` is the chat, its topic thread and the topic's name. Scripts that read session logs can use it instead of parsing the line.
46
55
  - Replies, quotes and forwards keep their context. The agent sees the text of the replied-to message (or only the quoted part), who wrote it and when; a replied-to photo or document is attached too. A forwarded message is labelled with its origin (person, hidden account, group or channel with a message link) and original date. Several messages forwarded together become one prompt, and forwarded text never runs as a command. Long quoted text is cut at about 2,000 characters with a note.
47
56
  - Replies to messages sent into a topic by a script, job, plugin or another session (see [Send to a topic](#send-to-a-topic)) also carry the full original text, when it was sent, and the sending session and job, even if the topic has moved to a new session since.
48
- - `/new`, `/workspace`, `/last`, `/goal`, `/rename`, `/topic`, `/fork`, `/clear`, `/stop`, `/restart`, `/status` and `/menu`.
57
+ - `/new`, `/workspace`, `/last`, `/agents`, `/goal`, `/rename`, `/topic`, `/fork`, `/clear`, `/stop`, `/restart`, `/status` and `/menu`.
49
58
  - `/goal` shows the current DSH goal and the command hints. `/goal <objective>` creates or replaces the goal and starts working immediately; `/goal edit <objective>` updates the objective and resumes a paused, blocked or disarmed goal; `/goal pause`, `/goal resume` and `/goal clear` manage the current goal. `/goal <objective>` and `/goal edit <objective>` replace the current panel with one fresh message; later goal changes edit that message in place. The panel is never pinned.
50
59
  - `/new` in a session topic starts a fresh DSH session in the same workspace and binds it to the same topic; the previous session stays in DSH. In All it opens a new session topic.
51
60
  - One session is bound to at most one topic: binding it to a new topic releases the old one.
@@ -145,7 +154,7 @@ Private-chat topics send with `message_thread_id`. If Telegram answers `message
145
154
 
146
155
  ## Send to a topic
147
156
 
148
- Scripts, scheduled jobs, other plugins and agents can post into a topic by its numeric thread id (`/id` in the topic shows it). The message (text, files, a sticker) appears in the topic and is also recorded in the session bound to that topic, without starting a turn, labelled with who sent it. This holds for the bound session's own sends too, so a Web UI reader of that session sees the same pictures, files and stickers: pictures and stickers (as their still image) are recorded as images, other files as file attachments. An idle session gets the record at once; a busy one gets it at its next step. Thread ids belong to one chat, so the message goes to the chat the bot saw that thread in (its binding or known name), not the chat that last wrote to the bot. A thread id the bot does not know in any chat is an error; nothing is created. In the rare case two chats have a topic with the same thread id, the send fails until the caller names the chat (`chat`, `--chat`). Topic names are only labels, never addresses. A known topic with no session gets a new one first, made the way `/new` makes it (the configured working directory and preset, titled after the topic), so the message is recorded; only a topic whose user is still choosing its session just shows the message, and the result says so (`note`). If no session can take the record, the message is still sent and the result carries `recordError`.
157
+ Scripts, scheduled jobs, other plugins and agents can post into a topic by its numeric thread id (`/id` in the topic shows it). The message (text, files, a sticker) appears in the topic and is also recorded in the session bound to that topic, without starting a turn, labelled with who sent it. The record's source (kind `telegram-post`) names the topic it went to (`channel: 'telegram'`, `chatId`, `threadId`, `topic`) and the sender as `principal`: `{ kind: 'cron-job', channel: 'cron', id, name, sessionId? }` for a scheduled job, `{ kind: 'dsh-session', channel: 'dsh', id }` for a DSH session, or `{ kind: 'external', channel: 'external', id: <label> }` for a caller that gave only a label. This holds for the bound session's own sends too, so a Web UI reader of that session sees the same pictures, files and stickers: pictures and stickers (as their still image) are recorded as images, other files as file attachments. An idle session gets the record at once; a busy one gets it at its next step. Thread ids belong to one chat, so the message goes to the chat the bot saw that thread in (its binding or known name), not the chat that last wrote to the bot. A thread id the bot does not know in any chat is an error; nothing is created. In the rare case two chats have a topic with the same thread id, the send fails until the caller names the chat (`chat`, `--chat`). Topic names are only labels, never addresses. A known topic with no session gets a new one first, made the way `/new` makes it (the configured working directory and preset, titled after the topic), so the message is recorded; only a topic whose user is still choosing its session just shows the message, and the result says so (`note`). If no session can take the record, the message is still sent and the result carries `recordError`.
149
158
 
150
159
  The bot also remembers each such send (`$DSH_HOME/telegram/sent-messages.json`: the last 1,000 sends from the past 180 days, text up to 20,000 characters). When someone replies to or quotes one of those messages, the agent gets the whole original text, however many Telegram messages it was split into, with its send time, job name and sending session, and a hint to open that session for more context. This works after `/new` too, when the current session never saw the message.
151
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soffits/dsh-telegram-bot",
3
- "version": "0.4.4",
3
+ "version": "0.6.0",
4
4
  "description": "Telegram sessions, topics, model controls and approvals for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/agents.js ADDED
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Subagent activity in Telegram topics.
3
+ *
4
+ * When a turn the topic is showing starts subagents, one status message lists
5
+ * them (description, model, elapsed time) and is edited in place as they
6
+ * start and finish. Changes that land close together share one edit, and a
7
+ * running board is refreshed at most once a minute so its times move. Once
8
+ * every subagent on it is done the message is left as it is; the next
9
+ * subagent opens a new one. Subagents started by subagents count under the
10
+ * topic of the session at the top of the chain.
11
+ *
12
+ * `/agents` lists a session's subagents from DSH's durable catalogs, so it
13
+ * also works after a restart.
14
+ */
15
+ import { messageAnswer } from './final-text.js'
16
+
17
+ /** Pause that lets several starts or ends share one edit. */
18
+ export const EDIT_DELAY_MS = 1500
19
+ /** How often a board with running subagents is refreshed for its times. */
20
+ export const TICK_MS = 60_000
21
+ /** Parent links followed from a subagent up to a bound session. */
22
+ const MAX_DEPTH = 8
23
+ /** Subagents `/agents` lists at most, running ones first. */
24
+ export const LIST_LIMIT = 8
25
+ /** Lines a status message shows at most; older finished lines are folded. */
26
+ const MAX_LINES = 25
27
+ /** Longest description shown on a line. */
28
+ const LABEL_MAX = 60
29
+ /** Longest report preview `/agents` shows per subagent. */
30
+ export const REPORT_MAX = 240
31
+
32
+ /** @returns the text cut to `max` characters with an ellipsis. */
33
+ export function clip(text, max) {
34
+ const value = String(text ?? '')
35
+ return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`
36
+ }
37
+
38
+ /**
39
+ * Format a duration. Running times are shown in whole minutes, so a board
40
+ * refreshed once a minute stays accurate; finished times also show seconds.
41
+ * @param ms - duration in milliseconds.
42
+ * @param precise - include seconds.
43
+ */
44
+ export function formatElapsed(ms, precise) {
45
+ const seconds = Math.max(0, Math.floor(ms / 1000))
46
+ const minutes = Math.floor(seconds / 60)
47
+ const hours = Math.floor(minutes / 60)
48
+ if (hours > 0) return `${hours}h ${minutes % 60}m`
49
+ if (!precise) return minutes < 1 ? '<1m' : `${minutes}m`
50
+ if (minutes < 1) return `${seconds}s`
51
+ return `${minutes}m ${seconds % 60}s`
52
+ }
53
+
54
+ /** @returns true when a finished run did not complete normally. */
55
+ function failed(stopReason) {
56
+ return stopReason !== undefined && stopReason !== 'completed'
57
+ }
58
+
59
+ /** @returns the reason text for a failed run (`max_tokens` → `max tokens`). */
60
+ function reasonText(stopReason) {
61
+ return String(stopReason).replace(/[_-]+/gu, ' ')
62
+ }
63
+
64
+ /**
65
+ * The status text of one board.
66
+ * @param entries - `{ label, model, depth, startedAt, endedAt, stopReason }` in start order.
67
+ * @param now - current time in ms.
68
+ * @param tr - `(en, ru) => text` for the bot language.
69
+ */
70
+ export function boardText(entries, now, tr = (en) => en) {
71
+ const running = entries.filter((entry) => entry.endedAt === undefined).length
72
+ const failures = entries.filter((entry) => entry.endedAt !== undefined && failed(entry.stopReason)).length
73
+ const total = entries.length
74
+ const noun = tr(total === 1 ? 'subagent' : 'subagents', 'субагент(ов)')
75
+ let head
76
+ if (running > 0) head = `🤖 ${total} ${noun} · ${running} ${tr('running', 'работают')}`
77
+ else if (failures > 0) head = `🤖 ${total} ${noun} · ${tr('done', 'готово')}, ${failures} ${tr('failed', 'с ошибкой')}`
78
+ else head = `🤖 ${total} ${noun} · ${tr('all done', 'все готовы')}`
79
+ const lines = entries.map((entry) => {
80
+ const indent = entry.depth > 1 ? `${' '.repeat(entry.depth - 2)}↳ ` : ''
81
+ const parts = [clip(entry.label, LABEL_MAX)]
82
+ if (entry.model) parts.push(entry.model)
83
+ if (entry.endedAt === undefined) {
84
+ parts.push(formatElapsed(now - entry.startedAt, false))
85
+ return `${indent}⏳ ${parts.join(' · ')}`
86
+ }
87
+ parts.push(formatElapsed(entry.endedAt - entry.startedAt, true))
88
+ if (failed(entry.stopReason)) parts.push(reasonText(entry.stopReason))
89
+ return `${indent}${failed(entry.stopReason) ? '✗' : '✓'} ${parts.join(' · ')}`
90
+ })
91
+ if (lines.length > MAX_LINES) {
92
+ // Keep running lines and the newest ones; fold the oldest finished lines.
93
+ const keep = new Set()
94
+ entries.forEach((entry, index) => { if (entry.endedAt === undefined) keep.add(index) })
95
+ for (let index = entries.length - 1; index >= 0 && keep.size < MAX_LINES - 1; index -= 1) keep.add(index)
96
+ const folded = lines.length - keep.size
97
+ const shown = lines.filter((_line, index) => keep.has(index))
98
+ return [head, `… ${folded} ${tr('earlier finished', 'завершённых ранее')}`, ...shown].join('\n')
99
+ }
100
+ return [head, ...lines].join('\n')
101
+ }
102
+
103
+ /** @returns whether a tool name is the subagent reporting tool (`send_message`, maybe prefixed). */
104
+ function isReportTool(name) {
105
+ return typeof name === 'string' && (name === 'send_message' || name.endsWith('__send_message'))
106
+ }
107
+
108
+ /** @returns the `message` argument of a tool call, or ''. */
109
+ function reportArgument(block) {
110
+ let args = block?.arguments ?? block?.input
111
+ if (typeof args === 'string') {
112
+ try {
113
+ args = JSON.parse(args)
114
+ } catch {
115
+ return ''
116
+ }
117
+ }
118
+ return typeof args?.message === 'string' ? args.message.trim() : ''
119
+ }
120
+
121
+ /**
122
+ * The latest report a subagent left: a message it sent its parent, or the
123
+ * answer of its last tool-free message, whichever is newer.
124
+ * @param events - the child's session events (`{ type, data }`).
125
+ * @returns the report text, or ''.
126
+ */
127
+ export function reportOf(events) {
128
+ if (!Array.isArray(events)) return ''
129
+ for (let index = events.length - 1; index >= 0; index -= 1) {
130
+ const event = events[index]
131
+ if (event?.type !== 'assistant/message') continue
132
+ const message = event.data?.message ?? event.data
133
+ const content = Array.isArray(message?.content) ? message.content : []
134
+ for (let block = content.length - 1; block >= 0; block -= 1) {
135
+ const item = content[block]
136
+ if (item?.type === 'tool-call' && isReportTool(item.name)) {
137
+ const text = reportArgument(item)
138
+ if (text.length > 0) return text
139
+ }
140
+ }
141
+ const answer = messageAnswer(message)
142
+ if (answer.length > 0) return answer
143
+ }
144
+ return ''
145
+ }
146
+
147
+ /**
148
+ * State of one listed subagent, from its live agent and durable timing.
149
+ * @param facts - `{ status, timing }`: live agent status (or undefined when not loaded) and the `subagentTiming` view.
150
+ * @param now - current time in ms.
151
+ * @returns `{ state, elapsedMs }`; state is running, done, failed, interrupted or idle.
152
+ */
153
+ export function childState({ status, timing }, now) {
154
+ const settled = Number.isFinite(timing?.settledMs) ? timing.settledMs : 0
155
+ const active = timing?.active
156
+ if (status === 'running') {
157
+ return { state: 'running', elapsedMs: active === undefined ? settled : settled + Math.max(0, now - active.since) }
158
+ }
159
+ if (active !== undefined) {
160
+ // A turn left open with no live agent was cut off, usually by a restart.
161
+ return { state: status === undefined ? 'interrupted' : 'running', elapsedMs: settled + Math.max(0, (status === undefined ? active.through : now) - active.since) }
162
+ }
163
+ if (timing?.lastTurnCompleted === false) return { state: 'failed', elapsedMs: settled }
164
+ if (timing?.lastTurnCompleted === true) return { state: 'done', elapsedMs: settled }
165
+ return { state: 'idle', elapsedMs: settled }
166
+ }
167
+
168
+ /** @returns relative age such as `5m ago`. */
169
+ export function ageText(createdAt, now, tr = (en) => en) {
170
+ if (!Number.isFinite(createdAt)) return ''
171
+ const minutes = Math.max(0, Math.floor((now - createdAt) / 60_000))
172
+ if (minutes < 1) return tr('just now', 'только что')
173
+ if (minutes < 60) return tr(`${minutes}m ago`, `${minutes} мин назад`)
174
+ const hours = Math.floor(minutes / 60)
175
+ if (hours < 48) return tr(`${hours}h ago`, `${hours} ч назад`)
176
+ return tr(`${Math.floor(hours / 24)}d ago`, `${Math.floor(hours / 24)} дн назад`)
177
+ }
178
+
179
+ const STATE_ICONS = { running: '⏳', done: '✓', failed: '✗', interrupted: '⚠️', idle: '•' }
180
+
181
+ /**
182
+ * The `/agents` reply.
183
+ * @param rows - `{ label, model, depth, state, elapsedMs, createdAt, report }`, newest first.
184
+ * @param total - how many subagents the session has in all.
185
+ * @param now - current time in ms.
186
+ * @param tr - `(en, ru) => text`.
187
+ */
188
+ export function agentsListText(rows, total, now, tr = (en) => en) {
189
+ if (rows.length === 0) return tr('This session has started no subagents.', 'Эта сессия не запускала субагентов.')
190
+ const head = total > rows.length
191
+ ? tr(`🤖 Subagents (latest ${rows.length} of ${total}):`, `🤖 Субагенты (последние ${rows.length} из ${total}):`)
192
+ : tr('🤖 Subagents:', '🤖 Субагенты:')
193
+ const states = {
194
+ running: tr('running', 'работает'),
195
+ done: tr('done', 'готово'),
196
+ failed: tr('failed', 'ошибка'),
197
+ interrupted: tr('interrupted', 'прерван'),
198
+ idle: tr('idle', 'ожидает'),
199
+ }
200
+ const blocks = rows.map((row) => {
201
+ const indent = row.depth > 1 ? '↳ ' : ''
202
+ const facts = [states[row.state] ?? row.state]
203
+ if (row.model) facts.push(row.model)
204
+ if (row.elapsedMs > 0 || row.state === 'running') facts.push(formatElapsed(row.elapsedMs, row.state !== 'running'))
205
+ const age = ageText(row.createdAt, now, tr)
206
+ if (age) facts.push(age)
207
+ const lines = [`${indent}${STATE_ICONS[row.state] ?? '•'} ${clip(row.label, LABEL_MAX)}`, ` ${facts.join(' · ')}`]
208
+ const report = clip(String(row.report ?? '').replace(/\s+/gu, ' ').trim(), REPORT_MAX)
209
+ if (report) lines.push(` “${report}”`)
210
+ return lines.join('\n')
211
+ })
212
+ return [head, '', blocks.join('\n\n')].join('\n')
213
+ }
214
+
215
+ /**
216
+ * Track subagent runs and keep one status message per batch in the topic.
217
+ * @param deps - Host and Telegram access:
218
+ * - `parentOf(sessionId)`: the parent session id of a live session, or undefined.
219
+ * - `topicOf(sessionId)`: the topic state a session is bound to, or undefined.
220
+ * - `turnOf(topic)`: a key for the turn the topic is showing, or undefined when none.
221
+ * - `labelOf(parentId, childId)`: the child's description from its parent's catalog.
222
+ * - `modelOf(childId)`: the child's model id.
223
+ * - `post(topic, text)`: send a message, resolves to its id or undefined.
224
+ * - `edit(topic, messageId, text)`: resolves to `ok`, `gone` (send a new one) or `failed` (try later).
225
+ * - `reportError(scope, error)`, `tr(en, ru)`, `now()`, `delayMs`, `tickMs`.
226
+ * @returns `{ onStart, onEnd, dispose, boards }`.
227
+ */
228
+ export function createAgentBoards(deps) {
229
+ const {
230
+ parentOf, topicOf, turnOf, labelOf, modelOf, post, edit, reportError,
231
+ tr = (en) => en, now = Date.now, delayMs = EDIT_DELAY_MS, tickMs = TICK_MS,
232
+ } = deps
233
+ /** Open boards by topic key, oldest first. */
234
+ const open = new Map()
235
+ let disposed = false
236
+
237
+ function boardsOf(key) {
238
+ let list = open.get(key)
239
+ if (list === undefined) {
240
+ list = []
241
+ open.set(key, list)
242
+ }
243
+ return list
244
+ }
245
+
246
+ function closeBoard(board) {
247
+ const list = open.get(board.topic.key)
248
+ if (list !== undefined) {
249
+ const index = list.indexOf(board)
250
+ if (index !== -1) list.splice(index, 1)
251
+ if (list.length === 0) open.delete(board.topic.key)
252
+ }
253
+ if (board.tick !== undefined) clearInterval(board.tick)
254
+ board.tick = undefined
255
+ }
256
+
257
+ /** Publish soon; changes before the timer fires share the edit. */
258
+ function schedule(board) {
259
+ if (disposed || board.timer !== undefined) return
260
+ board.timer = setTimeout(() => {
261
+ board.timer = undefined
262
+ board.chain = board.chain
263
+ .then(() => publish(board))
264
+ .catch((error) => reportError('subagent status', error))
265
+ }, delayMs)
266
+ board.timer.unref?.()
267
+ }
268
+
269
+ async function fillLabels(board) {
270
+ await Promise.all([...board.entries.values()].filter((entry) => !entry.labelKnown).map(async (entry) => {
271
+ const label = await Promise.resolve(labelOf(entry.parentId, entry.id)).catch(() => undefined)
272
+ if (typeof label === 'string' && label.trim()) {
273
+ entry.label = label.trim()
274
+ entry.labelKnown = true
275
+ }
276
+ }))
277
+ }
278
+
279
+ async function publish(board) {
280
+ if (disposed) return
281
+ await fillLabels(board)
282
+ const entries = [...board.entries.values()]
283
+ const done = entries.every((entry) => entry.endedAt !== undefined)
284
+ // Freeze before any wait, so a subagent starting meanwhile opens a new board.
285
+ if (done) closeBoard(board)
286
+ const text = boardText(entries, now(), tr)
287
+ if (text === board.lastText) return
288
+ if (board.messageId !== undefined) {
289
+ const outcome = await edit(board.topic, board.messageId, text)
290
+ if (outcome === 'ok') {
291
+ board.lastText = text
292
+ return
293
+ }
294
+ if (outcome !== 'gone') {
295
+ // A running board retries with its next change or tick; a finished
296
+ // one has neither, so it tries once more after a pause.
297
+ if (done) {
298
+ await new Promise((resolve) => { setTimeout(resolve, delayMs * 4).unref?.() })
299
+ if (!disposed && (await edit(board.topic, board.messageId, text)) === 'ok') board.lastText = text
300
+ }
301
+ return
302
+ }
303
+ board.messageId = undefined
304
+ }
305
+ const messageId = await post(board.topic, text)
306
+ if (messageId !== undefined) {
307
+ board.messageId = messageId
308
+ board.lastText = text
309
+ }
310
+ }
311
+
312
+ /**
313
+ * A subagent run started (`subagent/start`).
314
+ * @param info - `{ runId, id }`.
315
+ */
316
+ async function onStart(info) {
317
+ if (disposed || typeof info?.id !== 'string') return
318
+ const parentId = parentOf(info.id)
319
+ let topic
320
+ const ancestors = []
321
+ let current = parentId
322
+ for (let depth = 0; depth < MAX_DEPTH && typeof current === 'string'; depth += 1) {
323
+ topic = topicOf(current)
324
+ if (topic !== undefined) break
325
+ ancestors.push(current)
326
+ current = parentOf(current)
327
+ }
328
+ if (topic === undefined) return
329
+ const list = boardsOf(topic.key)
330
+ // A nested subagent joins the board that shows its nearest ancestor.
331
+ let board
332
+ for (const ancestor of ancestors) {
333
+ board = list.find((candidate) => [...candidate.entries.values()].some((entry) => entry.id === ancestor))
334
+ if (board !== undefined) break
335
+ }
336
+ if (board === undefined) {
337
+ const turn = turnOf(topic)
338
+ if (turn === undefined) {
339
+ if (list.length === 0) open.delete(topic.key)
340
+ return
341
+ }
342
+ board = list.find((candidate) => candidate.turn === turn)
343
+ if (board === undefined) {
344
+ board = { topic, turn, entries: new Map(), messageId: undefined, lastText: undefined, timer: undefined, tick: undefined, chain: Promise.resolve() }
345
+ list.push(board)
346
+ board.tick = setInterval(() => schedule(board), tickMs)
347
+ board.tick.unref?.()
348
+ }
349
+ }
350
+ const entry = {
351
+ id: info.id,
352
+ parentId,
353
+ label: tr(`subagent ${info.id.slice(0, 8)}`, `субагент ${info.id.slice(0, 8)}`),
354
+ labelKnown: false,
355
+ model: '',
356
+ depth: ancestors.length + 1,
357
+ startedAt: now(),
358
+ endedAt: undefined,
359
+ stopReason: undefined,
360
+ }
361
+ board.entries.set(info.runId ?? info.id, entry)
362
+ schedule(board)
363
+ const model = await Promise.resolve(modelOf(info.id)).catch(() => undefined)
364
+ if (typeof model === 'string' && model.length > 0) entry.model = model
365
+ }
366
+
367
+ /**
368
+ * A subagent run ended (`subagent/end`).
369
+ * @param info - `{ runId, id, stopReason }`.
370
+ */
371
+ function onEnd(info) {
372
+ const key = info?.runId ?? info?.id
373
+ for (const list of open.values()) {
374
+ for (const board of list) {
375
+ const entry = board.entries.get(key)
376
+ if (entry === undefined || entry.endedAt !== undefined) continue
377
+ entry.endedAt = now()
378
+ entry.stopReason = info.stopReason
379
+ schedule(board)
380
+ return
381
+ }
382
+ }
383
+ }
384
+
385
+ function dispose() {
386
+ disposed = true
387
+ for (const list of open.values()) {
388
+ for (const board of list) {
389
+ if (board.timer !== undefined) clearTimeout(board.timer)
390
+ if (board.tick !== undefined) clearInterval(board.tick)
391
+ }
392
+ }
393
+ open.clear()
394
+ }
395
+
396
+ return { onStart, onEnd, dispose, boards: open }
397
+ }
398
+
399
+ /**
400
+ * Collect a session's subagents for `/agents`: every descendant reachable
401
+ * through the catalogs of recent children, newest first, running ones kept.
402
+ * @param listChildren - `(sessionId) => Promise<[{ id, label, createdAt, mode }]>`.
403
+ * @param rootId - the topic's session.
404
+ * @param isRunning - `(id) => boolean` for live, working subagents.
405
+ * @param limit - rows to return.
406
+ * @returns `{ rows: [{ id, label, createdAt, depth }], total }`.
407
+ */
408
+ export async function collectSubagents(listChildren, rootId, isRunning, limit = LIST_LIMIT) {
409
+ const found = []
410
+ const seen = new Set([rootId])
411
+ /** Catalog reads are bounded: descendants of old children are old too. */
412
+ let budget = limit * 4
413
+ const walk = async (parentId, depth) => {
414
+ let children
415
+ try {
416
+ children = await listChildren(parentId)
417
+ } catch {
418
+ return
419
+ }
420
+ if (!Array.isArray(children)) return
421
+ const recent = [...children].sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
422
+ for (const child of recent) {
423
+ if (seen.has(child.id)) continue
424
+ seen.add(child.id)
425
+ found.push({ id: child.id, label: child.label, createdAt: child.createdAt, depth })
426
+ }
427
+ for (const child of recent) {
428
+ if (child.mode === 'external' || depth >= MAX_DEPTH) continue
429
+ if (budget <= 0 && !isRunning(child.id)) continue
430
+ budget -= 1
431
+ await walk(child.id, depth + 1)
432
+ }
433
+ }
434
+ await walk(rootId, 1)
435
+ found.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
436
+ const running = found.filter((row) => isRunning(row.id))
437
+ const rows = [...running, ...found.filter((row) => !isRunning(row.id)).slice(0, Math.max(0, limit - running.length))]
438
+ rows.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0))
439
+ return { rows, total: found.length }
440
+ }
package/src/app.js CHANGED
@@ -35,13 +35,15 @@ import {
35
35
  toTelegramMarkdown,
36
36
  toolArg,
37
37
  } from './formatting.js'
38
- import { admitText } from './admission.js'
38
+ import { admitText, promptRequestId } from './admission.js'
39
39
  import { STEER_REACTION, busyMode, clearTelegramInbox, hasPendingInput, steerPrompt } from './steer.js'
40
+ import { createPrincipalTagger, sendPrincipal, telegramUserPrincipal } from './sender.js'
40
41
  import { loadState, STATE_PATH } from './state.js'
41
42
  import { createUpdateAdmission } from './update-ledger.js'
42
43
  import { publishStatus } from './status.js'
43
44
  import { messageAnswer, turnAnswer } from './final-text.js'
44
45
  import { createOutbox } from './outbox.js'
46
+ import { agentsListText, childState, collectSubagents, createAgentBoards, reportOf } from './agents.js'
45
47
  import { deliveredToTopic, isAutoCommandTopic, isPlaceholderTitle, isTopicGone, plannedTitle, resolveSendTarget, sessionTitleOf, topicNameOf, topicTargetFor } from './topics.js'
46
48
  import { runProgram, spokenMediaOf, transcribe, transcriptNote } from './voice.js'
47
49
  import { createStickerLibrary, describeSticker, STICKER_PREVIEW_DIR } from './stickers.js'
@@ -129,6 +131,10 @@ export function apply(ctx, config) {
129
131
  })
130
132
  /** Ids of legacy follow-up messages the bridge submitted from Telegram. */
131
133
  const telegramMessageIds = new Set()
134
+ // Telegram senders by `<chatId>:<messageId>`, kept from routing until the
135
+ // prompt is handed to DSH (a topic binding or the queue may come between).
136
+ const promptSenders = new Map()
137
+ const principals = createPrincipalTagger()
132
138
 
133
139
  /**
134
140
  * Persist the chat → session mapping atomically.
@@ -2754,7 +2760,7 @@ export function apply(ctx, config) {
2754
2760
  }
2755
2761
  let requestId
2756
2762
  try {
2757
- requestId = await steerPrompt(ctx, agent, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text, attachments }, signal)
2763
+ requestId = await steerPrompt(ctx, agent, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text, attachments, principal: promptPrincipal(chat, messageId) }, signal, principals)
2758
2764
  } catch (error) {
2759
2765
  // Not admitted: the bot's queue still delivers it after the turn.
2760
2766
  reportError('steer', error)
@@ -2846,14 +2852,16 @@ export function apply(ctx, config) {
2846
2852
  const agent = await ensureAgent(chat)
2847
2853
  await unarchiveForTopic(chat)
2848
2854
  let admitted = false
2855
+ const principal = promptPrincipal(chat, messageId)
2849
2856
  if (nativeText) {
2857
+ principals.remember(promptRequestId(chat.chatId, messageId), principal)
2850
2858
  admitted = admitText(ctx, { sessionId: chat.sessionId, chatId: chat.chatId, messageId, text }, signal)
2851
2859
  if (admitted !== false) await admitted
2852
2860
  }
2853
2861
  if (!admitted) {
2854
2862
  const id = randomUUID()
2855
2863
  telegramMessageIds.add(id)
2856
- agent.followup({ id, role: 'user', content: [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments], source: { kind: 'user' } })
2864
+ agent.followup({ id, role: 'user', content: [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments], source: { kind: 'user', ...(principal === undefined ? {} : { principal }) } })
2857
2865
  }
2858
2866
  // The turn/end listener finalizes the Telegram turn. whenIdle is only a
2859
2867
  // fallback for the turn that started this epoch, never a queued turn.
@@ -3952,13 +3960,17 @@ export function apply(ctx, config) {
3952
3960
  * line the model also reads, `from` the request's sender.
3953
3961
  * @returns the message source.
3954
3962
  */
3955
- function postSource({ header, threadId, topic, from, self }) {
3963
+ function postSource({ header, chatId, threadId, topic, from, self }) {
3956
3964
  const job = typeof from?.job === 'string' && from.job.trim().length > 0 ? from.job.trim() : null
3957
3965
  const label = typeof from?.label === 'string' && from.label.trim().length > 0 ? from.label.trim() : null
3966
+ const principal = sendPrincipal(from)
3958
3967
  return {
3959
3968
  kind: POST_SOURCE_KIND,
3960
3969
  form: 'notice',
3961
3970
  summary: header.slice(0, 120),
3971
+ ...(principal === undefined ? {} : { principal }),
3972
+ channel: 'telegram',
3973
+ chatId: String(chatId),
3962
3974
  threadId,
3963
3975
  topic: topic ?? null,
3964
3976
  job,
@@ -4105,7 +4117,7 @@ export function apply(ctx, config) {
4105
4117
  id: randomUUID(),
4106
4118
  role: 'user',
4107
4119
  content,
4108
- source: postSource({ header, threadId, topic: target.name, from: request?.from, self }),
4120
+ source: postSource({ header, chatId, threadId, topic: target.name, from: request?.from, self }),
4109
4121
  })
4110
4122
  recorded = true
4111
4123
  } catch (error) {
@@ -4252,6 +4264,110 @@ export function apply(ctx, config) {
4252
4264
  return texts
4253
4265
  }
4254
4266
 
4267
+ /**
4268
+ * The description a parent gave one of its subagents, from its catalog.
4269
+ * @param parentId - parent session.
4270
+ * @param childId - subagent session.
4271
+ * @returns the label, or undefined.
4272
+ */
4273
+ async function subagentLabel(parentId, childId) {
4274
+ if (typeof parentId !== 'string') return undefined
4275
+ const children = await ctx.get('subagents')?.listChildren(parentId).catch(() => undefined)
4276
+ const label = Array.isArray(children) ? children.find((child) => child.id === childId)?.label : undefined
4277
+ return typeof label === 'string' && label.trim().length > 0 ? label.trim() : undefined
4278
+ }
4279
+
4280
+ /**
4281
+ * Post a subagent status message, after what the topic has queued.
4282
+ * @param chat - topic state.
4283
+ * @param text - status text.
4284
+ * @returns the message id, or undefined.
4285
+ */
4286
+ function postAgentStatus(chat, text) {
4287
+ const posted = (chat.answerEdit ?? Promise.resolve()).catch(() => {}).then(async () => {
4288
+ try {
4289
+ const result = await call('sendMessage', {
4290
+ chat_id: chat.chatId,
4291
+ ...topicTarget(chat.chatId, chat.threadId),
4292
+ text,
4293
+ disable_notification: true,
4294
+ }, bridgeAbort.signal)
4295
+ return typeof result?.message_id === 'number' ? result.message_id : undefined
4296
+ } catch (error) {
4297
+ if (!bridgeAbort.signal.aborted) warn(`subagent status send failed: ${messageOf(error)}`)
4298
+ return undefined
4299
+ }
4300
+ })
4301
+ chat.answerEdit = posted.then(() => {}, () => {})
4302
+ return posted
4303
+ }
4304
+
4305
+ /**
4306
+ * Edit a subagent status message in place.
4307
+ * @returns `ok`, `gone` when the message can no longer be edited, or `failed`.
4308
+ */
4309
+ async function editAgentStatus(chat, messageId, text) {
4310
+ try {
4311
+ await call('editMessageText', {
4312
+ chat_id: chat.chatId,
4313
+ message_id: messageId,
4314
+ ...(chat.threadId === undefined || chat.threadId === 0 ? {} : { message_thread_id: chat.threadId }),
4315
+ text,
4316
+ }, bridgeAbort.signal)
4317
+ return 'ok'
4318
+ } catch (error) {
4319
+ const message = messageOf(error)
4320
+ if (/not modified/i.test(message)) return 'ok'
4321
+ if (/message to edit not found|message can't be edited|message_id_invalid/i.test(message)) return 'gone'
4322
+ if (!bridgeAbort.signal.aborted) warn(`subagent status edit failed: ${message}`)
4323
+ return 'failed'
4324
+ }
4325
+ }
4326
+
4327
+ /**
4328
+ * The `/agents` text for a session: its running and latest subagents with
4329
+ * their state and the start of their last report. Read from DSH's durable
4330
+ * catalogs and session logs, so it works for subagents of earlier runs.
4331
+ * @param sessionId - the topic's session.
4332
+ * @returns the reply text, or undefined when this DSH has no subagent service.
4333
+ */
4334
+ async function subagentsText(sessionId) {
4335
+ const subagents = ctx.get('subagents')
4336
+ if (typeof subagents?.listChildren !== 'function') return undefined
4337
+ const agents = ctx.get('agents')
4338
+ const isRunning = (id) => agents?.get(id)?.status === 'running'
4339
+ const { rows, total } = await collectSubagents((id) => subagents.listChildren(id), sessionId, isRunning)
4340
+ const now = Date.now()
4341
+ const query = ctx.get('sessionQuery')
4342
+ const detailed = await Promise.all(rows.map(async (row) => {
4343
+ let timing
4344
+ let model
4345
+ let observation
4346
+ try {
4347
+ observation = await query?.observeSession(row.id)
4348
+ const values = observation?.projections?.values
4349
+ timing = values?.subagentTiming
4350
+ const selection = values?.modelSelection
4351
+ model = (selection?.next ?? selection?.pending ?? selection?.lastUsed)?.model
4352
+ } catch {
4353
+ // State falls back to what the live agent says.
4354
+ } finally {
4355
+ observation?.[Symbol.dispose]?.()
4356
+ }
4357
+ const live = agents?.get(row.id)
4358
+ if (typeof live?.model?.current === 'function') model = live.model.current()?.model ?? model
4359
+ let events = []
4360
+ try {
4361
+ events = (await query?.readSurface(row.id))?.events ?? []
4362
+ } catch {
4363
+ // No report preview.
4364
+ }
4365
+ const { state, elapsedMs } = childState({ status: live?.status, timing }, now)
4366
+ return { ...row, label: row.label || `subagent ${String(row.id).slice(0, 8)}`, model, state, elapsedMs, report: reportOf(events) }
4367
+ }))
4368
+ return agentsListText(detailed, total, now, tr)
4369
+ }
4370
+
4255
4371
  /**
4256
4372
  * Deliver a session preview into its topic.
4257
4373
  * @param record - topic mapping.
@@ -5241,7 +5357,7 @@ export function apply(ctx, config) {
5241
5357
  ? `${tr('To start a conversation, create a topic and send a message in it, or just send a message here in All: Telegram makes a topic for it. In the topic, pick the workspace, then a new session or an existing one. /sessions opens existing sessions in new topics.', 'Чтобы начать разговор, создай топик и напиши в него, или просто напиши здесь, в All: Telegram сам создаст для сообщения топик. В топике выбери workspace, затем новую или существующую сессию. /sessions открывает существующие сессии в новых топиках.')}\n`
5242
5358
  : `${tr('This topic has no session yet. Send a message to choose a workspace.', 'У этого топика ещё нет сессии. Напиши сообщение, чтобы выбрать workspace.')}\n`) +
5243
5359
  `\n<b>${tr('First run', 'Первый запуск')}:</b> @BotFather → Bot Settings → <b>Threaded mode</b> → ${tr('Enable', 'Включить')}.\n` +
5244
- `${tr('Commands', 'Команды')}: /sessions, /new, /workspace, /current, /menu, /rename, /topic, /fork, /last, /clear, /stop, /restart, /status, /id, /ping, /goal.`,
5360
+ `${tr('Commands', 'Команды')}: /sessions, /new, /workspace, /current, /menu, /rename, /topic, /fork, /last, /agents, /clear, /stop, /restart, /status, /id, /ping, /goal.`,
5245
5361
  signal,
5246
5362
  threadId,
5247
5363
  )
@@ -5442,6 +5558,17 @@ export function apply(ctx, config) {
5442
5558
  return
5443
5559
  }
5444
5560
 
5561
+ if (command === '/agents') {
5562
+ if (!inSession) {
5563
+ await send(chat.id, tr('This topic is not bound to a session. /sessions.', 'Этот topic не привязан к сессии. /sessions.'), signal, threadId)
5564
+ return
5565
+ }
5566
+ const listed = await subagentsText(chatState.sessionId)
5567
+ const body = listed ?? tr('This DSH has no subagent service.', 'В этом DSH нет сервиса субагентов.')
5568
+ await sendPlain(chat.id, body.length > MAX_TELEGRAM_TEXT ? `${body.slice(0, MAX_TELEGRAM_TEXT - 1)}…` : body, signal, threadId)
5569
+ return
5570
+ }
5571
+
5445
5572
  if (command === '/fork') {
5446
5573
  if (!inSession) {
5447
5574
  await send(chat.id, tr('Open a session first: /sessions.', 'Сначала открой сессию: /sessions.'), signal, threadId)
@@ -5873,6 +6000,36 @@ export function apply(ctx, config) {
5873
6000
  return { text, attachments, messageId: message.message_id, from: message.from }
5874
6001
  }
5875
6002
 
6003
+ /**
6004
+ * Keep who wrote a Telegram message until its prompt reaches DSH.
6005
+ * @param chatId - Telegram chat.
6006
+ * @param messageId - Telegram message id.
6007
+ * @param from - Telegram `from` of the message.
6008
+ */
6009
+ function rememberSender(chatId, messageId, from) {
6010
+ if (!Number.isSafeInteger(messageId) || from === undefined || from === null) return
6011
+ const key = `${chatId}:${messageId}`
6012
+ promptSenders.delete(key)
6013
+ promptSenders.set(key, from)
6014
+ if (promptSenders.size > 1024) promptSenders.delete(promptSenders.keys().next().value)
6015
+ }
6016
+
6017
+ /**
6018
+ * The sender of a Telegram message, recorded on its prompt's source.
6019
+ * @param chat - the topic the prompt runs in.
6020
+ * @param messageId - Telegram message id.
6021
+ * @returns the principal, or undefined when the sender is not known.
6022
+ */
6023
+ function promptPrincipal(chat, messageId) {
6024
+ const from = promptSenders.get(`${chat.chatId}:${messageId}`)
6025
+ if (from === undefined) return undefined
6026
+ return telegramUserPrincipal(from, {
6027
+ chatId: chat.chatId,
6028
+ threadId: chat.threadId,
6029
+ topic: typeof chat.threadId === 'number' ? topicNameFor(chat.chatId, chat.threadId) : undefined,
6030
+ })
6031
+ }
6032
+
5876
6033
  /**
5877
6034
  * Route one prompt: a new topic from All, the workspace picker in a topic
5878
6035
  * without a session, or a turn in the bound session.
@@ -5886,6 +6043,7 @@ export function apply(ctx, config) {
5886
6043
  // Say who is writing: several allowed users may share one bot.
5887
6044
  const label = senderLabel(prompt.from)
5888
6045
  if (label !== undefined) prompt = { ...prompt, text: prompt.text.length > 0 ? `${label}\n${prompt.text}` : label }
6046
+ rememberSender(chatId, prompt.messageId, prompt.from)
5889
6047
  const route = routeMessage({ threadId, inSession: typeof chatState.sessionId === 'string' })
5890
6048
  if (route === 'new-topic') {
5891
6049
  await createNewSessionTopic(chatId, threadId, signal, defaultWorkspaceCwd(), prompt.text, prompt.attachments, prompt.messageId)
@@ -6527,6 +6685,31 @@ export function apply(ctx, config) {
6527
6685
  return () => outbox.stop()
6528
6686
  }, 'dsh-telegram-bot: outbox')
6529
6687
 
6688
+ // Subagents started by a turn the topic shows get one status message per
6689
+ // batch (src/agents.js).
6690
+ const agentBoards = createAgentBoards({
6691
+ parentOf: (id) => ctx.get('agents')?.get(id)?.session?.header?.parentSession,
6692
+ topicOf: (sessionId) => (lifetime === undefined ? undefined : chatForSession(sessionId)),
6693
+ turnOf: (chat) => (chat.busy && !chat.done ? chat.turnEpoch : undefined),
6694
+ labelOf: subagentLabel,
6695
+ modelOf: async (id) => (await sessionModelOf(ctx, id).catch(() => undefined))?.model,
6696
+ post: postAgentStatus,
6697
+ edit: editAgentStatus,
6698
+ reportError,
6699
+ tr,
6700
+ })
6701
+ ctx.on('subagent/start', (info) => fireAndForget('subagent status', () => agentBoards.onStart(info)), { global: true })
6702
+ ctx.on('subagent/end', (info) => {
6703
+ try {
6704
+ agentBoards.onEnd(info)
6705
+ } catch (error) {
6706
+ reportError('subagent status', error)
6707
+ }
6708
+ }, { global: true })
6709
+ ctx.effect(() => () => agentBoards.dispose(), 'dsh-telegram-bot: subagent status')
6710
+
6711
+ // Record each Telegram sender on the prompt the session controller built for it.
6712
+ ctx.on('agent/pre-step', (payload, next) => principals.preStep(payload, next), { global: true })
6530
6713
  // Host-wide feeds: every session's events, whichever client started the
6531
6714
  // turn. Only sessions bound to a topic are mirrored.
6532
6715
  ctx.on('session/event', (session, event) => {
package/src/constants.js CHANGED
@@ -41,6 +41,7 @@ const BOT_COMMANDS = [
41
41
  { command: 'topic', description: 'Manage topics: delete, wipe' },
42
42
  { command: 'fork', description: 'Fork this session into a new topic' },
43
43
  { command: 'last', description: 'Show the latest assistant messages' },
44
+ { command: 'agents', description: 'List running and recent subagents' },
44
45
  { command: 'goal', description: 'Goal: /goal <objective>, edit, pause, resume, clear' },
45
46
  { command: 'restart', description: 'Restart the Telegram bot runtime' },
46
47
  { command: 'id', description: 'Show user and chat ids' },
@@ -63,6 +64,7 @@ const BOT_COMMANDS_RU = [
63
64
  { command: 'topic', description: 'Управление топиками: delete, wipe' },
64
65
  { command: 'fork', description: 'Форкнуть сессию в новый topic' },
65
66
  { command: 'last', description: 'Показать последние ответы ассистента' },
67
+ { command: 'agents', description: 'Работающие и недавние субагенты' },
66
68
  { command: 'goal', description: 'Цель: /goal <цель>, edit, pause, resume, clear' },
67
69
  { command: 'restart', description: 'Перезапустить Telegram-бота' },
68
70
  { command: 'id', description: 'Показать user id и chat id' },
package/src/sender.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Who sent a message, as data on the message itself.
3
+ *
4
+ * DSH's field for the caller behind a user message is `source.principal`:
5
+ * the session controller writes it for callers other than the owner, so an
6
+ * absent principal means the owner. The bot admits every Telegram prompt as
7
+ * the owner, so it fills the field itself, and also on the copies of sends it
8
+ * records in bound sessions. Readers then get the sender without parsing the
9
+ * sender line, which stays in the text for the model.
10
+ *
11
+ * Every principal has `kind`, `channel` and `id`, and `name` when one is
12
+ * known; the other fields depend on the kind. None carries a credential, so
13
+ * all are safe in a session log.
14
+ */
15
+
16
+ /** Remembered prompts waiting for their step; the oldest go first past this. */
17
+ export const MAX_PENDING_PRINCIPALS = 1024
18
+
19
+ function nonEmpty(value) {
20
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined
21
+ }
22
+
23
+ /**
24
+ * The Telegram user who wrote a message, and where.
25
+ * @param user - Telegram `from` of the message.
26
+ * @param where - `{ chatId, threadId, topic }`: the chat, its topic thread and the topic's name.
27
+ * @returns the principal, or undefined without a sender.
28
+ */
29
+ export function telegramUserPrincipal(user, { chatId, threadId, topic } = {}) {
30
+ if (user === undefined || user === null || user.id === undefined || user.id === null) return undefined
31
+ const name = [user.first_name, user.last_name].map(nonEmpty).filter((part) => part !== undefined).join(' ')
32
+ const username = nonEmpty(user.username)
33
+ const chat = chatId === undefined || chatId === null
34
+ ? undefined
35
+ : {
36
+ id: String(chatId),
37
+ ...(Number.isSafeInteger(threadId) && threadId > 0 ? { thread: threadId } : {}),
38
+ ...(nonEmpty(topic) === undefined ? {} : { name: nonEmpty(topic) }),
39
+ }
40
+ return {
41
+ kind: 'telegram-user',
42
+ channel: 'telegram',
43
+ id: String(user.id),
44
+ ...(name.length > 0 ? { name } : {}),
45
+ ...(username === undefined ? {} : { username }),
46
+ ...(chat === undefined ? {} : { chat }),
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The sender of a send into a topic, from the request's `from`: a scheduled
52
+ * job, else a DSH session, else the free-form label of an outside caller.
53
+ * @param from - `{ job?, jobId?, sessionId?, label? }`.
54
+ * @returns the principal, or undefined when the request names no sender.
55
+ */
56
+ export function sendPrincipal(from) {
57
+ const job = nonEmpty(from?.job)
58
+ const jobId = nonEmpty(from?.jobId)
59
+ const sessionId = nonEmpty(from?.sessionId)
60
+ const label = nonEmpty(from?.label)
61
+ if (job !== undefined || jobId !== undefined) {
62
+ return {
63
+ kind: 'cron-job',
64
+ channel: 'cron',
65
+ id: jobId ?? job,
66
+ ...(job === undefined ? {} : { name: job }),
67
+ ...(sessionId === undefined ? {} : { sessionId }),
68
+ }
69
+ }
70
+ if (sessionId !== undefined) {
71
+ return { kind: 'dsh-session', channel: 'dsh', id: sessionId, ...(label === undefined ? {} : { name: label }) }
72
+ }
73
+ if (label !== undefined) return { kind: 'external', channel: 'external', id: label, name: label }
74
+ return undefined
75
+ }
76
+
77
+ /**
78
+ * A frozen copy of a message whose source records its sender.
79
+ * @param message - the user message.
80
+ * @param principal - its sender.
81
+ * @returns the copy; the message itself when there is no principal.
82
+ */
83
+ export function withPrincipal(message, principal) {
84
+ if (principal === undefined) return message
85
+ return Object.freeze({ ...message, source: Object.freeze({ ...message.source, principal: deepFreeze(principal) }) })
86
+ }
87
+
88
+ function deepFreeze(value) {
89
+ if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) {
90
+ for (const child of Object.values(value)) deepFreeze(child)
91
+ Object.freeze(value)
92
+ }
93
+ return value
94
+ }
95
+
96
+ /**
97
+ * Principals for prompts the bot hands to DSH's session controller, which
98
+ * builds the message itself. The bot remembers the sender by request id
99
+ * before the hand-over; `preStep`, an `agent/pre-step` listener, records it
100
+ * on the message as it enters a step, so it is in the session log with the
101
+ * message (DSH lets that hook replace the messages of a step).
102
+ * @param limit - most principals remembered at once.
103
+ */
104
+ export function createPrincipalTagger(limit = MAX_PENDING_PRINCIPALS) {
105
+ const pending = new Map()
106
+ return {
107
+ /** Remember the sender of the prompt with this request id. */
108
+ remember(requestId, principal) {
109
+ if (typeof requestId !== 'string' || principal === undefined) return
110
+ pending.delete(requestId)
111
+ pending.set(requestId, principal)
112
+ if (pending.size > limit) pending.delete(pending.keys().next().value)
113
+ },
114
+ /** Pending principals, for tests. */
115
+ get size() {
116
+ return pending.size
117
+ },
118
+ /** `agent/pre-step` listener: record remembered senders on the step's messages. */
119
+ async preStep(_payload, next) {
120
+ const decision = await next()
121
+ if (decision?.kind !== 'enter' || pending.size === 0 || !Array.isArray(decision.messages)) return decision
122
+ let changed = false
123
+ const messages = decision.messages.map((message) => {
124
+ const rpcId = message?.source?.kind === 'user' ? message.source.rpcId : undefined
125
+ const principal = typeof rpcId === 'string' ? pending.get(rpcId) : undefined
126
+ if (principal === undefined || message.source.principal !== undefined) return message
127
+ pending.delete(rpcId)
128
+ changed = true
129
+ return withPrincipal(message, principal)
130
+ })
131
+ return changed ? { ...decision, messages } : decision
132
+ },
133
+ }
134
+ }
package/src/steer.js CHANGED
@@ -52,20 +52,24 @@ function hasRequest(agent, requestId) {
52
52
  * dropped. A request id already admitted is skipped, so it is never sent twice.
53
53
  * @param ctx - plugin context.
54
54
  * @param agent - the session's live agent.
55
- * @param prompt - `{ sessionId, chatId, messageId, text, attachments }`.
55
+ * @param prompt - `{ sessionId, chatId, messageId, text, attachments, principal }`;
56
+ * `principal` is the sender recorded on the message's source.
56
57
  * @param signal - request lifetime.
58
+ * @param tagger - remembers the principal of a prompt the session controller
59
+ * builds (see `createPrincipalTagger`).
57
60
  * @returns the request id, or `undefined` when this DSH cannot steer.
58
61
  */
59
- export async function steerPrompt(ctx, agent, { sessionId, chatId, messageId, text, attachments = [] }, signal) {
62
+ export async function steerPrompt(ctx, agent, { sessionId, chatId, messageId, text, attachments = [], principal }, signal, tagger) {
60
63
  const requestId = steerRequestId(chatId, messageId)
61
64
  const content = [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments]
62
65
  const controller = ctx.get('sessionController')
63
66
  if (attachments.length === 0 && content.length > 0 && typeof controller?.prompt === 'function') {
67
+ tagger?.remember(requestId, principal)
64
68
  await controller.prompt({ sessionId, requestId, mode: 'steer', content }, signal)
65
69
  return requestId
66
70
  }
67
71
  if (hasRequest(agent, requestId)) return requestId
68
- const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId } }
72
+ const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId, ...(principal === undefined ? {} : { principal }) } }
69
73
  if (typeof agent?.steer === 'function') agent.steer(message)
70
74
  else if (typeof agent?.send === 'function') agent.send(message, 'next-step', true)
71
75
  else return undefined