@llblab/pi-telegram 0.55.0 → 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.
- package/AGENTS.md +3 -2
- package/BACKLOG.md +4 -1
- package/CHANGELOG.md +14 -0
- package/README.md +27 -18
- package/dist/lib/bindings.d.ts +8 -4
- package/dist/lib/bindings.js +41 -1
- package/dist/lib/bus-follower.d.ts +1 -0
- package/dist/lib/bus-follower.js +5 -1
- package/dist/lib/bus-leader.js +9 -1
- package/dist/lib/bus.d.ts +5 -0
- package/dist/lib/bus.js +17 -6
- package/dist/lib/extension.js +14 -2
- package/dist/lib/lifecycle.d.ts +2 -1
- package/dist/lib/lifecycle.js +28 -20
- package/dist/lib/menu-queue.d.ts +1 -0
- package/dist/lib/menu-queue.js +102 -99
- package/dist/lib/pi.d.ts +3 -2
- package/dist/lib/pi.js +3 -0
- package/dist/lib/polling.js +3 -1
- package/dist/lib/queue.d.ts +57 -5
- package/dist/lib/queue.js +313 -25
- package/dist/lib/routing.d.ts +1 -1
- package/dist/lib/routing.js +261 -29
- package/dist/lib/status.d.ts +1 -2
- package/dist/lib/status.js +6 -7
- package/dist/lib/turns.d.ts +4 -0
- package/dist/lib/turns.js +6 -0
- package/dist/lib/updates.d.ts +10 -6
- package/dist/lib/updates.js +20 -4
- package/dist/package.json +1 -1
- package/docs/README.md +1 -1
- package/docs/architecture.md +17 -14
- package/docs/callback-namespaces.md +4 -0
- package/docs/multi-instance-bus.md +4 -2
- package/docs/ui-style.md +14 -10
- package/lib/bindings.ts +52 -3
- package/lib/bus-follower.ts +5 -0
- package/lib/bus-leader.ts +9 -0
- package/lib/bus.ts +20 -3
- package/lib/extension.ts +14 -1
- package/lib/lifecycle.ts +34 -28
- package/lib/menu-queue.ts +116 -162
- package/lib/pi.ts +8 -0
- package/lib/polling.ts +3 -1
- package/lib/queue.ts +328 -33
- package/lib/routing.ts +299 -29
- package/lib/status.ts +7 -7
- package/lib/turns.ts +7 -0
- package/lib/updates.ts +24 -10
- package/package.json +1 -1
package/dist/lib/updates.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
const
|
|
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
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/
|
|
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
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
|
@@ -477,10 +477,12 @@ With confirmed Threaded Mode, live bindings or a retained temporary entry, a kno
|
|
|
477
477
|
|
|
478
478
|
#### Temporary Thread Multi-Input Lifecycle (Approved Design)
|
|
479
479
|
|
|
480
|
-
Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. Mobile clients sending from `All` first create an implicitly named native tab and then deliver the input without a thread ID, although the client shows it inside that tab: an `All` input whose text starts with the name of such an observation (mobile names the tab after the input), or whose update immediately follows it (generic names such as desktop's `New Chat`), from the same exact scope and created at most ten seconds earlier, registers that native tab as its temporary tab through `registerImplicitTemporaryThread` instead of creating a second one, and renames it once to the routing name. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. Every kind of input sent from All takes this path: a lone text message may carry a command, while a voice note, file or sticker is held untouched as a prompt, and an album first gathers through the shared pending-group machine into one input whose whole update group the tab records as its creating input (`reserveTemporaryThread` with a group starting at the source update). All is input-only: no assistant answer, chooser, fallback guidance, draft or activity is emitted there. A failed routing-tab creation retains the source, including a coalesced album, rather than silently settling it. A stale Thread does not prove Classic Mode. The transport/API fence rejects private output without a valid top-level Thread, including retained aggregate markers; Classic DM and explicit channel/business namespaces remain separate. No historical All-output cleanup is scheduled. The Bot API files every threadless All input in All, a desktop menu-picked command as well as a mobile input its client first draws inside its new native tab. So a tab opened for such an input (bot-created or adopted, never on restart re-entry) first receives one silent best-effort `forwardMessage` of it, its chooser replies to that copy, and only a confirmed copy lets the bot delete the All original at once: the input moves into its tab on every client. A failed forward keeps the original in All; it is then deleted best-effort when the input is consumed by Forward, Cancel or expiry, which remove the tab too, but never by Restore, which keeps the tab as the Thread and must not leave it without its input. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
|
|
480
|
+
Every temporary routing tab carries one fixed routing name ([copy](./ui-style.md#route-chooser)), never its first input, because later prompts may join it: the bot creates its own tabs with that name and renames a native tab once, best-effort, when it adopts it. Commands and prompts share the same mode chooser. A command delivered with a Telegram-provided unbound private target uses the same chooser and fresh exact-source Cancel capability in that tab, without renaming it or creating another one. Mobile clients sending from `All` first create an implicitly named native tab and then deliver the input without a thread ID, although the client shows it inside that tab: an `All` input whose text starts with the name of such an observation (mobile names the tab after the input), or whose update immediately follows it (generic names such as desktop's `New Chat`), from the same exact scope and created at most ten seconds earlier, registers that native tab as its temporary tab through `registerImplicitTemporaryThread` instead of creating a second one, and renames it once to the routing name. A received target or successful rename alone never manufactures temporary authority. A fresh owner-authenticated private `forum_topic_created` with literal `is_name_implicit: true` supplies native creation evidence: routing retains a bounded process-local observation tied to the exact context, session generation, admission scope, operator, journal binding and executor. The next live input group in that exact target, of any content kind and either one message or an album, registers it through Threads `registerImplicitTemporaryThread` under profile admission, with that group as the entry's first input (its smallest update ID is the source), without calling or fabricating `createForumTopic` (adoption renames it once, best-effort); the publication CAS refuses any binding, owner record, reservation, provisioning, cleanup or Restore conflict. Normal grouped membership, one-shot Forward, source disposition, last-Cancel quiet period and cleanup issuance then apply identically. Historical creation signals, context/epoch changes, manual names, string flags, foreign creators and missing observations grant nothing. The observation is consumed on registration, never reconstructed from a title or restart. Existing bindings/reservations and acknowledged temporary targets retain their protections. Their root chooser offers Reroute, Restore and eligible Cancel; each mode opens its own submenu of live thread targets without dispatching the held input. A submenu Back returns to the mode chooser with its exact original description, retained on that pending chooser; navigation never substitutes or recomputes the root copy. Copy, emoji and button layout live in [UI Style](./ui-style.md#route-chooser). Cancel appears only at the root. After cancellation, the immediate cleanup remains proof-gated. Private-chat temporary tabs skip the unsupported `closeForumTopic` call and issue only one `deleteForumTopic` attempt; literal positive deletion acknowledgement is still required, and uncertainty never authorizes a retry. Every kind of input sent from All takes this path: a lone text message may carry a command, while a voice note, file or sticker is held untouched as a prompt, and an album first gathers through the shared pending-group machine into one input whose whole update group the tab records as its creating input (`reserveTemporaryThread` with a group starting at the source update). All is input-only: no assistant answer, chooser, fallback guidance, draft or activity is emitted there. A failed routing-tab creation retains the source, including a coalesced album, rather than silently settling it. A stale Thread does not prove Classic Mode. The transport/API fence rejects private output without a valid top-level Thread, including retained aggregate markers; Classic DM and explicit channel/business namespaces remain separate. No historical All-output cleanup is scheduled. The Bot API files every threadless All input in All, a desktop menu-picked command as well as a mobile input its client first draws inside its new native tab. So a tab opened for such an input (bot-created or adopted, never on restart re-entry) first receives one silent best-effort `forwardMessage` of it, its chooser replies to that copy, and only a confirmed copy lets the bot delete the All original at once: the input moves into its tab on every client. That opening forward has one unretried, generation-fenced API attempt. Only a complete set of distinct positive safe-integer copy IDs acknowledged under current authority permits removal of the All originals. The live chooser retains the exact target and full source/copy ID mapping as display provenance, not input acceptance or cleanup authority; this warm mapping is not a cold-recovery grant and never rewrites journal originals. Temporary Reroute resolves its forwarding addresses from fresh worker-owned original snapshots before selecting the destination: exact source binding/update, paired sender, bot-chat message namespace and positive unique original IDs must all match. Complete warm copies take precedence; original All IDs may be reused only when this live opening attempt is known not to have removed them. A revived chooser cannot reconstruct that fact from journal IDs or its saved location. Unknown cold All copies, unavailable readers, partial group proof and synthetic zero IDs refuse Reroute before native issuance, leaving the source waiting with its exact Cancel capability; actual native-Thread originals may still be recovered. Warm target projection is not proof of an original message location. Restore retains its separate held-input path. Ended authority stops subsequent original-removal delegation and chooser publication; already issued requests cannot be unsent. A failed forward keeps the original in All; it is then deleted best-effort when the input is consumed by Forward, Cancel or expiry, which remove the tab too, but never by Restore, which keeps the tab as the Thread and must not leave it without its input. Failure is recorded and never retried. Pending-cancellation recovery deletes nothing, and typed All inputs already live in their own tab and leave with it.
|
|
481
481
|
|
|
482
482
|
This multi-input lifecycle is implemented in the local candidate, not authority for live deletion. It supersedes unconditional per-chooser tab removal. The reject-only guard prevents cleanup/retirement while another known same-tab chooser remains, including unresolved cleanup-only work. `temporaryThreads.inputs` now records bounded append-only journal source groups through the unbound producer before chooser publication; duplicates are read-only, partial/foreign/overlapping groups refuse, and faults retain the source without a new chooser. Cold reads preserve this protection without process-local chooser memory. A group has no readiness or disposition flag, and an absent legacy field does not prove complete coverage. `cancelledInputs` records exact whole known donor groups after either same-journal/operator retained-cancellation evidence or the separate body-free expiry tombstone. Expiry recording may clear a group's unknown Forward issuance or its expired Restore intent, but never mutates a canonical binding, recipient queue or immutable scoped settlement proof. If Restore already bound the tab, expiry releases the temporary frame without deletion. Otherwise fully resolved groups enter the existing one-second quiet-period cleanup. A partially acknowledged known cohort may finish donor disposition when every member is absent from that exact source journal and at least one member has same-operator expiry proof; this is not recipient completion or non-delivery proof. Fresh strict namespace/protection evidence still gates deletion; independent work and bound Threads remain protected. Store publication and returned/retained acknowledgement recheck that proof and operator/executor authority; a lost reply reconciles the exact fact read-only, while missing/foreign/corrupt proof stays protective. The producer composes this into explicit Cancel before temporary cleanup or chooser retirement. Cancelled groups cannot Restore; independent sources remain untouched. More than one known group still blocks cleanup and raw retirement, including all-cancelled membership: these facts grant no deletion. Each successful Cancel retires only its own chooser. If durable membership is fully resolved by exact cancellation/completion facts and no chooser remains, a process-local cleanup is queued with no grace delay (it still waits behind the resolving operation's Workspace admission, which Cancel releases right after its notice, answering the toast and deleting any All copy alongside the cleanup; new input in the tab before it runs cancels it). Its single attempt, under profile admission, rechecks authority, whole-group cancellation/expiry evidence, exact entry CAS, a strict per-Thread journal census (every namespace source must read strictly; any retained update naming the Thread in any state, any of its own groups, and any custody-bearing, failed or non-plain entry anywhere still protect; only an unrelated waiting plain message, edit, callback or reaction elsewhere is exempt, so a chooser held in another tab cannot pin every temporary tab, and so is a plain button tap in this tab, since it carries no input and the in-flight Cancel itself may outlast the quiet period), absence of choosers and the normal reconciler protections. `issueTemporaryThreadCleanup` publishes canonical `cleanupIssued: true` only for an exact fully resolved entry with strict unbound canonical evidence checked inside the publication transaction; the normal target/source guards precede issuance and run again at API boundaries. The reconciler requires that exact retained issued frame and limits its plan to this tab; unrelated expired-provision cleanup never borrows the grant. Close/delete calls disable retries and transport fallback and require a positive acknowledgement. Confirmed current deletion alone permits entry retirement; skip, negative/lost reply, authority loss or post-rename publication interruption retains the marker. Cold reads classify already-issued entries as unknown, new membership refuses, and executor adoption never renews the grant. Missing evidence only refuses; an unpublished grant issues no API call and may still receive its first grant. The quiet period remains process-local, but issued uncertainty survives restart. The census cannot see updates in transit before journal append. Restore from a multi-input tab is allowed only when each other known group is durably cancelled, Forward-completed or a live unselected chooser that supports private abandonment. A previously completed Forward never supplies a new selection or cancellation grant. After positive settlement of every selected source, such siblings are abandoned with cancellation facts and their controls edited to cancelled, then `retireTemporaryThread(expected, authority, completed)` releases the entry (one newly completed selected group, all others durably cancelled or previously Forward-completed). Missing authority/proof or an unproven Restore leaves siblings pending and protected. On startup, a retained journal input recorded in a created temporary tab whose target a Workspace binding now owns is held as historical, never rerouted; an exact saved chooser clock may expire its unclaimed donor attempt without affecting the binding. Live pending sources and due retry-wait sources (including startup retries) use the same fresh membership/binding predicate through `shouldHoldPendingInput` before execution, with source/context/binding/transport checks after classification awaits. The hold grants neither cancellation nor delivery and does not block unrelated inputs. Recipient custody never uses this direct-source classifier. A native production witness admits a recorded command after the worker has already processed an unrelated command, proving it cannot fall through to ordinary bound dispatch. A production retry witness keeps recorded failure metadata intact without command execution, cancellation or deletion. Claimed retries cannot schedule repeated due-time wakes; independent due and future retries remain executable. Real-IPC Forward/Restore sibling witnesses now include recipient journal drain through the real worker and follower router: only the selected message executes, source disposal is observed, and repeated controls do not replay the command. Existing message-ownership composition keeps a locally published chooser's callbacks at its leader publisher after follower rebinding; the native fixture now records publication ownership rather than relying only on target ownership. Four native delivery-reply-loss witnesses cover both routing actions (Forward/Restore), before recipient execution and after worker disposal: the leader source remains unresolved without ACK, independent sibling Cancel preserves the selected group/tab, and a late reply or repeated issued Restore click cannot settle/replay it. Explicit Forward retry after a lost ACK reproduced command duplication when the recipient worker had already disposed its journal record; stable delivery identity alone was not terminal deduplication. The follower's bounded process-local delivery window now acknowledges such a retry without re-execution while it retains that delivery. Ordinary Forward from a temporary tab first publishes a durable per-group `forwardedInputs` issuance fact in the canonical Threads owner under current authority, before either local command handling/prompt preparation/queue admission or follower RPC. Only positively unpublished issuance may receive its first grant; a published fact (even if its reply is ambiguous) is never retried, including after reload, restart or leader replacement. Local repeated selection refuses the retained grant before handler or queue re-entry. Captured All-tab provenance and acknowledged typed-input membership remain protective if the metadata reader disappears; unavailable evidence never falls back to unguarded generic dispatch. Restore retains its separate canonical routing grant. Native local fixtures interrupt source completion before/after commit, prompt preparation and issuance publication/authority; issued sources stay nonterminal, independent siblings remain cancellable and cold restart never manufactures completion or another attempt. A process-local `forward-unknown` chooser phase gates the live chooser. An issued group is refused by repeat Forward, explicit Cancel and Restore (the store also rejects them); before its fixed chooser deadline it keeps sibling accounting protective, while body-free donor expiry or positive `completedInputs` may resolve it; independent siblings remain operable and a restarted chooser cannot resend. Issuance is not proof of delivery, and recipient-side deduplication is limited to the bounded process-local delivery window; the chooser deadline supplies a loss-accepting exit for unclaimed unknown issued groups; generic journal forwarding and non-chooser retry policy are unchanged. Canonical Restore keeps its existing durable grants. Mixed native witnesses prove that a completed Forward allows Restore of another group without replay/recancellation, while an unknown Forward prevents sibling Restore before an intent is created. Prompt Restore leaves a command sibling pending at queue admission, then privately cancels it only after exact scoped worker receipt disposition. Independently Forwarded queued input is neither unassigned nor cancellation-capable: either receipt may dispose first, the other retains custody, and only both positive dispositions release temporary membership while keeping the bound tab/slot. An ordinary queued ACK emits the existing source-completion hint only after whole acknowledged removal and fresh context, owner, generation and originating journal-binding checks; it creates no immutable Restore marker. A published independent Forward group fact wakes pure inspection of already settled Restore only after its Workspace admission releases. The store refuses both Forward facts and cancellation for any source overlapping a retained Restore grant, regardless of target; malformed partial selected groups stay protective. Native ordinary prompt Forward plus sibling Cancel also delays deletion until receipt disposition and fresh all-resolved cleanup. Pi handoff is supplied by the fixture, not observed model/task completion. The retained All-command fixture registers 84 scenarios across creation, Forward, Cancel, cleanup/membership and Restore, including full slots, publication faults, sibling protection, native Unix IPC and recipient worker drain through the real non-reentrant Workspace gate. Full-slot follower cases hold a real Unix response before apply, after apply, and after read-only inspection until client timeout. Fresh same-session registration/context generations reconcile only through inspection; late replies never publish readiness, repeat apply or dispatch, cancel siblings, release independent queued recipient work or delete the tab. Successful continuation retains exact scoped source-disposition proof. A separate cold-successor witness loads fresh stores and a journal binding after an issued command lost acceptance authority, then starts a fresh context/generation/epoch under supplied same-session owner publication. Original command and sibling stay pending, spent routing/canonical facts remain unchanged, and stale Restore/Forward/Cancel controls cannot replay or dispose them. No chooser is reconstructed: the protective hold is proven, not a usable cold recovery surface. Pi effects and startup registration remain supplied adapters/preconditions; actual Pi startup and live behavior were accepted in the operator's 0.52.0 live smoke. Forward completes only its own input: no Forward removes the tab, the worker's completion ACK records a durable `completedInputs` group (process-local accumulation across the group's sources), and when every known group is cancelled or completed the delayed, fully rechecked cleanup removes the tab once. Cancelled and completed groups are disjoint, cannot Restore, and a completed Forward queued prompt holds the tab until its receipt completes. The tab chooser discloses removal only when no other input remains; a failed removal no longer edits the original chooser. Source-only prompt Cancel remains unchanged. The candidate's temporary-tab chooser is the default under the owned leader epoch, not a configurable feature flag. Upgrade all prospective canonical readers/writers before live activation; the metadata (including `forwardProtocol`, `forwardedInputs` and `cleanupIssued`) is not downgrade-safe through older snapshot writers, which could drop issuance facts.
|
|
483
483
|
|
|
484
|
+
Temporary Reroute publishes the prompt in its selected destination before local queue/command admission or follower IPC. The existing exact whole-group `forwardedInputs` grant precedes this one unretried Bot API request as well as native dispatch; complete distinct positive returned IDs provide display/reply anchors, never source acceptance or settlement. `forwardMessages` preserves albums and requires ascending source IDs, so the warm map correlates each destination ID with its immutable original source update ID rather than a routed zero ID or array position. Acknowledged routing copies are preferred; a proven still-retained All original or real native-Thread original may supply the source instead. Before issuance and after awaits, guards recheck source/executor/operator/context/generation/profile, exact issued group, live recipient record and Workspace session binding; followers also retain their exact registration/protocol ownership. API and IPC callbacks stay local. Reroute passes recipient/session authority plus each original execution fence to the existing socket connect/write/ACK checks, with one guarded transport attempt; a stale post-write ACK cannot settle donor custody or undo accepted recipient work. The input's warm projection uses the confirmed copy ID and clears former-tab reply suppression before carrying the original execution fence; donor journal originals remain untouched. A partial/malformed/lost API reply or revoked authority keeps the issued group nonterminal with no output or native retry, even after restart. A lost native IPC ACK similarly retains donor authority and cannot replay an already accepted recipient input or its display copies. Local receipt disposal and exact whole-group foreign source-removal ACKs still own temporary cleanup; neither a visible copy nor model completion grants deletion. A follower donor ACK may precede recipient receipt disposal, so a fully resolved but protected, unissued tab retains one metadata-only one-second recheck and accepts existing session-delivery drain wakes. Every attempt reacquires profile admission and fresh namespace/binding/source/authority proof; time is not a deletion grant. A fresh runtime drain can reobserve retained completed membership without replaying its input. Issued or unknown deletion never repeats. Restore's held-input sequencing and cleanup schedules are unchanged.
|
|
485
|
+
|
|
484
486
|
- `Membership`: A positively source-linked disposable temporary Thread owns no Pi binding or A–Z slot and may contain multiple independent unassigned inputs, each with its own routing controls. Track exact journal source/group membership, including additional inputs, rather than treating the first chooser or a process-local count as the whole Thread. Serialize admission, selection, cancellation, Restore and cleanup through the existing Workspace admission and mutation owners; unreadable, incomplete, selected, accepted, running or unknown-issued evidence blocks deletion.
|
|
485
487
|
- `Per-input Cancel`: Privately retain the exact original and positively commit source-only cancellation before acknowledging `Routing cancelled` and deleting its one-shot chooser message. Other unassigned inputs retain their choosers and custody; cancelling one input neither delivers nor cancels siblings. No separate Cancel-and-delete action or extra confirmation dialog is required, but the chooser must disclose the conditional whole-tab deletion consequence.
|
|
486
488
|
- `Last-resolution cleanup`: Only an explicit Cancel or positive completion of an explicitly issued Forward that resolves the final known group may schedule deletion of the entire still-temporary Telegram tab, including all its messages. The operator-approved grace is now zero, superseding the earlier 1-second and 2–3-second designs: deletion is attempted right after the chooser is deleted. For Last Cancel, scheduling follows positively recorded whole-group cancellation and successful retirement of the final chooser, not the initial button tap. The timer establishes earliest eligibility for one proof-gated cleanup attempt, not an exact deletion time: Workspace admission, fresh protection checks and Bot API latency may delay or refuse it. Actual client timing was accepted in the operator's 0.52.0 live smoke. Mirrored native witnesses use shortened injected delays, not a production-default or Telegram-client timing measurement. This grace is presentation timing, not proof of emptiness or non-delivery. New input or a changed binding/authority cancels the scheduled cleanup. Immediately before issuance, reacquire current Workspace admission, recheck exact operator/profile/session/leader/source/target authority, positive disposable-tab provenance, complete source dispositions and all Thread protection, then publish the canonical one-shot temporary cleanup grant. A restored/bound Thread or any unresolved/unverifiable work remains protected. TTL, startup, silence and a zero chooser count do not manufacture this last-Cancel grant. Failed/unknown deletion cannot undo cancellation or cause automatic repeat issuance.
|
|
@@ -575,35 +577,36 @@ Long-text split recovery remains conservative: only human text at or above the n
|
|
|
575
577
|
|
|
576
578
|
### Queue And Dispatch Safety
|
|
577
579
|
|
|
578
|
-
The bridge keeps its own Telegram queue. The Pi status bar
|
|
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.
|
|
579
581
|
|
|
580
582
|
Queue items have two explicit dimensions:
|
|
581
583
|
|
|
582
584
|
- `kind`: `prompt` or `control`.
|
|
583
|
-
- `queueLane`: `control`, `priority`, or `default`.
|
|
585
|
+
- `queueLane`: `control`, `steer`, `priority`, or `default`.
|
|
584
586
|
|
|
585
|
-
Dispatch rank:
|
|
587
|
+
Dispatch rank (distinct from the ascending menu selector order):
|
|
586
588
|
|
|
587
589
|
1. `control` lane.
|
|
588
|
-
2. `
|
|
589
|
-
3. `
|
|
590
|
+
2. `steer` prompt lane, using its guarded active-boundary or idle-packet attempt.
|
|
591
|
+
3. `priority` prompt lane.
|
|
592
|
+
4. `default` prompt lane.
|
|
590
593
|
|
|
591
594
|
#### Priority, Reactions, Keep, and Skip
|
|
592
595
|
|
|
593
|
-
Waiting
|
|
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:
|
|
594
597
|
|
|
595
|
-
- `Priority` / `
|
|
598
|
+
- `Normal` / `Priority` / `Steer` selects the FIFO lane and scheduling opportunity in increasing intervention order. Control remains producer-owned.
|
|
596
599
|
- `Keep` / `Skip` selects whether the prompt executes when dispatch reaches it.
|
|
597
600
|
|
|
598
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.
|
|
599
602
|
|
|
600
|
-
Telegram reactions are shortcut controls over those dimensions.
|
|
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.
|
|
601
604
|
|
|
602
|
-
Priority and Skip may coexist when a prompt carries both
|
|
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.
|
|
603
606
|
|
|
604
|
-
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
|
|
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.
|
|
605
608
|
|
|
606
|
-
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.
|
|
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.
|
|
607
610
|
|
|
608
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.
|
|
609
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
|
|
|
@@ -281,11 +281,13 @@ Negotiated identities remain on the live follower registry and appear in `/teleg
|
|
|
281
281
|
|
|
282
282
|
Foreign update forwarding returns an explicit `accepted`, `retryable`, or `terminal-rejected` settlement. Acceptance requires an acknowledgement for the exact request whose receipt contains the expected stable `deliveryId` and source `update_id`; a callback error popup never substitutes for that receipt. Missing or negative acknowledgements, stale registrations, absent follower context, binding rejection, journal admission failure, and missing or mismatched receipts all retain the leader source. Message, edited-message, reaction, and callback paths share this contract. The follower remembers each admitted `deliveryId` in a process-local window (24 hours, at most 4,096 deliveries); a repeated delivery inside it, including after its journal entry completed and was removed, is acknowledged with the same receipt without another append or handler execution. This absorbs a leader retry after a lost acknowledgement; it is bounded at-most-once behavior, not exactly-once across recipient restart or window eviction.
|
|
283
283
|
|
|
284
|
+
Foreign message forwards may carry one process-local `assertAuthority` callback; it is never serialized into the envelope or recipient journal. Guarded Reroute supplies both its captured source/context/session/recipient authority and each original message's execution fence. The existing local client rechecks them before connecting, immediately before the encoded socket write and after reading the ACK; the forwarder also revalidates exact live ownership at those boundaries. A guarded send has one transport attempt with no reconnect replay, while unguarded generic forwarding retains its established retry policy. Before-write refusal issues nothing; after-write refusal cannot unsend recipient work and retains donor custody without accepting the stale ACK. Wire protocol, stable delivery/source identity and recipient receipt validation are unchanged.
|
|
285
|
+
|
|
284
286
|
The delivery id excludes the replaceable runtime instance and registration generation: it derives from envelope kind, source `update_id`, and the stable manual-follower binding. Stored message ownership carries that binding and may rebind to the current authenticated registration after follower replacement, preserving one retry identity while still fencing each attempt by the current generation. A lost acknowledgement retries with the same delivery identity and becomes accepted only when the exact durable receipt returns. Journal deduplication covers retained entries, not lifetime completion history: after ordinary receipt completion removes an entry, cursorless admission can accept that source again. Stable delivery IDs therefore do not by themselves establish exactly-once execution. Owned local IPC evidence reproduces the gap: a proxy drops actual ACK bytes; real forwarding, authenticated receiving, paired admission, journals and admission workers leave the leader source in retry-wait after follower completion. The next attempt reaches an injected authorized callback handler twice in total, then both journals settle. A queued-message variant likewise re-admits the source under a fresh acquisition after a real worker receipt handoff. This proves repeated handler/queue-admission invocation in the composed local pipeline, not actual Pi queue dispatch, live Telegram effects or model execution. Delivery custody, including role-dependent local replay, must be reconciled before a consume-once guarantee can be claimed; the [design counterexamples](./architecture.md#busjournal-design-acceptance) rule out treating origin disappearance as proof of a specific handoff.
|
|
285
287
|
|
|
286
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.
|
|
287
289
|
|
|
288
|
-
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.
|
|
289
291
|
|
|
290
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.
|
|
291
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
|
|
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
|
-
| `🟣` |
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
| `⚡` |
|
|
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
|
-
| `🔥` |
|
|
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
|
|
185
|
-
- `🟡` for
|
|
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` /
|
|
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
|
},
|
package/lib/bus-follower.ts
CHANGED
|
@@ -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.",
|