@llblab/pi-kit 0.34.3 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BACKLOG.md CHANGED
@@ -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.0 Telegram smoke (operator-owned):** After separately authorized installation/reload of both peers, verify same-process follower session replacement keeps the Thread, letter slot and title without a second connected notice. Check shared `threads.displayMode` through Settings across profiles and confirm the retired profile key is ignored without migration. Package validation does not certify installed clients.
7
8
  - **Installed 0.34.3 typing smoke (operator-owned):** After separately authorized installation/reload of both peers, verify native typing during streaming, UI prompts and compaction without recurring local authorization churn. Check actual client versions, including iOS; successful HTTP issuance and synthetic tests do not prove client visibility. Use disposable sessions and preserve accepted work.
8
9
  - **Installed 0.34.2 smoke (operator-owned):** After separately authorized installation/reload, verify Pi 1.2 custom-message completions no longer raise the Telegram prompt-hook error, adjacent button matrices retain their rows and callbacks, and native Windows Codex CLI fallback works on an actual Windows host. Use disposable settings; package validation and synthetic SDK probes do not certify installed clients or authorize live fault injection.
9
10
  - **Installed 0.34.0 Telegram smoke (operator-owned):** After separately authorized installation/reload of both peers, verify per-prompt Steer, canonical thumbs-up/thumbs-down/fire controls with other reactions ignored, reconnection after a deleted tab, follower `/start` Restore and first-click leader Restore confirmation with separately proven old-tab cleanup. Unknown creation can leave an extra tab; do not erase accepted work or replay uncertain input. Package/CI validation does not certify live clients.
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.0: Telegram Thread Continuity and Shared Settings
6
+
7
+ - `Telegram continuity`: Advances only the exact Telegram pin to `0.57.0`. Same-process follower session replacement preserves the Thread, letter slot and acknowledged title without a second connected notice; cross-process handoff notices use the acknowledged title. Other pins, package membership and resource order are unchanged.
8
+ - `Shared settings`: Automatic Thread display now uses top-level `threads.displayMode` for every bot profile. The old profile-scoped key is ignored without migration; configurations without the new key use Letters until Settings saves a choice. Profiles hold only bot identity and the paired owner.
9
+
5
10
  ## 0.34.3: Telegram Typing Continuity
6
11
 
7
12
  - `Telegram typing`: Advances only the exact Telegram pin to `0.56.4`. Streaming keepalives preserve the same owner's in-flight typing request, while session, transport and target changes still revoke old authority. Compaction cannot claim an existing agent loop. Other pins, package membership and resource order are unchanged; client-specific typing visibility remains a live check.
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.56.4` | 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.0` | 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.
@@ -47,6 +47,8 @@ Prefer the kit instead of separately loading the same packages. If you already u
47
47
 
48
48
  Telegram provides Normal/Priority/Steer prompt lanes and independent Keep/Skip controls. Only thumbs-up (Priority), thumbs-down (Skip) and fire (Steer) control the queue; other reactions are ignored. Steer inserts a correction at a model-step boundary, while Normal remains the default. Reroute preserves visible prompt copies and reply anchors, while Restore keeps command completion and old-tab cleanup separate. Explicit connection recovery may leave an extra tab after a lost creation response, but does not clear accepted input.
49
49
 
50
+ Telegram keeps the follower's Thread, letter slot and acknowledged title silently across same-process session replacement. Automatic Thread display is shared across bot profiles through top-level `threads.displayMode`; choose it through Telegram Settings. Profiles hold only bot identity and the paired owner.
51
+
50
52
  Telegram keeps bus protocol v3: update every Pi process that runs pi-telegram together; 0.53.x followers cannot register with current leaders. Restore and connection recovery additionally negotiate compatible capabilities, including follower `/start` and `/help` menu restoration; update both peers to use the new paths. Runtime storage stays in `tmp/pi-telegram`. Drain the prompt queue or accept losing waiting work before reload/restart; damaged-state reset discards all profiles' runtime continuity. `npm run validate` checks exact installed pins, declared resources, dependency audit and bundled inventory. It does not certify installed-client rendering; carried checks remain in [Backlog](./BACKLOG.md).
51
53
 
52
54
  ```bash
@@ -77,7 +77,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
77
77
  - Runtime files live under `<agent-dir>/tmp/pi-telegram` (releases before 0.52.0 used `tmp/telegram`, which a new release never writes, migrates or deletes). The older `owners.json` is read-only evidence: a live fresh owner there blocks acquisition so two release generations never poll one bot, and only its identity is exposed, never its bus endpoint or secret. Only `state.json` and `logs.jsonl` are persistent root files: the per-profile `transport` section of `state.json` is the sole transport-owner authority, `workspace` owns canonical Workspace/Restore evidence and `admission` its leases; the `runtime` section and `logs.jsonl` never grant routing authority. Each section changes only through the shared transaction under its own domain authority (nonleader admission remains process/birth-owned, not transport-owned); Workspace/runtime publication captures exact session authority, and acquisition, refresh, release, takeover, and irreversible leader work fence the exact owner/epoch. Ordinary reads and section mutations still refuse a damaged shared envelope without repair. Explicit `/telegram-connect` additionally grants one profile-admitted reset of matching untargeted unfinished Thread-creation metadata before Workspace identity allocation; it may leave an unknown Telegram tab behind, but never deletes a known target, Workspace binding or journal receipt. Automatic startup/re-registration and Restore-only admission cannot grant that reset. Known pending targets retain their exact binding key and slot; fresh provisioning uses unique operation IDs so late recovery evidence cannot attach to a replacement attempt. Follower reset intent uses authenticated `explicit-connect-recovery-v1` registration. Only a non-election leader start (`/telegram-connect` or session auto-start) runs the operator-approved optimistic reset: under the shared transaction guard it replaces damaged envelope/transport/Workspace/admission evidence with an empty envelope, accepting loss of every profile's runtime continuity; filesystem access errors still refuse (see [Damaged-State Reset](./docs/architecture.md#damaged-state-reset-operator-approved)). Use the [session journal contract](./docs/multi-instance-bus.md#session-owned-journal-storage) for owners-named polling continuity and the explicitly approved optimistic session-family sweep; that sweep is not Thread deletion or receipt settlement.
78
78
  - Threaded Mode has exactly one live leader per bot profile. Followers are real operator-started Pi processes and must authenticate/register over local IPC; Telegram never spawns hidden Pi processes. A live but unreachable owner does not authorize split-brain polling.
79
79
  - Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings. Follower registration identity may prepare a binding, but inbound generation authority is published only after successful preparation for the same generation and current context; pending or failed startup cannot append into a retained old journal. Readiness also binds the active Pi context and supplied session generation. Same-session refresh awaits binding preparation without re-registering; a changed session ID cannot publish readiness until its exact leader registration is acknowledged. Reusing a context object cannot carry readiness across a session-generation or ID change. Registration requests capture session authority before asynchronous startup and fence every publication/finalization against the current attempt. Stop or supersession invalidates that attempt; obsolete cleanup cannot erase a newer registration or a refreshed context.
80
- - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register`, capability-gated restore-only `follower.restoreWorkspace` and side-effect-free liveness `bus.probe` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader owns their serialized profile preference and title application.
80
+ - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register`, capability-gated restore-only `follower.restoreWorkspace` and side-effect-free liveness `bus.probe` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader serializes the shared preference write and owns title application.
81
81
  - Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Shutdown cancels/drains diagnostics publications and fences deferred status reads before they can acquire guards or repair files. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Follower heartbeat and leader-to-follower Restore/live-rebind control reply deadlines align with the eight-second leader stale-liveness window rather than the generic one-second local-RPC default; an ordinary multi-second Pi/TUI event-loop stall or a slower Windows named pipe is not registration or recipient loss. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
82
82
  - Runtime state is event-driven reconciliation of local assumptions against Telegram signals, not a complete bot read-model and not permission to query Telegram on every action. Destructive thread cleanup goes through `thread-reconciler` with current proof and leader fencing. Fresh Workspace Thread creation derives its initial Bot API title from the active display mode before issuance; the stable generated `threadName` remains separate from the acknowledged `displayTitle`.
83
83
 
@@ -109,7 +109,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
109
109
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for the captured generation's preceding preview work, never later drafts blocked by that publication, and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Reset/invalidation detaches predecessor dependencies; late settlements cannot mutate replacement state. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
110
110
  - Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Compaction may stop only a typing loop it actually started; it must preserve a pre-existing agent-owned loop. Refresh only the assigned Thread on a conservative three-second cadence; do not mirror typing to aggregate `All` and multiply shared-chat flood pressure. Typing is best-effort presence only, never a Thread-existence or message-delivery probe: a failed exact-target action remains a structured diagnostic and must not replace a healthy connected/leader/follower status with `error`. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
111
111
  - Public activity handlers and connected companion delivery are asynchronous, target-bound, generation-fenced surfaces. Connected companion projection has no independent opt-out: disconnect or authority loss is its boundary. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
112
- - Thread display defaults to the profile-scoped Letters strategy, with Names, Directory Snake, and Directory Title as the other automatic choices; Names projects the generated dictionary name for the slot. Unsupported retained display keys resolve to Letters without rewriting persisted configuration. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. After leader startup, automatic display contraction waits one follower-staleness window so election and follower re-registration cannot briefly remove and restore an acknowledged same-cwd suffix; stop/start generation cancels stale reconciliation. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable action labels use `emoji + space + text`; emoji-free action text is only a reasoned no-semantic-marker fallback. State/value controls follow the [button control hierarchy](./docs/ui-style.md#button-control-hierarchy), not the action-label grammar. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
112
+ - Profiles hold only bot identity and the paired owner; Settings-menu preferences live in shared top-level config. Thread display (`threads.displayMode`) defaults to the Letters strategy, with Names, Directory Snake, and Directory Title as the other automatic choices; Names projects the generated dictionary name for the slot. Unsupported retained display keys resolve to Letters without rewriting persisted configuration. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. After leader startup, automatic display contraction waits one follower-staleness window so election and follower re-registration cannot briefly remove and restore an acknowledged same-cwd suffix; stop/start generation cancels stale reconciliation. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable action labels use `emoji + space + text`; emoji-free action text is only a reasoned no-semantic-marker fallback. State/value controls follow the [button control hierarchy](./docs/ui-style.md#button-control-hierarchy), not the action-label grammar. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
113
113
 
114
114
  ## 5. Domain Ownership Index
115
115
 
@@ -4,6 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.57.0: Thread Continuity and Shared Settings
8
+
9
+ - `Config`: Thread display now lives in shared top-level `threads.displayMode`, beside the other Settings preferences; profiles hold only bot identity and the paired owner. The old `profiles.<name>.threadDisplayMode` key is not migrated and is ignored, so configs without the new key use the Letters default until Settings saves a choice.
10
+ - `Session replacement`: A follower's `/new` or `/fork` in the same Pi process now keeps its Thread silently. It keeps the same tab, letter slot and acknowledged display title, with no second connected notice. A connected notice for a handoff from another process now shows the acknowledged title instead of the generated dictionary name.
11
+
7
12
  ## 0.56.4: Typing Keepalive Continuity
8
13
 
9
14
  - `Typing continuity`: Streaming updates keep the current work's pending presence request alive instead of cancelling it through repeated loop replacement. Explicit starts, session or transport changes, Thread changes and stopping still revoke the previous action; compaction cannot claim an existing agent loop.
@@ -261,7 +261,7 @@ Classic private DM mode is the base product mode. When Telegram private-chat Thr
261
261
  - When all `A`–`Z` slots are reserved, a new connection may reclaim the oldest inactive, unprotected Thread. **This deletes that Thread and all its messages**, not Pi session history or project files. Active or protected work blocks reclamation; restoring a remembered session does not evict another binding.
262
262
  - Telegram never launches hidden Pi processes.
263
263
 
264
- In Settings → **🧵 Thread display**, choose Letters (default), Names, Directory Title or Directory Snake. Use `/name` for a custom Thread name and **Reset to automatic** to restore the selected naming style. Renaming preserves the Thread and its queued work. See [Thread Display](./docs/architecture.md#threaded-mode-multi-instance-bus) for naming details.
264
+ In Settings → **🧵 Thread display**, choose Letters (default), Names, Directory Title or Directory Snake. The choice is shared across all profiles through `threads.displayMode`; profiles contain only bot identity and the paired owner. Use `/name` for a custom Thread name and **Reset to automatic** to restore the selected naming style. Renaming preserves the Thread and its queued work. See [Thread Display](./docs/architecture.md#threaded-mode-multi-instance-bus) for naming details.
265
265
 
266
266
  | Mode | Best for | Runtime shape |
267
267
  | --- | --- | --- |
@@ -575,11 +575,7 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
575
575
  throw new Error(`Telegram profile "${profileName}" has no token.`);
576
576
  }
577
577
  await configStore.load();
578
- const latestProfile = configStore.getStoredConfig().profiles?.[profileName];
579
- configStore.setProfile(profileName, {
580
- ...profile,
581
- threadDisplayMode: latestProfile?.threadDisplayMode,
582
- });
578
+ configStore.setProfile(profileName, profile);
583
579
  configStore.activateProfile(profileName);
584
580
  await onTransportChanged?.();
585
581
  await persistConfig(configStore.get());
@@ -927,6 +927,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
927
927
  ? deps.topicTargetStore.claimWorkspaceIdentity(registration.cwd, registration.instanceId, registration.previousInstanceId ?? restoredPredecessor?.instanceId, {
928
928
  existingBindingOnly: options?.existingWorkspaceBindingOnly === true,
929
929
  sessionId: registration.sessionId,
930
+ sessionHandoff: registration.previousInstanceId !== undefined,
930
931
  provisionProfileKey: registration.profileKey ?? `manual:${registration.instanceId}`,
931
932
  onCapacityUnavailable() {
932
933
  capacityUnavailable = true;
@@ -1140,10 +1141,45 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
1140
1141
  };
1141
1142
  }
1142
1143
  const probeRequiredRecord = reconnectRecord?.status === "probe-required";
1143
- const requiresVisibilityProbe = crossSessionReuse ||
1144
+ // Pi /new or /fork inside one live process keeps its Thread; that process already proves the tab.
1145
+ const sameProcessSessionHandoff = crossSessionReuse &&
1146
+ reconnectRecord?.status === "active" &&
1147
+ registration.previousInstanceId === reconnectRecord.instanceId &&
1148
+ recoverableTarget === undefined &&
1149
+ Threads.isSameTelegramProcessInstance(reconnectRecord.instanceId, registration.instanceId);
1150
+ const requiresVisibilityProbe = (crossSessionReuse && !sameProcessSessionHandoff) ||
1144
1151
  probeRequiredRecord ||
1145
1152
  recoverableTarget !== undefined;
1146
- const retainedDisplayBinding = deps.topicTargetStore.getWorkspaceBindingByTarget(result.target, registration.sessionId);
1153
+ const transferSessionHandoffRecord = async () => {
1154
+ const nowMs = getNowMs();
1155
+ const transferredRecord = deps.topicTargetStore.upsert({
1156
+ ...result.record,
1157
+ profileKey: followerProfileKey,
1158
+ owner: followerOwner.kind === "manual-follower"
1159
+ ? followerOwner
1160
+ : {
1161
+ kind: "manual-follower",
1162
+ instanceId: registration.instanceId,
1163
+ },
1164
+ instanceId: registration.instanceId,
1165
+ updatedAtMs: nowMs,
1166
+ lastSyncObservedAtMs: nowMs,
1167
+ lastReconcileAction: "follower-session-handoff",
1168
+ });
1169
+ await deps.topicTargetStore.persist();
1170
+ result = {
1171
+ target: transferredRecord.target,
1172
+ reused: true,
1173
+ record: transferredRecord,
1174
+ };
1175
+ };
1176
+ if (sameProcessSessionHandoff) {
1177
+ assertRestoreRegistration();
1178
+ await transferSessionHandoffRecord();
1179
+ }
1180
+ // A session handoff still owns the predecessor session's binding until the Workspace commit re-keys it.
1181
+ const retainedDisplayBinding = deps.topicTargetStore.getWorkspaceBindingByTarget(result.target, registration.sessionId) ??
1182
+ deps.topicTargetStore.getWorkspaceBindingByTarget(result.target);
1147
1183
  let connectedAnnouncement = !result.reused || requiresVisibilityProbe
1148
1184
  ? createTelegramBusInstanceLifecycleAnnouncement({
1149
1185
  target: result.target,
@@ -1194,27 +1230,7 @@ export function createTelegramBusFollowerTargetProvisioner(deps) {
1194
1230
  };
1195
1231
  }
1196
1232
  else if (crossSessionReuse && reconnectRecord) {
1197
- const nowMs = getNowMs();
1198
- const transferredRecord = deps.topicTargetStore.upsert({
1199
- ...result.record,
1200
- profileKey: followerProfileKey,
1201
- owner: followerOwner.kind === "manual-follower"
1202
- ? followerOwner
1203
- : {
1204
- kind: "manual-follower",
1205
- instanceId: registration.instanceId,
1206
- },
1207
- instanceId: registration.instanceId,
1208
- updatedAtMs: nowMs,
1209
- lastSyncObservedAtMs: nowMs,
1210
- lastReconcileAction: "follower-session-handoff",
1211
- });
1212
- await deps.topicTargetStore.persist();
1213
- result = {
1214
- target: transferredRecord.target,
1215
- reused: true,
1216
- record: transferredRecord,
1217
- };
1233
+ await transferSessionHandoffRecord();
1218
1234
  }
1219
1235
  connectedAnnouncement = undefined;
1220
1236
  }
@@ -36,7 +36,7 @@ export interface ResolvedTelegramTimeConfig {
36
36
  timezone: string;
37
37
  }
38
38
  export type TelegramThreadDisplayMode = "letters" | "names" | "directories" | "directory-snake" | "directory-title";
39
- export declare function resolveTelegramThreadDisplayMode(config: Pick<TelegramConfig, "threadDisplayMode">): TelegramThreadDisplayMode;
39
+ export declare function resolveTelegramThreadDisplayMode(config: Pick<TelegramConfig, "threads">): TelegramThreadDisplayMode;
40
40
  export declare function setTelegramThreadDisplayMode(store: TelegramConfigStore, mode: TelegramThreadDisplayMode, isCurrent: () => boolean): Promise<void>;
41
41
  export type TelegramAssistantRenderingMode = "rich" | "html";
42
42
  export type TelegramActivityVerbosity = "quiet" | "thinking" | "tools" | "verbose";
@@ -49,8 +49,6 @@ export interface TelegramConfig {
49
49
  botId?: number;
50
50
  /** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
51
51
  allowedUserId?: number;
52
- /** Effective view; persisted under profiles.<name>. */
53
- threadDisplayMode?: TelegramThreadDisplayMode;
54
52
  inboundHandlers?: TelegramInboundHandlerConfig[];
55
53
  attachmentHandlers?: TelegramInboundHandlerConfig[];
56
54
  outboundHandlers?: TelegramOutboundHandlerConfig[];
@@ -76,22 +74,23 @@ export interface TelegramConfig {
76
74
  threads?: {
77
75
  /** Delete this instance's bound Telegram thread on graceful Pi quit. */
78
76
  automaticCleanup?: boolean;
77
+ /** Automatic Thread title strategy shared by every profile; absent or invalid resolves to `letters`. */
78
+ displayMode?: TelegramThreadDisplayMode;
79
79
  };
80
80
  /** Canonical bot/session profiles, including profiles.default. */
81
81
  profiles?: Record<string, TelegramBotProfile>;
82
82
  }
83
83
  /**
84
- * Per-profile bot/session identity and Thread display preference.
84
+ * Per-profile bot identity and paired owner only.
85
85
  * Stored under `profiles.<name>` in telegram.json.
86
- * Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
87
- * assistant) stay at the top level.
86
+ * Shared bridge settings (handlers, assistant, voice, time, threads) stay at
87
+ * the top level and never leak into profiles.
88
88
  */
89
89
  export interface TelegramBotProfile {
90
90
  botToken: string;
91
91
  botUsername?: string;
92
92
  botId?: number;
93
93
  allowedUserId?: number;
94
- threadDisplayMode?: TelegramThreadDisplayMode;
95
94
  }
96
95
  export declare function isValidTelegramProfileName(name: string): boolean;
97
96
  /** List defined profile names. */
@@ -86,8 +86,9 @@ const TELEGRAM_THREAD_DISPLAY_MODES = [
86
86
  "directory-title",
87
87
  ];
88
88
  export function resolveTelegramThreadDisplayMode(config) {
89
- return TELEGRAM_THREAD_DISPLAY_MODES.includes(config.threadDisplayMode)
90
- ? config.threadDisplayMode
89
+ const mode = config.threads?.displayMode;
90
+ return TELEGRAM_THREAD_DISPLAY_MODES.includes(mode)
91
+ ? mode
91
92
  : "letters";
92
93
  }
93
94
  export async function setTelegramThreadDisplayMode(store, mode, isCurrent) {
@@ -102,7 +103,8 @@ export async function setTelegramThreadDisplayMode(store, mode, isCurrent) {
102
103
  if (!current() || !store.hasBotToken()) {
103
104
  throw new Error("Telegram Thread display setting lost its configured profile.");
104
105
  }
105
- await store.persist({ ...store.get(), threadDisplayMode: mode }, { isCurrent: current });
106
+ const latest = store.get();
107
+ await store.persist({ ...latest, threads: { ...latest.threads, displayMode: mode } }, { isCurrent: current });
106
108
  if (!current())
107
109
  throw new Error("Telegram Thread display setting changed during persistence.");
108
110
  }
@@ -253,14 +255,11 @@ function pickTelegramRootProfileFields(config) {
253
255
  ...(config.allowedUserId !== undefined
254
256
  ? { allowedUserId: config.allowedUserId }
255
257
  : {}),
256
- ...(config.threadDisplayMode !== undefined
257
- ? { threadDisplayMode: config.threadDisplayMode }
258
- : {}),
259
258
  ...(legacyCursor !== undefined ? { lastUpdateId: legacyCursor } : {}),
260
259
  };
261
260
  }
262
261
  function omitTelegramRootProfileFields(config) {
263
- const { botToken: _botToken, botUsername: _botUsername, botId: _botId, allowedUserId: _allowedUserId, threadDisplayMode: _threadDisplayMode, lastUpdateId: _lastUpdateId, ...sharedConfig } = config;
262
+ const { botToken: _botToken, botUsername: _botUsername, botId: _botId, allowedUserId: _allowedUserId, lastUpdateId: _lastUpdateId, ...sharedConfig } = config;
264
263
  return sharedConfig;
265
264
  }
266
265
  function omitRetiredProactivePush(config) {
@@ -286,7 +285,6 @@ export function normalizeTelegramDefaultProfileConfig(config) {
286
285
  "botUsername",
287
286
  "botId",
288
287
  "allowedUserId",
289
- "threadDisplayMode",
290
288
  "lastUpdateId",
291
289
  ].some((field) => Object.hasOwn(config, field));
292
290
  if (!hasLegacyRootProfile) {
@@ -561,6 +561,8 @@ export interface TelegramTopicTargetStore {
561
561
  existingBindingOnly?: boolean;
562
562
  sessionId?: string;
563
563
  provisionProfileKey?: string;
564
+ /** Explicit same-process session handoff from `previousInstanceId`; never inferred. */
565
+ sessionHandoff?: boolean;
564
566
  onCapacityUnavailable?: () => void;
565
567
  }) => TelegramWorkspaceBindingIdentity | undefined;
566
568
  releaseWorkspaceClaim: (instanceId: string) => boolean;
@@ -4892,10 +4892,25 @@ export function createTelegramTopicTargetStore(options) {
4892
4892
  if (ownedProvisions.length > 1)
4893
4893
  return undefined;
4894
4894
  const ownProvision = ownedProvisions[0];
4895
- const retainedTarget = ownProvision?.target ?? binding?.target ?? legacyRecord?.target;
4896
- const retainedSlot = ownProvision?.slot ?? binding?.slot ?? legacyRecord?.slot;
4895
+ // A same-cwd session handoff keeps its predecessor's Thread slot; the Workspace commit re-keys that binding.
4896
+ const handoffBindings = binding ||
4897
+ ownProvision ||
4898
+ !options?.sessionHandoff ||
4899
+ !previousInstanceId ||
4900
+ !isSameTelegramProcessInstance(previousInstanceId, instanceId)
4901
+ ? []
4902
+ : Array.from(workspaceBindings.values()).filter((candidate) => candidate.cwd === identity.cwd &&
4903
+ candidate.sessionId !== identity.sessionId &&
4904
+ Array.from(records.values()).some((record) => record.instanceId === previousInstanceId &&
4905
+ record.status === "active" &&
4906
+ targetMatches(record.target, candidate.target)));
4907
+ const handoffBinding = handoffBindings.length === 1 ? handoffBindings[0] : undefined;
4908
+ const retainedTarget = ownProvision?.target ?? binding?.target ?? handoffBinding?.target ?? legacyRecord?.target;
4909
+ const retainedSlot = ownProvision?.slot ?? binding?.slot ?? handoffBinding?.slot ?? legacyRecord?.slot;
4897
4910
  const otherBindings = Array.from(workspaceBindings.values())
4898
- .filter((other) => other.bindingKey !== identity.bindingKey && other.slot)
4911
+ .filter((other) => other.bindingKey !== identity.bindingKey &&
4912
+ other.bindingKey !== handoffBinding?.bindingKey &&
4913
+ other.slot)
4899
4914
  .map((other) => ({
4900
4915
  bindingKey: other.bindingKey,
4901
4916
  slot: other.slot.toLowerCase(),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.4",
3
+ "version": "0.57.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -216,7 +216,7 @@ Production retirement requires a durable profile-scoped reader/writer ledger own
216
216
  - Retirement intent preparation/adoption/execution owns its exact gate-and-ledger protocol. The allocation wrapper invokes it outside ordinary registration/provisioning admission, after a typed capacity failure or to reconcile a retained retirement. Status projection and polling/routing bot-mode writes change only diagnostic or capability metadata; they neither create nor remove Thread/Workspace authority and serialize through the store's local persistence queue.
217
217
  - Production callers of reservation, provision/cleanup intent, target-record, Workspace-binding, and display mutators are contained by the owners above. The generic store remains policy-free for isolated tests and domain composition; calling a primitive directly is not production retirement authority.
218
218
  - Workspace identity remains the selected bot profile plus normalized exact full `cwd`; directory basenames are presentation, never routing keys. Each concurrent binding receives one profile-wide unique lowercase slot from `a` through `z`, persisted on the wire/store as its uppercase equivalent, independent of directory and leader/follower role. This replaces the two competing displayed allocation identities; immutable legacy `instanceSlot` and `bindingKey` remain recovery keys, not another displayed pool.
219
- - Automatic display mode is a bot-profile setting shared by Telegram Thread titles and Pi TUI status. The selector offers `letters`, `names`, `directory-snake`, then `directory-title`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; Names shows the generated dictionary name for the slot, such as `Anchor` for `A`. The retired `directories` value is unsupported: it resolves to `letters`, is omitted from Settings, and is not rewritten automatically. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides any automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Store the automatic preference at `profiles.<name>.threadDisplayMode`, not as a process-local choice or a setting shared by unrelated bots.
219
+ - Automatic display mode is one shared setting used by Telegram Thread titles and Pi TUI status across every bot profile. The selector offers `letters`, `names`, `directory-snake`, then `directory-title`; absent or invalid values resolve to `letters`. Letters show `A`, `B`, `C`; Names shows the generated dictionary name for the slot, such as `Anchor` for `A`. The retired `directories` value is unsupported: it resolves to `letters`, is omitted from Settings, and is not rewritten automatically. A durable per-Workspace `manualThreadName`, set through Telegram `/name`, overrides any automatic projection until explicitly reset. Bare `/name` immediately enters exact-target rename input: cancel is always available, while reset is shown only when a manual override exists; no intermediate action-selection step exists. Store the automatic preference at top-level `threads.displayMode`, never as a process-local choice or inside `profiles.<name>`; the retired profile key is ignored.
220
220
  - Directory formatting operates on the shortest path-segment suffix that distinguishes the normalized exact cwd from every other displayed cwd, while routing and identity continue to use the unmodified full cwd. Each segment is tokenized deterministically at runs of non-letter/non-number characters, lowercase-or-number to uppercase transitions, and the acronym boundary before an uppercase-plus-lowercase word (`apiPRDServer` becomes `api`, `PRD`, `Server`). Empty tokens are discarded. `directory-snake` lowercases tokens and joins them with `_`; segment qualifiers also join with `_`, so `/work/API tools` previews as `api_tools`. `directory-title` joins words with spaces and qualified segments with ` / `; an all-uppercase token containing a letter is preserved (`PRD` remains `PRD`), while every other token is lowercased and capitalized (`api_tools` becomes `Api Tools`). Unicode letters and numbers participate in tokenization and locale-independent Unicode case conversion; punctuation is only a separator. Root and token-empty segments retain the existing safe fallback instead of fabricating an empty title. On Linux/macOS, a binding whose normalized cwd exactly equals the current OS user's normalized home directory uses the display segment `home`: `home` in Snake and `Home` in Title. This is display-only, not a change to cwd, binding or routing identity. Child paths, another user's home and Windows use ordinary formatting; Letters, Names, legacy presentation and manual overrides are unchanged. The synthetic segment participates in qualification, so an unrelated `/work/home` remains `work_home` or `Work / Home` rather than colliding; ordinary live-owner suffix rules still produce `home_a` or `Home A`.
221
221
  - Settings previews are computed by the same pure projector used for initial creation and reconciliation, against the current binding set rather than illustrative hard-coded text. Slot suffixes are applied after base formatting (`api_tools_a` and `Api Tools A`) and remain outside abbreviation/case normalization; title-case suffixes use one plain space and never a middle-dot separator. Manual names are never normalized. Case-insensitive or 128-character-bounded collisions remain fail-closed under the existing ambiguity contract. New persisted modes and follower requests require a new negotiated display-format capability; an older peer may continue operating only while the effective profile mode remains Letters or Names, and cannot silently reinterpret either new value.
222
222
  - The unreachable legacy `directories` projector remains only for decoding old internal snapshots; effective configuration falls back to Letters. The `directory-snake` and `directory-title` modes derive suffix visibility exclusively from the leader's current authenticated live-owner snapshot: one live binding for a normalized exact cwd has no suffix; two or more live bindings for that cwd all expose their globally assigned slots; dormant retained bindings never affect the count. `showSlotSuffix` remains readable for legacy presentation but is ignored by both new projectors and is never written merely because live concurrency changed. The leader captures exact profile, epoch, binding target, and follower registration generation before projection; a provisioning candidate counts only after its authenticated exact owner is admitted to the same serialized Workspace mutation, while an owner losing authority is excluded before reconciliation. Registration/provision completion, authenticated disconnect or confirmed-dead prune, target replacement, and promotion schedule one serialized reconciliation of every affected live same-cwd binding. A failed or partial Telegram rename retains acknowledged per-binding progress and retries only under a fresh live snapshot; dormant tabs are not renamed until they regain authenticated ownership. Leader startup treats its authenticated follower roster as unsettled for one follower-staleness window: automatic display reconciliations requested by election or incremental re-registration are deferred and coalesced until that boundary. A same-cwd follower returning within the window therefore preserves the already acknowledged suffix without an `unsuffixed → suffixed` Telegram rename; if it stays absent, the settled reconcile removes the suffix once. Explicit mode changes remain immediate, and leader stop/restart invalidates the pending timer. Equal basenames from different paths still require deterministic parent-path qualification independently from same-cwd suffixing. Preserve the existing `threadName` as generated/recovery identity while automatic modes are selected. New manual names live only in `manualThreadName`; do not guess manual provenance from a legacy name or palette membership. Telegram `/name Name` changes the manual override and displayed title only for its exact originating target. Leader and follower requests carry that target through final generation/binding checks, so replacement cannot redirect a stale dialog mutation. Reset uses the same negotiated `workspace-thread-rename-v1` capability and exact follower generation; the leader computes the current automatic projection from the latest live snapshot, edits the exact target, clears only `manualThreadName`, and persists before acknowledging. Follower metadata refresh preserves an acknowledged display title only while target and registration generation stay unchanged. Named-profile setup preserves the latest saved automatic preference even if another instance changes it while the token form is open.
@@ -561,7 +561,7 @@ All files containing routing, chat ids, thread ids, or process details use priva
561
561
  - Each unfinished preservation retains one admission operation ID across sequential attempts, so a lost acquisition acknowledgement reuses and releases the exact lease instead of leaking another. A rejected pre-rename write leaves the owner intact; a committed write with lost acknowledgement is recognized without rewriting first inactivity. A liveness interruption is retryable, not successful settlement. Every attempt reacquires profile admission and the shared mutation gate, rechecks authority at rename, and preserves accepted work. Cancellation or scope loss never guesses away an unresolved admission lease; existing exact lease/dead-owner recovery remains authoritative.
562
562
  - Followers treat rejected/missing heartbeat acknowledgements as registration loss: retain the last known target locally, clear registered truth, show `reconnecting` instead of a healthy `follower` status, try to re-register with the current leader, wait a short leader-reload grace window, and retry. They promote only after the exact leader lease becomes stale or inactive; a live owner with an unreachable endpoint leaves the follower disconnected/retrying rather than creating a competing poller.
563
563
  - Persisted current manual-follower bindings survive abrupt process absence as restoration hints when Thread cleanup is disabled. When enabled, graceful Pi quit requests exact-generation teardown before lifecycle suspension; if that envelope is missed, stale pruning may delete only after the leader's OS confirms the exact registered PID has exited.
564
- - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. Cross-process/session restoration, including an exact immediate handoff, uses that connected message as its visibility probe before publishing registration. Typing is not proof that a retained tab accepts messages. A proven stale target enters existing replacement provisioning; an ambiguous send refuses registration without inventing deletion or retry authority.
564
+ - Fresh registration sends one compact connected notice in the assigned thread. Reusing a retained exact-session target resolves its acknowledged Workspace display title, even if the provisional claim uses another instance slot; a generated dictionary name is only fallback when no acknowledged title exists. Cross-process/session restoration, including an exact cross-process handoff, uses that connected message as its visibility probe before publishing registration. A same-process session replacement (`/new`, `/fork`) that carries the exact active predecessor record is silent: it transfers that record, keeps the Thread, letter slot and acknowledged title, and re-keys the Workspace binding to the new session without sending a notice or allocating another slot. Typing is not proof that a retained tab accepts messages. A proven stale target enters existing replacement provisioning; an ambiguous send refuses registration without inventing deletion or retry authority.
565
565
  - Registration requires a present generation, and explicit disconnect requires that same exact live generation. Leader-side registration and disconnect mutations serialize per durable follower profile across old and replacement runtime instance IDs, so a replacement registration cannot overtake awaited destructive cleanup and an old disconnect cannot remove its successor's routing authority.
566
566
  - Successful forwarded updates and follower-originated API calls refresh liveness, so active followers are not pruned only because the interval heartbeat tick lagged. Each follower lifecycle owns at most one in-flight heartbeat for its exact registration generation; stop/replacement makes late settlement inert, and recovery or diagnostic failure cannot escape as an unhandled interval Promise.
567
567
  - Destructive follower thread teardown belongs to confirmed `/telegram-disconnect`, graceful Pi quit, or confirmed reconciliation actions, not generic heartbeat pruning. Manual disconnect retains its destructive confirmation and clears restart ownership; quit deletes the tab without prompting when Thread cleanup is enabled (default) but preserves the owner slot independently so a same-directory restart can reclaim leadership. Confirmed leader/follower teardown first persists an exact target/runtime-generation cleanup intent. The active leader attempts deletion under its current epoch; interruption preserves the intent so that leader or a successor can replay it under current authority, and confirmed deletion removes the binding plus intent in the same persisted state transition. If the graceful request is missed, stale heartbeat plus OS-confirmed absence of the exact registered PID may authorize the same cleanup while enabled; this action serializes ahead of replacement registration. Disabled cleanup, silence, heartbeat expiry alone, IPC/auth failure, and live or unknown process liveness remain non-destructive. Incomplete cleanup preserves durable intent for retry. A promoted leader uses its current owned leader epoch even when the inherited record still carries a historical `manual-follower` owner label.
@@ -93,7 +93,6 @@ interface TelegramBotProfile {
93
93
  botUsername?: string; // runtime-managed
94
94
  botId?: number; // runtime-managed
95
95
  allowedUserId?: number;
96
- threadDisplayMode?: "letters" | "names" | "directory-snake" | "directory-title";
97
96
  }
98
97
 
99
98
  interface TelegramConfig {
@@ -115,15 +114,16 @@ interface TelegramConfig {
115
114
  };
116
115
  threads?: {
117
116
  automaticCleanup?: boolean;
117
+ displayMode?: "letters" | "names" | "directory-snake" | "directory-title";
118
118
  };
119
119
  }
120
120
  ```
121
121
 
122
- Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, `time`, and `threads` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
122
+ Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. A profile holds only bot identity and its paired owner; shared handlers plus every Settings-menu preference (`assistant`, `voice`, `time`, and `threads`) remain top-level and apply to all profiles. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
123
123
 
124
124
  The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
125
125
 
126
- Threaded Mode Settings exposes **Thread display** as Letters (default), Names, `directory-snake`, or `directory-title`; retained `directories` is unsupported, resolves to Letters, and is never rewritten automatically. `profiles.<name>.threadDisplayMode` is profile-scoped; absent and invalid values resolve to `letters`. Settings previews the current binding set through the same projector used for fresh titles and reconciliation. A manual `/name` override appears as `custom`; choosing any automatic Thread display option clears that override for the current Thread and immediately reapplies the selected projection. Names projects the generated dictionary name for the slot; the two directory modes tokenize Unicode path segments deterministically and render snake case or humanized title case, with `_a` or ` A` slot suffixes when required. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1`, the additional `directory-display-format-v1` capability for either new directory mode, and its exact registration generation. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
126
+ Threaded Mode Settings exposes **Thread display** as Letters (default), Names, `directory-snake`, or `directory-title`; retained `directories` is unsupported, resolves to Letters, and is never rewritten automatically. `threads.displayMode` is shared by every profile; absent and invalid values resolve to `letters`. The retired `profiles.<name>.threadDisplayMode` key is ignored, not migrated. Another profile's already-running leader applies a shared change at its next startup or display reconciliation. Settings previews the current binding set through the same projector used for fresh titles and reconciliation. A manual `/name` override appears as `custom`; choosing any automatic Thread display option clears that override for the current Thread and immediately reapplies the selected projection. Names projects the generated dictionary name for the slot; the two directory modes tokenize Unicode path segments deterministically and render snake case or humanized title case, with `_a` or ` A` slot suffixes when required. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1`, the additional `directory-display-format-v1` capability for either new directory mode, and its exact registration generation. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
127
127
 
128
128
  Hidden/default semantics are represented by absence:
129
129
 
@@ -1043,12 +1043,7 @@ export function registerTelegramCommandsAndTools({
1043
1043
  );
1044
1044
  }
1045
1045
  await configStore.load();
1046
- const latestProfile =
1047
- configStore.getStoredConfig().profiles?.[profileName];
1048
- configStore.setProfile(profileName, {
1049
- ...profile,
1050
- threadDisplayMode: latestProfile?.threadDisplayMode,
1051
- });
1046
+ configStore.setProfile(profileName, profile);
1052
1047
  configStore.activateProfile(profileName);
1053
1048
  await onTransportChanged?.();
1054
1049
  await persistConfig(configStore.get());
@@ -1591,6 +1591,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1591
1591
  {
1592
1592
  existingBindingOnly: options?.existingWorkspaceBindingOnly === true,
1593
1593
  sessionId: registration.sessionId,
1594
+ sessionHandoff: registration.previousInstanceId !== undefined,
1594
1595
  provisionProfileKey: registration.profileKey ?? `manual:${registration.instanceId}`,
1595
1596
  onCapacityUnavailable() {
1596
1597
  capacityUnavailable = true;
@@ -1854,11 +1855,52 @@ export function createTelegramBusFollowerTargetProvisioner(
1854
1855
  };
1855
1856
  }
1856
1857
  const probeRequiredRecord = reconnectRecord?.status === "probe-required";
1858
+ // Pi /new or /fork inside one live process keeps its Thread; that process already proves the tab.
1859
+ const sameProcessSessionHandoff =
1860
+ crossSessionReuse &&
1861
+ reconnectRecord?.status === "active" &&
1862
+ registration.previousInstanceId === reconnectRecord.instanceId &&
1863
+ recoverableTarget === undefined &&
1864
+ Threads.isSameTelegramProcessInstance(
1865
+ reconnectRecord.instanceId,
1866
+ registration.instanceId,
1867
+ );
1857
1868
  const requiresVisibilityProbe =
1858
- crossSessionReuse ||
1869
+ (crossSessionReuse && !sameProcessSessionHandoff) ||
1859
1870
  probeRequiredRecord ||
1860
1871
  recoverableTarget !== undefined;
1861
- const retainedDisplayBinding = deps.topicTargetStore.getWorkspaceBindingByTarget(result.target, registration.sessionId);
1872
+ const transferSessionHandoffRecord = async (): Promise<void> => {
1873
+ const nowMs = getNowMs();
1874
+ const transferredRecord = deps.topicTargetStore.upsert({
1875
+ ...result.record,
1876
+ profileKey: followerProfileKey,
1877
+ owner:
1878
+ followerOwner.kind === "manual-follower"
1879
+ ? followerOwner
1880
+ : {
1881
+ kind: "manual-follower",
1882
+ instanceId: registration.instanceId,
1883
+ },
1884
+ instanceId: registration.instanceId,
1885
+ updatedAtMs: nowMs,
1886
+ lastSyncObservedAtMs: nowMs,
1887
+ lastReconcileAction: "follower-session-handoff",
1888
+ });
1889
+ await deps.topicTargetStore.persist();
1890
+ result = {
1891
+ target: transferredRecord.target,
1892
+ reused: true,
1893
+ record: transferredRecord,
1894
+ };
1895
+ };
1896
+ if (sameProcessSessionHandoff) {
1897
+ assertRestoreRegistration();
1898
+ await transferSessionHandoffRecord();
1899
+ }
1900
+ // A session handoff still owns the predecessor session's binding until the Workspace commit re-keys it.
1901
+ const retainedDisplayBinding =
1902
+ deps.topicTargetStore.getWorkspaceBindingByTarget(result.target, registration.sessionId) ??
1903
+ deps.topicTargetStore.getWorkspaceBindingByTarget(result.target);
1862
1904
  let connectedAnnouncement =
1863
1905
  !result.reused || requiresVisibilityProbe
1864
1906
  ? createTelegramBusInstanceLifecycleAnnouncement({
@@ -1913,28 +1955,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1913
1955
  record: activatedRecord,
1914
1956
  };
1915
1957
  } else if (crossSessionReuse && reconnectRecord) {
1916
- const nowMs = getNowMs();
1917
- const transferredRecord = deps.topicTargetStore.upsert({
1918
- ...result.record,
1919
- profileKey: followerProfileKey,
1920
- owner:
1921
- followerOwner.kind === "manual-follower"
1922
- ? followerOwner
1923
- : {
1924
- kind: "manual-follower",
1925
- instanceId: registration.instanceId,
1926
- },
1927
- instanceId: registration.instanceId,
1928
- updatedAtMs: nowMs,
1929
- lastSyncObservedAtMs: nowMs,
1930
- lastReconcileAction: "follower-session-handoff",
1931
- });
1932
- await deps.topicTargetStore.persist();
1933
- result = {
1934
- target: transferredRecord.target,
1935
- reused: true,
1936
- record: transferredRecord,
1937
- };
1958
+ await transferSessionHandoffRecord();
1938
1959
  }
1939
1960
  connectedAnnouncement = undefined;
1940
1961
  } catch (error) {
@@ -146,12 +146,11 @@ const TELEGRAM_THREAD_DISPLAY_MODES: readonly TelegramThreadDisplayMode[] = [
146
146
  ];
147
147
 
148
148
  export function resolveTelegramThreadDisplayMode(
149
- config: Pick<TelegramConfig, "threadDisplayMode">,
149
+ config: Pick<TelegramConfig, "threads">,
150
150
  ): TelegramThreadDisplayMode {
151
- return TELEGRAM_THREAD_DISPLAY_MODES.includes(
152
- config.threadDisplayMode as TelegramThreadDisplayMode,
153
- )
154
- ? (config.threadDisplayMode as TelegramThreadDisplayMode)
151
+ const mode = config.threads?.displayMode;
152
+ return TELEGRAM_THREAD_DISPLAY_MODES.includes(mode as TelegramThreadDisplayMode)
153
+ ? (mode as TelegramThreadDisplayMode)
155
154
  : "letters";
156
155
  }
157
156
 
@@ -173,8 +172,9 @@ export async function setTelegramThreadDisplayMode(
173
172
  "Telegram Thread display setting lost its configured profile.",
174
173
  );
175
174
  }
175
+ const latest = store.get();
176
176
  await store.persist(
177
- { ...store.get(), threadDisplayMode: mode },
177
+ { ...latest, threads: { ...latest.threads, displayMode: mode } },
178
178
  { isCurrent: current },
179
179
  );
180
180
  if (!current())
@@ -196,8 +196,6 @@ export interface TelegramConfig {
196
196
  botId?: number;
197
197
  /** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
198
198
  allowedUserId?: number;
199
- /** Effective view; persisted under profiles.<name>. */
200
- threadDisplayMode?: TelegramThreadDisplayMode;
201
199
  inboundHandlers?: TelegramInboundHandlerConfig[];
202
200
  attachmentHandlers?: TelegramInboundHandlerConfig[];
203
201
  outboundHandlers?: TelegramOutboundHandlerConfig[];
@@ -223,23 +221,24 @@ export interface TelegramConfig {
223
221
  threads?: {
224
222
  /** Delete this instance's bound Telegram thread on graceful Pi quit. */
225
223
  automaticCleanup?: boolean;
224
+ /** Automatic Thread title strategy shared by every profile; absent or invalid resolves to `letters`. */
225
+ displayMode?: TelegramThreadDisplayMode;
226
226
  };
227
227
  /** Canonical bot/session profiles, including profiles.default. */
228
228
  profiles?: Record<string, TelegramBotProfile>;
229
229
  }
230
230
 
231
231
  /**
232
- * Per-profile bot/session identity and Thread display preference.
232
+ * Per-profile bot identity and paired owner only.
233
233
  * Stored under `profiles.<name>` in telegram.json.
234
- * Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
235
- * assistant) stay at the top level.
234
+ * Shared bridge settings (handlers, assistant, voice, time, threads) stay at
235
+ * the top level and never leak into profiles.
236
236
  */
237
237
  export interface TelegramBotProfile {
238
238
  botToken: string;
239
239
  botUsername?: string;
240
240
  botId?: number;
241
241
  allowedUserId?: number;
242
- threadDisplayMode?: TelegramThreadDisplayMode;
243
242
  }
244
243
 
245
244
  interface TelegramLegacyCursorCarrier {
@@ -510,9 +509,6 @@ function pickTelegramRootProfileFields(
510
509
  ...(config.allowedUserId !== undefined
511
510
  ? { allowedUserId: config.allowedUserId }
512
511
  : {}),
513
- ...(config.threadDisplayMode !== undefined
514
- ? { threadDisplayMode: config.threadDisplayMode }
515
- : {}),
516
512
  ...(legacyCursor !== undefined ? { lastUpdateId: legacyCursor } : {}),
517
513
  };
518
514
  }
@@ -523,7 +519,6 @@ function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
523
519
  botUsername: _botUsername,
524
520
  botId: _botId,
525
521
  allowedUserId: _allowedUserId,
526
- threadDisplayMode: _threadDisplayMode,
527
522
  lastUpdateId: _lastUpdateId,
528
523
  ...sharedConfig
529
524
  } = config as TelegramConfig & TelegramLegacyCursorCarrier;
@@ -561,7 +556,6 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
561
556
  "botUsername",
562
557
  "botId",
563
558
  "allowedUserId",
564
- "threadDisplayMode",
565
559
  "lastUpdateId",
566
560
  ].some((field) => Object.hasOwn(config, field));
567
561
  if (!hasLegacyRootProfile) {
@@ -904,6 +904,8 @@ export interface TelegramTopicTargetStore {
904
904
  existingBindingOnly?: boolean;
905
905
  sessionId?: string;
906
906
  provisionProfileKey?: string;
907
+ /** Explicit same-process session handoff from `previousInstanceId`; never inferred. */
908
+ sessionHandoff?: boolean;
907
909
  onCapacityUnavailable?: () => void;
908
910
  },
909
911
  ) => TelegramWorkspaceBindingIdentity | undefined;
@@ -7634,11 +7636,35 @@ export function createTelegramTopicTargetStore(
7634
7636
  (options?.provisionProfileKey !== undefined && value.profileKey === options.provisionProfileKey)));
7635
7637
  if (ownedProvisions.length > 1) return undefined;
7636
7638
  const ownProvision = ownedProvisions[0];
7637
- const retainedTarget = ownProvision?.target ?? binding?.target ?? legacyRecord?.target;
7638
- const retainedSlot = ownProvision?.slot ?? binding?.slot ?? legacyRecord?.slot;
7639
+ // A same-cwd session handoff keeps its predecessor's Thread slot; the Workspace commit re-keys that binding.
7640
+ const handoffBindings =
7641
+ binding ||
7642
+ ownProvision ||
7643
+ !options?.sessionHandoff ||
7644
+ !previousInstanceId ||
7645
+ !isSameTelegramProcessInstance(previousInstanceId, instanceId)
7646
+ ? []
7647
+ : Array.from(workspaceBindings.values()).filter(
7648
+ (candidate) =>
7649
+ candidate.cwd === identity.cwd &&
7650
+ candidate.sessionId !== identity.sessionId &&
7651
+ Array.from(records.values()).some(
7652
+ (record) =>
7653
+ record.instanceId === previousInstanceId &&
7654
+ record.status === "active" &&
7655
+ targetMatches(record.target, candidate.target),
7656
+ ),
7657
+ );
7658
+ const handoffBinding =
7659
+ handoffBindings.length === 1 ? handoffBindings[0] : undefined;
7660
+ const retainedTarget = ownProvision?.target ?? binding?.target ?? handoffBinding?.target ?? legacyRecord?.target;
7661
+ const retainedSlot = ownProvision?.slot ?? binding?.slot ?? handoffBinding?.slot ?? legacyRecord?.slot;
7639
7662
  const otherBindings = Array.from(workspaceBindings.values())
7640
7663
  .filter(
7641
- (other) => other.bindingKey !== identity.bindingKey && other.slot,
7664
+ (other) =>
7665
+ other.bindingKey !== identity.bindingKey &&
7666
+ other.bindingKey !== handoffBinding?.bindingKey &&
7667
+ other.slot,
7642
7668
  )
7643
7669
  .map((other) => ({
7644
7670
  bindingKey: other.bindingKey,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.56.4",
3
+ "version": "0.57.0",
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.34.3",
3
+ "version": "0.35.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -47,7 +47,7 @@
47
47
  "@llblab/pi-codex-usage": "0.13.1",
48
48
  "@llblab/pi-grow-loop": "0.9.0",
49
49
  "@llblab/pi-state-flow": "0.26.4",
50
- "@llblab/pi-telegram": "0.56.4",
50
+ "@llblab/pi-telegram": "0.57.0",
51
51
  "@llblab/skills": "1.15.0"
52
52
  },
53
53
  "bundledDependencies": [