jeo-code 0.7.48 → 0.7.49

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.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Telegram HTML formatting helpers (gjc `notifications-sdk` HTML-format-source
3
+ * parity, scoped to jeo's needs). All notify-daemon Telegram output is sent
4
+ * with `parse_mode: "HTML"`. This module is the single source of truth for:
5
+ * escaping dynamic text, converting a bounded markdown subset into Telegram
6
+ * HTML, and safely truncating/splitting a finished message to Telegram's
7
+ * 4096-char limit without breaking tags or entities. `ask`-tool inline-button
8
+ * layout helpers (`buttonLabel`, `buildButtonGrid`, etc.) are OUT OF SCOPE —
9
+ * jeo has no `ask` tool (see Tier 2 contract).
10
+ *
11
+ * Discipline: escape first, tag second. Telegram only parses a small tag set
12
+ * (b, i, u, s, code, pre, a, blockquote, tg-spoiler); a stray `<` or unbalanced
13
+ * tag can make Telegram reject the whole message, so dynamic text is always
14
+ * escaped before any tag is emitted.
15
+ */
16
+
17
+ // Telegram Bot API constants (parse mode + hard per-message length cap).
18
+ export const TELEGRAM_PARSE_MODE = "HTML" as const;
19
+ export const TELEGRAM_MESSAGE_LIMIT = 4096;
20
+
21
+ /** Tags Telegram parses in HTML mode (used by the truncation/split guards). */
22
+ const ALLOWED_TAGS: Record<string, true> = { b: true, i: true, u: true, s: true, code: true, pre: true, a: true, blockquote: true, "tg-spoiler": true };
23
+
24
+ /** Escape text for Telegram HTML body content (`& < >`). */
25
+ export function escapeHtml(value: string): string {
26
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27
+ }
28
+
29
+ /** Escape a value for use inside a double-quoted HTML attribute. */
30
+ function escapeAttr(value: string): string {
31
+ return escapeHtml(value).replace(/"/g, "&quot;");
32
+ }
33
+
34
+ /** Wrap already-escaped text in a Telegram tag. */
35
+ function tag(name: string, escaped: string): string {
36
+ return `<${name}>${escaped}</${name}>`;
37
+ }
38
+
39
+ /** Bold the given raw text (escaped internally). */
40
+ export function bold(raw: string): string {
41
+ return tag("b", escapeHtml(raw));
42
+ }
43
+
44
+ /** Italicize the given raw text (escaped internally). */
45
+ export function italic(raw: string): string {
46
+ return tag("i", escapeHtml(raw));
47
+ }
48
+
49
+ /** Render the given raw text as inline code (escaped internally). */
50
+ export function code(raw: string): string {
51
+ return tag("code", escapeHtml(raw));
52
+ }
53
+
54
+ /** Render the given raw text as a preformatted block (escaped internally). */
55
+ export function pre(raw: string): string {
56
+ return tag("pre", escapeHtml(raw));
57
+ }
58
+
59
+ const PLACEHOLDER_PREFIX = "\u0000ph";
60
+ const PLACEHOLDER_SUFFIX = "\u0000";
61
+
62
+ /** Only http(s) and mailto links are emitted; anything else stays literal. */
63
+ function isSafeUrl(url: string): boolean {
64
+ return /^(https?:\/\/|mailto:)/i.test(url);
65
+ }
66
+
67
+ /** Column alignment parsed from a GFM table separator cell. */
68
+ type ColumnAlign = "left" | "right" | "center";
69
+
70
+ /** Split a markdown table row into trimmed cells, honoring escaped `\|`. */
71
+ function splitTableRow(line: string): string[] {
72
+ let s = line.trim();
73
+ if (s.startsWith("|")) s = s.slice(1);
74
+ if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
75
+ const cells: string[] = [];
76
+ let cur = "";
77
+ for (let i = 0; i < s.length; i++) {
78
+ const ch = s[i]!;
79
+ if (ch === "\\" && s[i + 1] === "|") {
80
+ cur += "|";
81
+ i++;
82
+ continue;
83
+ }
84
+ if (ch === "|") {
85
+ cells.push(cur);
86
+ cur = "";
87
+ continue;
88
+ }
89
+ cur += ch;
90
+ }
91
+ cells.push(cur);
92
+ return cells.map(c => c.trim());
93
+ }
94
+
95
+ /** A line is a candidate table row when it contains an unescaped `|`. */
96
+ function looksLikeTableRow(line: string): boolean {
97
+ return /(?:^|[^\\])\|/.test(line);
98
+ }
99
+
100
+ /** A separator row has only dashes/colons/spaces per cell, with at least one dash. */
101
+ function isTableSeparator(line: string): boolean {
102
+ if (!looksLikeTableRow(line)) return false;
103
+ const cells = splitTableRow(line);
104
+ return cells.length > 0 && cells.every(c => /^:?-+:?$/.test(c));
105
+ }
106
+
107
+ /** Derive a column alignment from a separator cell (`:---`, `---:`, `:---:`). */
108
+ function parseAlign(cell: string): ColumnAlign {
109
+ const c = cell.trim();
110
+ const left = c.startsWith(":");
111
+ const right = c.endsWith(":");
112
+ if (left && right) return "center";
113
+ if (right) return "right";
114
+ return "left";
115
+ }
116
+
117
+ /** Render parsed table parts as an aligned, monospace-friendly plain-text grid. */
118
+ function renderTableText(header: string[], aligns: ColumnAlign[], body: string[][]): string {
119
+ const rows = [header, ...body];
120
+ const cols = Math.max(header.length, ...body.map(r => r.length));
121
+ const widths: number[] = [];
122
+ for (let c = 0; c < cols; c++) {
123
+ let w = 1;
124
+ for (const row of rows) w = Math.max(w, (row[c] ?? "").length);
125
+ widths[c] = w;
126
+ }
127
+ const padCell = (value: string, c: number): string => {
128
+ const width = widths[c]!;
129
+ const pad = width - value.length;
130
+ if (pad <= 0) return value;
131
+ const align = aligns[c] ?? "left";
132
+ if (align === "right") return " ".repeat(pad) + value;
133
+ if (align === "center") {
134
+ const leftPad = Math.floor(pad / 2);
135
+ return " ".repeat(leftPad) + value + " ".repeat(pad - leftPad);
136
+ }
137
+ return value + " ".repeat(pad);
138
+ };
139
+ const renderRow = (row: string[]): string =>
140
+ Array.from({ length: cols }, (_v, c) => padCell(row[c] ?? "", c)).join(" | ");
141
+ const divider = widths.map(w => "-".repeat(w)).join("-|-");
142
+ return [renderRow(header), divider, ...body.map(renderRow)].join("\n");
143
+ }
144
+
145
+ /**
146
+ * Replace GFM tables with stashed monospace `<pre>` blocks. Telegram HTML has no
147
+ * table primitive, so a header row followed by a `|---|` separator is rendered as
148
+ * an aligned plain-text grid (cell content escaped by `pre`).
149
+ */
150
+ function convertMarkdownTables(text: string, stash: (html: string) => string): string {
151
+ const lines = text.split("\n");
152
+ const out: string[] = [];
153
+ for (let i = 0; i < lines.length; i++) {
154
+ const headerLine = lines[i]!;
155
+ const separatorLine = lines[i + 1];
156
+ if (looksLikeTableRow(headerLine) && separatorLine !== undefined && isTableSeparator(separatorLine)) {
157
+ const header = splitTableRow(headerLine);
158
+ const aligns = splitTableRow(separatorLine).map(parseAlign);
159
+ const body: string[][] = [];
160
+ let j = i + 2;
161
+ for (; j < lines.length; j++) {
162
+ const row = lines[j]!;
163
+ if (!looksLikeTableRow(row) || isTableSeparator(row)) break;
164
+ body.push(splitTableRow(row));
165
+ }
166
+ out.push(stash(pre(renderTableText(header, aligns, body))));
167
+ i = j - 1;
168
+ continue;
169
+ }
170
+ out.push(headerLine);
171
+ }
172
+ return out.join("\n");
173
+ }
174
+
175
+ /**
176
+ * Convert a bounded markdown subset into Telegram HTML. Supported: fenced code,
177
+ * GFM tables, inline code, links (`http(s)`/`mailto` only), `#`-headers (bolded,
178
+ * Telegram HTML has no heading tag), `>` blockquotes (merged across consecutive
179
+ * lines), and `**bold**`/`*italic*` emphasis. Everything else passes through
180
+ * escaped, so unsupported/malformed markdown degrades to literal text rather
181
+ * than breaking the message.
182
+ */
183
+ export function markdownToTelegramHtml(markdown: string): string {
184
+ const placeholders: string[] = [];
185
+ const stash = (html: string): string => {
186
+ const token = `${PLACEHOLDER_PREFIX}${placeholders.length}${PLACEHOLDER_SUFFIX}`;
187
+ placeholders.push(html);
188
+ return token;
189
+ };
190
+
191
+ let text = markdown;
192
+
193
+ // 1. Fenced code blocks (protect literal content before any other transform).
194
+ text = text.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_m, body: string) => stash(pre(body)));
195
+
196
+ // 1b. GFM tables -> aligned monospace <pre> block (no native table primitive).
197
+ text = convertMarkdownTables(text, stash);
198
+
199
+ // 2. Inline code.
200
+ text = text.replace(/`([^`\n]+)`/g, (_m, body: string) => stash(code(body)));
201
+
202
+ // 3. Links (capture raw URL before escaping). Unsafe/malformed links stay literal.
203
+ text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (whole, label: string, url: string) => {
204
+ if (!isSafeUrl(url)) return whole;
205
+ return stash(`<a href="${escapeAttr(url)}">${escapeHtml(label)}</a>`);
206
+ });
207
+
208
+ // 4. Escape everything that remains (placeholders contain no escapable chars).
209
+ text = escapeHtml(text);
210
+
211
+ // 5. Line-level transforms on escaped text: headers and merged blockquotes.
212
+ const lines = text.split("\n");
213
+ const out: string[] = [];
214
+ let quoteBuffer: string[] | null = null;
215
+ const flushQuote = () => {
216
+ if (quoteBuffer) {
217
+ out.push(tag("blockquote", quoteBuffer.join("\n")));
218
+ quoteBuffer = null;
219
+ }
220
+ };
221
+ for (const line of lines) {
222
+ const quote = /^&gt;\s?(.*)$/.exec(line);
223
+ if (quote) {
224
+ if (!quoteBuffer) {
225
+ quoteBuffer = [];
226
+ }
227
+ quoteBuffer.push(quote[1] ?? "");
228
+ continue;
229
+ }
230
+ flushQuote();
231
+ const header = /^(#{1,6})\s+(.*)$/.exec(line);
232
+ out.push(header ? tag("b", header[2] ?? "") : line);
233
+ }
234
+ flushQuote();
235
+ text = out.join("\n");
236
+
237
+ // 6. Inline emphasis (bold before italic; unbalanced markers stay literal).
238
+ text = text.replace(/\*\*([^*\n]+)\*\*/g, (_m, body: string) => tag("b", body));
239
+ text = text.replace(/\*([^*\n]+)\*/g, (_m, body: string) => tag("i", body));
240
+
241
+ // 7. Restore protected placeholders.
242
+ text = text.replace(
243
+ new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, "g"),
244
+ (_m, i: string) => placeholders[Number(i)] ?? "",
245
+ );
246
+
247
+ return text;
248
+ }
249
+
250
+ interface Token {
251
+ value: string;
252
+ /** Tag name if this token opens a tag, else undefined. */
253
+ open?: string;
254
+ /** Exact opening tag text, including attributes, for safe chunk reopening. */
255
+ openTag?: string;
256
+ /** Tag name if this token closes a tag, else undefined. */
257
+ close?: string;
258
+ }
259
+
260
+ /** Tokenize HTML into tags, entities, and single characters (never splits them). */
261
+ function tokenize(html: string): Token[] {
262
+ const tokens: Token[] = [];
263
+ let i = 0;
264
+ while (i < html.length) {
265
+ const ch = html[i]!;
266
+ if (ch === "<") {
267
+ const end = html.indexOf(">", i);
268
+ if (end !== -1) {
269
+ const raw = html.slice(i, end + 1);
270
+ const close = /^<\/([a-z-]+)>$/i.exec(raw);
271
+ const openMatch = /^<([a-z-]+)(?:\s[^>]*)?>$/i.exec(raw);
272
+ const token: Token = { value: raw };
273
+ if (close && ALLOWED_TAGS[close[1]!.toLowerCase()]) token.close = close[1]!.toLowerCase();
274
+ else if (openMatch && ALLOWED_TAGS[openMatch[1]!.toLowerCase()]) {
275
+ token.open = openMatch[1]!.toLowerCase();
276
+ token.openTag = raw;
277
+ }
278
+ tokens.push(token);
279
+ i = end + 1;
280
+ continue;
281
+ }
282
+ }
283
+ if (ch === "&") {
284
+ const end = html.indexOf(";", i);
285
+ if (end !== -1 && end - i <= 10) {
286
+ tokens.push({ value: html.slice(i, end + 1) });
287
+ i = end + 1;
288
+ continue;
289
+ }
290
+ }
291
+ tokens.push({ value: ch });
292
+ i++;
293
+ }
294
+ return tokens;
295
+ }
296
+
297
+ /**
298
+ * Truncate a finished Telegram HTML message to at most `max` chars without
299
+ * splitting a tag or entity, closing any still-open allowed tags and appending
300
+ * `marker`. The final string is guaranteed to be <= `max`.
301
+ */
302
+ export function truncateTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT, marker = "… [truncated]"): string {
303
+ if (message.length <= max) return message;
304
+
305
+ // When `max` is too small to even hold the marker, drop it so the hard
306
+ // length guarantee (output.length <= max) still holds.
307
+ const effectiveMarker = marker.length <= max ? marker : "";
308
+
309
+ const tokens = tokenize(message);
310
+ const stack: string[] = [];
311
+ let out = "";
312
+
313
+ const closersFor = (s: string[]): string =>
314
+ s
315
+ .map(t => `</${t}>`)
316
+ .reverse()
317
+ .join("");
318
+
319
+ for (const token of tokens) {
320
+ // Simulate accepting this token, then ensure we can still close + mark.
321
+ const nextStack = [...stack];
322
+ if (token.open) nextStack.push(token.open);
323
+ else if (token.close) {
324
+ const idx = nextStack.lastIndexOf(token.close);
325
+ if (idx !== -1) nextStack.splice(idx, 1);
326
+ }
327
+ const projected = out.length + token.value.length + closersFor(nextStack).length + effectiveMarker.length;
328
+ if (projected > max) break;
329
+ out += token.value;
330
+ if (token.open) stack.push(token.open);
331
+ else if (token.close) {
332
+ const idx = stack.lastIndexOf(token.close);
333
+ if (idx !== -1) stack.splice(idx, 1);
334
+ }
335
+ }
336
+
337
+ return out + closersFor(stack) + effectiveMarker;
338
+ }
339
+
340
+ interface OpenTag {
341
+ name: string;
342
+ tag: string;
343
+ }
344
+
345
+ /** Split a finished Telegram HTML message into ordered chunks without splitting tags or entities. */
346
+ export function splitTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
347
+ if (message.length <= max) return [message];
348
+ const chunks: string[] = [];
349
+ const tokens = tokenize(message);
350
+ const stack: OpenTag[] = [];
351
+ let out = "";
352
+ let chunkHasBody = false;
353
+
354
+ const closersFor = (s: OpenTag[]): string =>
355
+ s
356
+ .map(t => `</${t.name}>`)
357
+ .reverse()
358
+ .join("");
359
+ const openersFor = (s: OpenTag[]): string => s.map(t => t.tag).join("");
360
+ const minimumChunkLengthFor = (s: OpenTag[]): number => openersFor(s).length + closersFor(s).length + 1;
361
+ const flush = (): void => {
362
+ if (!chunkHasBody) return;
363
+ chunks.push(out + closersFor(stack));
364
+ out = openersFor(stack);
365
+ chunkHasBody = false;
366
+ };
367
+
368
+ const updateStackForClose = (name: string): boolean => {
369
+ const idx = stack.findLastIndex(t => t.name === name);
370
+ if (idx === -1) return false;
371
+ stack.splice(idx, 1);
372
+ return true;
373
+ };
374
+
375
+ for (const token of tokens) {
376
+ if (token.open) {
377
+ const nextStack = [...stack, { name: token.open, tag: token.openTag ?? token.value }];
378
+ if (minimumChunkLengthFor(nextStack) > max) continue;
379
+ if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
380
+ if (out.length + token.value.length + closersFor(nextStack).length > max) continue;
381
+ out += token.value;
382
+ chunkHasBody = true;
383
+ stack.push({ name: token.open, tag: token.openTag ?? token.value });
384
+ continue;
385
+ }
386
+
387
+ if (token.close) {
388
+ const idx = stack.findLastIndex(t => t.name === token.close);
389
+ if (idx === -1) continue;
390
+ const nextStack = stack.toSpliced(idx, 1);
391
+ if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
392
+ if (out.length + token.value.length + closersFor(nextStack).length > max) {
393
+ updateStackForClose(token.close);
394
+ continue;
395
+ }
396
+ out += token.value;
397
+ chunkHasBody = true;
398
+ updateStackForClose(token.close);
399
+ continue;
400
+ }
401
+
402
+ if (chunkHasBody && out.length + token.value.length + closersFor(stack).length > max) flush();
403
+ out += token.value;
404
+ chunkHasBody = true;
405
+ }
406
+ if (chunkHasBody) chunks.push(out + closersFor(stack));
407
+ return chunks;
408
+ }
409
+
410
+ /** Finalize an optional message: undefined passthrough, else safe-truncate. */
411
+ export function finalizeTelegramHtml(message?: string): string | undefined {
412
+ if (message === undefined) return undefined;
413
+ return truncateTelegramHtml(message);
414
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Per-session forum-topic registry for the threaded session surface (gjc
3
+ * `topic-registry.ts` parity, adapted: numeric topicId, no identitySent
4
+ * tracking).
5
+ *
6
+ * Each jeo session owns one active Telegram forum topic in the paired private
7
+ * DM. The topic is created via `createForumTopic`, reused while the session
8
+ * remains active, and removed from the registry when the daemon deletes it on
9
+ * shutdown. Unlike gjc, this registry does NOT track whether the one-time
10
+ * identity header has been sent — that is a session-side decision (the
11
+ * session-endpoint owns a simple boolean for "have I sent my own identity
12
+ * this run") and does not need to survive daemon restarts.
13
+ *
14
+ * State is a plain serializable map persisted beside the daemon state files;
15
+ * topic creation is injected so this module is pure and unit-testable without
16
+ * a live Bot API.
17
+ */
18
+
19
+ /** Persisted record for one session's topic. */
20
+ export interface TopicRecord {
21
+ /** Telegram forum topic id (message_thread_id). */
22
+ topicId: number;
23
+ /** Creation timestamp (ms epoch). */
24
+ createdAt: number;
25
+ /** Last applied topic title (for rename detection). */
26
+ name?: string;
27
+ }
28
+
29
+ /** Serializable shape persisted to disk. */
30
+ export interface TopicRegistryState {
31
+ /** sessionId -> record. */
32
+ topics: Record<string, TopicRecord>;
33
+ }
34
+
35
+ export function emptyTopicRegistryState(): TopicRegistryState {
36
+ return { topics: {} };
37
+ }
38
+
39
+ /**
40
+ * In-memory registry over a serializable state. Topic creation is injected via
41
+ * `getOrCreateTopic`'s `create` callback (the daemon supplies a real
42
+ * `createForumTopic` call); reuse-on-resume is automatic when a record exists.
43
+ */
44
+ export class TopicRegistry {
45
+ private readonly topics: Map<string, TopicRecord>;
46
+ /** Maps topicId -> sessionId for fast inbound routing. */
47
+ private readonly byTopic = new Map<number, string>();
48
+ /** In-flight create promises, keyed by session, to dedupe concurrent creates. */
49
+ private readonly inflight = new Map<string, Promise<TopicRecord>>();
50
+
51
+ constructor(state: TopicRegistryState = emptyTopicRegistryState()) {
52
+ this.topics = new Map(Object.entries(state.topics ?? {}));
53
+ for (const [sessionId, record] of this.topics) this.byTopic.set(record.topicId, sessionId);
54
+ }
55
+
56
+ /** Merge a serialized state into this registry, preserving all persisted fields. */
57
+ load(state: TopicRegistryState): void {
58
+ for (const [sessionId, record] of Object.entries(state.topics ?? {})) {
59
+ this.topics.set(sessionId, record);
60
+ this.byTopic.set(record.topicId, sessionId);
61
+ }
62
+ }
63
+
64
+ /** Resolve the owning session for a topic id (for fail-closed inbound routing). */
65
+ sessionForTopic(topicId: number): string | undefined {
66
+ return this.byTopic.get(topicId);
67
+ }
68
+
69
+ /** All session ids with a persisted topic record. */
70
+ sessionIds(): string[] {
71
+ return [...this.topics.keys()];
72
+ }
73
+
74
+ /** The existing topic record for a session, if any. */
75
+ get(sessionId: string): TopicRecord | undefined {
76
+ return this.topics.get(sessionId);
77
+ }
78
+
79
+ /**
80
+ * Return the existing active topic for `sessionId`, or create one via
81
+ * `create` (called only on first use).
82
+ */
83
+ async getOrCreateTopic(
84
+ sessionId: string,
85
+ create: () => Promise<number>,
86
+ now: () => number = Date.now,
87
+ name?: string,
88
+ ): Promise<TopicRecord> {
89
+ const existing = this.topics.get(sessionId);
90
+ if (existing) return existing;
91
+ // Concurrency guard: many session frames (identity/idle/turn/ask) can race
92
+ // to first-use the same session. Without this, each call passes the
93
+ // `existing` check before `create()` resolves and creates a DUPLICATE
94
+ // forum topic. Share a single in-flight create per session id.
95
+ const pending = this.inflight.get(sessionId);
96
+ if (pending) return pending;
97
+ const promise = (async () => {
98
+ const topicId = await create();
99
+ const record: TopicRecord = { topicId, name, createdAt: now() };
100
+ this.topics.set(sessionId, record);
101
+ this.byTopic.set(topicId, sessionId);
102
+ return record;
103
+ })();
104
+ this.inflight.set(sessionId, promise);
105
+ try {
106
+ return await promise;
107
+ } finally {
108
+ this.inflight.delete(sessionId);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Record the topic's applied title. Returns `true` when it changed (so the
114
+ * caller should `editForumTopic`), `false` when already current or unknown.
115
+ */
116
+ applyName(sessionId: string, name: string): boolean {
117
+ const record = this.topics.get(sessionId);
118
+ if (!record || record.name === name) return false;
119
+ record.name = name;
120
+ return true;
121
+ }
122
+
123
+ /** Remove a session topic record after Telegram deletes the topic. */
124
+ delete(sessionId: string): boolean {
125
+ const record = this.topics.get(sessionId);
126
+ if (!record) return false;
127
+ this.topics.delete(sessionId);
128
+ this.byTopic.delete(record.topicId);
129
+ return true;
130
+ }
131
+
132
+ /** Serialize for atomic persistence beside the daemon state. */
133
+ serialize(): TopicRegistryState {
134
+ return { topics: Object.fromEntries(this.topics) };
135
+ }
136
+ }
@@ -61,11 +61,18 @@ export interface Config {
61
61
  /** Remote subagent visibility/control over Telegram (see `src/agent/notify/`). */
62
62
  notifications?: {
63
63
  enabled?: boolean;
64
+ /** Session-local default; see `config-schema.ts` for the mutability contract. */
65
+ verbosity?: "lean" | "verbose";
66
+ /** Session-local default; see `config-schema.ts` for the mutability contract. */
67
+ redact?: boolean;
64
68
  telegram?: {
65
69
  botToken?: string;
66
70
  chatId?: string;
67
71
  /** Forum-topic thread id (message_thread_id) for daemon pushes. */
68
72
  topicId?: number;
73
+ /** Auto-create/manage one forum topic per interactive session instead of
74
+ * the flat/global `topicId` above. See `TopicRegistry`. */
75
+ perSessionTopics?: boolean;
69
76
  };
70
77
  };
71
78
 
@@ -29,7 +29,7 @@ import {
29
29
  } from "./subagents";
30
30
  import { resolveMaxOutputTokens } from "../ai/model-manager";
31
31
  import type { SubagentRegistry } from "./subagent-registry";
32
- import { ensureSessionNotifyEndpoint } from "./notify/session-endpoint";
32
+ import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notify/session-endpoint";
33
33
 
34
34
 
35
35
  /** Lifecycle event emitted while a delegated subagent runs. */
@@ -70,6 +70,10 @@ export interface TaskToolOptions {
70
70
  /** When present, a `task` call with `detached: true` registers a background run
71
71
  * here and returns immediately; the parent controls it via the `subagent` tool. */
72
72
  registry?: SubagentRegistry;
73
+ /** The interactive session's own `SessionNotifyEndpoint`, when running inside
74
+ * one — a detached launch attaches `registry` to THIS instead of starting a
75
+ * second, competing endpoint (see `ensureSessionNotifyEndpoint`). */
76
+ sessionEndpoint?: SessionNotifyEndpoint;
73
77
  }
74
78
 
75
79
  /** Max concurrent subagents in a fan-out batch (both read-only AND the mutating
@@ -485,7 +489,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
485
489
  );
486
490
  // Remote subagent visibility/control over Telegram (gjc daemon parity, see
487
491
  // `src/agent/notify/`): best-effort, no-op unless `notifications.enabled`.
488
- ensureSessionNotifyEndpoint(opts.registry, cwd);
492
+ ensureSessionNotifyEndpoint(opts.registry, cwd, opts.sessionEndpoint);
489
493
 
490
494
  return {
491
495
  success: true,