@llblab/pi-telegram 0.56.1 → 0.56.3

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/AGENTS.md CHANGED
@@ -99,9 +99,9 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
99
99
 
100
100
  ### 4.4 Queue, Delivery, And User Surfaces
101
101
 
102
- - Queue lane/kind admission is explicit. Ordinary new-task dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal shows one warning-yellow `+N` suffix for executable waiting work across all lanes (Normal/Priority/Steer/Control), omitted when zero. It excludes the dispatched head, frozen attempts and Skip, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Control/Steer/Priority/Normal have explicit ranks. For ordinary prompts, crossing lanes removes it from the source and appends it at the destination tail; Keep/Skip and unchanged effective intent preserve lane position. Complete reaction sets derive Steer from fire, otherwise Priority from thumbs-up/heart/dove, and independent Skip from negative emoji. Fire dominates weaker positives; lightning is a Control marker, never a command classifier or Priority alias. Decorative changes cannot reset a menu lane choice; positive intent and Skip may coexist, suppressed turns retain durable receipts while waiting, and Skip settles them only when the prompt reaches dispatch before dropping it without inference. Suppressed turns remain visible at a struck-through physical ordinal without contributing to executable queue counters, while graceful session shutdown discards all remaining queue authority before clearing memory.
102
+ - Queue lane/kind admission is explicit. Ordinary new-task dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal shows one warning-yellow `+N` suffix for executable waiting work across all lanes (Normal/Priority/Steer/Control), omitted when zero. It excludes the dispatched head, frozen attempts and Skip, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Control/Steer/Priority/Normal have explicit ranks. For ordinary prompts, crossing lanes removes it from the source and appends it at the destination tail; Keep/Skip and unchanged effective intent preserve lane position. Only fire, thumbs-up and thumbs-down have queue reaction semantics. Complete reaction sets derive Steer from fire, otherwise Priority from thumbs-up, and independent Skip from thumbs-down. All other reactions are decorative and cannot flush or mutate queued work. Fire dominates thumbs-up; lightning is a Control marker, never a command classifier or Priority alias. Decorative changes cannot reset a menu lane choice; positive intent and Skip may coexist, suppressed turns retain durable receipts while waiting, and Skip settles them only when the prompt reaches dispatch before dropping it without inference. Suppressed turns remain visible at a struck-through physical ordinal without contributing to executable queue counters, while graceful session shutdown discards all remaining queue authority before clearing memory.
103
103
  - Experimental Steer stays in the existing Queue/admission owner until one protected whole-packet attempt. Active insertion uses public `turn_end` after one response/full tool batch without resetting current Telegram reply/preview ownership; idle uses ordinary `sendUserMessage`. Recheck exact context/generation/source/native/compaction guards, freeze original objects before whole-receipt journal disposition, and never treat it as SDK/model-use proof. Partial/refused/unknown attempts remain non-executable and immutable without replay. Idle originals stay behind one pinned pending head until `agent_start` projects one active reply/voice/attachment owner. Keep identity fencing separate from same-session readiness refusal so old failures cannot clear replacement flags and unissued packets cannot contaminate native agent starts. Queue transfer allows unspent Steer only with `queue-steer-v1` plus baseline handoff support at donor/leader/recipient; missing capability refuses before offer, and frozen/pending work never transfers as unspent input.
104
- - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. A negative reaction to an original standalone `/continue` settles and removes only its exact waiting source-addressed continuation; positive reactions do not change its control lane. Protect the Pi-owned pending head and already-started work, preserve other queue positions, and never address synthetic model continuations by their reply anchor. Settlement failure retains the continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
104
+ - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. A thumbs-down reaction to an original standalone `/continue` settles and removes only its exact waiting source-addressed continuation; positive reactions do not change its control lane. Protect the Pi-owned pending head and already-started work, preserve other queue positions, and never address synthetic model continuations by their reply anchor. Settlement failure retains the continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
105
105
  - A model selection from an authorized Telegram target may stop and continue any interruptible active agent run in that same Pi session, including local/TUI work. Queue the synthetic control-lane continuation before aborting; when no Telegram prompt owns the run, bind continuation and reply ownership to the exact model-menu chat/Thread/message. Delay abort until every active tool execution settles, and clear both selection and fallback target on cancellation, agent start, settlement, or session replacement.
106
106
  - Telegram extension side effects must not hold Pi's core lifecycle hostage after semantic completion. Preserve ordering in extension-owned background work, record failures, and fence target/profile/transport/session authority.
107
107
  - In Threaded Mode, All is service/input-only. New output, previews, typing and routing UI require a concrete Thread; neither implicit fallback nor explicit aggregate delivery may write to All. Unavailable routing authority retains input without an All notice or silent settlement. Classic DM and explicit channel/business destinations remain separate; historical All output is not auto-cleaned.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.56.3: Button Matrices and Custom-Message Starts
8
+
9
+ - `Button grammar`: Adjacent complete JSON/CML cells and matrices now compose one button surface in source order instead of dropping fenced controls or truncating footer controls. Each matrix contributes its existing rows; horizontal grouping, disabled controls and callback semantics remain unchanged. Invalid fenced groups register no callbacks. This extends button syntax only; rendering mode and voice syntax are unchanged.
10
+ - `Pi compatibility`: Extension-triggered runs on Pi 1.2 no longer fail when `before_agent_start` carries a custom message without a user prompt. Custom starts retain connected bridge guidance without being mistaken for fresh Telegram user input; legacy user events, unavailable-transport filtering and ordered system-prompt blocks remain supported.
11
+
12
+ ## 0.56.2: Canonical Queue Reactions
13
+
14
+ - `Queue reactions`: Only thumbs-up selects Priority, thumbs-down selects Skip, and fire selects Steer. Heart, dove, ghost, broken-heart, poop and wastebasket aliases are no longer controls. Unsupported reactions neither flush waiting input nor change queue intent; removing a supported reaction does not fall back to a retired alias. Only thumbs-down cancels a waiting standalone Continue.
15
+ - `Queue display`: Priority and Skip badges use thumbs-up and thumbs-down even for retained older metadata. Existing lane, disposition, source and receipt authority remain unchanged; Control keeps its lightning label, not a reaction shortcut.
16
+
7
17
  ## 0.56.1: Recover interrupted connections
8
18
 
9
19
  - `Connection recovery`: Explicit `/telegram-connect` can abandon matching unknown Thread-creation attempts and connect afresh without deleting tabs or clearing accepted work. Known pending targets keep their Workspace key and slot; unique creation IDs prevent late replies from attaching to a new attempt. Follower recovery requires `explicit-connect-recovery-v1` on both peers. A lost API reply may leave an extra tab; automatic startup and Restore-only registration never reset it.
package/README.md CHANGED
@@ -208,7 +208,7 @@ In Threaded Mode, `All` is input-only: messages enter temporary routing tabs, an
208
208
 
209
209
  Messages sent while Pi is busy become queued turns. Queue controls let you inspect, prioritize, keep or skip, and dispatch work without touching the terminal.
210
210
 
211
- Use reactions or the queue menu to control waiting prompts.
211
+ Use reactions or the queue menu to control waiting prompts. Only `👍`, `👎`, and `🔥` have queue semantics; all other reactions are ignored.
212
212
 
213
213
  Priority — when the prompt runs:
214
214
  - **Normal — no dedicated reaction emoji.** The default lane for ordinary queued work.
@@ -227,7 +227,7 @@ Queue policy:
227
227
  - One yellow terminal counter shows waiting work across all lanes, excluding current or already-dispatched work, Skip and frozen attempts.
228
228
  - Dispatched or frozen prompts are inspection-only. An uncertain Steer attempt is not automatically retried.
229
229
  - The bot cannot remove your Telegram reactions; changing a menu choice may leave the original reaction visible.
230
- - A negative reaction to your waiting standalone `/continue` cancels it immediately rather than marking it for reversible Skip. Already-running work is unaffected.
230
+ - A `👎` reaction to your waiting standalone `/continue` cancels it immediately rather than marking it for reversible Skip. Already-running work is unaffected.
231
231
  - The queue is session-local. Let it drain before reload or shutdown, or accept losing waiting work.
232
232
 
233
233
  See [Queue Behavior and Safety](./docs/architecture.md#priority-reactions-keep-and-skip) for reaction precedence, dispatch rules and recovery details.
@@ -71,7 +71,7 @@ function buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex = 0) {
71
71
  return { inline_keyboard: [backRow, refreshRow] };
72
72
  const rows = items.map((item) => {
73
73
  const badge = getTelegramQueueMenuLaneBadge(item);
74
- const prefix = item.reactionSuppressionEmoji ? `${item.reactionSuppressionEmoji} ` :
74
+ const prefix = item.reactionSuppressionEmoji ? "👎 " :
75
75
  badge ? `${badge} ` : item.hasAttachments ? "📎 " : "";
76
76
  const position = item.reactionSuppressionEmoji
77
77
  ? formatSkippedTelegramQueuePosition(item.queuePosition)
@@ -130,11 +130,10 @@ function getTelegramQueueMenuLaneBadge(item) {
130
130
  return "🔥";
131
131
  if (item.queueLane !== "priority")
132
132
  return "";
133
- const emoji = item.priorityEmoji?.replace(/\uFE0F/g, "");
134
- return emoji === "❤" || emoji === "🕊" ? emoji : "👍";
133
+ return "👍";
135
134
  }
136
135
  function getTelegramQueueMenuItemText(item) {
137
- const marker = item.reactionSuppressionEmoji ?? getTelegramQueueMenuLaneBadge(item);
136
+ const marker = item.reactionSuppressionEmoji ? "👎" : getTelegramQueueMenuLaneBadge(item);
138
137
  const badge = marker ? ` ${marker}` : "";
139
138
  const position = item.reactionSuppressionEmoji
140
139
  ? `<s>${item.queuePosition}</s>.`
@@ -441,11 +440,10 @@ function applyQueuedTelegramPromptMenuFlags(chatId, replyToMessageId, ctx, deps,
441
440
  const disposition = { kind: "reaction-transition" };
442
441
  if (flags.lane !== undefined) {
443
442
  disposition.promptLane = flags.lane;
444
- disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ?
445
- getTelegramQueueMenuLaneBadge({ queueLane: "priority", priorityEmoji: item.queueLane === "priority" ? item.priorityEmoji : undefined }) : null;
443
+ disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ? "👍" : null;
446
444
  }
447
445
  if (flags.skipped !== undefined)
448
- disposition.suppressionEmoji = flags.skipped ? (item.reactionSuppressionEmoji ?? "👎") : null;
446
+ disposition.suppressionEmoji = flags.skipped ? "👎" : null;
449
447
  return deps.queueMutationRuntime.applyReactionByMessageId(replyToMessageId, disposition, ctx) ||
450
448
  ((flags.lane === undefined || flags.lane === item.queueLane) &&
451
449
  (flags.skipped === undefined || flags.skipped === (item.reactionSuppressionEmoji !== undefined)));
@@ -463,23 +463,33 @@ function parseTelegramAdaptiveActionPayloadRows(source, parseCompactPayload, opt
463
463
  }
464
464
  return undefined;
465
465
  };
466
- const parseVerticalSequence = () => {
466
+ const parseTopLevelRows = () => {
467
467
  const rows = [];
468
468
  while (offset < source.length) {
469
469
  skipWhitespace();
470
- if (source[offset] !== "{")
470
+ const character = source[offset];
471
+ if (character === "{") {
472
+ const cell = parseCell();
473
+ if (!cell)
474
+ return undefined;
475
+ rows.push([cell]);
476
+ }
477
+ else if (character === "[" && options.allowMatrixGroups) {
478
+ const matrix = parseMatrix();
479
+ if (!matrix)
480
+ return undefined;
481
+ rows.push(...matrix);
482
+ }
483
+ else {
471
484
  break;
472
- const cell = parseCell();
473
- if (!cell)
474
- return undefined;
475
- rows.push([cell]);
485
+ }
476
486
  if (!consumeOptionalSeparator())
477
487
  return undefined;
478
488
  }
479
489
  return rows.length > 0 ? rows : undefined;
480
490
  };
481
491
  skipWhitespace();
482
- const rows = source[offset] === "{" ? parseVerticalSequence() : parseMatrix();
492
+ const rows = parseTopLevelRows();
483
493
  if (!rows)
484
494
  return undefined;
485
495
  skipWhitespace();
@@ -527,10 +537,10 @@ function isPlausibleTelegramMatrixStart(source, start) {
527
537
  return (source[offset] === "{" || source[offset] === "[" || source[offset] === "]");
528
538
  }
529
539
  export function parseTelegramButtonPayloadRows(source) {
530
- return parseTelegramAdaptiveActionPayloadRows(source.trim(), parseTelegramButtonCompactActionPayload, { maxCompactAtoms: 4 })?.rows;
540
+ return parseTelegramAdaptiveActionPayloadRows(source.trim(), parseTelegramButtonCompactActionPayload, { maxCompactAtoms: 4, allowMatrixGroups: true })?.rows;
531
541
  }
532
542
  export function parseTelegramActionPayloadRows(comment, command) {
533
- const scanned = scanTelegramActionComment(comment, command, (content, offset) => parseTelegramAdaptiveActionPayloadRows(content.slice(offset), parseTelegramButtonCompactActionPayload, { allowTrailing: true, maxCompactAtoms: 4 })?.rows);
543
+ const scanned = scanTelegramActionComment(comment, command, (content, offset) => parseTelegramAdaptiveActionPayloadRows(content.slice(offset), parseTelegramButtonCompactActionPayload, { allowTrailing: true, maxCompactAtoms: 4, allowMatrixGroups: true })?.rows);
534
544
  if (!scanned)
535
545
  return undefined;
536
546
  if ("payload" in scanned)
@@ -29,7 +29,9 @@ export declare function createTelegramModelContextAvailabilityRuntime(deps: {
29
29
  memory?: TelegramModelContextAvailabilityMemory;
30
30
  }): TelegramModelContextAvailabilityRuntime;
31
31
  export type TelegramSystemPrompt = string | string[];
32
- type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt"> & {
32
+ type TelegramBeforeAgentStartEvent = Omit<BeforeAgentStartEvent, "systemPrompt" | "prompt" | "messageType"> & {
33
+ messageType?: "user" | "custom";
34
+ prompt?: string;
33
35
  systemPrompt?: TelegramSystemPrompt | null;
34
36
  };
35
37
  type TelegramBeforeAgentStartResult = {
@@ -37,7 +39,7 @@ type TelegramBeforeAgentStartResult = {
37
39
  };
38
40
  type TelegramBeforeAgentStartHook = (event: TelegramBeforeAgentStartEvent) => TelegramBeforeAgentStartResult;
39
41
  export declare function buildTelegramBridgeSystemPrompt(options: {
40
- prompt: string;
42
+ prompt?: string;
41
43
  systemPrompt?: TelegramSystemPrompt | null;
42
44
  telegramPrefix?: string;
43
45
  localSystemPromptSuffix: string;
@@ -113,7 +113,7 @@ export function buildTelegramBridgeSystemPrompt(options) {
113
113
  const telegramHead = telegramPrefix.endsWith("]")
114
114
  ? telegramPrefix.slice(0, -1)
115
115
  : telegramPrefix;
116
- const trimmedPrompt = options.prompt.trimStart();
116
+ const trimmedPrompt = options.prompt?.trimStart() ?? "";
117
117
  const telegramTurn = trimmedPrompt.startsWith(`${telegramHead}]`) ||
118
118
  trimmedPrompt.startsWith(`${telegramHead}|`);
119
119
  const telegramSuffix = telegramTurn
@@ -127,7 +127,7 @@ export function buildTelegramBridgeSystemPrompt(options) {
127
127
  }
128
128
  export function createTelegramBeforeAgentStartHook(options = {}) {
129
129
  return (event) => buildTelegramBridgeSystemPrompt({
130
- prompt: event.prompt,
130
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
131
131
  systemPrompt: event.systemPrompt,
132
132
  telegramPrefix: options.telegramPrefix,
133
133
  localSystemPromptSuffix: options.localSystemPromptSuffix ?? LOCAL_SYSTEM_PROMPT_SUFFIX,
@@ -22,14 +22,6 @@ export declare const TELEGRAM_PRIORITY_REACTIONS: readonly [{
22
22
  readonly id: 10;
23
23
  readonly name: "like";
24
24
  readonly emoji: "👍";
25
- }, {
26
- readonly id: 12;
27
- readonly name: "heart";
28
- readonly emoji: "❤";
29
- }, {
30
- readonly id: 13;
31
- readonly name: "dove";
32
- readonly emoji: "🕊";
33
25
  }];
34
26
  export declare const TELEGRAM_STEER_REACTIONS: readonly [{
35
27
  readonly id: 14;
@@ -40,25 +32,9 @@ export declare const TELEGRAM_REMOVAL_REACTIONS: readonly [{
40
32
  readonly id: 20;
41
33
  readonly name: "dislike";
42
34
  readonly emoji: "👎";
43
- }, {
44
- readonly id: 21;
45
- readonly name: "ghost";
46
- readonly emoji: "👻";
47
- }, {
48
- readonly id: 22;
49
- readonly name: "broken-heart";
50
- readonly emoji: "💔";
51
- }, {
52
- readonly id: 23;
53
- readonly name: "poop";
54
- readonly emoji: "💩";
55
- }, {
56
- readonly id: 24;
57
- readonly name: "wastebasket";
58
- readonly emoji: "🗑";
59
35
  }];
60
- export declare const TELEGRAM_PRIORITY_REACTION_EMOJIS: ("👍" | "❤" | "🕊")[];
61
- export declare const TELEGRAM_REMOVAL_REACTION_EMOJIS: ("👎" | "👻" | "💔" | "💩" | "🗑")[];
36
+ export declare const TELEGRAM_PRIORITY_REACTION_EMOJIS: "👍"[];
37
+ export declare const TELEGRAM_REMOVAL_REACTION_EMOJIS: "👎"[];
62
38
  export interface TelegramUpdateDeletion {
63
39
  deleted_business_messages?: {
64
40
  message_ids?: unknown;
@@ -14,16 +14,10 @@ import { createTelegramPrivateTarget, createTelegramThreadTarget, } from "./targ
14
14
  import { isWireRecord as isTelegramUpdateAdmissionRecord, isNonEmptyWireString as isTelegramUpdateAdmissionString, } from "./wire.js";
15
15
  export const TELEGRAM_PRIORITY_REACTIONS = [
16
16
  { id: 10, name: "like", emoji: "👍" },
17
- { id: 12, name: "heart", emoji: "❤" },
18
- { id: 13, name: "dove", emoji: "🕊" },
19
17
  ];
20
18
  export const TELEGRAM_STEER_REACTIONS = [{ id: 14, name: "fire", emoji: "🔥" }];
21
19
  export const TELEGRAM_REMOVAL_REACTIONS = [
22
20
  { id: 20, name: "dislike", emoji: "👎" },
23
- { id: 21, name: "ghost", emoji: "👻" },
24
- { id: 22, name: "broken-heart", emoji: "💔" },
25
- { id: 23, name: "poop", emoji: "💩" },
26
- { id: 24, name: "wastebasket", emoji: "🗑" },
27
21
  ];
28
22
  export const TELEGRAM_PRIORITY_REACTION_EMOJIS = TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
29
23
  export const TELEGRAM_REMOVAL_REACTION_EMOJIS = TELEGRAM_REMOVAL_REACTIONS.map((reaction) => reaction.emoji);
@@ -46,7 +40,7 @@ function getTelegramReactionEmoji(emojis, candidates) {
46
40
  function getTelegramQueueReactionTransition(oldReactions, newReactions) {
47
41
  const oldEmojis = collectTelegramReactionEmojis(oldReactions);
48
42
  const newEmojis = collectTelegramReactionEmojis(newReactions);
49
- // Fire is effective Steer intent; weaker positive aliases under it do not reorder the lane.
43
+ // Fire selects Steer; thumbs-up beneath it does not reorder the lane. Other emoji carry no queue intent.
50
44
  const steerEmoji = TELEGRAM_STEER_REACTIONS[0].emoji;
51
45
  const oldPriorityEmoji = oldEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(oldEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
52
46
  const newPriorityEmoji = newEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(newEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.1",
3
+ "version": "0.56.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,7 +60,7 @@ Every enabled button has a self-contained prompt and an optional selection style
60
60
  - `{label|prompt}` separates visible label from queued prompt.
61
61
  - `{label|prompt|selected_style}` and `{|prompt|selected_style}` accept `primary`, `success`, or `danger`.
62
62
  - Fourth-position `1`/`true` disables, `0`/`false` enables; omission means enabled. JSON uses boolean `disabled`. `{|Next||1}` omits label/style; `{Next|||1}` omits prompt/style; `{|||1}` is blank (Telegram receives a non-breaking space). Prefer meaningful labels and retain a useful enabled action. Enabled CML requires a prompt. Disabled controls stay visible but have no callback, queued prompt, or bound-method invocation.
63
- - Top-level cells form vertical rows; inside a fenced block, adjacent top-level JSON/CML objects may omit the outer array and commas. One nested row groups horizontal peers.
63
+ - Both wrappers concatenate rows from adjacent JSON/CML cells or matrices, with optional commas. Cells are vertical; horizontal peers require a nested row: `[[{A|a}{B|b}]]`.
64
64
  - Prefer one matrix per related group. Fenced blocks stay in place in Rich mode; HTML compatibility moves them to the footer. Native rows allow eight buttons. Malformed/oversized/unclosed blocks activate nothing; drafts hide them. Outer code fences and quoted/indented examples remain literal.
65
65
  - Both placements share prompt/app routing. In-body clicks acknowledge without recoloring; selected-style highlighting remains footer-only.
66
66
 
@@ -610,7 +610,7 @@ Bridge-owned Steer uses the existing public Pi SDK. Busy-run operator smoke cove
610
610
 
611
611
  A prompt is one queue object with exactly one active lane membership and one current position. It has no duplicate, shadow entry, or reserved return slot in the other lane. Each prompt admitted directly to a lane joins that lane's tail. A `Normal → Priority` transition removes it from Normal and appends that same object to the Priority tail; a later `Priority → Normal` transition removes it from Priority and appends it to the current Normal tail rather than restoring any historical position. The immutable `queueOrder` records original admission identity only and is never a return address; `laneOrder` records the current destination-lane position. Keep/Skip changes and emoji changes within the same reaction category preserve both lane and lane position exactly.
612
612
 
613
- Telegram reactions are shortcut controls over those dimensions. Fire selects Steer and dominates other positives; thumbs-up/heart/dove select Priority and recognized negatives control Skip. Lightning is now a Control marker only, not a reaction shortcut; the emoji sets live in the [queue reaction registry](./ui-style.md#queue-reaction-shortcuts). The runtime compares effective intent from complete old and new reaction sets and mutates only dimensions that changed. Weaker positives under fire and decorative changes cannot reset a menu lane choice; so adding or removing a negative reaction cannot silently change Priority, and changing a positive reaction cannot silently change Skip. The registry order selects the retained display emoji when several recognized emoji from one category coexist; it does not let one category override the other.
613
+ Telegram reactions are shortcut controls over those dimensions. Only fire selects Steer, thumbs-up selects Priority and thumbs-down selects Skip. Fire dominates thumbs-up; all other reactions are ignored, including former heart/dove and removal aliases. Lightning is now a Control marker only, not a reaction shortcut; the emoji sets live in the [queue reaction registry](./ui-style.md#queue-reaction-shortcuts). The runtime compares effective intent from complete old and new reaction sets and mutates only dimensions that changed. Weaker positives under fire and decorative changes cannot reset a menu lane choice; so adding or removing a negative reaction cannot silently change Priority, and changing a positive reaction cannot silently change Skip. Ignored emoji cannot retain a lane or Skip when its supported reaction is removed. Existing explicit queue state and receipts are not rewritten during upgrade; menu badges project that state as thumbs-up, fire or thumbs-down rather than reviving retired aliases.
614
614
 
615
615
  Steer or Priority and Skip may coexist when a prompt carries both positive and negative reactions. The prompt remains at its current lane position while waiting, and its durable journal receipts remain intact so Keep stays reversible and exact live handoff remains possible. Skip wins when dispatch reaches the prompt: the dispatcher first settles those receipts durably, then drops it without a model turn and continues; settlement failure retains the skipped head instead of allowing replay ambiguity. The prompt stays visible with only its negative emoji, is excluded immediately from the executable queue count shared by the Pi status bar and Telegram main menu, and keeps a struck-through physical ordinal. Returning it to Keep restores its contribution to the count without moving it. Graceful session shutdown discards all remaining queue receipts before clearing session-local memory, and startup discards receipts only after proving their former process dead, so a new or unrelated session never inherits queued work. Queue item detail exposes horizontal Normal/Priority/Steer and Keep/Skip selectors instead of irreversible Delete. Legacy published Priority callbacks retain explicit lane intent. Dispatched/frozen items expose inspection only; an oversized idle Steer item stays editable with a Priority/Normal escape and never splits its source group.
616
616
 
@@ -618,7 +618,7 @@ Menu and reaction controls share the same canonical queue state. A menu Keep can
618
618
 
619
619
  Guest Mode is the deliberate exception to deferred Keep/Skip semantics. A guest prompt has no source message reaction surface and may share zero-valued chat/message placeholders with other guest prompts, so queue-menu callbacks address it by immutable `queueOrder` and expose an immediate `Skip`. The leader settles its durable admission, removes it while other agent work may continue, stops placeholder animation, and replaces the inline globe placeholder with non-printing content. Bot API inline messages have no delete method, so this visual clear is the supported deletion equivalent. A successfully skipped waiting guest prompt never reaches a model turn. Once dispatched, its retained head is inspection-only until `agent_start` consumes that exact reply owner; stale Guest Skip callbacks refuse without receipt disposal or placeholder dismissal. Later waiting guests remain independently skippable.
620
620
 
621
- A standalone `/continue` is a source-addressed control-lane prompt, not a Normal/Priority prompt. An authorized negative reaction to its original chat/message immediately settles the exact waiting continuation's receipts before removing it, even while unrelated work is active. Failed or unknown settlement retains it for the existing reaction retry path; duplicate reactions cannot settle twice. Positive reactions and removal of the negative reaction neither change its control lane nor reconstruct cancelled work. The dispatched head awaiting `agent_start` and already-started work are never cancelled; appends and reorders preserve that Pi-owned head. Synthetic model-switch continuations have no source message IDs and are not reaction-addressable by their fallback reply anchor. Reaction authorization and chat/message namespace isolation remain unchanged.
621
+ A standalone `/continue` is a source-addressed control-lane prompt, not a Normal/Priority prompt. An authorized thumbs-down reaction to its original chat/message immediately settles the exact waiting continuation's receipts before removing it, even while unrelated work is active. Failed or unknown settlement retains it for the existing reaction retry path; duplicate reactions cannot settle twice. Positive reactions and removal of the negative reaction neither change its control lane nor reconstruct cancelled work. The dispatched head awaiting `agent_start` and already-started work are never cancelled; appends and reorders preserve that Pi-owned head. Synthetic model-switch continuations have no source message IDs and are not reaction-addressable by their fallback reply anchor. Reaction authorization and chat/message namespace isolation remain unchanged.
622
622
 
623
623
  Admission and planning validate lane contracts. Invalid lane/kind pairings fail predictably instead of being silently coerced.
624
624
 
@@ -715,7 +715,7 @@ Assistant-authored final-message actions use hidden top-level comments, with an
715
715
  - `telegram_voice` accepts one positional compact action cell or JSON object and creates one voice artifact through configured outbound handlers, programmatic voice handlers, or registered synthesis providers.
716
716
  - `telegram_button` accepts a JSON object, adaptive JSON/CML matrix, or positional Compact Matrix Literal. Named JSON objects and positional cells may coexist, with commas optional only between completed matrix or row elements. Each top-level cell creates one full-width row, while a nested row groups buttons horizontally without an artificial parser-width cap; every callback enqueues its configured prompt text as a normal Telegram prompt turn. The JSON-first grammar, positional trim/escape rules, atomic rejection, and renderer-owned width policy are specified in [Adaptive Button Literal](./compact-matrix-literal.md).
717
717
 
718
- Standalone column-zero triple-backtick `telegram_button` blocks reuse the same cell/matrix grammar and callback store, including adjacent top-level JSON/CML objects as bracketless comma-free vertical rows, and compile to native Rich Markdown button rows between paragraphs. Rendering validates each complete block before registering any callbacks, escapes label text, preserves disabled cells, and leaves larger enclosing fences literal. Previews hide complete and unfinished action fences. HTML mode projects those controls into the footer. In-body callbacks acknowledge without rewriting the Rich message; selected-style highlighting remains footer-only.
718
+ Standalone column-zero triple-backtick `telegram_button` blocks reuse the same cell/matrix grammar and callback store, including adjacent complete cells and matrices whose normalized rows concatenate in source order, and compile to native Rich Markdown button rows between paragraphs. Rendering validates each complete block before registering any callbacks, escapes label text, preserves disabled cells, and leaves larger enclosing fences literal. Previews hide complete and unfinished action fences. HTML mode projects those controls into the footer. In-body callbacks acknowledge without rewriting the Rich message; selected-style highlighting remains footer-only.
719
719
 
720
720
  Action recognition remains restricted to top-level column-zero comments and exact-name button fences so nested examples cannot trigger voice, buttons, or callbacks. The Telegram surface independently strips every complete assistant-authored HTML comment from previews and final delivery regardless of Markdown position or comment owner; an unclosed comment is withheld through the remaining tail, and a comment-only result sends no text message. Pi's terminal transcript and model context remain unchanged.
721
721
 
@@ -150,11 +150,11 @@ For `telegram_button` hidden comments and fenced blocks:
150
150
  - Positional `{label|prompt|selected_style|1}` is equivalent to JSON `{"label":"label","prompt":"prompt","selected_style":"selected_style","disabled":true}` with a valid selected style; `{|prompt||1}` is equivalent to `{"prompt":"prompt","disabled":true}`. Fourth-position `0` or `false` matches `disabled: false`; `true` is equivalent to `1`.
151
151
  - `{label|||1}` matches JSON `{"label":"label","disabled":true}`; `{|||1}` matches `{"disabled":true}`. Disabled cells require no prompt and do not retain action or selected-style semantics.
152
152
  - Disabled buttons serialize as `{ text, disabled: {} }`, without `callback_data`; they neither enqueue prompts nor invoke bound app methods. Enabled controls keep existing selection and callback behavior.
153
- - Top-level cells become full-width rows. Inside a fenced block, a whitespace-separated sequence of top-level JSON/CML objects is an alias for the same objects inside an outer matrix, so vertical rows need neither brackets nor commas.
153
+ - Top-level cells become full-width rows. Telegram button wrappers additionally accept adjacent complete JSON/CML cells and matrices, with optional commas between groups. Normalize each group independently and concatenate its rows in source order: `[{A|a}] [{B|b}]` and `{A|a} [{B|b}]` both equal `[{A|a}{B|b}]`. Horizontal rows remain nested inside a matrix: `[[{A|a}{B|b}]] [{C|c}]` equals `[[{A|a}{B|b}]{C|c}]`. This is a Telegram button wrapper extension, not a change to portable CML v4 or voice syntax.
154
154
  - Nested rows become horizontal keyboard rows; horizontal intent still requires the explicit nested array.
155
155
  - Invalid payloads are stripped with their recognized action comment and register no callbacks.
156
156
 
157
- The wrapper selects placement without changing cell semantics. A hidden `telegram_button` HTML comment builds the footer keyboard. A standalone column-zero fenced block opened by exactly three backticks plus `telegram_button` renders rows between paragraphs in Native Rich Markdown. A singleton JSON/CML object needs no array in either wrapper. Fenced content must be one complete payload without trailing envelope text. Larger outer fences and ordinary code blocks remain literal examples; unclosed action fences are withheld. Native Rich rows support at most eight buttons and must fit one message chunk; these are renderer constraints, not grammar limits. HTML compatibility places fenced controls in the footer. See [Outbound](./outbound.md) for delivery and callback behavior.
157
+ The wrapper selects placement without changing cell semantics. A hidden `telegram_button` HTML comment builds the footer keyboard. A standalone column-zero fenced block opened by exactly three backticks plus `telegram_button` renders rows between paragraphs in Native Rich Markdown. A singleton JSON/CML object needs no array in either wrapper. Fenced content must be one complete group sequence without trailing envelope text; any invalid group rejects the entire block before callback registration. Larger outer fences and ordinary code blocks remain literal examples; unclosed action fences are withheld. Native Rich rows support at most eight buttons and must fit one message chunk; these are renderer constraints, not grammar limits. HTML compatibility places fenced controls in the footer. See [Outbound](./outbound.md) for delivery and callback behavior.
158
158
 
159
159
  ````markdown
160
160
  Description.
@@ -468,8 +468,8 @@ Threaded Mode should make follower threads behave like normal Telegram instance
468
468
  | Surface | Leader behavior | Follower requirement | Routing/ownership invariant | Regression evidence |
469
469
  | --- | --- | --- | --- | --- |
470
470
  | Prompt intake | Thread prompt queues locally | Thread prompt is forwarded and queued by the owning follower | Target ownership routes by `{ chatId, threadId }` before local handling | Routing tests for foreign target message forwarding |
471
- | Queued-message removal reactions | 👎/👻/💔/💩/🗑 marks a pending prompt/media turn for deletion when it reaches dispatch | Same reaction on a queued follower prompt marks that follower's pending turn for deletion before model dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
472
- | Queue priority reactions | 👍/⚡/❤/🕊/🔥 prioritizes queued prompts | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
471
+ | Queued-message removal reactions | Only 👎 selects Skip for a pending prompt/media turn; disposition occurs at dispatch | Same reaction on a queued follower prompt marks that follower's pending turn for deletion before model dispatch | When the leader forwards a prompt to a follower, it records `chatId/messageId -> follower instance` because Bot API reaction updates expose chat/message but not thread id | Update runtime regression records forwarded message ownership and forwards the later reaction |
472
+ | Queue priority reactions | 👍 selects Priority; 🔥 selects Steer; all other positive reactions are decorative | Same reactions prioritize follower queued prompts | Reaction forwarding uses stored message ownership, then follower mutates its local queue | Reaction mutation tests plus forwarded-reaction coverage |
473
473
  | Message edits | Edits update matching queued prompt text | Edits in a follower thread update that follower's queued prompt | Message target ownership forwards edits to the owning instance; stored message ownership is the fallback when Telegram edit payloads omit thread id | Update routing tests for foreign target and message-owned edited-message forwarding |
474
474
  | Callbacks/buttons/menus | Callback handled by the owning instance/menu state | Follower callbacks are forwarded to the owning follower; follower menu sends/edits/deletes route through leader transport | Leader records ownership for follower-sent Bot API messages so callbacks can route by message id even when Telegram omits thread id; Bot API edit/delete lacks thread id, so follower bus allows validated same-chat message operations | Callback forwarding, generated-button target, bus follower-sent ownership, and bus edit/delete allowlist tests |
475
475
  | Replies/finals | Final replies land in the same thread | Follower finals go through leader transport into follower thread | Outbound calls carry target and inject `message_thread_id` | Reply delivery and bus API tests |
package/docs/outbound.md CHANGED
@@ -182,7 +182,7 @@ Rules:
182
182
  - The opening marker must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples; otherwise it does not activate a button action and is still removed from the Telegram surface.
183
183
  - Prefer one matrix comment for multiple buttons. Each top-level JSON object or CML cell becomes one full-width inline-keyboard row in source order; a nested row groups one or more buttons horizontally. The parser imposes no artificial per-row width cap; empty rows, malformed cells, unknown/trailing CML escapes, a fourth unescaped CML separator, empty enabled-cell prompts or three-atom styles, invalid disabled flags, empty one-atom cells, unknown selected styles, and deeper nesting are rejected atomically. Multi-atom cells may omit the label; four-atom cells may additionally omit the selected style, and disabled cells may omit the prompt. Generated surfaces default to five columns and expand to six through eight only for short position-bearing labels. Repeated singular comments remain valid.
184
184
  - Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
185
- - Standalone column-zero fences opened by exactly three backticks and `telegram_button` use the same singleton/matrix JSON/CML grammar. Adjacent top-level JSON/CML objects inside one fence may omit the outer array and commas; each remains a vertical singleton row, while horizontal peers still require a nested array. Complete valid blocks become native `<tg-button-row>` elements in `InputRichMessage.markdown`, at their original positions. Each row supports at most eight buttons and must fit a Rich Message chunk; validation precedes callback registration for the entire block. Labels are escaped, prompts never enter the native markup, and disabled cells have no callback. Missing closures, trailing payload garbage, and invalid blocks produce no controls. Other language names and enclosing code examples stay literal. Draft previews withhold complete and unfinished action fences without registering actions. HTML compatibility projects fenced rows into the bottom keyboard before comment-authored footer rows; source ordering within each group is retained.
185
+ - Standalone column-zero fences opened by exactly three backticks and `telegram_button` use the same singleton/matrix JSON/CML grammar. Button wrappers accept adjacent complete JSON/CML cells and matrices with optional commas between groups. Their normalized rows concatenate in source order: `[{A|a}] [{B|b}]` equals `[{A|a}{B|b}]`; a singleton cell is one vertical row and horizontal peers still require a row nested inside a matrix. Any invalid group in a fence rejects the entire block before callback registration. Complete valid blocks become native `<tg-button-row>` elements in `InputRichMessage.markdown`, at their original positions. Each row supports at most eight buttons and must fit a Rich Message chunk; validation precedes callback registration for the entire block. Labels are escaped, prompts never enter the native markup, and disabled cells have no callback. Missing closures, trailing payload garbage, and invalid blocks produce no controls. Other language names and enclosing code examples stay literal. Draft previews withhold complete and unfinished action fences without registering actions. HTML compatibility projects fenced rows into the bottom keyboard before comment-authored footer rows; source ordering within each group is retained.
186
186
  - In-body controls share prompt/app dispatch, TTL, generation/revision checks, and sent-message target ownership with footer controls. Their acknowledgement is the selection feedback; `selected_style` does not recolor the Rich body. No callback-triggered body rewrite is introduced, preventing a stale click from overwriting newer app output. The operator confirmed the post-reload current-client smoke: singleton CML and mixed JSON/CML in-body rows, their ordinary prompt callbacks, a labeled disabled control, and the separate footer callback work as intended. Client type was not recorded. The operator also confirmed that HTML mode projects the in-body groups into the bottom keyboard as intended. Second-client coverage, follower routing, blank cells, and app-method dispatch/revision rejection remain live-gated. Bot API evidence: `InputRichBlockButtons` / Rich Markdown `tg-button-row` and `RichMessageButton` use ordinary `callback_data` and support disabled controls.
187
187
  - After Telegram accepts a generated footer button callback as a queued prompt, the bridge changes that exact button to its configured selection style without changing agent-authored text or emoji. Set `selected_style` to `primary` (blue), `success` (green), or `danger` (red); omitted or invalid values fall back to `primary`. The style never suppresses queue admission. Other choices stay visually unchanged and remain available; the callback acknowledgement remains the fallback on clients that do not render button styles.
188
188
  - When generated button markup is the entire assistant reply, the bridge supplies the standard `☑️ **Choose an option:**` heading as visible message text so Telegram has a message to which it can attach the inline keyboard.
@@ -197,6 +197,7 @@ The extension injects prompt guidance by context:
197
197
 
198
198
  - If no bot token is configured, no Telegram bridge suffix is injected.
199
199
  - For ordinary local/TUI prompts, the compact routing note points to bundled Skills, including portable `show-me`, while forbidding Telegram use unless explicitly requested.
200
+ - Pi 1.2 custom-message starts may omit the user `prompt`. They retain connected bridge guidance without acquiring Telegram-user origin from their message content; explicit custom origin also overrides any stale prompt field. Legacy user events without `messageType` retain prefix-based Telegram detection. Unavailable transport still strips Telegram tool guidance.
200
201
  - For Telegram-originated turns, the compact note routes the agent to `telegram-bridge` for voice/button/direct-delivery/Threaded Mode/formatting/debug guidance and to `show-me` when the user needs a visual explanation of work or behavior.
201
202
  - For ordinary Telegram-originated answers, write the full technical answer as phone-width Markdown. `show-me` may instead deliver a focused self-contained HTML artifact when explicitly requested or when spatial density earns it, while keeping the immediate reply useful.
202
203
  - Add `telegram_voice` with positional CML by default or JSON when multiline content, named fields, or escaping requires it. A companion summary is optional, no specific summary format is required.
@@ -75,7 +75,7 @@ Every assistant-authored HTML comment is transport-private on Telegram: previews
75
75
  - `telegram_message(text, chat_id?, media?, channel?, thread_id?, thread?)` sends a direct Telegram Markdown message when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus. A public `@username`, or an exact negative numeric channel ID with `channel: true`, is passed as `chat_id` without a local registry; channel delivery requires the direct leader, and Telegram enforces whether the bot has channel posting permission. Channel delivery accepts `media` as one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video (photo ≤ 10 MiB, video ≤ 50 MiB), uploaded through multipart `sendPhoto`/`sendVideo` with `text` as its HTML caption (≤ 1024 visible characters); unsupported media types and albums are rejected before issuance, and the durable channel-post journal binds media identity and caption so duplicate or lost-acknowledgement retries never re-upload. `thread` accepts a live numeric Thread id or its current acknowledged display title; name matching is case-insensitive and fails closed when absent or ambiguous, while delivery captures the numeric target. During an active Telegram turn, omitted targeting and an explicit target equal to that turn are rejected so the ordinary final-reply path remains the sole current-target response; an explicit different chat/thread target remains allowed. Outside active turns, paired/default local/TUI delivery remains unchanged. Top-level `telegram_button` comments inside `text` are parsed with the same planner used for normal replies and attached to that message; buttons are never standalone Telegram messages.
76
76
  - The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, Generative App operation, and profile-specific debugging guidance. The bundled `show-me` Skill owns portable evidence-honest explanations and adapts them to phone-width Markdown or self-contained HTML artifacts when Telegram is the active surface. The regular prompt routes applicable turns to these and the other bundled Skills. Compiled npm/git installations expose them through the package `pi.skills` manifest so Pi resource filters remain authoritative; a raw TypeScript checkout under Pi's `extensions` directory contributes its source Skill root at runtime instead. `telegram_attach`, `telegram_bind`, and `telegram_message` remain registered but are model-active only while this instance owns direct transport or holds a live follower registration; disconnect/loss suppresses their schemas and prompt metadata, and recovery restores only the operator's previously active pi-telegram subset.
77
77
  - `telegram_voice` hidden comments request Telegram-native voice delivery through `{text}`, `{text|lang}`, `{text|lang|rate}`, or a JSON object. JSON is the fallback for multiline content, named fields, or escaping; equivalent `text` or `value` supplies the spoken payload, with explicit `text` taking precedence.
78
- - `telegram_button` hidden comments create footer buttons; standalone column-zero triple-backtick `telegram_button` blocks create button rows between paragraphs in Native Rich Markdown. Both accept the same singleton or mixed JSON/CML matrix and share prompt/app routing; fenced blocks also accept adjacent top-level JSON/CML objects without an outer array or commas as vertical singleton rows. Native rows allow at most eight buttons and must fit one Rich Message chunk; invalid or incomplete blocks register nothing. Drafts hide action fences. HTML compatibility projects fenced controls into the footer. In-body clicks acknowledge without recoloring the Rich body; selected-style highlighting remains footer-only. One marker accepts a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row; separators are optional and one trailing comma is tolerated at matrix, row, and JSON-object boundaries. Top-level cells become full-width rows, while nested rows group one or more buttons horizontally without an artificial parser-width cap. CML uses `{value}`, `{label|prompt}`, prompt-only `{|prompt}`, or the corresponding three-atom form with `selected_style`; an omitted label uses the existing prompt-as-label fallback, and the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling; an omitted fourth position stays enabled, and the third atom may be empty in this form (`{|Next||1}`). JSON uses boolean `disabled`. Disabled cells need no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank (JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`). The Telegram renderer supplies a non-breaking space only when the label is empty. Disabled cells stay visible but carry `disabled: {}` instead of callback data and register no prompt or bound action; invalid disabled values reject the candidate matrix. It trims atom boundaries and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. Use top-level column-zero action wrappers, outside quotes, lists, or enclosing code examples. Ordinary code fences and larger outer fences preserve literal examples; bare JSON/CML in prose never activates.
78
+ - `telegram_button` hidden comments create footer buttons; standalone column-zero triple-backtick `telegram_button` blocks create button rows between paragraphs in Native Rich Markdown. Both accept the same singleton or mixed JSON/CML matrix and share prompt/app routing; both wrappers also accept adjacent complete JSON/CML cells and matrices with optional separators, concatenating their normalized rows in source order. Cells remain singleton rows and nested horizontal rows retain their grouping. Native rows allow at most eight buttons and must fit one Rich Message chunk; invalid or incomplete blocks register nothing. Drafts hide action fences. HTML compatibility projects fenced controls into the footer. In-body clicks acknowledge without recoloring the Rich body; selected-style highlighting remains footer-only. One marker accepts a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row; separators are optional and one trailing comma is tolerated at matrix, row, and JSON-object boundaries. Top-level cells become full-width rows, while nested rows group one or more buttons horizontally without an artificial parser-width cap. CML uses `{value}`, `{label|prompt}`, prompt-only `{|prompt}`, or the corresponding three-atom form with `selected_style`; an omitted label uses the existing prompt-as-label fallback, and the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling; an omitted fourth position stays enabled, and the third atom may be empty in this form (`{|Next||1}`). JSON uses boolean `disabled`. Disabled cells need no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank (JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`). The Telegram renderer supplies a non-breaking space only when the label is empty. Disabled cells stay visible but carry `disabled: {}` instead of callback data and register no prompt or bound action; invalid disabled values reject the candidate matrix. It trims atom boundaries and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. Use top-level column-zero action wrappers, outside quotes, lists, or enclosing code examples. Ordinary code fences and larger outer fences preserve literal examples; bare JSON/CML in prose never activates.
79
79
 
80
80
  Prompt guidance is context-aware: local/TUI prompts see only explicit direct-delivery guidance, while Telegram-originated turns receive the full action-comment syntax and phone-width output contract.
81
81
 
package/docs/ui-style.md CHANGED
@@ -56,7 +56,7 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
56
56
  | `☑️` | Activate / choose this item | Model detail activation action, generated button-only choice heading | Positive selection cue; use `🟢 Active` for already-current state. |
57
57
  | `⛔️` | Cancel source routing | `⛔️ Cancel routing` chooser action and the `Routing cancelled` toast | Not an abort of active Pi work. Private retention remains mandatory; disposable-tab removal stays bound to exact eligible target authority and follows only once every input in the tab is resolved. |
58
58
  | `❌` | No / cancel / terminal failure | Confirmation cancel buttons and terminal failure notices | Do not use for a recoverable operation failure that leaves session state intact. |
59
- | `🗑` | Delete / defer removal | Destructive confirmations and removal reaction | In the queue menu, reversible Keep/Skip selectors replace immediate deletion. |
59
+ | `🗑` | Delete | Destructive confirmations | Not a queue reaction shortcut; queue menus use reversible Keep/Skip selectors. |
60
60
 
61
61
  ### Informational Feedback
62
62
 
@@ -96,14 +96,10 @@ The menu may clear internal Skip but cannot remove a reaction created by the use
96
96
  | --- | --- | --- | --- |
97
97
  | `👍` | Promote to priority | Queue reaction shortcut | Normalized from variants like `👍️`. |
98
98
  | `⚡` | Producer-owned Control marker | Control queue rows | Not a positive-reaction alias or command classifier; old stored Priority badges project `👍` without changing lane authority. |
99
- | `❤` / `❤️` | Promote to priority | Queue reaction shortcut | Normalize display consistently where code normalizes reactions. |
100
- | `🕊` / `🕊️` | Promote to priority | Queue reaction shortcut | Soft/peaceful promotion gesture. |
101
- | `🔥` | Select Steer | Queue reaction shortcut and Steer badge | Dominates `👍`, heart and dove; not immediate issuance or model-use proof. |
99
+ | `🔥` | Select Steer | Queue reaction shortcut and Steer badge | Dominates `👍`; not immediate issuance or model-use proof. |
102
100
  | `👎` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible until the marked turn reaches dispatch; not negative feedback to the agent. |
103
- | `👻` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Disappear/remove metaphor. |
104
- | `💔` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible cancel metaphor. |
105
- | `💩` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible reject metaphor. |
106
- | `🗑` | Defer removal | Queue reaction shortcut and queue emoji marker | Like Skip, the reaction remains reversible until dispatch reaches the marked turn. |
101
+
102
+ Only `👍`, `👎`, and `🔥` are queue reaction shortcuts. Every other reaction is decorative, including the retired aliases. Retained queue state uses canonical badges without changing lane, disposition or receipt authority.
107
103
 
108
104
  ### Decorative Or Local-Example Emoji
109
105
 
package/lib/menu-queue.ts CHANGED
@@ -115,7 +115,7 @@ function buildTelegramQueueMenuReplyMarkup(
115
115
  if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
116
116
  const rows = items.map((item) => {
117
117
  const badge = getTelegramQueueMenuLaneBadge(item);
118
- const prefix = item.reactionSuppressionEmoji ? `${item.reactionSuppressionEmoji} ` :
118
+ const prefix = item.reactionSuppressionEmoji ? "👎 " :
119
119
  badge ? `${badge} ` : item.hasAttachments ? "📎 " : "";
120
120
  const position = item.reactionSuppressionEmoji
121
121
  ? formatSkippedTelegramQueuePosition(item.queuePosition)
@@ -179,16 +179,15 @@ function escapeTelegramQueueMenuHtmlPreview(text: string): string {
179
179
  return truncated ? escaped + suffix : escaped;
180
180
  }
181
181
 
182
- function getTelegramQueueMenuLaneBadge(item: Pick<TelegramQueueMenuItem, "queueLane" | "priorityEmoji">): string {
182
+ function getTelegramQueueMenuLaneBadge(item: Pick<TelegramQueueMenuItem, "queueLane">): string {
183
183
  if (item.queueLane === "control") return "⚡";
184
184
  if (item.queueLane === "steer") return "🔥";
185
185
  if (item.queueLane !== "priority") return "";
186
- const emoji = item.priorityEmoji?.replace(/\uFE0F/g, "");
187
- return emoji === "❤" || emoji === "🕊" ? emoji : "👍";
186
+ return "👍";
188
187
  }
189
188
 
190
189
  function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
191
- const marker = item.reactionSuppressionEmoji ?? getTelegramQueueMenuLaneBadge(item);
190
+ const marker = item.reactionSuppressionEmoji ? "👎" : getTelegramQueueMenuLaneBadge(item);
192
191
  const badge = marker ? ` ${marker}` : "";
193
192
  const position = item.reactionSuppressionEmoji
194
193
  ? `<s>${item.queuePosition}</s>.`
@@ -920,10 +919,9 @@ function applyQueuedTelegramPromptMenuFlags<Context>(
920
919
  const disposition: Extract<Queue.TelegramQueueReactionDisposition, { kind: "reaction-transition" }> = { kind: "reaction-transition" };
921
920
  if (flags.lane !== undefined) {
922
921
  disposition.promptLane = flags.lane;
923
- disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ?
924
- getTelegramQueueMenuLaneBadge({ queueLane: "priority", priorityEmoji: item.queueLane === "priority" ? item.priorityEmoji : undefined }) : null;
922
+ disposition.priorityEmoji = flags.lane === "steer" ? "🔥" : flags.lane === "priority" ? "👍" : null;
925
923
  }
926
- if (flags.skipped !== undefined) disposition.suppressionEmoji = flags.skipped ? (item.reactionSuppressionEmoji ?? "👎") : null;
924
+ if (flags.skipped !== undefined) disposition.suppressionEmoji = flags.skipped ? "👎" : null;
927
925
  return deps.queueMutationRuntime.applyReactionByMessageId(replyToMessageId, disposition, ctx) ||
928
926
  ((flags.lane === undefined || flags.lane === item.queueLane) &&
929
927
  (flags.skipped === undefined || flags.skipped === (item.reactionSuppressionEmoji !== undefined)));
@@ -364,7 +364,7 @@ function parseTelegramVoiceCompactActionPayload(
364
364
  function parseTelegramAdaptiveActionPayloadRows(
365
365
  source: string,
366
366
  parseCompactPayload: TelegramCompactActionPayloadParser,
367
- options: { allowTrailing?: boolean; maxCompactAtoms?: number } = {},
367
+ options: { allowTrailing?: boolean; maxCompactAtoms?: number; allowMatrixGroups?: boolean } = {},
368
368
  ): { rows: Record<string, unknown>[][]; end: number } | undefined {
369
369
  let offset = 0;
370
370
  const isStructuralWhitespace = (character: string | undefined): boolean =>
@@ -512,21 +512,29 @@ function parseTelegramAdaptiveActionPayloadRows(
512
512
  return undefined;
513
513
  };
514
514
 
515
- const parseVerticalSequence = (): Record<string, unknown>[][] | undefined => {
515
+ const parseTopLevelRows = (): Record<string, unknown>[][] | undefined => {
516
516
  const rows: Record<string, unknown>[][] = [];
517
517
  while (offset < source.length) {
518
518
  skipWhitespace();
519
- if (source[offset] !== "{") break;
520
- const cell = parseCell();
521
- if (!cell) return undefined;
522
- rows.push([cell]);
519
+ const character = source[offset];
520
+ if (character === "{") {
521
+ const cell = parseCell();
522
+ if (!cell) return undefined;
523
+ rows.push([cell]);
524
+ } else if (character === "[" && options.allowMatrixGroups) {
525
+ const matrix = parseMatrix();
526
+ if (!matrix) return undefined;
527
+ rows.push(...matrix);
528
+ } else {
529
+ break;
530
+ }
523
531
  if (!consumeOptionalSeparator()) return undefined;
524
532
  }
525
533
  return rows.length > 0 ? rows : undefined;
526
534
  };
527
535
 
528
536
  skipWhitespace();
529
- const rows = source[offset] === "{" ? parseVerticalSequence() : parseMatrix();
537
+ const rows = parseTopLevelRows();
530
538
  if (!rows) return undefined;
531
539
  skipWhitespace();
532
540
  if (!options.allowTrailing && offset !== source.length) return undefined;
@@ -581,7 +589,7 @@ export function parseTelegramButtonPayloadRows(
581
589
  return parseTelegramAdaptiveActionPayloadRows(
582
590
  source.trim(),
583
591
  parseTelegramButtonCompactActionPayload,
584
- { maxCompactAtoms: 4 },
592
+ { maxCompactAtoms: 4, allowMatrixGroups: true },
585
593
  )?.rows;
586
594
  }
587
595
 
@@ -596,7 +604,7 @@ export function parseTelegramActionPayloadRows(
596
604
  parseTelegramAdaptiveActionPayloadRows(
597
605
  content.slice(offset),
598
606
  parseTelegramButtonCompactActionPayload,
599
- { allowTrailing: true, maxCompactAtoms: 4 },
607
+ { allowTrailing: true, maxCompactAtoms: 4, allowMatrixGroups: true },
600
608
  )?.rows,
601
609
  );
602
610
  if (!scanned) return undefined;
package/lib/prompts.ts CHANGED
@@ -150,10 +150,13 @@ export function createTelegramModelContextAvailabilityRuntime(deps: {
150
150
 
151
151
  export type TelegramSystemPrompt = string | string[];
152
152
 
153
+ // Pi 1.0 user events lack messageType; Pi 1.2 custom starts carry no user prompt.
153
154
  type TelegramBeforeAgentStartEvent = Omit<
154
155
  BeforeAgentStartEvent,
155
- "systemPrompt"
156
+ "systemPrompt" | "prompt" | "messageType"
156
157
  > & {
158
+ messageType?: "user" | "custom";
159
+ prompt?: string;
157
160
  systemPrompt?: TelegramSystemPrompt | null;
158
161
  };
159
162
 
@@ -166,7 +169,7 @@ type TelegramBeforeAgentStartHook = (
166
169
  ) => TelegramBeforeAgentStartResult;
167
170
 
168
171
  export function buildTelegramBridgeSystemPrompt(options: {
169
- prompt: string;
172
+ prompt?: string;
170
173
  systemPrompt?: TelegramSystemPrompt | null;
171
174
  telegramPrefix?: string;
172
175
  localSystemPromptSuffix: string;
@@ -177,7 +180,7 @@ export function buildTelegramBridgeSystemPrompt(options: {
177
180
  const telegramHead = telegramPrefix.endsWith("]")
178
181
  ? telegramPrefix.slice(0, -1)
179
182
  : telegramPrefix;
180
- const trimmedPrompt = options.prompt.trimStart();
183
+ const trimmedPrompt = options.prompt?.trimStart() ?? "";
181
184
  const telegramTurn =
182
185
  trimmedPrompt.startsWith(`${telegramHead}]`) ||
183
186
  trimmedPrompt.startsWith(`${telegramHead}|`);
@@ -200,7 +203,7 @@ export function createTelegramBeforeAgentStartHook(
200
203
  ): TelegramBeforeAgentStartHook {
201
204
  return (event) =>
202
205
  buildTelegramBridgeSystemPrompt({
203
- prompt: event.prompt,
206
+ prompt: event.messageType === "custom" ? undefined : event.prompt,
204
207
  systemPrompt: event.systemPrompt,
205
208
  telegramPrefix: options.telegramPrefix,
206
209
  localSystemPromptSuffix:
package/lib/updates.ts CHANGED
@@ -108,16 +108,10 @@ export type TelegramReactionType =
108
108
 
109
109
  export const TELEGRAM_PRIORITY_REACTIONS = [
110
110
  { id: 10, name: "like", emoji: "👍" },
111
- { id: 12, name: "heart", emoji: "❤" },
112
- { id: 13, name: "dove", emoji: "🕊" },
113
111
  ] as const;
114
112
  export const TELEGRAM_STEER_REACTIONS = [{ id: 14, name: "fire", emoji: "🔥" }] as const;
115
113
  export const TELEGRAM_REMOVAL_REACTIONS = [
116
114
  { id: 20, name: "dislike", emoji: "👎" },
117
- { id: 21, name: "ghost", emoji: "👻" },
118
- { id: 22, name: "broken-heart", emoji: "💔" },
119
- { id: 23, name: "poop", emoji: "💩" },
120
- { id: 24, name: "wastebasket", emoji: "🗑" },
121
115
  ] as const;
122
116
  export const TELEGRAM_PRIORITY_REACTION_EMOJIS =
123
117
  TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
@@ -159,7 +153,7 @@ function getTelegramQueueReactionTransition(
159
153
  ): TelegramQueueReactionDisposition | undefined {
160
154
  const oldEmojis = collectTelegramReactionEmojis(oldReactions);
161
155
  const newEmojis = collectTelegramReactionEmojis(newReactions);
162
- // Fire is effective Steer intent; weaker positive aliases under it do not reorder the lane.
156
+ // Fire selects Steer; thumbs-up beneath it does not reorder the lane. Other emoji carry no queue intent.
163
157
  const steerEmoji = TELEGRAM_STEER_REACTIONS[0].emoji;
164
158
  const oldPriorityEmoji = oldEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(oldEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
165
159
  const newPriorityEmoji = newEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(newEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.1",
3
+ "version": "0.56.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -60,7 +60,7 @@ Every enabled button has a self-contained prompt and an optional selection style
60
60
  - `{label|prompt}` separates visible label from queued prompt.
61
61
  - `{label|prompt|selected_style}` and `{|prompt|selected_style}` accept `primary`, `success`, or `danger`.
62
62
  - Fourth-position `1`/`true` disables, `0`/`false` enables; omission means enabled. JSON uses boolean `disabled`. `{|Next||1}` omits label/style; `{Next|||1}` omits prompt/style; `{|||1}` is blank (Telegram receives a non-breaking space). Prefer meaningful labels and retain a useful enabled action. Enabled CML requires a prompt. Disabled controls stay visible but have no callback, queued prompt, or bound-method invocation.
63
- - Top-level cells form vertical rows; inside a fenced block, adjacent top-level JSON/CML objects may omit the outer array and commas. One nested row groups horizontal peers.
63
+ - Both wrappers concatenate rows from adjacent JSON/CML cells or matrices, with optional commas. Cells are vertical; horizontal peers require a nested row: `[[{A|a}{B|b}]]`.
64
64
  - Prefer one matrix per related group. Fenced blocks stay in place in Rich mode; HTML compatibility moves them to the footer. Native rows allow eight buttons. Malformed/oversized/unclosed blocks activate nothing; drafts hide them. Outer code fences and quoted/indented examples remain literal.
65
65
  - Both placements share prompt/app routing. In-body clicks acknowledge without recoloring; selected-style highlighting remains footer-only.
66
66