@llblab/pi-telegram 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,11 +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: 11, name: "lightning", emoji: "⚡" },
18
17
  { id: 12, name: "heart", emoji: "❤" },
19
18
  { id: 13, name: "dove", emoji: "🕊" },
20
- { id: 14, name: "fire", emoji: "🔥" },
21
19
  ];
20
+ export const TELEGRAM_STEER_REACTIONS = [{ id: 14, name: "fire", emoji: "🔥" }];
22
21
  export const TELEGRAM_REMOVAL_REACTIONS = [
23
22
  { id: 20, name: "dislike", emoji: "👎" },
24
23
  { id: 21, name: "ghost", emoji: "👻" },
@@ -47,8 +46,10 @@ function getTelegramReactionEmoji(emojis, candidates) {
47
46
  function getTelegramQueueReactionTransition(oldReactions, newReactions) {
48
47
  const oldEmojis = collectTelegramReactionEmojis(oldReactions);
49
48
  const newEmojis = collectTelegramReactionEmojis(newReactions);
50
- const oldPriorityEmoji = getTelegramReactionEmoji(oldEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
51
- const newPriorityEmoji = getTelegramReactionEmoji(newEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
49
+ // Fire is effective Steer intent; weaker positive aliases under it do not reorder the lane.
50
+ const steerEmoji = TELEGRAM_STEER_REACTIONS[0].emoji;
51
+ const oldPriorityEmoji = oldEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(oldEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
52
+ const newPriorityEmoji = newEmojis.has(steerEmoji) ? steerEmoji : getTelegramReactionEmoji(newEmojis, TELEGRAM_PRIORITY_REACTION_EMOJIS);
52
53
  const oldSuppressionEmoji = getTelegramReactionEmoji(oldEmojis, TELEGRAM_REMOVAL_REACTION_EMOJIS);
53
54
  const newSuppressionEmoji = getTelegramReactionEmoji(newEmojis, TELEGRAM_REMOVAL_REACTION_EMOJIS);
54
55
  if (oldPriorityEmoji === newPriorityEmoji &&
@@ -58,6 +59,8 @@ function getTelegramQueueReactionTransition(oldReactions, newReactions) {
58
59
  const transition = { kind: "reaction-transition" };
59
60
  if (oldPriorityEmoji !== newPriorityEmoji) {
60
61
  transition.priorityEmoji = newPriorityEmoji ?? null;
62
+ if (newPriorityEmoji === steerEmoji)
63
+ transition.promptLane = "steer";
61
64
  }
62
65
  if (oldSuppressionEmoji !== newSuppressionEmoji) {
63
66
  transition.suppressionEmoji = newSuppressionEmoji ?? null;
@@ -5533,6 +5536,7 @@ export async function coordinateTelegramQueueHandoff(input) {
5533
5536
  const handoff = createTelegramQueueHandoff({
5534
5537
  handoffToken: input.handoffToken,
5535
5538
  item: input.item,
5539
+ steerSupported: input.steerSupported,
5536
5540
  });
5537
5541
  const receipt = handoff.payload.admissionReceipts[0];
5538
5542
  if (!receipt || handoff.payload.admissionReceipts.length !== 1) {
@@ -5670,6 +5674,8 @@ export function createTelegramQueueHandoffRecipientRuntime(deps) {
5670
5674
  /** Own queue-handoff projections over journals, admission, IPC, and live queue state. */
5671
5675
  export function createTelegramQueueHandoffReconciliationRuntimeAssembly(deps) {
5672
5676
  return createTelegramQueueHandoffReconciler({
5677
+ canHandoffSteerWithRecipient: deps.canHandoffSteerWithRecipient,
5678
+ hasPendingDispatch: deps.hasPendingDispatch,
5673
5679
  ownsDirect: deps.ownsDirect,
5674
5680
  isFollowerRegistered: deps.isFollowerRegistered,
5675
5681
  isBusEnabled: deps.isBusEnabled,
@@ -5738,6 +5744,9 @@ export function createTelegramQueueHandoffReconciler(deps) {
5738
5744
  if (followers.length === 0)
5739
5745
  return;
5740
5746
  for (const item of [...deps.getQueuedItems()]) {
5747
+ if ((deps.hasPendingDispatch?.() && deps.getQueuedItems()[0] === item) ||
5748
+ (item.kind === "prompt" && (item.steerAttempted || item.steerPacketTail !== undefined || item.steerPacketSourceOrders !== undefined)))
5749
+ continue;
5741
5750
  const target = item.target;
5742
5751
  const recipient = target
5743
5752
  ? followers.find((candidate) => candidate.target?.chatId === target.chatId &&
@@ -5745,6 +5754,9 @@ export function createTelegramQueueHandoffReconciler(deps) {
5745
5754
  : undefined;
5746
5755
  if (!recipient)
5747
5756
  continue;
5757
+ const steerSupported = item.queueLane === "steer" && deps.canHandoffSteerWithRecipient?.(recipient) === true;
5758
+ if (item.queueLane === "steer" && !steerSupported)
5759
+ continue;
5748
5760
  const receipt = item.admissionReceipts?.[0];
5749
5761
  if (!receipt || item.admissionReceipts?.length !== 1)
5750
5762
  continue;
@@ -5768,6 +5780,7 @@ export function createTelegramQueueHandoffReconciler(deps) {
5768
5780
  const handoffToken = deps.createHandoffToken();
5769
5781
  const result = await coordinateTelegramQueueHandoff({
5770
5782
  item,
5783
+ steerSupported,
5771
5784
  expectedOwner,
5772
5785
  recipientOwner: {
5773
5786
  instanceId: recipient.instanceId,
@@ -5778,6 +5791,9 @@ export function createTelegramQueueHandoffReconciler(deps) {
5778
5791
  handoffToken,
5779
5792
  lifecycle,
5780
5793
  stageRemote: async ({ payload: sourcePayload }) => {
5794
+ if (sourcePayload.queueLane === "steer" && deps.canHandoffSteerWithRecipient?.(recipient) !== true) {
5795
+ throw new Error("Steer queue handoff capability changed before staging.");
5796
+ }
5781
5797
  const payload = {
5782
5798
  ...sourcePayload,
5783
5799
  admissionReceipts: [
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.55.1",
3
+ "version": "0.56.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/docs/README.md CHANGED
@@ -8,7 +8,7 @@ Living index of project documentation in `/docs`.
8
8
 
9
9
  ## Documents
10
10
 
11
- - [architecture.md](./architecture.md) — Overview of the Telegram bridge runtime, optional profile isolation, queueing model and Priority/Normal plus Keep/Skip reaction policy, native Rich Markdown delivery, UI/compat rendering, and interactive controls
11
+ - [architecture.md](./architecture.md) — Overview of the Telegram bridge runtime, optional profile isolation, queueing model and Normal/Priority/Steer plus Keep/Skip reaction policy, native Rich Markdown delivery, UI/compat rendering, and interactive controls
12
12
  - [public-api.md](./public-api.md) — Stable public API map: package entrypoints, commands, config, assistant markup, extension APIs, smoke examples, and compatibility boundaries
13
13
  - [delivery.md](./delivery.md) — Target-aware companion delivery contract for operational views, logical message handles, target scopes, lifecycle fencing, and leader/follower transport
14
14
  - [activity.md](./activity.md) — Normalized Pi lifecycle API for extension-owned reasoning, intermediate prose, tool activity, source identity, delivery contexts, and consumer policy examples
@@ -110,7 +110,7 @@ Only the live cleanup origin uses the approved historical-protection reduction.
110
110
  - `outbound-attachments`: `telegram_attach`, queued outbound files, stat/limit checks, ordinary photo/document delivery, and narrow single-artifact Rich Message planning/sending for probe-confirmed photo/video/audio formats. It owns known-failure fallback eligibility and ambiguous-send no-replay classification through structural error contracts without importing Bot API helpers.
111
111
  - `channel-posts`: profile/token-bound authority for agent-authored channel publication intents, one-shot send/edit/delete fencing, outcome-unknown retention, exact successful post identity, capacity refusal, and bounded local listing. It never reads Telegram history; the composition root supplies direct-leader Bot API effects only after its durable grants.
112
112
  - `status` / `logs`: status bar/status-message rendering, queue-lane summaries, the structural redacted event ring, profile-aware JSONL scope/reset/append behavior, exact-owner destructive commits, fail-soft synchronous and queued diagnostics persistence, status snapshot scheduling, grouped diagnostics, and allowlisted compact connection-failure copy shared by commands and lifecycle. `status` remains a structural leaf; `logs` composes filesystem evidence with status projections and contains every persistence failure so diagnostics cannot terminate or poison the runtime queue.
113
- - `bindings` / `lifecycle` / `prompts` / `prompt-templates` / `pi`: Pi-facing command/tool/hook registration and cohesive cross-domain binding assembly, including queue mutation/dispatch/watchdog composition over admission and transport ports; session-generation fencing and start/shutdown sequencing across Queue, grouped input, Delivery, polling, capability monitor, follower refresh, and assistant-output projection; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports. `lifecycle` also owns bounded same-process connect intent across local `/resume`, cancellation/supersession, and fresh-context startup scheduling; it does not own transport authority, durable bindings, queue custody, or source-target continuity.
113
+ - `bindings` / `lifecycle` / `prompts` / `prompt-templates` / `pi`: Pi-facing command/tool/hook registration and cohesive cross-domain binding assembly, including queue mutation/dispatch/watchdog composition over admission and transport ports; session-generation fencing and start/shutdown sequencing across Queue, grouped input, Delivery, polling, capability monitor, follower refresh, and assistant-output projection; Telegram prompt guidance; prompt-template discovery/expansion; and centralized direct Pi SDK imports. `lifecycle` also owns bounded same-process connect intent across local `/resume`, cancellation/supersession, and fresh-context startup scheduling; it does not own transport authority, durable bindings, queue custody, or source-target continuity. Each Queue start/shutdown invocation captures its session generation for inner post-await checks, including config/temp preparation and polling suspension. Reused context identity cannot authorize an obsolete reset/discard or successor service startup. Follower refresh uses that same invocation-local predicate through its awaited registration/context publication; no mutable shared generation or target-preservation flag crosses overlapping invocations.
114
114
  - `command-templates`: shell-free command-template helpers, composition expansion, placeholder substitution, executable resolution, warnings, and retry/timeout semantics.
115
115
 
116
116
  ### Host Compatibility Boundary
@@ -441,7 +441,7 @@ Named Telegram profiles are orthogonal to Threaded Mode. The selected profile ch
441
441
 
442
442
  Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so unrelated global/profile updates do not stale-replace one another. Each profile's journal transaction independently publishes its monotonic admission cursor together with admitted work; polling never writes runtime state through config persistence. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments are flat `kind-scope-messageId[-index][-name|.ext]` files under `tmp/pi-telegram/attachments` and are session artifacts rather than identity or routing authority. The scope is the bot username (else bot ID) for the bot's private chat, a group username or numeric ID, and for Guest Mode the remote peer's username or numeric ID, because guest message IDs belong to the peer chat. Scratch cleanup removes regular files older than 24 hours. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
443
443
 
444
- When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Followers heartbeat every `1s`; the leader uses a `15s` stale grace and a `1s` prune loop so transient IPC stalls do not create false routing gaps while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Heartbeat pruning alone preserves stored owner records and Workspace bindings. The same leader can retain bounded, non-routing observations of actually registered PID/generation/target tuples for later absence proof and retry of uncommitted preservation; the [pruning contract](./multi-instance-bus.md#follower-heartbeat-is-missed) owns cancellation, admission identity and lifetime limits. When Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, serialized ahead of replacement registration. With cleanup disabled, that proof instead permits a profile-admitted, fenced non-destructive detachment: the store removes the uniquely matching owner record and records first inactivity while retaining the exact Workspace binding and its letter. Publication rechecks PID absence, runtime generation, profile, leader epoch, replacement registrations and the record snapshot; missing or ambiguous restoration identity blocks it. The Thread and its messages remain untouched, accepted-work protection remains independent, and no deleted-Thread observation is invented. Successful follower target reuse refreshes the binding and clears inactivity. Durable Workspace bindings remain restoration hints after detachment; heartbeat silence or unverifiable process evidence cannot stamp inactivity. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/pi-telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/pi-telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, `workspaceBindings` stores profile-scoped normalized exact-`cwd` target/name/slot reuse hints, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
444
+ When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Followers heartbeat every `1s`; the leader uses a `15s` stale grace and a `1s` prune loop so transient IPC stalls do not create false routing gaps while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Heartbeat pruning alone preserves stored owner records and Workspace bindings. The same leader can retain bounded, non-routing observations of actually registered PID/generation/target tuples for later absence proof and retry of uncommitted preservation; the [pruning contract](./multi-instance-bus.md#follower-heartbeat-is-missed) owns cancellation, admission identity and lifetime limits. When Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, serialized ahead of replacement registration. With cleanup disabled, that proof instead permits a profile-admitted, fenced non-destructive detachment: the store removes the uniquely matching owner record and records first inactivity while retaining the exact Workspace binding and its letter. Publication rechecks PID absence, runtime generation, profile, leader epoch, replacement registrations and the record snapshot; missing or ambiguous restoration identity blocks it. The Thread and its messages remain untouched, accepted-work protection remains independent, and no deleted-Thread observation is invented. Successful follower target reuse refreshes the binding and clears inactivity. Durable Workspace bindings remain restoration hints after detachment; heartbeat silence or unverifiable process evidence cannot stamp inactivity. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/pi-telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/pi-telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, `workspaceBindings` stores profile-scoped normalized exact-`cwd` target/name/slot reuse hints, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. A retained `enabled` capability is not proof of a running local bus: after a startup probe timeout leaves classic polling active, a fresh positive monitor result restarts Threaded transport under the current lock owner even when the persisted mode is unchanged. Unknown results do not certify this recovery, healthy buses are not restarted, and followers do not acquire leader authority through the monitor. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
445
445
 
446
446
  Fresh follower binding is manual and process-first: the operator starts another Pi process, then runs `/telegram-connect`; only then may that process allocate a profile-scoped normalized exact-`cwd` Workspace identity and cause the leader to create a Thread. A later process reopening that remembered Workspace automatically sends capability-gated restore-only admission under a live leader. The leader may reclaim, visibility-probe, or stale-replace the remembered target, but an absent binding returns quietly without creating a Thread. `/telegram-connect [profile] as=Name` supplies a unique capitalized Latin-word identity only to fresh Workspace provisioning; an existing Workspace keeps its persisted name. Telegram `/name Name` stores the owning Thread's durable `manualThreadName` and immediately applies it over the active automatic display projection; bare `/name` opens five-minute exact-target input whose next valid text is consumed before agent dispatch. Name input, cancel, and reset are consume-once; stale scope, target, message ID, expiry, and duplicate callbacks cannot mutate. Reset clears only the override and restores the current automatic projection. Leader command routing reuses its already-held profile admission for the rename body rather than recursively entering the non-reentrant Workspace gate; standalone leader renames acquire their own admission. Followers send an authenticated exact-generation `workspace-thread-rename-v1` request, and the leader owns any Bot API mutation plus durable binding persistence. Concurrent processes from one directory receive deterministic Workspace suffixes, while leader/follower roles remain transient projections over that durable identity. Telegram does not expose `/thread`, auto-spawn arbitrary unbound threads, or launch hidden follower subprocesses. In Threaded Mode, `/telegram-connect` does not offer manual takeover while a live leader exists; takeover is reserved for stale-leader election/recovery. Leadership remains an ephemeral transport role that another live follower can take over after stale heartbeat detection. A confirmed runtime transition from Threaded to Singleton stops threaded transport and suspends the process-local leader target before classic polling can accept new work; durable Workspace slot, generated name, manual display name, and binding evidence remain retained. Re-enabling Threaded Mode restores or replaces that logical binding before publishing one new live target. Already-admitted turns keep their captured destination and are never silently retargeted or duplicated.
447
447
 
@@ -577,35 +577,36 @@ Long-text split recovery remains conservative: only human text at or above the n
577
577
 
578
578
  ### Queue And Dispatch Safety
579
579
 
580
- The bridge keeps its own Telegram queue. The Pi status bar's yellow `+N` suffix projects only executable prompts still waiting: current agent work from Telegram, terminal, or autonomous sources never contributes, and the dispatched Telegram head is subtracted while it remains retained pending `agent_start` consumption.
580
+ The bridge keeps its own Telegram queue. The Pi status bar projects executable waiting work across Normal, Priority, Steer and Control into one warning-yellow `+N` suffix, omitted when zero. It uses Queue's executability filter, so Skip and frozen attempts never contribute; current agent work from Telegram, terminal, or autonomous sources never contributes either. The dispatched Telegram head is subtracted while retained pending `agent_start` consumption.
581
581
 
582
582
  Queue items have two explicit dimensions:
583
583
 
584
584
  - `kind`: `prompt` or `control`.
585
- - `queueLane`: `control`, `priority`, or `default`.
585
+ - `queueLane`: `control`, `steer`, `priority`, or `default`.
586
586
 
587
- Dispatch rank:
587
+ Dispatch rank (distinct from the ascending menu selector order):
588
588
 
589
589
  1. `control` lane.
590
- 2. `priority` prompt lane.
591
- 3. `default` prompt lane.
590
+ 2. `steer` prompt lane, using its guarded active-boundary or idle-packet attempt.
591
+ 3. `priority` prompt lane.
592
+ 4. `default` prompt lane.
592
593
 
593
594
  #### Priority, Reactions, Keep, and Skip
594
595
 
595
- Waiting Normal/Priority prompts expose two independent dimensions:
596
+ Bridge-owned Steer uses the existing public Pi SDK. Busy-run operator smoke coverage is established; broader live and native-Windows acceptance remains follow-up work in the [backlog](../BACKLOG.md). Waiting ordinary prompts expose two independent dimensions:
596
597
 
597
- - `Priority` / `Normal` selects the FIFO lane and therefore scheduling order.
598
+ - `Normal` / `Priority` / `Steer` selects the FIFO lane and scheduling opportunity in increasing intervention order. Control remains producer-owned.
598
599
  - `Keep` / `Skip` selects whether the prompt executes when dispatch reaches it.
599
600
 
600
601
  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.
601
602
 
602
- Telegram reactions are shortcut controls over those dimensions. Recognized positive reactions control Priority and recognized negative reactions control Skip; the emoji sets live in the [queue reaction registry](./ui-style.md#queue-reaction-shortcuts). The runtime compares the complete old and new reaction sets and mutates only categories that changed, 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.
603
+ 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.
603
604
 
604
- Priority and Skip may coexist when a prompt carries both a positive and a negative reaction. The prompt remains at its Priority-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 symmetric Priority/Normal and Keep/Skip selectors instead of an irreversible Delete action.
605
+ 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.
605
606
 
606
- Menu and reaction controls share the same canonical queue state. A menu Keep can clear internal Skip without changing Priority or queue position, but Telegram's Bot API cannot remove a reaction created by the user; the visible user reaction can therefore remain until that user removes it. Once Pi has consumed or dropped a prompt, later reactions cannot retract or restore it.
607
+ Menu and reaction controls share the same canonical queue state. A menu Keep can clear internal Skip without changing Priority or queue position, but Telegram's Bot API cannot remove a reaction created by the user; the visible user reaction can therefore remain until that user removes it. Once a prompt is dispatched, frozen for an attempt, consumed or dropped, later reactions cannot change or reissue it. Steer freezes exact original objects before whole-receipt attempt disposition, which is not SDK/model-use proof. Active `turn_end` inserts one packet without resetting Telegram reply/preview ownership; packet content preserves original text/image blocks with blank-line separators only between prompts, without numbered Steer labels. Idle origin detection retains its Telegram prefix. Idle `sendUserMessage` pins the first original and its tail until `agent_start` projects one reply/voice/attachment owner. Partial/refused/unknown outcomes remain protected without replay, and Steer queue transfer requires independently negotiated `queue-steer-v1` plus base handoff support at every participant before custody changes. Frozen/pending work never transfers as unspent input; missing support or malformed attempted/projection fields fail closed.
607
608
 
608
- 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. The guest prompt never reaches a model turn.
609
+ 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.
609
610
 
610
611
  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.
611
612
 
@@ -25,6 +25,10 @@ myext:page:2
25
25
  - Put only opaque ids or small enum values in payloads; do not store secrets, full prompts, or large state.
26
26
  - Treat callbacks as untrusted input. Validate namespace, action, and payload before executing side effects.
27
27
 
28
+ ## Queue Controls
29
+
30
+ The Steer surface owns `queue:lane-set:<chat>:<message>:steer|priority|normal` and independent `queue:skip-set:<chat>:<message>:keep|skip`. Previously published `queue:prio-set` and `queue:prio` controls remain explicit Priority/Normal compatibility routes. Payloads only identify a current view; they cannot grant source/session authority or edit a protected pending/frozen item. Control lanes remain producer-owned.
31
+
28
32
  ## pi-telegram fallback
29
33
 
30
34
  If `pi-telegram` receives callback data that is not owned by its built-in prefixes and no built-in handler consumes it, it forwards the click to Pi as:
@@ -273,7 +273,7 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
273
273
 
274
274
  ### Protocol identity and compatibility
275
275
 
276
- The session-native local wire contract has protocol version `3`, independent from the npm package version. Version 3 moved journal source serialization from the `telegram.json` transaction to the journal-owned `runtime/journals.transaction`; because v2 peers would not exclude v3 journal writers, the base version check rejects them instead of mixing lock semantics. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity has been part of the base protocol since v2 rather than an optional capability. Every cwd-scoped registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Older-protocol peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
276
+ The session-native local wire contract has protocol version `3`, independent from the npm package version. Version 3 moved journal source serialization from the `telegram.json` transaction to the journal-owned `runtime/journals.transaction`; because v2 peers would not exclude v3 journal writers, the base version check rejects them instead of mixing lock semantics. Follower registration and the leader acknowledgement carry `{ protocolVersion, runtimeBuild, capabilities }`. Capability names are canonical, unique, and sorted. A leader rejects missing or mismatched protocol identity before provisioning a target or publishing the follower into live routing; a strict follower likewise rejects an acknowledgement without compatible leader identity. Different package builds remain compatible when their protocol versions agree. `durable-follower-admission-v1` gates source forwarding, while `queue-handoff-v1` independently gates live semantic queue transfer; every participant in a routed handoff must advertise it. Local experimental 0.56.0 adds `queue-steer-v1`: donor, leader and recipient must additionally support the unspent Steer lane before offer/staging. Protocol version and package version alone never imply this support. `workspace-follower-auto-connect-v1` gates restore-only startup admission. Session identity has been part of the base protocol since v2 rather than an optional capability. Every cwd-scoped registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Older-protocol peers are rejected by the base version check, avoiding mixed semantic branches. `thread-display-mode-v1` gates exact-generation follower display-setting requests and Letters/Directories require compatible connected followers; returning to Names allows legacy peers. Registration checks the current display-mode requirement before provisioning and again before live publication. The leader serializes config persistence and title application. `workspace-thread-rename-v1` independently gates follower rename requests whose exact registration generation is checked before the leader mutates Telegram and persists the Workspace binding. `session-replacement-intent-v1` gates Telegram `/new` from a follower Thread: followers never write `state.json`, so `follower.publishSessionReplacement` asks the leader to CAS-publish the durable intent only when the live registration generation, leader epoch, Profile, exact `cwd`, source session, Thread target, and the leader's own Workspace binding slot/name all match, with a bounded unexpired TTL. The intent records the source runtime instance; only a successor registration from that instance or its authenticated same-process handoff (`previousInstanceId`) may re-key the binding, and `follower.settleSessionReplacement` claims it once only after the leader's store shows that successor session bound to the same target. A missing capability, stale generation, or mismatch fails the command closed.
277
277
 
278
278
  The current root advertises `workspace-restore-v1`; all participating peers and potential leader successors must be upgraded before operator-controlled use. The retired `leader.replaceFollowerTarget` envelope is no longer parsed or produced, even with current authentication and registration generation. Its `leader.workspaceRestore` envelope carries only an operation ID, exact recipient registration generation and `apply`/`inspect` mode; the receiver derives binding and destination from the retained intent, not caller-supplied target data. Authentication and an explicitly enabled receiver are mandatory. The controller checks both peers' capability and protocol compatibility, captures live registration/session/slot/endpoint authority, sends once without transport retry, then validates the request, operation, recipient, observed target, slot and boolean readiness against the current registration. A lost response leaves the durable issuance consumed; a separate read-only inspection can prove readiness without applying again. Neither the controller nor receiver settles source input or writes follower-owned canonical state. A successor registration/process may inspect only its actual same-session canonical binding and local target; readiness records the new observer separately without changing the original issuance. An old local target cannot be repaired through inspection, and successor apply is rejected. Interrupted startup, terminal source/cleanup settlement and live behavior were accepted in the operator's 0.52.0 live smoke.
279
279
 
@@ -287,7 +287,7 @@ The delivery id excludes the replaceable runtime instance and registration gener
287
287
 
288
288
  Transport leadership does not own already-queued semantics. Each queued journal receipt binds the acquiring runtime instance, OS process birth, session generation, and acquisition. A replacement leader or follower process sees another live process's receipt as foreign and cannot replay or settle it; the original process may complete its local Pi queue after transport moves. Startup preserves foreign and legacy unowned receipts. Before a replacement admission worker starts, tri-state pid/process-birth proof: an absent PID or mismatched stable Linux/macOS birth identity permits recovery, while a live matching owner stays `alive` and Windows or inaccessible birth metadata stays `unverifiable`; both non-dead outcomes preserve the receipt may transactionally recover a dead owner's complete receipt to pending, after which replacement replay creates fresh queue authority. Registration publishes the replacement's exact pid/process-birth identity first; a concurrent recovery that observes it returns `owner-alive`, while live or unverifiable owners remain untouched.
289
289
 
290
- Live-process handoff combines journal CAS with authenticated bounded bus payloads. A donor-generated one-time token is hashed together with the exact receipt, donor acquisition, and recipient runtime/process/session identity. Offering retains donor ownership but freezes donor completion/discard and dead-owner recovery. Prompt payloads serialize queue data; control payloads serialize only stable `status`/`model` identity and rebuild closures at the recipient. Leader and follower receivers require exact live donor/recipient registration generations, reject malformed/oversized payloads, stage one complete receipt idempotently, accept its journal authority, and acknowledge only that exact receipt plus newly minted owner. Receipts name their source journal binding, while the donor derives the recipient follower-journal binding from the authenticated stable profile. The recipient accepts only that matching active lifecycle. Production advertises `queue-handoff-v1` with this exact role/path composition; legacy-unbound or unavailable bindings fail closed.
290
+ Live-process handoff combines journal CAS with authenticated bounded bus payloads. A donor-generated one-time token is hashed together with the exact receipt, donor acquisition, and recipient runtime/process/session identity. Offering retains donor ownership but freezes donor completion/discard and dead-owner recovery. Prompt payloads serialize queue data; control payloads serialize only stable `status`/`model` identity and rebuild closures at the recipient. Leader and follower receivers require exact live donor/recipient registration generations, reject malformed/oversized payloads, stage one complete receipt idempotently, accept its journal authority, and acknowledge only that exact receipt plus newly minted owner. Receipts name their source journal binding, while the donor derives the recipient follower-journal binding from the authenticated stable profile. The recipient accepts only that matching active lifecycle. Production advertises `queue-handoff-v1` with this exact role/path composition; legacy-unbound or unavailable bindings fail closed. The Steer codec preserves original lane/order, content/media, Keep/Skip and exact source receipts; syntax decoding alone grants no custody. Legacy peers refuse before offer, and every routing/receiver boundary rechecks support. Frozen/attempted packets, Pi-owned pending heads, synthetic packet projections and Steer guests cannot become transferable waiting input. Capability loss before staging cancels only an unaccepted offer; existing exact owner/ACK rules govern lost post-acceptance replies.
291
291
 
292
292
  As part of staging, the exact recipient presents the token and atomically receives a fresh acquisition carrying the handoff digest; donor settlement is then stale, and the donor trusts only an ACK carrying that accepted owner. Registration carries recipient process-birth and session generation so journal authority matches the live runtime exactly. The coordinator contract shares one ordering across leader→follower and follower→follower paths: offer, route bounded payload, require the exact staged-and-accepted receipt-and-owner ACK, remove donor work, then publish recipient dispatch readiness. Negative or mismatched acknowledgement cancels the still-unaccepted offer and retains donor work. A lost acknowledgement after recipient acceptance cannot revoke the accepted owner; cancellation fails closed and donor memory remains frozen for exact accepted-owner reconciliation. Repeated exact acceptance is idempotent; a different token cannot claim the accepted receipt. Queue authority has no elapsed-time lease: only exact handoff, owner action, or transaction-rechecked PID/process-birth death proof may move it.
293
293
 
package/docs/ui-style.md CHANGED
@@ -72,32 +72,33 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
72
72
  | Emoji | Meaning | Canonical surfaces | Notes |
73
73
  | --- | --- | --- | --- |
74
74
  | `🟢` | Current/active/enabled `On` | Current option in vertical lists, active state rows, active `On` toggle | One marker per selected list value; inactive list values stay unmarked. |
75
- | `🟡` | Active `Off` or elevated/filter state | Active `Off` toggle, Priority/Scoped active tab | Yellow means intentionally not-normal or off/default-caution, not error. |
75
+ | `🟡` | Active Steer, `Off`, or filter state | Steer queue selector, active `Off` toggle, Scoped active tab | Yellow distinguishes early insertion or off/filter state, not error. |
76
76
  | `🔴` | Active destructive/deferred disposition | Active queue `Skip` selector | Red distinguishes a prompt that will be discarded at dispatch from reversible neutral or elevated state. |
77
- | `🟣` | Normal/default active tab | Normal priority tab, All/default scope tab, active page picker | Use for neutral active tabs. |
77
+ | `🟣` | Active Priority lane or neutral navigation tab | Priority queue selector, All/default scope tab, active page picker | Queue lane meaning is distinct from neutral navigation. |
78
+ | `🔵` | Active Normal queue lane | Normal queue selector | Blue distinguishes ordinary queued tasks from Steer and Priority. |
78
79
  | `⚫️` | Inactive placeholder | Inactive toggle values and inactive tabs | Keeps row width stable. |
79
80
  | `⬆️` | Navigate upward | `⬆️ Main menu`, `⬆️ Back` | Always first row in submenus. |
80
81
 
81
82
  ### Queue Reaction Shortcuts
82
83
 
83
- Queue reactions are shortcut controls for waiting turns. Preserve their semantics across Telegram reactions, queue-menu rows, status previews, and tests. Positive emoji control the Priority/Normal lane; negative emoji control Keep/Skip. These categories are independent, may coexist, and mutate only their own dimension. Crossing lanes appends the prompt at the destination FIFO tail; changing Keep/Skip or changing emoji within one category preserves lane position. Skip wins only when dispatch reaches the prompt.
84
+ Queue reactions are shortcut controls for waiting turns. Preserve their semantics across Telegram reactions, queue-menu rows, status previews, and tests. Effective positive intent controls Steer/Priority/Normal; negative emoji control Keep/Skip. Fire selects Steer, dominates other positive reactions, and removing it falls back to remaining Priority aliases or Normal. Only changed effective intent overrides a menu choice; decorative changes and weaker positives under fire preserve lane/order. These categories are independent, may coexist, and mutate only their own dimension. Crossing lanes appends the prompt at the destination FIFO tail; changing Keep/Skip or changing emoji within one category preserves lane position. Skip wins only when dispatch reaches the prompt.
84
85
 
85
86
  Skipped queue ordinals strike only the numeric position. Detail HTML closes `<s>` before the period; list-button labels place a hair-space boundary between the combining-struck number and the plain period so Telegram font overhang cannot visually strike punctuation.
86
87
 
87
88
  Queue item detail renders two independent selector rows:
88
89
 
89
- - `🟡 Priority` / `⚫️ Normal` or `⚫️ Priority` / `🟣 Normal` selects the lane.
90
+ - `Normal` / `Priority` / `Steer` selects the lane in one horizontal row, increasing intervention from left to right. Active values are `🔵 Normal`, `🟣 Priority`, or `🟡 Steer`; inactive values use `⚫️`. Control is producer-owned, not a selectable lane.
90
91
  - `🟢 Keep` / `⚫️ Skip` or `⚫️ Keep` / `🔴 Skip` selects dispatch disposition.
91
92
 
92
- The menu may clear internal Skip but cannot remove a reaction created by the user through Telegram's Bot API.
93
+ The menu may clear internal Skip but cannot remove a reaction created by the user through Telegram's Bot API. Frozen attempts and the Pi-owned pending head expose inspection/Back only, with no success or model-use claim. An over-capacity idle Steer item remains editable and offers Priority/Normal rather than splitting its source group. Published legacy Priority/Normal callbacks remain explicit lane selections.
93
94
 
94
95
  | Emoji | Meaning | Canonical surfaces | Notes |
95
96
  | --- | --- | --- | --- |
96
97
  | `👍` | Promote to priority | Queue reaction shortcut | Normalized from variants like `👍️`. |
97
- | `⚡` | Promote to priority / fast lane | Queue reaction shortcut, priority fallback badge | Also used as the default priority badge when no specific priority emoji is stored. |
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. |
98
99
  | `❤` / `❤️` | Promote to priority | Queue reaction shortcut | Normalize display consistently where code normalizes reactions. |
99
100
  | `🕊` / `🕊️` | Promote to priority | Queue reaction shortcut | Soft/peaceful promotion gesture. |
100
- | `🔥` | Promote to priority | Queue reaction shortcut | Urgent/hot promotion gesture. |
101
+ | `🔥` | Select Steer | Queue reaction shortcut and Steer badge | Dominates `👍`, heart and dove; not immediate issuance or model-use proof. |
101
102
  | `👎` | 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. |
102
103
  | `👻` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Disappear/remove metaphor. |
103
104
  | `💔` | Defer removal of waiting turn | Queue reaction shortcut and queue emoji marker | Reversible cancel metaphor. |
@@ -181,15 +182,16 @@ Rules:
181
182
  - Use Capitalized labels.
182
183
  - Always show an indicator on every tab to avoid horizontal label shift.
183
184
  - Use active tab color to convey semantics:
184
- - `🟣` for the default / normal state (All models, Normal priority).
185
- - `🟡` for an elevated or filtered state (Scoped models, Priority).
185
+ - `🟣` for a default navigation scope (All models) or the current Priority lane choice.
186
+ - `🟡` for the current Steer lane choice or a filtered scope (Scoped models).
187
+ - `🔵` for the current Normal queue lane choice, independently of Keep/Skip.
186
188
  - `🟣` for neutral navigation controls (page picker).
187
189
  - Mark inactive tabs with `⚫️`.
188
190
 
189
191
  Examples:
190
192
 
191
193
  - `🟡 Scoped` / `⚫️ All`
192
- - `⚫️ Priority` / `🟣 Normal`
194
+ - `⚫️ Priority` / `🔵 Normal`
193
195
 
194
196
  ## Option Lists
195
197
 
@@ -250,6 +252,8 @@ A message (or All-tab command) held in an unbound or temporary tab gets one rout
250
252
 
251
253
  Pi TUI connection notices use plain text: a short known cause and one recovery action. Unknown failures use a generic connection-failed notice with `/telegram-status --debug`; never interpolate raw exceptions, credentials, stack traces or Pi lifecycle guidance. Technical evidence belongs in the redacted runtime recorder. Failed disconnect must retain the instruction to keep Pi open; do not also rethrow the same error as a second Pi banner. These TUI notices are distinct from Telegram bot message cards below.
252
254
 
255
+ The compact TUI status bar shows one `+N` counter in yellow `warning` for total waiting work across Normal, Priority, Steer and Control, hidden when zero. Custom themes control the exact hue. It excludes the pending dispatched head, Skip and frozen attempts; active work never adds to the counter. Queue state and dispatch precedence are unchanged.
256
+
253
257
  The compact TUI status bar preserves the acknowledged Thread display title and its casing, including manual `/name` overrides. Names equal to `Telegram`, `Leader` or `Follower` are ordinary titles, not fallback markers. Connection states, including disconnected and error, do not replace a supplied title; only an absent or empty title uses the generic `telegram` label.
254
258
 
255
259
  ## Message Cards
package/lib/bindings.ts CHANGED
@@ -209,6 +209,10 @@ export interface TelegramQueueBindingRuntime<TContext> {
209
209
  requestNextDispatchAnnouncement: () => void;
210
210
  cancelNextDispatchAnnouncement: () => void;
211
211
  watchdog: Queue.TelegramQueueDispatchWatchdogRuntime<TContext>;
212
+ onTurnEnd: (
213
+ event: Pick<Pi.TurnEndEvent, "turnIndex" | "outcome">,
214
+ ctx: TContext,
215
+ ) => Pi.TurnEndEventResult | undefined;
212
216
  }
213
217
 
214
218
  export function createTelegramQueueBindingRuntime<TContext>(deps: {
@@ -217,8 +221,8 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
217
221
  lifecycle: Pick<
218
222
  Runtime.TelegramBridgeRuntime["lifecycle"],
219
223
  "isCompactionInProgress" | "hasDispatchPending"
220
- >;
221
- activeTurn: Pick<Queue.TelegramActiveTurnStore, "has">;
224
+ > & Partial<Pick<Runtime.TelegramBridgeRuntime["lifecycle"], "getActiveToolExecutions">>;
225
+ activeTurn: Pick<Queue.TelegramActiveTurnStore, "has"> & Partial<Pick<Queue.TelegramActiveTurnStore, "get">>;
222
226
  admission: {
223
227
  getSettlement: () =>
224
228
  | {
@@ -228,7 +232,7 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
228
232
  ) => boolean;
229
233
  isItemReady: (item: Queue.TelegramQueueItem<TContext>) => boolean;
230
234
  onPromptHandedOff?: (
231
- item: Queue.PendingTelegramTurn,
235
+ item: Pick<Queue.PendingTelegramTurn, "admissionReceipts">,
232
236
  ctx: TContext,
233
237
  ) => boolean;
234
238
  onControlSettled: (
@@ -249,6 +253,8 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
249
253
  promptDispatch: Runtime.TelegramPromptDispatchRuntime<TContext>;
250
254
  isIdle: (ctx: TContext) => boolean;
251
255
  hasPendingMessages: (ctx: TContext) => boolean;
256
+ isContextCurrent?: (ctx: TContext) => boolean;
257
+ isContextAborted?: (ctx: TContext) => boolean;
252
258
  updateStatus: (ctx: TContext, error?: string) => void;
253
259
  sendTextReply: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendTextReply"];
254
260
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
@@ -269,6 +275,10 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
269
275
  if (!settlement) return false;
270
276
  return settlement.onItemsDiscarded(durableItems, ctx) === true;
271
277
  };
278
+ const settlePromptPacket = (items: readonly Queue.PendingTelegramTurn[], ctx: TContext): boolean => {
279
+ const receipts = items.flatMap(item => item.admissionReceipts ?? []);
280
+ return receipts.length === 0 || deps.admission.getSettlement()?.onPromptHandedOff?.({ admissionReceipts: receipts }, ctx) === true;
281
+ };
272
282
  const mutation = Queue.createTelegramQueueMutationController({
273
283
  ...deps.store,
274
284
  hasPendingDispatch: deps.lifecycle.hasDispatchPending,
@@ -309,6 +319,13 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
309
319
  if (!settlement?.onPromptHandedOff) return false;
310
320
  return settlement.onPromptHandedOff(item, ctx) === true;
311
321
  },
322
+ steerDispatch: {
323
+ commitPacket: settlePromptPacket,
324
+ isContextCurrent: (ctx: TContext) => deps.isContextCurrent?.(ctx) === true,
325
+ canSubmit: (ctx: TContext) => deps.isContextAborted?.(ctx) === false &&
326
+ deps.isIdle(ctx) && !deps.hasPendingMessages(ctx) && !deps.lifecycle.isCompactionInProgress() &&
327
+ !deps.activeTurn.has() && (deps.lifecycle.getActiveToolExecutions?.() ?? 0) === 0,
328
+ },
312
329
  onControlSettled(item, ctx) {
313
330
  deps.admission.getSettlement()?.onControlSettled(item, ctx);
314
331
  },
@@ -323,8 +340,32 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
323
340
  ...deps.promptDispatch,
324
341
  sendUserMessage: deps.sendUserMessage,
325
342
  });
343
+ const steer = Queue.createTelegramSteerBoundaryRuntime({
344
+ ...deps.store,
345
+ getGeneration: deps.deferredDispatch.getGeneration,
346
+ isContextCurrent: (ctx: TContext) => deps.isContextCurrent?.(ctx) === true,
347
+ isBlocked: (ctx: TContext) =>
348
+ deps.isContextAborted?.(ctx) !== false || !deps.deferredDispatch.isBound() ||
349
+ deps.isIdle(ctx) || deps.hasPendingMessages(ctx) ||
350
+ deps.lifecycle.isCompactionInProgress() || deps.lifecycle.hasDispatchPending() ||
351
+ (deps.lifecycle.getActiveToolExecutions?.() ?? 0) > 0 ||
352
+ (deps.activeTurn.has() && !deps.activeTurn.get?.()),
353
+ isItemReady(item: Queue.TelegramQueueItem<TContext>) {
354
+ return deps.transportStamp.isActive(item.transportStamp) &&
355
+ !deps.admission.hasPendingQueueMutationForItem(item) &&
356
+ (deps.admission.getSettlement()?.isItemReady(item) ?? (item.admissionReceipts?.length ?? 0) === 0);
357
+ },
358
+ isItemCurrent: (item) => deps.transportStamp.isActive(item.transportStamp) &&
359
+ !deps.admission.hasPendingQueueMutationForItem(item),
360
+ getActiveTurn: () => deps.activeTurn.get?.(),
361
+ commitPacket: settlePromptPacket,
362
+ discardItem: (item, ctx) => settleDiscardedItems([item], ctx),
363
+ updateStatus: deps.updateStatus,
364
+ recordRuntimeEvent: deps.recordRuntimeEvent,
365
+ });
326
366
  return {
327
367
  mutation,
368
+ onTurnEnd: steer.onTurnEnd,
328
369
  dispatchNext: dispatch.dispatchNext,
329
370
  requestNextDispatchAnnouncement: dispatch.requestNextDispatchAnnouncement,
330
371
  cancelNextDispatchAnnouncement: dispatch.cancelNextDispatchAnnouncement,
@@ -1156,6 +1197,7 @@ interface TelegramLifecycleBindingDeps {
1156
1197
  >["sendTextReply"] &
1157
1198
  NonNullable<OutboundHandlers.TelegramVoiceReplySenderDeps["sendTextReply"]>;
1158
1199
  dispatchNextQueuedTelegramTurn: (ctx: Pi.ExtensionContext) => void;
1200
+ onSteerTurnEnd?: TelegramQueueBindingRuntime<Pi.ExtensionContext>["onTurnEnd"];
1159
1201
  onPromptHandedOff?: (
1160
1202
  turn: Queue.PendingTelegramTurn,
1161
1203
  ctx: Pi.ExtensionContext,
@@ -1236,6 +1278,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1236
1278
  sendMarkdownReply,
1237
1279
  sendTextReply,
1238
1280
  dispatchNextQueuedTelegramTurn,
1281
+ onSteerTurnEnd,
1239
1282
  onPromptHandedOff,
1240
1283
  answerGuestQuery,
1241
1284
  deleteMessage,
@@ -1566,6 +1609,12 @@ export function registerTelegramLifecycleRuntimeHooks({
1566
1609
  isSessionActive: isSessionContextActive,
1567
1610
  ...sessionLifecycleRuntime,
1568
1611
  ...agentLifecycleHooks,
1612
+ ...(onSteerTurnEnd ? {
1613
+ onTurnEnd(event: Pi.TurnEndEvent, ctx: Pi.ExtensionContext) {
1614
+ const result = onSteerTurnEnd(event, ctx);
1615
+ return result ? { ...result, entries: [...event.entries, ...(result.entries ?? [])] } : undefined;
1616
+ },
1617
+ } : {}),
1569
1618
  onInput(event) {
1570
1619
  activityRuntime.recordInputSource(event.source ?? "unknown");
1571
1620
  },
@@ -35,6 +35,7 @@ import {
35
35
  getTelegramBusProtocolCompatibility,
36
36
  getTelegramBusSocketPath,
37
37
  hasTelegramBusCapability,
38
+ hasTelegramBusSteerQueueHandoffCapability,
38
39
  isTelegramBusEnvelopeAuthorized,
39
40
  parseTelegramBusEnvelope,
40
41
  resolveTelegramBusSocketPath,
@@ -4752,6 +4753,10 @@ export function createTelegramBusForwardedUpdateReceiverRuntime<TContext>(
4752
4753
  result: receipt,
4753
4754
  };
4754
4755
  }
4756
+ if (envelope.payload.queueLane === "steer" &&
4757
+ ![deps.getLocalProtocol?.(), deps.getLeaderProtocol?.()].every(hasTelegramBusSteerQueueHandoffCapability)) {
4758
+ throw new Error("Steer queue handoff capability was not negotiated.");
4759
+ }
4755
4760
  if (!deps.handleQueueHandoff) {
4756
4761
  throw new Error(
4757
4762
  "Telegram bus receiver cannot accept queue handoff payloads.",
package/lib/bus-leader.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
24
24
  TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION,
25
25
  TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF,
26
+ hasTelegramBusSteerQueueHandoffCapability,
26
27
  TELEGRAM_BUS_CAPABILITY_SELECTED_MENU_DELIVERY,
27
28
  TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
28
29
  TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
@@ -3273,6 +3274,10 @@ export function createTelegramBusLeaderEnvelopeHandler(
3273
3274
  "Telegram queue handoff capability was not negotiated.",
3274
3275
  );
3275
3276
  }
3277
+ if (envelope.payload.queueLane === "steer" &&
3278
+ ![deps.protocolIdentity, donor.protocol, recipient?.protocol].every(hasTelegramBusSteerQueueHandoffCapability)) {
3279
+ return rejectTelegramBusRequest(envelope.requestId, "Steer queue handoff capability was not negotiated.");
3280
+ }
3276
3281
  if (
3277
3282
  !recipient?.registrationGeneration ||
3278
3283
  envelope.recipientRegistrationGeneration !==
@@ -4477,6 +4482,10 @@ export function createTelegramBusLeaderRuntime<TContext>(
4477
4482
  "Telegram queue handoff capability was not negotiated.",
4478
4483
  );
4479
4484
  }
4485
+ if (input.payload.queueLane === "steer" &&
4486
+ ![deps.protocolIdentity, recipient?.protocol].every(hasTelegramBusSteerQueueHandoffCapability)) {
4487
+ return rejectTelegramBusRequest(input.requestId, "Steer queue handoff capability was not negotiated.");
4488
+ }
4480
4489
  if (
4481
4490
  !recipient?.registrationGeneration ||
4482
4491
  input.recipientRegistrationGeneration !== recipient.registrationGeneration
package/lib/bus.ts CHANGED
@@ -142,6 +142,8 @@ export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION =
142
142
  "durable-follower-admission-v1" as const;
143
143
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF =
144
144
  "queue-handoff-v1" as const;
145
+ /** Unspent Steer lane codec; independent from baseline receipt handoff support. */
146
+ export const TELEGRAM_BUS_CAPABILITY_QUEUE_STEER = "queue-steer-v1" as const;
145
147
  export const TELEGRAM_BUS_CAPABILITY_INPUT_CUSTODY_REFERENCE =
146
148
  "input-custody-reference-v1" as const;
147
149
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME =
@@ -461,6 +463,11 @@ export function hasTelegramBusCapability(
461
463
  return identity?.capabilities.includes(capability) ?? false;
462
464
  }
463
465
 
466
+ export function hasTelegramBusSteerQueueHandoffCapability(identity: TelegramBusProtocolIdentity | undefined): boolean {
467
+ return hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF) &&
468
+ hasTelegramBusCapability(identity, TELEGRAM_BUS_CAPABILITY_QUEUE_STEER);
469
+ }
470
+
464
471
  export function getTelegramInputCustodyPeerReadiness(
465
472
  followers: readonly Pick<
466
473
  TelegramBusFollowerView,
package/lib/extension.ts CHANGED
@@ -64,6 +64,7 @@ const telegramBusProtocolIdentity =
64
64
  Bus.createTelegramCurrentBusProtocolIdentity([
65
65
  Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION,
66
66
  Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF,
67
+ Bus.TELEGRAM_BUS_CAPABILITY_QUEUE_STEER,
67
68
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT,
68
69
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
69
70
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
@@ -717,7 +718,6 @@ export default function (pi: Pi.ExtensionAPI) {
717
718
  },
718
719
  getQueuedItems: telegramQueueStore.getQueuedItems,
719
720
  getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
720
- formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
721
721
  getRecentRuntimeEvents: runtimeEvents.getEvents,
722
722
  getRuntimeLockState: lockRuntime.getStatusLabel,
723
723
  ...threadStatusProjectionRuntime,
@@ -1026,6 +1026,7 @@ export default function (pi: Pi.ExtensionAPI) {
1026
1026
  requestNextDispatchAnnouncement,
1027
1027
  cancelNextDispatchAnnouncement,
1028
1028
  watchdog: queueDispatchWatchdogRuntime,
1029
+ onTurnEnd: onSteerTurnEnd,
1029
1030
  } = Bindings.createTelegramQueueBindingRuntime({
1030
1031
  store: telegramQueueStore,
1031
1032
  queue,
@@ -1037,6 +1038,8 @@ export default function (pi: Pi.ExtensionAPI) {
1037
1038
  promptDispatch: promptDispatchRuntime,
1038
1039
  isIdle,
1039
1040
  hasPendingMessages,
1041
+ isContextCurrent: telegramSessionContextStore.isCurrent,
1042
+ isContextAborted: Pi.isExtensionContextAbortRequested,
1040
1043
  updateStatus,
1041
1044
  sendTextReply,
1042
1045
  sendUserMessage,
@@ -1123,6 +1126,7 @@ export default function (pi: Pi.ExtensionAPI) {
1123
1126
 
1124
1127
  // --- Queue And Settings Menus ---
1125
1128
  const queueMenuRuntime = MenuQueue.createTelegramQueueMenuRuntime({
1129
+ hasPendingDispatch: lifecycle.hasDispatchPending,
1126
1130
  telegramQueueStore,
1127
1131
  queueMutationRuntime,
1128
1132
  sendInteractiveMessage,
@@ -2331,6 +2335,14 @@ export default function (pi: Pi.ExtensionAPI) {
2331
2335
  });
2332
2336
  queueHandoffReconciliationBinding.set(
2333
2337
  Updates.createTelegramQueueHandoffReconciliationRuntimeAssembly({
2338
+ hasPendingDispatch: lifecycle.hasDispatchPending,
2339
+ canHandoffSteerWithRecipient(recipient) {
2340
+ const live = telegramBusFollowerRegistry.get(recipient.instanceId);
2341
+ return live?.registrationGeneration === recipient.registrationGeneration &&
2342
+ [telegramBusProtocolIdentity, live?.protocol,
2343
+ ...(telegramBusFollowerRegistrationState.isRegistered() ? [telegramBusFollowerRegistrationState.getLeaderProtocol()] : [])]
2344
+ .every(Bus.hasTelegramBusSteerQueueHandoffCapability);
2345
+ },
2334
2346
  ownsDirect: lockRuntime.owns,
2335
2347
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
2336
2348
  isBusEnabled: isTelegramBusRuntimeEnabled,
@@ -3046,6 +3058,7 @@ export default function (pi: Pi.ExtensionAPI) {
3046
3058
  sendMarkdownReply,
3047
3059
  sendTextReply,
3048
3060
  dispatchNextQueuedTelegramTurn,
3061
+ onSteerTurnEnd,
3049
3062
  onPromptHandedOff(turn, ctx) {
3050
3063
  updateAdmissionRuntimeBinding
3051
3064
  .getSettlement()