@llblab/pi-telegram 0.45.11 → 0.46.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/BACKLOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  _This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
4
4
 
5
+ - [ ] `Session-aware Thread binding` (`next minor`, primary outcome): Replace profile-scoped exact-`cwd` reuse as the sole Workspace identity with a durable composite of normalized exact `cwd` and Pi's stable public session identity. `/telegram-connect` must persist that session-to-Thread binding so reopening, resuming, or continuing the same Pi session automatically restores the same Telegram Thread and letter slot when the retained binding has not been validly displaced. Different sessions in one directory must receive distinct live slots, while the same session must not allocate a duplicate after process restart. Session number/name are bounded lookup or presentation aliases only, never routing authority. Preserve authenticated live-owner, accepted-work, displacement, retirement, and slot-pressure fences. This is the principal deliverable of the next minor release.
6
+ - [x] `Identity and migration contract`: [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md#approved-next-contract-session-aware-workspace-identity) now fixes the public session source, composite authority, binding-key derivation, lifecycle semantics, exact legacy `{ cwd, ∅ }` coexistence, fail-closed malformed present identity behavior, mixed-version fence, and non-ownership boundaries.
7
+ - [x] `Store and allocation foundation`: Session-qualified identity, persistence, independent same-cwd claims/slots, strict digest validation, cleanup and retirement snapshots, and exact legacy cwd-only coexistence are locally implemented. The former staged-adoption claim state and commit exceptions have been removed, so no latent mutation path can consume a cwd-only binding. Absence of session identity remains its own key across reload and stale status persistence; same-session restore retains its letter while another session cannot reuse it.
8
+ - [x] `Leader/follower lifecycle wiring`: Leader assembly now reads public `ctx.sessionManager.getSessionId()`, passes it through sync provisioning, and restores the matching session-qualified Workspace without creating a duplicate Thread; cwd-only legacy state remains an independent `{ cwd, ∅ }` identity and is never exposed through a session lookup. Authenticated follower registration now carries a strict bounded session ID over the bus, preserves it in the live registry, and passes it into leader-owned provisioning; fresh same-cwd follower sessions therefore commit distinct session-qualified bindings. Production follower assembly sources the value from the current Pi context, independently of process instance and session generation. Follower-to-leader promotion now resolves the exact current session binding and preserves its target, slot, generated name, and acknowledged display title through leader handoff. Startup restore and `/telegram-connect` share the session-bearing registration envelope and leader provisioner. Protocol v2 makes session identity mandatory for every cwd-scoped participant and rejects protocol-v1 0.45.x peers before provisioning; the supported upgrade path stops all instances, updates them together, then starts a fresh session-native leader. Session-aware stale-target replacement is now regression-covered: replacing session A updates only A's binding and leaves same-cwd session B untouched. Capability rejection is covered independently from the older durable-admission gate. Startup restore eligibility now checks the current session exactly and never treats a legacy cwd-only binding as that session, so neither a sibling nor `{ cwd, ∅ }` can make a fresh session auto-connect. Explicit `/telegram-connect`, restore-only registration, and reconnect handoff all use the same session-bearing registration runtime; focused tests cover restore-only and handoff transmission. The production integration harness now supplies a distinct stable `sessionManager.getSessionId()` per mocked context, and the lifecycle/cleanup integration regression passes with session-aware leader provisioning. Legacy `{ cwd, ∅ }` records remain readable but inert and independent: they are never adopted, migrated, or used for a v2 session. Full-suite closure is green: the bundled Show Me wording is compressed within its disclosure budget, integration contexts preserve one session manager across idle/active phases, and the complete test command passes.
9
+ - [ ] `Compatibility and acceptance` (`local-actionable` plus live gate, current): Exact lookup now treats both presence and absence of session ID symmetrically: `{ cwd, ∅ }` cannot match a session binding, and `{ cwd, session }` cannot match legacy. Focused store, follower, leader, and polling suites pass across coexistence, restore-only, same-session process replacement, follower reload/handoff, distinct same-cwd session allocation (the `/fork`/`/new` identity effect), promotion, session-isolated rename, stale-target replacement, malformed identity, and rolling-upgrade capability behavior. Full slot-pressure retirement now selects the oldest eligible exact binding across legacy and session-qualified identities and persists the victim's session component; protection and exact-intent fences remain unchanged. The Pi adapter now has explicit evidence that restart/reload/resume/continue-recent aliases do not influence authority: only the public stable session ID is read, while a forked ID remains distinct. Local acceptance is complete: generation-fenced stale ownership cannot mutate a replacement; target displacement is exact-session isolated; strict legacy and session-qualified snapshots coexist without aliasing or stale resurrection; protocol-v1 peers are explicitly rejected, while all protocol-v2 cwd participants use session-qualified identity. Update the current-contract docs only with locally validated behavior. Remaining gate: a separately operator-authorized live smoke for same-session restoration and two concurrent sessions in one directory.
5
10
  - [ ] `Prompt enqueue hotfix` (`optional`, `operator-gated`): Optional nonblocking operator-authorized disposable-follower smoke: overlap delayed voice processing with turn completion and confirm one-time ordered consumption and truthful counts against the [queue contract](./docs/architecture.md#queue-and-dispatch-safety). Separately authorized supported recovery investigation remains open: prevention does not repair an already-wedged in-memory queue; establish the exact recovery path and preservation/discard consequences before mutation, otherwise report the blocker. No journal/ownership edits, replay of settled input, implicit queue clearing, or restart; live activation requires separate operator authorization.
6
11
  - [ ] `Channel multimedia posts` (`0.45.1`, live-acceptance-gated): `telegram_message` channel delivery accepts one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video, uploads it through the multipart transport as `sendPhoto`/`sendVideo` with `text` as the HTML caption, validates kind and size (photo ≤ 10 MiB, video ≤ 50 MiB) plus ≤ 1024 visible caption characters before issuance, and rejects unsupported types and albums instead of downgrading them to links. The channel-post journal binds kind/file name/byte size/SHA-256 and caption, so duplicate requests and lost acknowledgements never re-upload; media-post edits replace the caption through `editMessageCaption`, and Markdown spoilers render as `<tg-spoiler>`. Live image publication passed on `@llb_log`. Regressions cover confirmed publication, duplicate requests, lost ACK, pre-issuance rejection, caption edits, and reconnect replacement. Remaining: operator-authorized disposable-channel acceptance of rejected upload, duplicate request, and caption edit.
7
12
  - [ ] `Manual Thread naming` (`gated-but-preparable`, release priority): Local bot-owned `/name Name` and bare `/name` flows avoid model dispatch. One expiring exact-target input dialog immediately accepts the next valid name, always offers cancel, and offers **Reset to automatic** only while a manual override exists; duplicate/stale callbacks cannot repeat mutation. Durable manual override supersedes every automatic display mode, reset is leader/follower generation- and target-fenced, and Letters remains the default without rewriting recovery identity. Local review findings are remediated, including Bot-API-wait target-replacement regressions for leader/follower rename and reset. Remaining: disposable live acceptance for command-menu ordering, dialog, invalid input, duplicate callbacks, leader/follower rename and reset.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.46.0: Session-aware Workspace Threads
8
+
9
+ - `Session-aware Thread binding`: Workspace identity restored session-qualified bindings from Pi's stable public session identity, so reopening, resuming, or replacing a process reclaims the same Telegram Thread and letter while distinct sessions in one directory keep independent targets. Strict legacy cwd-only records remain inert and coexist safely; protocol v2 rejects mixed 0.45.x peers before provisioning, and exact-session cleanup, displacement, rename, promotion, and slot-pressure fences are preserved.
10
+
7
11
  ## 0.45.11: Consumer-owned companion semantics
8
12
 
9
13
  - `Extension boundaries`: Removes consumer-specific state-scope emoji from the Telegram UI registry. The bridge continues to own generic Rich Message headings and inspection semantics, while companion extensions independently own their domain labels, composition rules, and presentation through the public section API.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  `pi-telegram` turns a private Telegram DM into a mobile operator surface for Pi. It accepts prompts, queues work, streams readable previews, delivers final replies and files, exposes safe controls, and lets companion extensions add Telegram-native capabilities without owning a second bot loop.
8
8
 
9
- It is a **runtime adapter**, not a remote terminal. Start or supervise work in the Pi TUI, then continue from Telegram while away from the keyboard. Each Telegram destination follows a running Pi instance and sends prompts into that instance's currently active session; it is not permanently bound to one session file or session identity. The bridge preserves Pi session semantics instead of pretending Telegram is a PTY, shell, process launcher, or session browser. That boundary is the product: Telegram gets safe runtime handles, not raw terminal power.
9
+ It is a **runtime adapter**, not a remote terminal. Start or supervise work in the Pi TUI, then continue from Telegram while away from the keyboard. In Threaded Mode, a durable Workspace binding uses Pi's stable public session identity to restore the same Telegram Thread when that session resumes; live instance ownership and the exact Telegram target still authorize routing. The bridge preserves Pi session semantics instead of pretending Telegram is a PTY, shell, process launcher, or session browser. That boundary is the product: Telegram gets safe runtime handles, not raw terminal power.
10
10
 
11
11
  Every completed intermediate commentary block from a Telegram-originated turn is delivered once as its own message before the existing final reply. While Telegram is connected, local, autonomous, and unclassified extension follow-up work also projects visible checkpoints and the final answer to the authorized Telegram target once and in order, preserving assistant-authored `telegram_button` comments as interactive prompt buttons. This connected companion projection is always active rather than configurable. Neither path mirrors local prompts, thinking, tool traffic, token deltas, or stale-generation work. The separate `Activity` setting defaults to `verbose` so new installations discover collapsed provider-exposed thinking and tool evidence immediately; operators can narrow it to one class or choose `quiet`. See [Outbound](docs/outbound.md#public-assistant-output) and the [configuration reference](docs/public-api.md#configuration-api).
12
12
 
@@ -55,7 +55,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
55
55
  /telegram-connect
56
56
  ```
57
57
 
58
- The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile>` to activate a named profile, and optionally append `as=Name` to name a fresh Workspace Thread. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers. A reopened follower Workspace with a remembered Thread reconnects automatically at session startup; a new Workspace still requires explicit `/telegram-connect`.
58
+ The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile>` to activate a named profile, and optionally append `as=Name` to name a fresh Workspace Thread. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers. Reopening or resuming the same Pi session restores its remembered Thread at session startup; a distinct session in the same directory has its own binding and still requires explicit `/telegram-connect` when no remembered binding exists.
59
59
 
60
60
  After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Unsupported journal versions block recovery without rewriting or quarantining the retained files; use a compatible runtime rather than deleting journals. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
61
61
 
@@ -236,7 +236,7 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
236
236
  - Followers are visible Pi processes started by the operator.
237
237
  - Each connected instance gets a Telegram thread target.
238
238
  - Queued work for a live follower transfers through authenticated exact-journal handoff rather than replaying under the transport owner.
239
- - Follower session replacement preserves registration, and a reopened follower Workspace with a remembered Thread reconnects automatically without allocating a Thread for an unremembered Workspace.
239
+ - Reopening, resuming, or replacing the process for the same Pi session restores its remembered Thread; distinct sessions in one directory keep independent bindings and letter slots.
240
240
  - Unknown threads are preserved and offered explicit reroute/restore choices.
241
241
  - Telegram never launches hidden Pi processes.
242
242
 
@@ -245,7 +245,7 @@ In Threaded Mode, open Settings → **🧵 Thread display** to choose **Letters*
245
245
  | Mode | Best for | Runtime shape |
246
246
  | --- | --- | --- |
247
247
  | Classic DM | One running Pi instance and its active session controlled from one private bot chat | One polling owner, one queue/runtime surface |
248
- | Threaded Mode | Several visible Pi instances sharing one bot | One leader owns transport; each named private-chat thread follows its assigned instance and current session |
248
+ | Threaded Mode | Several visible Pi sessions sharing one bot | One leader owns transport; each private-chat Thread retains one session-qualified Workspace binding |
249
249
 
250
250
  ## Environment Configuration
251
251
 
@@ -178,6 +178,18 @@ Production retirement requires a durable profile-scoped reader/writer ledger own
178
178
  - A retired binding no longer promises its former name/letter on reopen; the Workspace may be provisioned anew. Retained message ownership and journal evidence must never route old work through a recycled letter: full binding generation and target remain mandatory independently of display labels.
179
179
  - Implement and validate using isolated stores and mocked Telegram APIs. Do not delete live Threads, migrate live journals/locks manually, publish, or restart operator instances as part of local preparation. Operator smoke on disposable Threads is a separate gate.
180
180
 
181
+ ## Approved Next Contract: Session-Aware Workspace Identity
182
+
183
+ Status: locally implemented and validated for the next minor release; final compatibility acceptance and the operator-authorized live smoke remain tracked in `BACKLOG.md`.
184
+
185
+ A durable Workspace binding is owned by the selected bot profile plus `{ cwd, sessionId? }`. `cwd` is the normalized exact full path. When present, `sessionId` is the exact non-empty bounded value returned by the public `ctx.sessionManager.getSessionId()` API. Session display names, selector positions, session file paths, process instance IDs, and lifecycle generations are metadata or transient fences, never substitutes for the stable session ID. Absence is a real identity value `{ cwd, ∅ }`, not a wildcard; malformed present values still fail closed.
186
+
187
+ The persisted binding stores the exact session ID and derives its new binding key from the existing collision-verified Workspace directory key plus a full SHA-256 digest of the session ID. Exact persisted values remain the collision check; the digest is an index component, not independent authority. Reopening, `/resume`, `/continue`/continue-recent, or process replacement with the same `{ cwd, sessionId }` reclaims the same retained target and profile-wide letter when current ownership, accepted-work, admission, and retirement evidence allow it. `/new` and `/fork` produce distinct session IDs and therefore distinct bindings even in the same directory. `/reload` changes neither component.
188
+
189
+ Legacy cwd-only bindings remain exact compatibility identities whose absent session component is itself a unique key. They are neither wildcard session bindings nor adoption candidates: session-aware claims never stamp, migrate, consume, or restore them, and instead allocate their own `{ cwd, sessionId }` binding subject to the same global slot and protection rules. `{ cwd, ∅ }`, `{ cwd, session-a }`, and `{ cwd, session-b }` may coexist as majestic degradation for older state and peers. The 0.46 runtime reads strict legacy records as inert independent `{ cwd, ∅ }` bindings but never migrates, adopts, or routes a session through them. Protocol v2 is session-native and intentionally incompatible with protocol-v1 0.45.x peers. Upgrade requires stopping all Telegram instances first, updating them together, and then starting them again; the first v2 process becomes leader with its own session-qualified binding.
190
+
191
+ Routing authority remains the authenticated live owner plus exact numeric Telegram target. Session identity selects and restores a durable binding but never authorizes message delivery by itself. Slot pressure and retirement continue to operate on exact binding snapshots; they must carry the session component so cleanup, displacement, and letter reuse cannot target another session in the same directory.
192
+
181
193
  ## Leader Election
182
194
 
183
195
  Leader election is heartbeat-gated and lock-backed. The polling owner checks exact lock ownership every second, refreshes its durable lease every two seconds, and becomes stale after eight seconds; the serialized expected-owner transaction remains the final cross-platform election authority.
@@ -194,7 +206,7 @@ Followers first try to re-register after leader reload or unknown-heartbeat resp
194
206
 
195
207
  ### Protocol identity and compatibility
196
208
 
197
- The local wire contract has protocol version `1`, independent from the npm package version. 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. `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.
209
+ The session-native local wire contract has protocol version `2`, independent from the npm package version. 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 is part of protocol v2 rather than an optional capability. Every cwd-scoped v2 registration carries the exact bounded session ID; missing identity is rejected before provisioning or publication. Reconnect, replacement, and promotion preserve it. Protocol-v1 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.
198
210
 
199
211
  Negotiated identities remain on the live follower registry and appear in `/telegram-status --debug` plus the observational state snapshot. The Threaded Mode capability monitor owns one in-flight probe across lifecycle generations: stop/restart invalidates a late read, and a replacement monitor waits for the previous request to settle instead of creating overlapping transport transitions. Durable follower admission is authorized only when both peers advertise `durable-follower-admission-v1`, never inferred from package version: a capable runtime rejects missing support before provisioning, inbound routing, or election-roster eligibility. Authentication and exact registration generation remain mandatory independently of protocol compatibility. `follower.register` is the explicit bootstrap request and may provision; capability-gated `follower.restoreWorkspace` is startup-only and may claim, probe, or replace a remembered binding but returns `workspace-binding-unavailable` without allocating when no binding or claim-fenced legacy exact-`cwd` record exists. Both carry a fresh generation; every later request is exact-generation-fenced against the live registry entry. `bus.ack` is response-only and is rejected if submitted as a server request. Leader forwarding never synthesizes authority for an unknown recipient and preserves the follower's exact durable receipt end to end.
200
212
 
@@ -293,6 +293,7 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
293
293
  getNowMs?: () => number;
294
294
  getPid?: () => number;
295
295
  getProcessBirthId?: () => string;
296
+ getSessionId?: (ctx: TContext) => string | undefined;
296
297
  getSessionGeneration?: () => number;
297
298
  timeoutMs?: number;
298
299
  registrationTimeoutMs?: number;
@@ -374,6 +375,7 @@ export function createTelegramBusFollowerPromotionHandler<
374
375
  topicTargetStore: Threads.TelegramTopicTargetStore;
375
376
  instanceId: string;
376
377
  getActiveProfileName: () => string | undefined;
378
+ getSessionId?: (ctx: TContext) => string | undefined;
377
379
  startLeader: (
378
380
  ctx: TContext,
379
381
  election: TelegramBusFollowerElection,
@@ -400,6 +402,7 @@ export function createTelegramBusFollowerPromotionHandler<
400
402
  store: input.topicTargetStore,
401
403
  instanceId: input.instanceId,
402
404
  cwd: ctx.cwd,
405
+ sessionId: input.getSessionId?.(ctx),
403
406
  telegramProfile: input.getActiveProfileName(),
404
407
  target: binding.target,
405
408
  slot: binding.slot,
@@ -1771,6 +1774,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
1771
1774
  ? { slot: deps.registrationState?.getSlot() ?? lastKnownSlot }
1772
1775
  : {}),
1773
1776
  cwd: ctx.cwd,
1777
+ sessionId: deps.getSessionId?.(ctx),
1774
1778
  pid: getPid(),
1775
1779
  processBirthId: deps.getProcessBirthId?.(),
1776
1780
  sessionGeneration: deps.getSessionGeneration?.(),
package/lib/bus-leader.ts CHANGED
@@ -113,6 +113,7 @@ export interface TelegramBusLeaderTargetProvisionerDeps<TContext> {
113
113
  getAllowedUserId: () => number | undefined;
114
114
  instanceId: string;
115
115
  getCwd?: (ctx: TContext) => string | undefined;
116
+ getSessionId?: (ctx: TContext) => string | undefined;
116
117
  getTelegramProfile?: () => string | undefined;
117
118
  shouldForceFreshUnnamed?: () => boolean;
118
119
  getRequestedThreadName?: () => string | undefined;
@@ -234,6 +235,7 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
234
235
  getAllowedUserId: () => number | undefined;
235
236
  instanceId: string;
236
237
  getCwd?: (ctx: TContext) => string | undefined;
238
+ getSessionId?: (ctx: TContext) => string | undefined;
237
239
  getTelegramProfile?: () => string | undefined;
238
240
  shouldForceFreshUnnamed?: () => boolean;
239
241
  getRequestedThreadName?: () => string | undefined;
@@ -413,6 +415,7 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
413
415
  ...provisionerPorts,
414
416
  instanceId: deps.instanceId,
415
417
  getCwd: deps.getCwd,
418
+ getSessionId: deps.getSessionId,
416
419
  getTelegramProfile: deps.getTelegramProfile,
417
420
  shouldForceFreshUnnamed: deps.shouldForceFreshUnnamed,
418
421
  getRequestedThreadName: deps.getRequestedThreadName,
@@ -908,6 +911,7 @@ export function createTelegramBusFollowerTargetProvisioner(
908
911
  {
909
912
  existingBindingOnly:
910
913
  options?.existingWorkspaceBindingOnly === true,
914
+ sessionId: registration.sessionId,
911
915
  onCapacityUnavailable() {
912
916
  capacityUnavailable = true;
913
917
  },
@@ -925,6 +929,7 @@ export function createTelegramBusFollowerTargetProvisioner(
925
929
  ? deps.topicTargetStore.getWorkspaceBinding(
926
930
  workspaceIdentity.cwd,
927
931
  workspaceIdentity.instanceSlot,
932
+ workspaceIdentity.sessionId,
928
933
  )
929
934
  : undefined;
930
935
  const provision = Threads.createTelegramTopicTargetProvisioner({
@@ -1567,6 +1572,7 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
1567
1572
  getAllowedUserId: deps.getAllowedUserId,
1568
1573
  instanceId: deps.instanceId,
1569
1574
  cwd: normalizedCwd,
1575
+ sessionId: deps.getSessionId?.(ctx),
1570
1576
  telegramProfile: deps.getTelegramProfile?.(),
1571
1577
  forceFreshUnnamed: deps.shouldForceFreshUnnamed?.(),
1572
1578
  requestedThreadName: deps.getRequestedThreadName?.(),
@@ -2101,6 +2107,8 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
2101
2107
  );
2102
2108
  if (
2103
2109
  !compatibility.compatible || !displayCompatible() ||
2110
+ (envelope.registration.cwd !== undefined &&
2111
+ envelope.registration.sessionId === undefined) ||
2104
2112
  (restoringWorkspace && !supportsWorkspaceAutoConnect)
2105
2113
  ) {
2106
2114
  return {
@@ -2110,7 +2118,11 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
2110
2118
  protocol: deps.protocolIdentity,
2111
2119
  error: { code: "incompatible-protocol" as const },
2112
2120
  message: `Incompatible Telegram bus protocol: ${
2113
- compatibility.reason ?? "missing-capability"
2121
+ compatibility.reason ??
2122
+ (envelope.registration.cwd !== undefined &&
2123
+ envelope.registration.sessionId === undefined
2124
+ ? "missing-session-identity"
2125
+ : "missing-capability")
2114
2126
  }.`,
2115
2127
  };
2116
2128
  }
package/lib/bus.ts CHANGED
@@ -220,7 +220,7 @@ export function createTelegramBusAuthSecret(): string {
220
220
  return randomBytes(32).toString("base64url");
221
221
  }
222
222
 
223
- export const TELEGRAM_BUS_PROTOCOL_VERSION = 1 as const;
223
+ export const TELEGRAM_BUS_PROTOCOL_VERSION = 2 as const;
224
224
  export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION =
225
225
  "durable-follower-admission-v1" as const;
226
226
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF =
@@ -369,6 +369,7 @@ export interface TelegramBusInstanceRegistration {
369
369
  threadName?: string;
370
370
  slot?: string;
371
371
  cwd?: string;
372
+ sessionId?: string;
372
373
  pid?: number;
373
374
  target?: TelegramTarget;
374
375
  busSocketPath?: string;
@@ -2991,6 +2992,12 @@ function parseRegistration(
2991
2992
  registration.slot = value.slot;
2992
2993
  }
2993
2994
  if (typeof value.cwd === "string") registration.cwd = value.cwd;
2995
+ if (value.sessionId !== undefined) {
2996
+ if (typeof value.sessionId !== "string" || !value.sessionId ||
2997
+ value.sessionId !== value.sessionId.trim() ||
2998
+ Buffer.byteLength(value.sessionId, "utf8") > 256) return undefined;
2999
+ registration.sessionId = value.sessionId;
3000
+ }
2994
3001
  if (typeof value.pid === "number") registration.pid = value.pid;
2995
3002
  if (typeof value.busSocketPath === "string") {
2996
3003
  registration.busSocketPath = value.busSocketPath;
package/lib/extension.ts CHANGED
@@ -1124,6 +1124,7 @@ export default function (pi: Pi.ExtensionAPI) {
1124
1124
  topicTargetStore: threadStore,
1125
1125
  instanceId: telegramInstanceId,
1126
1126
  getActiveProfileName: configStore.getActiveProfileName,
1127
+ getSessionId: Pi.getExtensionContextSessionId,
1127
1128
  getWorkspaceAdmission: workspaceAdmissionRuntime.resolve,
1128
1129
  async startLeader(ctx, election, onAcquired): Promise<boolean> {
1129
1130
  const result = await lockedPollingRuntime.start(ctx, {
@@ -1202,6 +1203,7 @@ export default function (pi: Pi.ExtensionAPI) {
1202
1203
  getProcessBirthId() {
1203
1204
  return telegramQueueProcessBirthId;
1204
1205
  },
1206
+ getSessionId: Pi.getExtensionContextSessionId,
1205
1207
  getSessionGeneration: telegramSessionContextStore.getGeneration,
1206
1208
  async onRegistered(ctx) {
1207
1209
  await followerAdmissionLifecycleRuntime.onTransportChanged(ctx);
@@ -1318,6 +1320,7 @@ export default function (pi: Pi.ExtensionAPI) {
1318
1320
  getAllowedUserId: configStore.getAllowedUserId,
1319
1321
  instanceId: telegramInstanceId,
1320
1322
  getCwd: Pi.getExtensionContextCwd,
1323
+ getSessionId: Pi.getExtensionContextSessionId,
1321
1324
  getTelegramProfile: configStore.getActiveProfileName,
1322
1325
  getThreadDisplayMode() {
1323
1326
  return Config.resolveTelegramThreadDisplayMode(configStore.get());
@@ -1412,7 +1415,10 @@ export default function (pi: Pi.ExtensionAPI) {
1412
1415
  });
1413
1416
  },
1414
1417
  hasRememberedWorkspaceBinding(ctx) {
1415
- return threadStore.hasWorkspaceBinding(Pi.getExtensionContextCwd(ctx));
1418
+ return threadStore.hasWorkspaceBinding(
1419
+ Pi.getExtensionContextCwd(ctx),
1420
+ Pi.getExtensionContextSessionId(ctx),
1421
+ );
1416
1422
  },
1417
1423
  suspendLiveThreadTarget: telegramBusLeaderState.clear,
1418
1424
  stopFollowerRegistration: telegramBusFollowerRegistration.stop,
package/lib/pi.ts CHANGED
@@ -260,6 +260,10 @@ export function getExtensionContextCwd(ctx: ExtensionContext): string {
260
260
  return ctx.cwd;
261
261
  }
262
262
 
263
+ export function getExtensionContextSessionId(ctx: ExtensionContext): string {
264
+ return ctx.sessionManager.getSessionId();
265
+ }
266
+
263
267
  export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
264
268
  return ctx.isIdle();
265
269
  }
package/lib/sync.ts CHANGED
@@ -46,6 +46,7 @@ export interface TelegramLeaderThreadSyncDeps {
46
46
  getAllowedUserId: () => number | undefined;
47
47
  instanceId: string;
48
48
  cwd?: string;
49
+ sessionId?: string;
49
50
  telegramProfile?: string;
50
51
  forceFreshUnnamed?: boolean;
51
52
  requestedThreadName?: string;
@@ -654,6 +655,7 @@ export async function ensureTelegramLeaderThreadBinding(
654
655
  normalizedLeaderCwd,
655
656
  deps.instanceId,
656
657
  legacyLeaderRecord?.instanceId,
658
+ { sessionId: deps.sessionId },
657
659
  )
658
660
  : undefined;
659
661
  if (deps.cwd && !workspaceIdentity) {
@@ -715,6 +717,7 @@ export async function ensureTelegramLeaderThreadBinding(
715
717
  ? deps.topicTargetStore.getWorkspaceBinding(
716
718
  workspaceIdentity.cwd,
717
719
  workspaceIdentity.instanceSlot,
720
+ workspaceIdentity.sessionId,
718
721
  )
719
722
  : undefined;
720
723
  const legacyWorkspaceBinding =
@@ -17,6 +17,8 @@ import type { TelegramWorkspaceDeletionPermit,
17
17
  export interface TelegramThreadCleanupBindingSnapshot {
18
18
  cwd: string;
19
19
  workspaceKey: string;
20
+ sessionId?: string;
21
+ sessionKey?: string;
20
22
  instanceSlot: string;
21
23
  slot?: string;
22
24
  bindingKey: string;
@@ -40,6 +42,8 @@ export interface TelegramThreadCleanupCandidate {
40
42
  bindingKey: string;
41
43
  cwd: string;
42
44
  workspaceKey: string;
45
+ sessionId?: string;
46
+ sessionKey?: string;
43
47
  instanceSlot: string;
44
48
  slot: string;
45
49
  target: { chatId: number; threadId: number };
@@ -56,6 +60,21 @@ function validTarget(target: { chatId: number; threadId: number }): boolean {
56
60
  target.threadId > 0;
57
61
  }
58
62
 
63
+ function validSessionIdentity(value: {
64
+ sessionId?: unknown;
65
+ sessionKey?: unknown;
66
+ }): boolean {
67
+ if (value.sessionId === undefined && value.sessionKey === undefined) return true;
68
+ if (typeof value.sessionId !== "string" || !value.sessionId ||
69
+ value.sessionId !== value.sessionId.trim() ||
70
+ Buffer.byteLength(value.sessionId, "utf8") > 256 ||
71
+ typeof value.sessionKey !== "string" || !/^[a-f0-9]{64}$/u.test(value.sessionKey)) {
72
+ return false;
73
+ }
74
+ return createHash("sha256").update(value.sessionId).digest("hex") ===
75
+ value.sessionKey;
76
+ }
77
+
59
78
  /** Returns no candidates when any identity/evidence ambiguity exists. */
60
79
  export function planTelegramInactiveThreadCleanup(input: {
61
80
  profileName: string;
@@ -72,7 +91,7 @@ export function planTelegramInactiveThreadCleanup(input: {
72
91
  const key = targetKey(binding.target);
73
92
  const slot = binding.slot;
74
93
  if (!binding.bindingKey || !binding.cwd || !binding.workspaceKey || !binding.instanceSlot ||
75
- typeof slot !== "string" || !slot ||
94
+ typeof slot !== "string" || !slot || !validSessionIdentity(binding) ||
76
95
  !validTarget(binding.target) || bindingKeys.has(binding.bindingKey) || bindingTargets.has(key)) return [];
77
96
  bindingKeys.add(binding.bindingKey);
78
97
  bindingTargets.add(key);
@@ -101,8 +120,11 @@ export function planTelegramInactiveThreadCleanup(input: {
101
120
  evidence.liveOwner !== "clear" || evidence.acceptedWork !== "clear" ||
102
121
  evidence.deliveryAuthority !== "clear" || competing.has(targetKey(binding.target))) continue;
103
122
  candidates.push({ profileName: input.profileName, bindingKey: binding.bindingKey,
104
- cwd: binding.cwd, workspaceKey: binding.workspaceKey, instanceSlot: binding.instanceSlot,
105
- slot, target: { ...binding.target }, inactiveSinceMs,
123
+ cwd: binding.cwd, workspaceKey: binding.workspaceKey,
124
+ ...(binding.sessionId && binding.sessionKey
125
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
126
+ : {}),
127
+ instanceSlot: binding.instanceSlot, slot, target: { ...binding.target }, inactiveSinceMs,
106
128
  bindingUpdatedAtMs: binding.updatedAtMs });
107
129
  }
108
130
  return candidates.sort((left, right) => left.bindingKey.localeCompare(right.bindingKey));
@@ -124,7 +146,11 @@ export function captureTelegramInactiveThreadCleanupEvidence<
124
146
  }): Parameters<typeof planTelegramInactiveThreadCleanup>[0] {
125
147
  const sourceBindings = input.listBindings();
126
148
  const bindings = sourceBindings.map(binding => ({
127
- cwd: binding.cwd, workspaceKey: binding.workspaceKey, instanceSlot: binding.instanceSlot,
149
+ cwd: binding.cwd, workspaceKey: binding.workspaceKey,
150
+ ...(binding.sessionId && binding.sessionKey
151
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
152
+ : {}),
153
+ instanceSlot: binding.instanceSlot,
128
154
  ...(binding.slot === undefined ? {} : { slot: binding.slot }), bindingKey: binding.bindingKey, target: { ...binding.target },
129
155
  ...(binding.inactiveSinceMs === undefined ? {} : { inactiveSinceMs: binding.inactiveSinceMs }),
130
156
  updatedAtMs: binding.updatedAtMs,
@@ -237,16 +263,20 @@ function validateWorkSet(value: unknown, profileName: string): TelegramThreadCle
237
263
  const createdAtMs = value.createdAtMs as number;
238
264
  const entries = value.entries.map(raw => {
239
265
  if (!isObject(raw) || !onlyKeys(raw, ["profileName", "bindingKey", "cwd", "workspaceKey",
240
- "instanceSlot", "slot", "target", "inactiveSinceMs", "bindingUpdatedAtMs", "state", "updatedAtMs",
266
+ "sessionId", "sessionKey", "instanceSlot", "slot", "target", "inactiveSinceMs", "bindingUpdatedAtMs", "state", "updatedAtMs",
241
267
  "issuedAtMs", "permitOperationId", "permitIntentId", "permitLeaderEpoch", "deletedAtMs"]) || raw.profileName !== profileName || typeof raw.bindingKey !== "string" ||
242
268
  !raw.bindingKey || typeof raw.cwd !== "string" || !raw.cwd || typeof raw.workspaceKey !== "string" ||
243
- !raw.workspaceKey || typeof raw.instanceSlot !== "string" || !raw.instanceSlot ||
269
+ !raw.workspaceKey || !validSessionIdentity(raw) ||
270
+ typeof raw.instanceSlot !== "string" || !raw.instanceSlot ||
244
271
  typeof raw.slot !== "string" || !raw.slot || !isObject(raw.target) ||
245
272
  !onlyKeys(raw.target, ["chatId", "threadId"]) || !validTarget(raw.target as unknown as { chatId: number; threadId: number }) ||
246
273
  !safeTime(raw.inactiveSinceMs) || !safeTime(raw.bindingUpdatedAtMs) ||
247
274
  (raw.bindingUpdatedAtMs as number) < (raw.inactiveSinceMs as number) || !safeTime(raw.updatedAtMs))
248
275
  throw new Error("Telegram Thread cleanup entry schema is invalid.");
249
276
  const base = { profileName, bindingKey: raw.bindingKey, cwd: raw.cwd, workspaceKey: raw.workspaceKey,
277
+ ...(typeof raw.sessionId === "string" && typeof raw.sessionKey === "string"
278
+ ? { sessionId: raw.sessionId, sessionKey: raw.sessionKey }
279
+ : {}),
250
280
  instanceSlot: raw.instanceSlot, slot: raw.slot, target: { chatId: raw.target.chatId as number,
251
281
  threadId: raw.target.threadId as number }, inactiveSinceMs: raw.inactiveSinceMs as number,
252
282
  bindingUpdatedAtMs: raw.bindingUpdatedAtMs as number, updatedAtMs: raw.updatedAtMs as number };
package/lib/threads.ts CHANGED
@@ -52,6 +52,10 @@ export interface TelegramThreadNameInput {
52
52
  export interface TelegramWorkspaceBindingIdentity {
53
53
  cwd: string;
54
54
  workspaceKey: string;
55
+ /** Exact durable Pi session identity; absent only on legacy cwd-only bindings. */
56
+ sessionId?: string;
57
+ /** Full SHA-256 index component for session-qualified bindings. */
58
+ sessionKey?: string;
55
59
  /** Immutable legacy binding-key component, not the displayed global letter. */
56
60
  instanceSlot: string;
57
61
  bindingKey: string;
@@ -60,6 +64,27 @@ export interface TelegramWorkspaceBindingIdentity {
60
64
  }
61
65
 
62
66
  const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
67
+ const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
68
+
69
+ export function normalizeTelegramSessionId(
70
+ sessionId: string,
71
+ ): string | undefined {
72
+ if (typeof sessionId !== "string") return undefined;
73
+ const normalized = sessionId.trim();
74
+ return normalized && Buffer.byteLength(normalized, "utf8") <=
75
+ TELEGRAM_SESSION_ID_MAX_LENGTH
76
+ ? normalized
77
+ : undefined;
78
+ }
79
+
80
+ export function createTelegramSessionKey(
81
+ sessionId: string,
82
+ ): string | undefined {
83
+ const normalized = normalizeTelegramSessionId(sessionId);
84
+ return normalized
85
+ ? createHash("sha256").update(normalized).digest("hex")
86
+ : undefined;
87
+ }
63
88
 
64
89
  export function normalizeTelegramWorkspacePath(
65
90
  cwd: string,
@@ -116,21 +141,39 @@ function createTelegramWorkspaceBindingIdentityWithKey(
116
141
  cwd: string,
117
142
  workspaceKey: string,
118
143
  ordinal: number,
144
+ sessionId?: string,
119
145
  ): TelegramWorkspaceBindingIdentity | undefined {
120
146
  const instanceSlot = createTelegramWorkspaceInstanceSlot(ordinal);
121
147
  if (!instanceSlot) return undefined;
148
+ const normalizedSessionId = sessionId === undefined
149
+ ? undefined
150
+ : normalizeTelegramSessionId(sessionId);
151
+ const sessionKey = normalizedSessionId
152
+ ? createTelegramSessionKey(normalizedSessionId)
153
+ : undefined;
154
+ if (sessionId !== undefined && (!normalizedSessionId || !sessionKey)) {
155
+ return undefined;
156
+ }
157
+ const legacyBindingKey = instanceSlot === "a"
158
+ ? workspaceKey
159
+ : `${workspaceKey}${instanceSlot}`;
122
160
  return {
123
161
  cwd,
124
162
  workspaceKey,
163
+ ...(normalizedSessionId && sessionKey
164
+ ? { sessionId: normalizedSessionId, sessionKey }
165
+ : {}),
125
166
  instanceSlot,
126
- bindingKey:
127
- instanceSlot === "a" ? workspaceKey : `${workspaceKey}${instanceSlot}`,
167
+ bindingKey: sessionKey
168
+ ? `${legacyBindingKey}-s-${sessionKey}`
169
+ : legacyBindingKey,
128
170
  };
129
171
  }
130
172
 
131
173
  export function createTelegramWorkspaceBindingIdentity(
132
174
  cwd: string,
133
175
  ordinal = 0,
176
+ sessionId?: string,
134
177
  ): TelegramWorkspaceBindingIdentity | undefined {
135
178
  const normalized = normalizeTelegramWorkspacePath(cwd);
136
179
  const workspaceKey = normalized
@@ -141,6 +184,7 @@ export function createTelegramWorkspaceBindingIdentity(
141
184
  normalized,
142
185
  workspaceKey,
143
186
  ordinal,
187
+ sessionId,
144
188
  );
145
189
  }
146
190
 
@@ -249,6 +293,10 @@ export interface TelegramThreadIdentityRecord {
249
293
  export interface TelegramWorkspaceThreadBinding {
250
294
  cwd: string;
251
295
  workspaceKey: string;
296
+ /** Exact durable Pi session identity; absent only on legacy cwd-only bindings. */
297
+ sessionId?: string;
298
+ /** Full SHA-256 index component for session-qualified bindings. */
299
+ sessionKey?: string;
252
300
  instanceSlot: string;
253
301
  bindingKey: string;
254
302
  target: TelegramTarget & { threadId: number };
@@ -453,7 +501,8 @@ export interface TelegramTopicTargetStore {
453
501
  ) => Promise<boolean>;
454
502
  commitInactiveWorkspaceCleanup: (
455
503
  expected: TelegramWorkspaceThreadBinding | {
456
- cwd: string; workspaceKey: string; instanceSlot: string; slot: string; bindingKey: string;
504
+ cwd: string; workspaceKey: string; sessionId?: string; sessionKey?: string;
505
+ instanceSlot: string; slot: string; bindingKey: string;
457
506
  target: { chatId: number; threadId: number }; inactiveSinceMs: number; bindingUpdatedAtMs: number;
458
507
  },
459
508
  isCurrent: () => boolean,
@@ -465,7 +514,7 @@ export interface TelegramTopicTargetStore {
465
514
  ) => TelegramWorkspaceExternalProtectionEvidence,
466
515
  options?: { expectedRetirement?: TelegramWorkspaceRetirementIntent },
467
516
  ) => TelegramWorkspaceSlotOccupancySnapshot;
468
- hasWorkspaceBinding: (cwd: string) => boolean;
517
+ hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
469
518
  setWorkspaceDisplayTitle: (
470
519
  expected: TelegramWorkspaceThreadBinding,
471
520
  title: string,
@@ -478,6 +527,7 @@ export interface TelegramTopicTargetStore {
478
527
  getWorkspaceBinding: (
479
528
  cwd: string,
480
529
  instanceSlot?: string,
530
+ sessionId?: string,
481
531
  ) => TelegramWorkspaceThreadBinding | undefined;
482
532
  claimWorkspaceIdentity: (
483
533
  cwd: string,
@@ -485,6 +535,7 @@ export interface TelegramTopicTargetStore {
485
535
  previousInstanceId?: string,
486
536
  options?: {
487
537
  existingBindingOnly?: boolean;
538
+ sessionId?: string;
488
539
  onCapacityUnavailable?: () => void;
489
540
  },
490
541
  ) => TelegramWorkspaceBindingIdentity | undefined;
@@ -1124,9 +1175,12 @@ function cloneIdentityRecord(
1124
1175
  }
1125
1176
 
1126
1177
  function getWorkspaceBindingMapKey(
1127
- binding: Pick<TelegramWorkspaceThreadBinding, "cwd" | "instanceSlot">,
1178
+ binding: Pick<
1179
+ TelegramWorkspaceThreadBinding,
1180
+ "cwd" | "instanceSlot" | "sessionId"
1181
+ >,
1128
1182
  ): string {
1129
- return `${binding.cwd}\u0000${binding.instanceSlot}`;
1183
+ return `${binding.cwd}\u0000${binding.sessionId ?? ""}\u0000${binding.instanceSlot}`;
1130
1184
  }
1131
1185
 
1132
1186
  function normalizeWorkspaceBindingRecord(
@@ -1145,15 +1199,30 @@ function normalizeWorkspaceBindingRecord(
1145
1199
  return undefined;
1146
1200
  }
1147
1201
  const cwd = normalizeTelegramWorkspacePath(record.cwd);
1202
+ const sessionId = typeof record.sessionId === "string"
1203
+ ? normalizeTelegramSessionId(record.sessionId)
1204
+ : undefined;
1205
+ const sessionKey = typeof record.sessionKey === "string"
1206
+ ? record.sessionKey
1207
+ : undefined;
1208
+ const hasSessionFields = record.sessionId !== undefined ||
1209
+ record.sessionKey !== undefined;
1210
+ const expectedSessionKey = sessionId
1211
+ ? createTelegramSessionKey(sessionId)
1212
+ : undefined;
1213
+ const legacyBindingKey = record.instanceSlot === "a"
1214
+ ? record.workspaceKey
1215
+ : `${record.workspaceKey}${record.instanceSlot}`;
1216
+ const expectedBindingKey = expectedSessionKey
1217
+ ? `${legacyBindingKey}-s-${expectedSessionKey}`
1218
+ : legacyBindingKey;
1148
1219
  if (
1149
1220
  !cwd ||
1150
1221
  cwd !== record.cwd ||
1151
1222
  !record.workspaceKey ||
1152
1223
  !/^[a-z]+$/u.test(record.instanceSlot) ||
1153
- record.bindingKey !==
1154
- (record.instanceSlot === "a"
1155
- ? record.workspaceKey
1156
- : `${record.workspaceKey}${record.instanceSlot}`)
1224
+ (hasSessionFields && (!sessionId || sessionKey !== expectedSessionKey)) ||
1225
+ record.bindingKey !== expectedBindingKey
1157
1226
  ) {
1158
1227
  return undefined;
1159
1228
  }
@@ -1191,6 +1260,7 @@ function normalizeWorkspaceBindingRecord(
1191
1260
  return {
1192
1261
  cwd,
1193
1262
  workspaceKey: record.workspaceKey,
1263
+ ...(sessionId && sessionKey ? { sessionId, sessionKey } : {}),
1194
1264
  instanceSlot: record.instanceSlot,
1195
1265
  bindingKey: record.bindingKey,
1196
1266
  ...(record.showSlotSuffix === true ? { showSlotSuffix: true } : {}),
@@ -1684,7 +1754,10 @@ export function createTelegramTopicTargetStore(
1684
1754
  );
1685
1755
  let workspaceClaims = new Map<
1686
1756
  string,
1687
- { identity: TelegramWorkspaceBindingIdentity; instanceId: string }
1757
+ {
1758
+ identity: TelegramWorkspaceBindingIdentity;
1759
+ instanceId: string;
1760
+ }
1688
1761
  >();
1689
1762
  let reservations: TelegramThreadReservation[] = [];
1690
1763
  let pendingProvisions: TelegramThreadPendingProvision[] = [];
@@ -2404,8 +2477,16 @@ export function createTelegramTopicTargetStore(
2404
2477
  if (workspaceRetirementCommitInFlight || !isCurrent()) return false;
2405
2478
  const cleanupSnapshot = "bindingUpdatedAtMs" in expected ? expected : undefined;
2406
2479
  const normalized = cleanupSnapshot ? undefined : normalizeWorkspaceBindingRecord(expected);
2480
+ const cleanupSessionId = cleanupSnapshot?.sessionId === undefined
2481
+ ? undefined
2482
+ : normalizeTelegramSessionId(cleanupSnapshot.sessionId);
2483
+ const cleanupSessionKey = cleanupSessionId
2484
+ ? createTelegramSessionKey(cleanupSessionId)
2485
+ : undefined;
2407
2486
  if (cleanupSnapshot && (!cleanupSnapshot.cwd || !cleanupSnapshot.workspaceKey ||
2408
2487
  !cleanupSnapshot.instanceSlot || !cleanupSnapshot.slot || !cleanupSnapshot.bindingKey ||
2488
+ ((cleanupSnapshot.sessionId !== undefined || cleanupSnapshot.sessionKey !== undefined) &&
2489
+ (!cleanupSessionId || cleanupSnapshot.sessionKey !== cleanupSessionKey)) ||
2409
2490
  !Number.isSafeInteger(cleanupSnapshot.inactiveSinceMs) ||
2410
2491
  !Number.isSafeInteger(cleanupSnapshot.bindingUpdatedAtMs) ||
2411
2492
  !Number.isSafeInteger(cleanupSnapshot.target.chatId) ||
@@ -2416,6 +2497,7 @@ export function createTelegramTopicTargetStore(
2416
2497
  if (!binding) return isCurrent();
2417
2498
  const exact = cleanupSnapshot ? binding.cwd === cleanupSnapshot.cwd &&
2418
2499
  binding.workspaceKey === cleanupSnapshot.workspaceKey &&
2500
+ binding.sessionId === cleanupSessionId && binding.sessionKey === cleanupSessionKey &&
2419
2501
  binding.instanceSlot === cleanupSnapshot.instanceSlot && binding.slot === cleanupSnapshot.slot &&
2420
2502
  binding.bindingKey === cleanupSnapshot.bindingKey && targetMatches(binding.target, cleanupSnapshot.target) &&
2421
2503
  binding.inactiveSinceMs === cleanupSnapshot.inactiveSinceMs &&
@@ -2612,10 +2694,17 @@ export function createTelegramTopicTargetStore(
2612
2694
  : [...localReservedSlots, "invalid"];
2613
2695
  return { bindings, reservedSlots };
2614
2696
  },
2615
- hasWorkspaceBinding(cwd) {
2697
+ hasWorkspaceBinding(cwd, sessionId) {
2616
2698
  const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
2617
- return !!normalizedCwd && Array.from(workspaceBindings.values()).some(
2618
- (binding) => binding.cwd === normalizedCwd,
2699
+ const normalizedSessionId = sessionId === undefined
2700
+ ? undefined
2701
+ : normalizeTelegramSessionId(sessionId);
2702
+ if (!normalizedCwd || (sessionId !== undefined && !normalizedSessionId)) {
2703
+ return false;
2704
+ }
2705
+ return Array.from(workspaceBindings.values()).some(
2706
+ (binding) => binding.cwd === normalizedCwd &&
2707
+ binding.sessionId === normalizedSessionId,
2619
2708
  );
2620
2709
  },
2621
2710
  setWorkspaceDisplayTitle(expected, title) {
@@ -2651,18 +2740,29 @@ export function createTelegramTopicTargetStore(
2651
2740
  }
2652
2741
  return false;
2653
2742
  },
2654
- getWorkspaceBinding(cwd, instanceSlot = "a") {
2743
+ getWorkspaceBinding(cwd, instanceSlot = "a", sessionId) {
2655
2744
  const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
2656
- if (!normalizedCwd || !/^[a-z]+$/u.test(instanceSlot)) return undefined;
2657
- const binding = workspaceBindings.get(
2658
- getWorkspaceBindingMapKey({ cwd: normalizedCwd, instanceSlot }),
2659
- );
2745
+ const normalizedSessionId = sessionId === undefined
2746
+ ? undefined
2747
+ : normalizeTelegramSessionId(sessionId);
2748
+ if (!normalizedCwd || !/^[a-z]+$/u.test(instanceSlot) ||
2749
+ (sessionId !== undefined && !normalizedSessionId)) return undefined;
2750
+ const mapKey = getWorkspaceBindingMapKey({
2751
+ cwd: normalizedCwd,
2752
+ instanceSlot,
2753
+ ...(normalizedSessionId ? { sessionId: normalizedSessionId } : {}),
2754
+ });
2755
+ const binding = workspaceBindings.get(mapKey);
2660
2756
  return binding ? cloneWorkspaceBinding(binding) : undefined;
2661
2757
  },
2662
2758
  claimWorkspaceIdentity(cwd, instanceId, previousInstanceId, options) {
2663
2759
  if (workspaceRetirementCommitInFlight) return undefined;
2664
2760
  const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
2761
+ const normalizedSessionId = options?.sessionId === undefined
2762
+ ? undefined
2763
+ : normalizeTelegramSessionId(options.sessionId);
2665
2764
  if (!normalizedCwd || !instanceId ||
2765
+ (options?.sessionId !== undefined && !normalizedSessionId) ||
2666
2766
  hasWorkspaceRetirementConflict({ cwd: normalizedCwd })) return undefined;
2667
2767
  const externalReservedSlots = captureExternalReservedSlots();
2668
2768
  if (!externalReservedSlots) {
@@ -2674,7 +2774,9 @@ export function createTelegramTopicTargetStore(
2674
2774
  );
2675
2775
  for (const claim of workspaceClaims.values()) {
2676
2776
  if (claim.instanceId !== instanceId) continue;
2677
- if (claim.identity.cwd !== normalizedCwd || !claim.identity.slot ||
2777
+ if (claim.identity.cwd !== normalizedCwd ||
2778
+ claim.identity.sessionId !== normalizedSessionId ||
2779
+ !claim.identity.slot ||
2678
2780
  externalReservedSlotKeys.includes(claim.identity.slot.toLowerCase())) {
2679
2781
  return undefined;
2680
2782
  }
@@ -2686,11 +2788,13 @@ export function createTelegramTopicTargetStore(
2686
2788
  }
2687
2789
  const workspaceKey = resolveWorkspaceKey(normalizedCwd);
2688
2790
  if (!workspaceKey) return undefined;
2689
- let legacyRecord = findLegacyWorkspaceMigrationRecord(
2690
- normalizedCwd,
2691
- instanceId,
2692
- previousInstanceId,
2693
- );
2791
+ let legacyRecord = normalizedSessionId
2792
+ ? undefined
2793
+ : findLegacyWorkspaceMigrationRecord(
2794
+ normalizedCwd,
2795
+ instanceId,
2796
+ previousInstanceId,
2797
+ );
2694
2798
  const legacyTarget = legacyRecord?.target;
2695
2799
  const targetBinding = legacyTarget
2696
2800
  ? Array.from(workspaceBindings.values()).find(
@@ -2772,12 +2876,16 @@ export function createTelegramTopicTargetStore(
2772
2876
  }
2773
2877
  if (!slot || !/^[A-Z]$/u.test(slot)) return undefined;
2774
2878
  const claimedIdentity = { ...identity, slot };
2775
- workspaceClaims.set(mapKey, { identity: claimedIdentity, instanceId });
2879
+ workspaceClaims.set(mapKey, {
2880
+ identity: claimedIdentity,
2881
+ instanceId,
2882
+ });
2776
2883
  return { ...claimedIdentity };
2777
2884
  };
2778
2885
  if (options?.existingBindingOnly) {
2779
2886
  const candidates = Array.from(workspaceBindings.values())
2780
- .filter((binding) => binding.cwd === normalizedCwd)
2887
+ .filter((binding) => binding.cwd === normalizedCwd &&
2888
+ binding.sessionId === normalizedSessionId)
2781
2889
  .sort((left, right) =>
2782
2890
  left.instanceSlot.length - right.instanceSlot.length ||
2783
2891
  left.instanceSlot.localeCompare(right.instanceSlot),
@@ -2786,6 +2894,9 @@ export function createTelegramTopicTargetStore(
2786
2894
  const claimed = claimIdentity({
2787
2895
  cwd: binding.cwd,
2788
2896
  workspaceKey: binding.workspaceKey,
2897
+ ...(binding.sessionId && binding.sessionKey
2898
+ ? { sessionId: binding.sessionId, sessionKey: binding.sessionKey }
2899
+ : {}),
2789
2900
  instanceSlot: binding.instanceSlot,
2790
2901
  bindingKey: binding.bindingKey,
2791
2902
  });
@@ -2811,6 +2922,7 @@ export function createTelegramTopicTargetStore(
2811
2922
  normalizedCwd,
2812
2923
  workspaceKey,
2813
2924
  ordinal,
2925
+ normalizedSessionId,
2814
2926
  );
2815
2927
  if (!identity) return undefined;
2816
2928
  const mapKey = getWorkspaceBindingMapKey(identity);
@@ -2850,6 +2962,8 @@ export function createTelegramTopicTargetStore(
2850
2962
  const next = normalizeWorkspaceBindingRecord(binding);
2851
2963
  if (next && hasWorkspaceRetirementConflict(next)) return undefined;
2852
2964
  if (!next) return undefined;
2965
+ const nextMapKey = getWorkspaceBindingMapKey(next);
2966
+ const claim = workspaceClaims.get(nextMapKey);
2853
2967
  if (next.slot && Array.from(workspaceBindings.values()).some((existing) =>
2854
2968
  existing.bindingKey !== next.bindingKey && existing.slot === next.slot,
2855
2969
  )) return undefined;
@@ -2861,9 +2975,11 @@ export function createTelegramTopicTargetStore(
2861
2975
  return undefined;
2862
2976
  }
2863
2977
  }
2864
- const nextMapKey = getWorkspaceBindingMapKey(next);
2865
2978
  const previous = workspaceBindings.get(nextMapKey);
2866
2979
  if (previous?.showSlotSuffix) next.showSlotSuffix = true;
2980
+ if (previous?.threadName && !next.threadName) {
2981
+ next.threadName = previous.threadName;
2982
+ }
2867
2983
  if (previous?.manualThreadName && !next.manualThreadName) {
2868
2984
  next.manualThreadName = previous.manualThreadName;
2869
2985
  }
@@ -2884,7 +3000,6 @@ export function createTelegramTopicTargetStore(
2884
3000
  if (previous.displayTitle) next.displayTitle = previous.displayTitle;
2885
3001
  if (previous.inactiveSinceMs !== undefined) next.inactiveSinceMs = previous.inactiveSinceMs;
2886
3002
  }
2887
- const claim = workspaceClaims.get(nextMapKey);
2888
3003
  if (claimInstanceId) {
2889
3004
  if (claim?.instanceId !== claimInstanceId) return undefined;
2890
3005
  if (next.slot !== undefined && claim.identity.slot !== next.slot) return undefined;
@@ -3460,6 +3575,7 @@ export interface TelegramPromoteFollowerBindingToLeaderDeps {
3460
3575
  store: TelegramTopicTargetStore;
3461
3576
  instanceId: string;
3462
3577
  cwd?: string;
3578
+ sessionId?: string;
3463
3579
  telegramProfile?: string;
3464
3580
  target?: TelegramTarget;
3465
3581
  slot?: string;
@@ -3483,11 +3599,15 @@ export async function promoteTelegramFollowerBindingToLeader(
3483
3599
  );
3484
3600
  const workspaceIdentity = deps.cwd
3485
3601
  ? deps.store.claimWorkspaceIdentity(deps.cwd, deps.instanceId,
3486
- existing?.instanceId, { existingBindingOnly: true })
3602
+ existing?.instanceId, {
3603
+ existingBindingOnly: true,
3604
+ sessionId: deps.sessionId,
3605
+ })
3487
3606
  : undefined;
3488
3607
  const workspaceBinding = workspaceIdentity
3489
3608
  ? deps.store.getWorkspaceBinding(
3490
3609
  workspaceIdentity.cwd, workspaceIdentity.instanceSlot,
3610
+ workspaceIdentity.sessionId,
3491
3611
  )
3492
3612
  : undefined;
3493
3613
  const exactWorkspaceBinding = workspaceBinding &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.45.11",
3
+ "version": "0.46.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -10,7 +10,7 @@ Help the user understand the current topic. Skip the preamble and keep prose bri
10
10
  - Infer the subject from the conversation and honor an explicitly requested format. With plain `show me`, choose the form that best explains the subject.
11
11
  - Markdown in the reply is a complete output format for both chat and terminal surfaces. Use headings, emphasis, lists, and focused code blocks to explain outcomes, comparisons, and reasoning; add a diagram when relationships need one.
12
12
  - `Show me markdown` requests a rendered Markdown reply. Create a Markdown file only when the user asks for a saved document or file artifact.
13
- - `Show me html` requests a focused HTML file. Save file artifacts in the project or filesystem as appropriate and deliver them through the active environment's file-delivery mechanism; open locally when that is the requested surface.
13
+ - `Show me html` requests a focused HTML file. Prefer an accessible, art-directed interactive infographic when timelines, comparisons, layers, or progressive disclosure clarify the subject; otherwise stay static. Preserve truthful labels and reduced motion. Save and deliver the artifact through the active environment; open it locally when requested.
14
14
 
15
15
  ### Surface Routing
16
16