@llblab/pi-kit 0.35.3 → 0.35.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -23,6 +23,7 @@
23
23
  - An explicitly requested bundled-extension release also authorizes synchronizing and releasing the kit in the same task; do not stop at local preparation. Follow the guarded repository release flow. Installation and live-client reload remain separately authorized.
24
24
  - Bundle every included Pi package so npm installation is self-contained under this package's module root. Keep repository-local `legacy-peer-deps=true` so host-provided Pi peers are not resolved into the kit's lockfile; Pi supplies them at runtime.
25
25
  - Expose only resources declared by each included package. Prefer published distribution entrypoints over source entrypoints when both exist.
26
+ - Extension entrypoints are pointers, not copies: every declared extension re-exports the exact bundled entrypoint from `extensions/<name>/index.<ext>`, and that folder is published so Pi shows a compact label. A bundled package must therefore resolve auxiliary files, workers, spawned scripts, sibling packages, and extension-local resources from its own module URL (`import.meta.url`), never from the declared entry path or `process.argv[1]`; only the declared entry is redirected, so anything located from that path breaks.
26
27
  - Do not copy extension source, Skills, or documentation into this repository.
27
28
  - Preserve package independence: a kit release may advance any subset of included extensions without forcing lockstep extension releases.
28
29
  - Adding packages or newly exposed capabilities requires a kit minor bump. For updates to the existing composition, derive the kit version bump from the highest dependency bump in the synchronized release cohort: dependency patch-only updates require a kit patch bump, while any dependency minor update requires a kit minor bump. Do not let lower-level dependency bumps force a larger kit bump.
@@ -34,4 +35,5 @@
34
35
  - Run `npm test` after manifest or inventory changes.
35
36
  - Run `npm run pack:check` before declaring package readiness.
36
37
  - Inspect the dry-run tarball inventory to confirm every package in `bundledDependencies` and its declared resources are present.
38
+ - Confirm that a newly added or advanced package resolves its auxiliary files from `import.meta.url`; a pointer-loaded extension must still start its own workers and scripts.
37
39
  - Run `npm run validate` at completion.
package/BACKLOG.md CHANGED
@@ -4,6 +4,7 @@ Package pins, resource order and bundled runtime ownership remain authoritative
4
4
 
5
5
  ## Carried checks
6
6
 
7
+ - **Installed 0.35.4 Telegram smoke (operator-owned):** After separately authorized kit installation/reload of every Pi process together, run `/telegram-new` in one follower instance and confirm the same Thread is reused with a new letter slot and title, with no second tab and no orphan left behind. Then restart that instance, open a new session in the same folder, and confirm it adopts the retained Thread instead of provisioning another. Do not erase accepted work or replay uncertain input. Packed validation does not certify installed clients.
7
8
  - **Installed 0.35.3 label smoke (operator-owned):** After separately authorized kit installation/reload, confirm the startup list and `pi config` show one compact entry per bundled extension (`@llblab/pi-kit:pi-actors` and so on), that every extension still loads exactly once without duplicate-registration warnings, and that Skills are unaffected. Packed validation does not certify installed clients.
8
9
  - **Installed 0.35.2 Telegram smoke (operator-owned):** After separately authorized kit installation/reload, connect a new Thread with every letter slot occupied and confirm it appears in seconds without the leader stalling or its followers going stale. Repeat the operator's earlier checks for leader failover after `/telegram-disconnect`, follower takeover, and collapsed repeated log records. Packed validation does not certify installed clients.
9
10
  - **Installed 0.35.1 Telegram smoke (operator-owned):** After separately authorized kit installation/reload, verify Reroute removes an exhausted temporary tab and Restore keeps the new tab while deleting its unprotected predecessor. Confirm protected work survives. Guest PNG/JPEG, document and MP3 replacement plus staging cleanup remain explicitly deferred upstream live checks; verify unsupported OGG/Opus feedback separately. Upstream idle-leader acceptance and kit packaging checks do not certify this installation.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.35.4: Telegram Session Thread Continuity
6
+
7
+ - `Telegram pin`: Advances only the exact Telegram pin from `0.57.2` to `0.57.3`. A session replaced inside one Pi process now reuses its preserved Thread on every registration path instead of provisioning a second tab, and a new session registering in a folder whose previous session is provably gone adopts that binding's exact target and slot while the predecessor binding is re-keyed. A live predecessor or an ambiguous candidate set keeps ordinary provisioning.
8
+ - `Checks`: The exact-pin test, README inventory and pointer targets now read `0.57.3`; the seven published pointer entrypoints and declared Skill paths are unchanged. Packed validation covers build, types, tests, audit and bundle inventory only, so installed-client behavior remains an operator check in [Backlog](./BACKLOG.md).
9
+
5
10
  ## 0.35.3: Compact Bundled Extension Labels
6
11
 
7
12
  - `Extension wiring`: Each bundled extension is now declared through a thin pointer at `extensions/<name>/index.js` that re-exports the exact bundled entrypoint, so the Pi startup list and `pi config` show `@llblab/pi-kit:pi-telegram` instead of the full nested path. Pins, load order and package code are unchanged, and an explicit `pi.extensions` still suppresses conventional directory discovery, so nothing loads twice.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.1` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
20
  | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.26.4` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
21
- | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.2` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
21
+ | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.3` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
24
24
  Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
@@ -56,7 +56,7 @@ npm install
56
56
  npm run validate
57
57
  ```
58
58
 
59
- The repository-local `.npmrc` keeps Pi-provided peer packages out of the kit's lockfile; Pi supplies those peers at runtime. To advance an included package, update its exact version in `dependencies`, run `npm install`, synchronize bundled dependencies, each declared extension pointer and its target, declared Skill paths, tests, the table above, and the changelog, then validate the packed artifact. Keep every pointer target identical to the entrypoint its pin declares. Expose only resources declared by the published owning package; do not use version ranges or unpublished local paths.
59
+ The repository-local `.npmrc` keeps Pi-provided peer packages out of the kit's lockfile; Pi supplies those peers at runtime. To advance an included package, update its exact version in `dependencies`, run `npm install`, synchronize bundled dependencies, each declared extension pointer and its target, declared Skill paths, tests, the table above, and the changelog, then validate the packed artifact. Keep every pointer target identical to the entrypoint its pin declares. A bundled package must resolve auxiliary files, workers and sibling packages from its own module URL rather than from the declared entry path, because a pointer redirects only that entry. Expose only resources declared by the published owning package; do not use version ranges or unpublished local paths.
60
60
 
61
61
  ## Security
62
62
 
@@ -87,6 +87,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
87
87
  - Admission is journal-first: validate and persist the complete `getUpdates` response before one monotonic offset commit, then signal an independent worker without awaiting semantic execution. Missing cursor with a non-empty journal, malformed/foreign authority, or capacity exhaustion fails closed. “Durable” means process-crash recovery after atomic rename, not unflushed host/kernel/filesystem/device/power-loss survival.
88
88
  - Storage cutovers must reconcile actual consumer locations before correcting path adapters. Never normalize a relative historical reference into new authority or treat equal reference strings / empty canonical storage as source completeness. Exact-path preflight is lexical only; physical identity, historical coverage, writer closure and migration remain separate proofs.
89
89
  - Workspace mutations acquire cross-process admission before their shared process-local gate and hold it through asynchronous API work and durable settlement. Topic lifecycle, complete unbound/reroute target handling, manual disconnect, and session-restart cleanup use profile-wide scope; either retained retirement-fence phase rejects them before state access. Cleanup admission spans intent publication, target mutation, persistence, and successful transport release. Explicit manual disconnect may stop only its captured local transport after cleanup admission/effects fail; it must not bypass Thread mutation fences, release a replacement connection, claim unconfirmed deletion, or retry an uncertain stop. Session-restart cleanup retains its admission-required behavior. Detached reconciliation that mutates Thread state must reacquire fresh profile admission through the same gate; it cannot inherit a caller lease that ended before its timer runs. A live operation ID has one process-local caller: concurrent reuse is rejected before lease acquisition, while retry after the caller exits may resume exact durable authority.
90
+ - One Thread keeps one owner across session replacement. A session replaced inside the same process reuses its preserved handoff Thread instead of provisioning another. A new session in a folder whose previous session is provably gone (no live registration for its target, absent runtime PID) adopts that binding's exact target and slot, and the predecessor binding is re-keyed to the successor rather than left as an orphan active row. A live predecessor, an ambiguous candidate set, an unknown runtime, or a missing liveness probe keeps ordinary provisioning.
90
91
  - Workspace retirement is capacity-pressure-only. Elapsed time and heartbeat silence never trigger deletion; only complete `A`–`Z` exhaustion may propose the oldest continuously proven inactive, fully unprotected binding. Exact deletion, durable retirement, and fence completion precede slot reuse. Authorized demand-driven rotation retries one failed fresh allocation after retirement; restore-only follower startup never evicts. Release ordinary registration/provisioning leases before acquiring the destructive fence, retain the shared mutation gate across retirement, and validate the exact permit immediately before one non-retried deletion. Persist an exact method/target-matched rejection before withdrawing its intent; release that fence only after durable withdrawal, retaining the binding. A later attempt requires fresh operation authority. Protection reads must never repair, quarantine, or reset journals. Non-destructive owner detachment must atomically retain one exact Workspace binding and its letter while removing only its uniquely matched owner record and stamping first inactivity; it never manufactures Thread-deletion evidence or clears accepted work. During authenticated follower registration, a mismatched retained target record is repaired to the exact Workspace claim before binding commit; if same-target commit adopts the retained binding's slot, its returned binding then owns the registration result and owner-record slot. Unrelated bindings that own a stale or provisional letter remain untouched. Same-target re-keying retains historical journal keys and exact source tuples without upgrading unknown completeness. Retained prune observations are bounded, non-routing and registration/profile/epoch/runtime-fenced. One unfinished preservation operation retains its admission identity across fresh-PID-proof retries; it can never become deletion authority. Leader quit requires completed delivery/polling/worker teardown under the captured session generation, profile and epoch; reload/new/resume/fork never establish inactivity. Unknown deletion outcomes retain their fence; only confirmed durable completion permits reuse.
91
92
  - Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only. Exception: the operator-approved fixed chooser deadline may discard its exact unclaimed donor pending source without a body archive or delivery-success claim. Accepted recipient queue/work and bound Threads are untouched; old donor controls and late reports cannot replay it. The lifetime and temporary cleanup contract lives in `docs/architecture.md`.
92
93
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
@@ -2,4 +2,10 @@
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
- No open tasks.
5
+ ## Transit Thread lifecycle
6
+
7
+ An All input's temporary routing Thread must outlive its pending choice and leave no residue.
8
+
9
+ - [ ] A Thread with a pending Reroute/Restore choice is never deleted by unbound-topic reconciliation; only its own Reroute, cancel or expiry removes it.
10
+ - [ ] Cancel resolves from the durable journal, so it still works after the tab or chooser message is gone.
11
+ - [ ] Expiry reports a visible "prompt not delivered" result instead of silently discarding the input as `routing-expiry`.
@@ -4,6 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.57.3: Session Thread Continuity
8
+
9
+ - `Session handoff`: A session replaced inside one Pi process now reuses its preserved Thread on every registration path, not only connect recovery. The successor re-keys onto the same tab and slot with the new session id, so `/new` no longer provisions a second tab and leaves the previous one without an owner.
10
+ - `Successor adoption`: A new session registering in a folder whose previous session is provably gone adopts that binding's exact target and slot, and the retained binding is re-keyed to the successor instead of leaving an orphan active row. A live predecessor, an ambiguous candidate set, an unknown runtime or a missing liveness probe keeps ordinary provisioning.
11
+
7
12
  ## 0.57.2: Connect Latency and Session Identity
8
13
 
9
14
  - `Connect latency`: A new Thread no longer waits about 20 seconds when all 26 letter slots are occupied. Slot rotation read the complete journal namespace and every shared journal once per binding inside one synchronous pass, freezing the leader's event loop and staling healthy follower heartbeats. One occupancy snapshot now reads that binding-independent evidence once, and every fresh snapshot still re-reads it so post-fence checks see newly arrived work.
@@ -1851,9 +1851,9 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1851
1851
  deps.setActiveAuthSecret?.(undefined);
1852
1852
  await deps.stopReceiving?.();
1853
1853
  };
1854
- const pendingHandoff = options && !options.recoverConnection
1855
- ? undefined
1856
- : getTelegramFollowerSessionHandoff();
1854
+ // A fresh handoff belongs to this same process, so a replaced session reuses its Thread: the tab keeps
1855
+ // one identity and only its owner rotates. Explicit options still override the preserved target.
1856
+ const pendingHandoff = getTelegramFollowerSessionHandoff();
1857
1857
  const pendingHandoffOptions = isTelegramFollowerSessionHandoffFresh(pendingHandoff)
1858
1858
  ? {
1859
1859
  target: pendingHandoff.target,
@@ -922,6 +922,55 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
922
922
  if (candidates.length === 1)
923
923
  restoredPredecessor = candidates[0];
924
924
  }
925
+ // A new session in a folder whose previous session is gone takes over that Thread: the tab keeps a single
926
+ // owner and the retired binding is re-keyed instead of being left behind as an orphan.
927
+ if (!restoredPredecessor &&
928
+ registration.cwd &&
929
+ registration.sessionId &&
930
+ !registration.previousInstanceId &&
931
+ deps.getLiveFollowers &&
932
+ deps.isFollowerProcessAlive) {
933
+ const identity = Threads.createTelegramWorkspaceBindingIdentity(registration.cwd, 0, registration.sessionId);
934
+ const liveFollowers = deps.getLiveFollowers();
935
+ const candidates = deps.topicTargetStore
936
+ .listWorkspaceBindings()
937
+ .filter((binding) => !!(identity &&
938
+ binding.cwd === identity.cwd &&
939
+ binding.sessionId !== identity.sessionId))
940
+ .flatMap((binding) => {
941
+ const matches = deps.topicTargetStore
942
+ .list()
943
+ .filter((record) => record.status === "active" &&
944
+ record.owner?.kind === "manual-follower" &&
945
+ record.target.chatId === binding.target.chatId &&
946
+ record.target.threadId === binding.target.threadId);
947
+ return matches.length === 1
948
+ ? [{ binding, record: matches[0] }]
949
+ : [];
950
+ })
951
+ .filter(({ record }) => {
952
+ if (liveFollowers.some((peer) => peer.instanceId === record.instanceId ||
953
+ (peer.target?.chatId === record.target.chatId &&
954
+ peer.target.threadId === record.target.threadId)))
955
+ return false;
956
+ const match = /^([1-9][0-9]*):[0-9]+$/.exec(record.instanceId ?? ""), pid = match ? Number(match[1]) : undefined;
957
+ if (pid === undefined || !Number.isSafeInteger(pid))
958
+ return false;
959
+ try {
960
+ return deps.isFollowerProcessAlive(pid) === false;
961
+ }
962
+ catch {
963
+ return false;
964
+ }
965
+ });
966
+ // Ambiguity keeps ordinary provisioning: never guess which retained tab a new session inherits.
967
+ if (candidates.length === 1 && identity?.sessionId) {
968
+ const candidate = candidates[0];
969
+ if (candidate.binding.sessionId &&
970
+ deps.topicTargetStore.adoptWorkspaceThreadBindingForSession(candidate.binding.cwd, candidate.binding.sessionId, identity.sessionId, candidate.binding.target))
971
+ restoredPredecessor = candidate.record;
972
+ }
973
+ }
925
974
  let capacityUnavailable = false;
926
975
  const workspaceIdentity = registration.cwd
927
976
  ? deps.topicTargetStore.claimWorkspaceIdentity(registration.cwd, registration.instanceId, registration.previousInstanceId ?? restoredPredecessor?.instanceId, {
@@ -569,6 +569,11 @@ export interface TelegramTopicTargetStore {
569
569
  sessionHandoff?: boolean;
570
570
  onCapacityUnavailable?: () => void;
571
571
  }) => TelegramWorkspaceBindingIdentity | undefined;
572
+ /**
573
+ * Re-keys one retained Thread binding from a retired session to its successor at the same slot and target,
574
+ * so the tab keeps a single owner. Fails without mutation when the source binding or slot identity is absent.
575
+ */
576
+ adoptWorkspaceThreadBindingForSession: (cwd: string, sourceSessionId: string, sessionId: string, target: TelegramTarget) => boolean;
572
577
  releaseWorkspaceClaim: (instanceId: string) => boolean;
573
578
  upsertWorkspaceBinding: (binding: TelegramWorkspaceThreadBinding, claimInstanceId?: string) => TelegramWorkspaceThreadBinding | undefined;
574
579
  upsert: (record: TelegramTopicTargetRecord) => TelegramTopicTargetRecord;
@@ -2877,6 +2877,34 @@ export function createTelegramTopicTargetStore(options) {
2877
2877
  }
2878
2878
  };
2879
2879
  };
2880
+ /**
2881
+ * Re-keys one retained Thread binding from a retired session to its successor at the same slot and target,
2882
+ * so the tab keeps a single owner. Fails without mutation when the source binding or slot identity is absent.
2883
+ */
2884
+ const rekeyWorkspaceBindingForSession = (cwd, sourceSessionId, sessionId, target) => {
2885
+ const sourceEntry = Array.from(workspaceBindings.entries()).find(([, binding]) => binding.cwd === cwd &&
2886
+ binding.sessionId === sourceSessionId &&
2887
+ targetMatches(binding.target, target));
2888
+ if (!sourceEntry)
2889
+ return false;
2890
+ const [sourceKey, sourceBinding] = sourceEntry;
2891
+ const replacementIdentity = Array.from({
2892
+ length: TELEGRAM_WORKSPACE_SLOTS.length,
2893
+ })
2894
+ .map((_, ordinal) => createTelegramWorkspaceBindingIdentityWithKey(sourceBinding.cwd, sourceBinding.workspaceKey, ordinal, sessionId))
2895
+ .find((identity) => identity?.instanceSlot === sourceBinding.instanceSlot);
2896
+ if (!replacementIdentity ||
2897
+ replacementIdentity.instanceSlot !== sourceBinding.instanceSlot)
2898
+ return false;
2899
+ workspaceBindings.delete(sourceKey);
2900
+ workspaceBindings.set(getWorkspaceBindingMapKey(replacementIdentity), {
2901
+ ...sourceBinding,
2902
+ ...replacementIdentity,
2903
+ updatedAtMs: getNowMs(),
2904
+ });
2905
+ markDirty();
2906
+ return true;
2907
+ };
2880
2908
  return {
2881
2909
  async load() {
2882
2910
  if (workspaceIO && dirty && !loadedScopeMatches(getPath()))
@@ -4797,6 +4825,20 @@ export function createTelegramTopicTargetStore(options) {
4797
4825
  const binding = workspaceBindings.get(mapKey);
4798
4826
  return binding ? cloneWorkspaceBinding(binding) : undefined;
4799
4827
  },
4828
+ adoptWorkspaceThreadBindingForSession(cwd, sourceSessionId, sessionId, target) {
4829
+ if (workspaceRetirementCommitInFlight)
4830
+ return false;
4831
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
4832
+ const normalizedSource = normalizeTelegramSessionId(sourceSessionId);
4833
+ const normalizedSession = normalizeTelegramSessionId(sessionId);
4834
+ if (!normalizedCwd ||
4835
+ !normalizedSource ||
4836
+ !normalizedSession ||
4837
+ normalizedSource === normalizedSession ||
4838
+ hasWorkspaceRetirementConflict({ cwd: normalizedCwd }))
4839
+ return false;
4840
+ return rekeyWorkspaceBindingForSession(normalizedCwd, normalizedSource, normalizedSession, target);
4841
+ },
4800
4842
  claimWorkspaceIdentity(cwd, instanceId, previousInstanceId, options) {
4801
4843
  if (workspaceRetirementCommitInFlight)
4802
4844
  return undefined;
@@ -4824,24 +4866,9 @@ export function createTelegramTopicTargetStore(options) {
4824
4866
  binding.sessionId === replacement.sourceSessionId &&
4825
4867
  targetMatches(binding.target, replacement.target));
4826
4868
  if (sourceEntry) {
4827
- const [sourceKey, sourceBinding] = sourceEntry;
4828
- const replacementIdentity = Array.from({
4829
- length: TELEGRAM_WORKSPACE_SLOTS.length,
4830
- })
4831
- .map((_, ordinal) => createTelegramWorkspaceBindingIdentityWithKey(sourceBinding.cwd, sourceBinding.workspaceKey, ordinal, normalizedSessionId))
4832
- .find((identity) => identity?.instanceSlot === sourceBinding.instanceSlot);
4833
- if (replacementIdentity &&
4834
- replacementIdentity.instanceSlot === sourceBinding.instanceSlot) {
4835
- const existingTargetRecord = Array.from(records.values()).find((record) => targetMatches(record.target, replacement.target));
4869
+ const existingTargetRecord = Array.from(records.values()).find((record) => targetMatches(record.target, replacement.target));
4870
+ if (rekeyWorkspaceBindingForSession(normalizedCwd, replacement.sourceSessionId, normalizedSessionId, replacement.target))
4836
4871
  replacementPreviousInstanceId = existingTargetRecord?.instanceId;
4837
- workspaceBindings.delete(sourceKey);
4838
- workspaceBindings.set(getWorkspaceBindingMapKey(replacementIdentity), {
4839
- ...sourceBinding,
4840
- ...replacementIdentity,
4841
- updatedAtMs: getNowMs(),
4842
- });
4843
- markDirty();
4844
- }
4845
4872
  }
4846
4873
  }
4847
4874
  const effectivePreviousInstanceId = previousInstanceId ?? replacementPreviousInstanceId;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.2",
3
+ "version": "0.57.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -3027,9 +3027,9 @@ export function createTelegramBusFollowerRegistrationRuntime<
3027
3027
  deps.setActiveAuthSecret?.(undefined);
3028
3028
  await deps.stopReceiving?.();
3029
3029
  };
3030
- const pendingHandoff = options && !options.recoverConnection
3031
- ? undefined
3032
- : getTelegramFollowerSessionHandoff();
3030
+ // A fresh handoff belongs to this same process, so a replaced session reuses its Thread: the tab keeps
3031
+ // one identity and only its owner rotates. Explicit options still override the preserved target.
3032
+ const pendingHandoff = getTelegramFollowerSessionHandoff();
3033
3033
  const pendingHandoffOptions = isTelegramFollowerSessionHandoffFresh(
3034
3034
  pendingHandoff,
3035
3035
  )
@@ -1583,6 +1583,80 @@ export function createTelegramBusFollowerTargetProvisioner(
1583
1583
  });
1584
1584
  if (candidates.length === 1) restoredPredecessor = candidates[0];
1585
1585
  }
1586
+ // A new session in a folder whose previous session is gone takes over that Thread: the tab keeps a single
1587
+ // owner and the retired binding is re-keyed instead of being left behind as an orphan.
1588
+ if (
1589
+ !restoredPredecessor &&
1590
+ registration.cwd &&
1591
+ registration.sessionId &&
1592
+ !registration.previousInstanceId &&
1593
+ deps.getLiveFollowers &&
1594
+ deps.isFollowerProcessAlive
1595
+ ) {
1596
+ const identity = Threads.createTelegramWorkspaceBindingIdentity(
1597
+ registration.cwd,
1598
+ 0,
1599
+ registration.sessionId,
1600
+ );
1601
+ const liveFollowers = deps.getLiveFollowers();
1602
+ const candidates = deps.topicTargetStore
1603
+ .listWorkspaceBindings()
1604
+ .filter(
1605
+ (binding) =>
1606
+ !!(
1607
+ identity &&
1608
+ binding.cwd === identity.cwd &&
1609
+ binding.sessionId !== identity.sessionId
1610
+ ),
1611
+ )
1612
+ .flatMap((binding) => {
1613
+ const matches = deps.topicTargetStore
1614
+ .list()
1615
+ .filter(
1616
+ (record) =>
1617
+ record.status === "active" &&
1618
+ record.owner?.kind === "manual-follower" &&
1619
+ record.target.chatId === binding.target.chatId &&
1620
+ record.target.threadId === binding.target.threadId,
1621
+ );
1622
+ return matches.length === 1
1623
+ ? [{ binding, record: matches[0]! }]
1624
+ : [];
1625
+ })
1626
+ .filter(({ record }) => {
1627
+ if (
1628
+ liveFollowers.some(
1629
+ (peer) =>
1630
+ peer.instanceId === record.instanceId ||
1631
+ (peer.target?.chatId === record.target.chatId &&
1632
+ peer.target.threadId === record.target.threadId),
1633
+ )
1634
+ )
1635
+ return false;
1636
+ const match = /^([1-9][0-9]*):[0-9]+$/.exec(record.instanceId ?? ""),
1637
+ pid = match ? Number(match[1]) : undefined;
1638
+ if (pid === undefined || !Number.isSafeInteger(pid)) return false;
1639
+ try {
1640
+ return deps.isFollowerProcessAlive!(pid) === false;
1641
+ } catch {
1642
+ return false;
1643
+ }
1644
+ });
1645
+ // Ambiguity keeps ordinary provisioning: never guess which retained tab a new session inherits.
1646
+ if (candidates.length === 1 && identity?.sessionId) {
1647
+ const candidate = candidates[0]!;
1648
+ if (
1649
+ candidate.binding.sessionId &&
1650
+ deps.topicTargetStore.adoptWorkspaceThreadBindingForSession(
1651
+ candidate.binding.cwd,
1652
+ candidate.binding.sessionId,
1653
+ identity.sessionId,
1654
+ candidate.binding.target,
1655
+ )
1656
+ )
1657
+ restoredPredecessor = candidate.record;
1658
+ }
1659
+ }
1586
1660
  let capacityUnavailable = false;
1587
1661
  const workspaceIdentity = registration.cwd
1588
1662
  ? deps.topicTargetStore.claimWorkspaceIdentity(
@@ -914,6 +914,16 @@ export interface TelegramTopicTargetStore {
914
914
  onCapacityUnavailable?: () => void;
915
915
  },
916
916
  ) => TelegramWorkspaceBindingIdentity | undefined;
917
+ /**
918
+ * Re-keys one retained Thread binding from a retired session to its successor at the same slot and target,
919
+ * so the tab keeps a single owner. Fails without mutation when the source binding or slot identity is absent.
920
+ */
921
+ adoptWorkspaceThreadBindingForSession: (
922
+ cwd: string,
923
+ sourceSessionId: string,
924
+ sessionId: string,
925
+ target: TelegramTarget,
926
+ ) => boolean;
917
927
  releaseWorkspaceClaim: (instanceId: string) => boolean;
918
928
  upsertWorkspaceBinding: (
919
929
  binding: TelegramWorkspaceThreadBinding,
@@ -4971,6 +4981,50 @@ export function createTelegramTopicTargetStore(
4971
4981
  };
4972
4982
  };
4973
4983
 
4984
+ /**
4985
+ * Re-keys one retained Thread binding from a retired session to its successor at the same slot and target,
4986
+ * so the tab keeps a single owner. Fails without mutation when the source binding or slot identity is absent.
4987
+ */
4988
+ const rekeyWorkspaceBindingForSession = (
4989
+ cwd: string,
4990
+ sourceSessionId: string,
4991
+ sessionId: string,
4992
+ target: TelegramTarget,
4993
+ ): boolean => {
4994
+ const sourceEntry = Array.from(workspaceBindings.entries()).find(
4995
+ ([, binding]) =>
4996
+ binding.cwd === cwd &&
4997
+ binding.sessionId === sourceSessionId &&
4998
+ targetMatches(binding.target, target),
4999
+ );
5000
+ if (!sourceEntry) return false;
5001
+ const [sourceKey, sourceBinding] = sourceEntry;
5002
+ const replacementIdentity = Array.from({
5003
+ length: TELEGRAM_WORKSPACE_SLOTS.length,
5004
+ })
5005
+ .map((_, ordinal) =>
5006
+ createTelegramWorkspaceBindingIdentityWithKey(
5007
+ sourceBinding.cwd,
5008
+ sourceBinding.workspaceKey,
5009
+ ordinal,
5010
+ sessionId,
5011
+ ),
5012
+ )
5013
+ .find((identity) => identity?.instanceSlot === sourceBinding.instanceSlot);
5014
+ if (
5015
+ !replacementIdentity ||
5016
+ replacementIdentity.instanceSlot !== sourceBinding.instanceSlot
5017
+ )
5018
+ return false;
5019
+ workspaceBindings.delete(sourceKey);
5020
+ workspaceBindings.set(getWorkspaceBindingMapKey(replacementIdentity), {
5021
+ ...sourceBinding,
5022
+ ...replacementIdentity,
5023
+ updatedAtMs: getNowMs(),
5024
+ });
5025
+ markDirty();
5026
+ return true;
5027
+ };
4974
5028
  return {
4975
5029
  async load() {
4976
5030
  if (workspaceIO && dirty && !loadedScopeMatches(getPath()))
@@ -7505,6 +7559,31 @@ export function createTelegramTopicTargetStore(
7505
7559
  const binding = workspaceBindings.get(mapKey);
7506
7560
  return binding ? cloneWorkspaceBinding(binding) : undefined;
7507
7561
  },
7562
+ adoptWorkspaceThreadBindingForSession(
7563
+ cwd,
7564
+ sourceSessionId,
7565
+ sessionId,
7566
+ target,
7567
+ ) {
7568
+ if (workspaceRetirementCommitInFlight) return false;
7569
+ const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
7570
+ const normalizedSource = normalizeTelegramSessionId(sourceSessionId);
7571
+ const normalizedSession = normalizeTelegramSessionId(sessionId);
7572
+ if (
7573
+ !normalizedCwd ||
7574
+ !normalizedSource ||
7575
+ !normalizedSession ||
7576
+ normalizedSource === normalizedSession ||
7577
+ hasWorkspaceRetirementConflict({ cwd: normalizedCwd })
7578
+ )
7579
+ return false;
7580
+ return rekeyWorkspaceBindingForSession(
7581
+ normalizedCwd,
7582
+ normalizedSource,
7583
+ normalizedSession,
7584
+ target,
7585
+ );
7586
+ },
7508
7587
  claimWorkspaceIdentity(cwd, instanceId, previousInstanceId, options) {
7509
7588
  if (workspaceRetirementCommitInFlight) return undefined;
7510
7589
  const normalizedCwd = normalizeTelegramWorkspacePath(cwd);
@@ -7539,41 +7618,18 @@ export function createTelegramTopicTargetStore(
7539
7618
  targetMatches(binding.target, replacement.target),
7540
7619
  );
7541
7620
  if (sourceEntry) {
7542
- const [sourceKey, sourceBinding] = sourceEntry;
7543
- const replacementIdentity = Array.from({
7544
- length: TELEGRAM_WORKSPACE_SLOTS.length,
7545
- })
7546
- .map((_, ordinal) =>
7547
- createTelegramWorkspaceBindingIdentityWithKey(
7548
- sourceBinding.cwd,
7549
- sourceBinding.workspaceKey,
7550
- ordinal,
7551
- normalizedSessionId,
7552
- ),
7553
- )
7554
- .find(
7555
- (identity) =>
7556
- identity?.instanceSlot === sourceBinding.instanceSlot,
7557
- );
7621
+ const existingTargetRecord = Array.from(records.values()).find(
7622
+ (record) => targetMatches(record.target, replacement.target),
7623
+ );
7558
7624
  if (
7559
- replacementIdentity &&
7560
- replacementIdentity.instanceSlot === sourceBinding.instanceSlot
7561
- ) {
7562
- const existingTargetRecord = Array.from(records.values()).find(
7563
- (record) => targetMatches(record.target, replacement.target),
7564
- );
7625
+ rekeyWorkspaceBindingForSession(
7626
+ normalizedCwd,
7627
+ replacement.sourceSessionId,
7628
+ normalizedSessionId,
7629
+ replacement.target,
7630
+ )
7631
+ )
7565
7632
  replacementPreviousInstanceId = existingTargetRecord?.instanceId;
7566
- workspaceBindings.delete(sourceKey);
7567
- workspaceBindings.set(
7568
- getWorkspaceBindingMapKey(replacementIdentity),
7569
- {
7570
- ...sourceBinding,
7571
- ...replacementIdentity,
7572
- updatedAtMs: getNowMs(),
7573
- },
7574
- );
7575
- markDirty();
7576
- }
7577
7633
  }
7578
7634
  }
7579
7635
  const effectivePreviousInstanceId =
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.57.2",
3
+ "version": "0.57.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.35.3",
3
+ "version": "0.35.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -48,7 +48,7 @@
48
48
  "@llblab/pi-codex-usage": "0.13.1",
49
49
  "@llblab/pi-grow-loop": "0.9.0",
50
50
  "@llblab/pi-state-flow": "0.26.4",
51
- "@llblab/pi-telegram": "0.57.2",
51
+ "@llblab/pi-telegram": "0.57.3",
52
52
  "@llblab/skills": "1.15.0"
53
53
  },
54
54
  "bundledDependencies": [