@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 +5 -0
- package/CHANGELOG.md +4 -0
- package/README.md +4 -4
- package/docs/multi-instance-bus.md +13 -1
- package/lib/bus-follower.ts +4 -0
- package/lib/bus-leader.ts +13 -1
- package/lib/bus.ts +8 -1
- package/lib/extension.ts +7 -1
- package/lib/pi.ts +4 -0
- package/lib/sync.ts +3 -0
- package/lib/thread-cleanup-manager.ts +36 -6
- package/lib/threads.ts +150 -30
- package/package.json +1 -1
- package/skills/show-me/SKILL.md +1 -1
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.
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
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 `
|
|
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
|
|
package/lib/bus-follower.ts
CHANGED
|
@@ -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 ??
|
|
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 =
|
|
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(
|
|
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,
|
|
105
|
-
|
|
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,
|
|
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 ||
|
|
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
|
-
|
|
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;
|
|
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<
|
|
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
|
-
|
|
1154
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
2618
|
-
|
|
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
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
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 ||
|
|
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 =
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
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, {
|
|
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, {
|
|
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
package/skills/show-me/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|