@llblab/pi-telegram 0.51.3 → 0.51.5

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
@@ -125,7 +125,7 @@ The detailed map is canonical in [`docs/architecture.md`](./docs/architecture.md
125
125
  - Command templates remain compact and shell-free. Use string leaves or ordered `template` arrays; shell operators are not an execution contract. Examples use portable executable placeholders, never machine-local paths.
126
126
  - `telegram_attach` is the canonical file path and `telegram_message` the direct Markdown text/buttons path. Both require current direct or registered-follower authority and must not replace the normal active-turn reply.
127
127
  - Inbound handlers transform text/media before queueing; outbound handlers precede programmatic/provider fallbacks. Public contracts and ordering live in `docs/inbound.md`, `docs/outbound.md`, and `docs/public-api.md`.
128
- - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
128
+ - Pi integration uses public hooks and APIs. Telegram `/new` is scheduled against the exact durable update, dispatched only after that update is removed from the journal, and then routed through the single `/telegram-internal` Pi gateway via `pi.sendUserMessage(..., { expandPromptTemplates: true })`; only a runtime-armed typed action may execute, manual invocation reports that the command cannot be run manually, and the handler receives the real `ExtensionCommandContext` before calling `ctx.newSession()`. Before replacement, CAS-publish one exact expiring handoff in the profile target snapshot. `workspace-thread` successors re-key the matching Workspace binding; `classic-chat` successors preserve Profile/CWD/session/chat continuity without creating a binding or invoking topic APIs. Both atomically claim the intent before one terminal result, and the old `withSession` path never publishes the same success. A registered follower cannot persist leader-owned state: its Workspace Thread intent is published and claimed by the leader over capability-gated exact-generation bus RPC after leader-side binding validation, and records its source runtime instance so only that follower lineage may re-key or claim it. Never replace the session while inbound authority is unsettled, store stale command contexts, accept an expired or mismatched handoff, inject terminal input, spawn a shadow Pi process, or mutate session files.
129
129
 
130
130
  ## 7. Engineering Conventions
131
131
 
@@ -152,7 +152,7 @@ Before non-trivial work:
152
152
  While working:
153
153
 
154
154
  - Keep changes inside this repository; updating an installed Pi checkout is a separate operator action.
155
- - Rebuild the package with `npm run build` after edits. Pi loads `dist/pi-telegram/index.js`, so source-only changes are not live and `/reload` or process restart alone will reload stale compiled output.
155
+ - Rebuild the package with `npm run build` after edits. Pi loads `dist/pi-telegram/index.js`, so source-only changes are not live and `/reload` or process restart alone will reload stale compiled output. Keep the committed `dist/` synchronized for Git installs; builds use a temporary candidate and rollback-safe swap, while `npm run build:check` rejects drift without rewriting the tree.
156
156
  - Read large artifacts search-first and range-bounded. For `CHANGELOG.md`, inspect only the current release section unless older history is relevant.
157
157
  - Keep successful validation output compact; inspect focused failure tails. Prefer focused tests/typecheck during iteration and broad validation at a stable gate.
158
158
  - Preserve unrelated work and do not commit, publish, tag, deploy, or perform external actions without explicit authorization.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.51.5: Follower Thread new-session hotfix
8
+
9
+ - `Follower Thread /new`: Telegram `/new` now starts a new session in a follower's Pi process and preserves its Thread binding. The leader publishes and later claims the durable replacement intent through capability-gated, generation-fenced bus requests, validating its own live registration and Workspace binding before accepting the follower's request. Incompatible or stale leaders fail closed rather than silently switching sessions.
10
+ - `Follower restore diagnostics`: Session startup now records when automatic follower restore lacks a session binding, Threaded Mode, or leader-side Workspace authority, or finishes without connecting. Restore-only refusal still cannot create a new Thread; `/telegram-connect` remains the explicit recovery path.
11
+
12
+ ## 0.51.4: Resolver-owned Skills and drift-safe Git installs
13
+
14
+ - `Resolver-owned Skills`: Auto-discovered user/project checkouts now contribute source Skills even when Pi selects their compiled entrypoint, while manifest-loaded npm, Git, and Pi Kit packages retain `pi.skills` filters and package provenance. Resolver regressions cover checkout and filtered-package behavior; unsupported source manifest aliases are removed.
15
+ - `Drift-safe distributive`: The compiled runtime and Skill tree are now committed for self-contained Git installs. Builds compile into a temporary candidate and use a rollback-safe swap; validation rejects stale committed output without silently repairing it.
16
+
7
17
  ## 0.51.3: Follower recovery and filterable Skills
8
18
 
9
19
  - `Follower slot reconciliation`: Re-registration now treats the exact Workspace claim as canonical when a retained follower record carries a different slot. It repairs the record before binding commit, preserves the unrelated binding that owns the stale letter, and makes repeated registration idempotent instead of returning `Telegram Workspace binding claim changed.` forever.
package/README.md CHANGED
@@ -26,7 +26,7 @@ From git:
26
26
  pi install git:github.com/llblab/pi-telegram
27
27
  ```
28
28
 
29
- Installed npm/git packages expose bundled Skills through their `pi.skills` manifest, so Pi package filters can select individual Skills. A raw TypeScript checkout placed directly under Pi's `extensions` directory instead contributes its adjacent source Skills at runtime; the two discovery paths are mutually exclusive.
29
+ Installed npm/git packages expose bundled Skills through their `pi.skills` manifest, so Pi package filters and package provenance remain authoritative. A checkout auto-discovered directly under Pi's user or project `extensions` directory contributes its adjacent source Skills even when Pi selects the checkout's compiled entrypoint; the two discovery paths are mutually exclusive.
30
30
 
31
31
  The extension requires Pi `0.84.4` or newer, matching the package's peer dependencies. Its Activity API uses the public `agent_settled` lifecycle event to keep retries/continuations under one activity identity and release that identity only after the run fully settles.
32
32
 
@@ -319,7 +319,7 @@ The docs index lives at [docs/README.md](./docs/README.md).
319
319
 
320
320
  ## Development
321
321
 
322
- Pi loads the compiled `dist/pi-telegram/index.js` entrypoint. After every project change, run `npm run build` before `/reload`, restart, or live verification; reloading source without rebuilding can leave the running extension on stale compiled code.
322
+ Pi loads the compiled `dist/pi-telegram/index.js` entrypoint. The committed distributive also makes Git installs self-contained. After every project change, run `npm run build` before `/reload`, restart, or live verification; `npm run build:check` verifies source/artifact synchronization without rewriting it.
323
323
 
324
324
  ```bash
325
325
  npm run build
@@ -54,6 +54,8 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
54
54
  threadId: number;
55
55
  }) => Promise<string>;
56
56
  setThreadDisplayMode?: (mode: TelegramThreadDisplayMode) => Promise<void>;
57
+ /** Leader-mediated durable publication or successor claim; true only after exact commit. */
58
+ requestSessionReplacement?: (operation: "publish" | "settle", intent: Threads.TelegramSessionReplacementIntent) => Promise<boolean>;
57
59
  stop: () => void;
58
60
  }
59
61
  export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
@@ -11,7 +11,7 @@ import * as Threads from "./threads.js";
11
11
  import { parseTelegramUpdateJournalQueueOwner } from "./journal.js";
12
12
  import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS, } from "./locks.js";
13
13
  import { isTelegramApiMethodRetrySafe, TelegramApiCommitUnknownError, TelegramApiStaleTargetError, } from "./telegram-api.js";
14
- import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
14
+ import { createTelegramBusFollowerDeliveryIdentity, createTelegramBusFollowerTargetController, createTelegramBusForeignOwnedUpdateForwarder, createTelegramBusLocalServer, createTelegramBusRequestIdFactory, createUnauthorizedBusAck, getTelegramBusProtocolCompatibility, getTelegramBusSocketPath, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, resolveTelegramBusSocketPath, sendTelegramBusLocalEnvelope, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, } from "./bus.js";
15
15
  import { getTelegramBusTransportRetryPolicy, TELEGRAM_BUS_REGISTRATION_RETRY, } from "./bus-transport.js";
16
16
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
17
17
  export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
@@ -1214,8 +1214,13 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1214
1214
  deps.setActiveAuthSecret?.(undefined);
1215
1215
  await deps.stopReceiving?.();
1216
1216
  if (registrationOptions?.restoreWorkspace &&
1217
- response.error?.code === "workspace-binding-unavailable")
1217
+ response.error?.code === "workspace-binding-unavailable") {
1218
+ deps.recordRuntimeEvent?.("bus", "Telegram follower auto-connect refused by leader: Workspace binding unavailable.", {
1219
+ phase: "follower-auto-connect-skip",
1220
+ reason: "leader-binding-unavailable",
1221
+ });
1218
1222
  return false;
1223
+ }
1219
1224
  throw new Error(response.message ??
1220
1225
  "Telegram bus follower registration was rejected.");
1221
1226
  }
@@ -1426,6 +1431,54 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
1426
1431
  }
1427
1432
  return resetName;
1428
1433
  },
1434
+ async requestSessionReplacement(operation, intent) {
1435
+ if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
1436
+ throw new Error("Telegram follower is not registered with the leader.");
1437
+ }
1438
+ if (!hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT) ||
1439
+ !hasTelegramBusCapability(deps.registrationState?.getLeaderProtocol(), TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT)) {
1440
+ throw new Error("The active Telegram leader does not support follower session replacement. Update or restart that Pi instance.");
1441
+ }
1442
+ const expectedLeaderSocketPath = activeLeaderSocketPath;
1443
+ const expectedRegistrationGeneration = activeRegistrationGeneration;
1444
+ const expectedAuthSecret = activeAuthSecret;
1445
+ const requestId = deps.createRequestId();
1446
+ const response = await sendTelegramBusLocalEnvelope({
1447
+ socketPath: expectedLeaderSocketPath,
1448
+ timeoutMs: registrationTimeoutMs,
1449
+ retry: getTelegramBusTransportRetryPolicy({
1450
+ endpoint: expectedLeaderSocketPath,
1451
+ operation: "operation",
1452
+ }),
1453
+ envelope: {
1454
+ kind: operation === "publish"
1455
+ ? "follower.publishSessionReplacement"
1456
+ : "follower.settleSessionReplacement",
1457
+ requestId,
1458
+ auth: expectedAuthSecret,
1459
+ instanceId: deps.instanceId,
1460
+ registrationGeneration: expectedRegistrationGeneration,
1461
+ intent,
1462
+ sentAtMs: getNowMs(),
1463
+ },
1464
+ });
1465
+ if (response?.kind !== "bus.ack" || !response.ok ||
1466
+ response.requestId !== requestId ||
1467
+ !isRecord(response.result) || response.result.committed !== true) {
1468
+ throw new Error(response?.kind === "bus.ack"
1469
+ ? (response.message ?? "Telegram session replacement was rejected.")
1470
+ : "Telegram session replacement was not acknowledged.");
1471
+ }
1472
+ if (activeLeaderSocketPath !== expectedLeaderSocketPath ||
1473
+ activeRegistrationGeneration !== expectedRegistrationGeneration ||
1474
+ activeAuthSecret !== expectedAuthSecret ||
1475
+ (deps.registrationState &&
1476
+ deps.registrationState.getGeneration() !==
1477
+ expectedRegistrationGeneration)) {
1478
+ throw new Error("Telegram session replacement completed for a stale follower registration.");
1479
+ }
1480
+ return true;
1481
+ },
1429
1482
  async disconnectFromLeader() {
1430
1483
  if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
1431
1484
  return false;
@@ -135,6 +135,23 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
135
135
  runWorkspaceOperation?: TelegramBusWorkspaceAdmissionRunner;
136
136
  workspaceRotation?: TelegramWorkspaceSlotRotationPorts;
137
137
  }
138
+ export type TelegramBusFollowerSessionReplacementOperation = (follower: TelegramBusFollowerView, intent: Threads.TelegramSessionReplacementIntent, isCurrent: () => boolean) => Promise<boolean>;
139
+ /**
140
+ * Leader-owned durable session-replacement authority for registered followers.
141
+ * Followers cannot persist `state.json`; the leader validates the request
142
+ * against its live registry entry and authoritative Workspace binding before
143
+ * one CAS publication or claim. Follower memory is never accepted as binding
144
+ * evidence.
145
+ */
146
+ export declare function createTelegramBusFollowerSessionReplacementAuthority(deps: {
147
+ store: Pick<Threads.TelegramTopicTargetStore, "load" | "getWorkspaceBindingByTarget" | "getSessionReplacementIntent" | "commitSessionReplacementIntent" | "removeSessionReplacementIntent">;
148
+ getTelegramProfile?: () => string | undefined;
149
+ getNowMs?: () => number;
150
+ ttlMs?: number;
151
+ }): {
152
+ publish: TelegramBusFollowerSessionReplacementOperation;
153
+ settle: TelegramBusFollowerSessionReplacementOperation;
154
+ };
138
155
  export declare function createTelegramBusLeaderRuntimeAssembly<TContext>(deps: TelegramBusLeaderRuntimeAssemblyDeps<TContext>): TelegramBusLeaderRuntime<TContext> & {
139
156
  renameLeaderThreadAdmitted: (threadName: string, expectedTarget?: Threads.TelegramTopicTargetRecord["target"]) => Promise<Threads.TelegramTopicTargetRecord>;
140
157
  resetLeaderThreadName: (expectedTarget: Threads.TelegramTopicTargetRecord["target"]) => Promise<{
@@ -188,6 +205,8 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
188
205
  }> | {
189
206
  threadName: string;
190
207
  };
208
+ publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
209
+ settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
191
210
  getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
192
211
  onFollowerRegistered?: () => void;
193
212
  applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
@@ -273,6 +292,8 @@ export declare function createTelegramBusLeaderEnvelopeHandler(deps: {
273
292
  }> | {
274
293
  threadName: string;
275
294
  };
295
+ publishFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
296
+ settleFollowerSessionReplacement?: TelegramBusFollowerSessionReplacementOperation;
276
297
  getFollowerDisplayTitle?: (follower: TelegramBusFollowerView) => string | undefined;
277
298
  onFollowerRegistered?: () => void;
278
299
  applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
@@ -9,7 +9,7 @@ import { createTelegramThreadDisplayReconciler, resolveTelegramInitialWorkspaceD
9
9
  import * as ThreadReconciler from "./thread-reconciler.js";
10
10
  import { getTelegramApiErrorRequestTarget, isTelegramApiCommitUnknownError, } from "./telegram-api.js";
11
11
  import * as Threads from "./threads.js";
12
- import { createTelegramBusLocalServer, createUnauthorizedBusAck, getTelegramBusEnvelopeTrafficClass, getTelegramBusProtocolCompatibility, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, getTelegramBusFollowerSocketPath, sendTelegramBusLocalEnvelope, stripTelegramBusApiMetadata, TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, } from "./bus.js";
12
+ import { createTelegramBusLocalServer, createUnauthorizedBusAck, getTelegramBusEnvelopeTrafficClass, getTelegramBusProtocolCompatibility, hasTelegramBusCapability, isTelegramBusEnvelopeAuthorized, getTelegramBusFollowerSocketPath, sendTelegramBusLocalEnvelope, stripTelegramBusApiMetadata, TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION, TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF, TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT, TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME, TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE, TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT, } from "./bus.js";
13
13
  import { getTelegramBusTransportRetryPolicy } from "./bus-transport.js";
14
14
  import { createTelegramWorkspaceOperationRuntime, createTelegramWorkspaceSlotRotation, } from "./workspace-retirement.js";
15
15
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
@@ -27,6 +27,75 @@ function formatTelegramBusInstanceLabel(input) {
27
27
  return escapeHtml(threadName);
28
28
  return input.slot && /^[A-Z]$/.test(input.slot) ? input.slot : "?";
29
29
  }
30
+ /**
31
+ * Leader-owned durable session-replacement authority for registered followers.
32
+ * Followers cannot persist `state.json`; the leader validates the request
33
+ * against its live registry entry and authoritative Workspace binding before
34
+ * one CAS publication or claim. Follower memory is never accepted as binding
35
+ * evidence.
36
+ */
37
+ export function createTelegramBusFollowerSessionReplacementAuthority(deps) {
38
+ const getNowMs = deps.getNowMs ?? Date.now;
39
+ const ttlMs = deps.ttlMs ?? Threads.TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS;
40
+ const assertCommon = (follower, intent) => {
41
+ const cwd = follower.cwd
42
+ ? Threads.normalizeTelegramWorkspacePath(follower.cwd)
43
+ : undefined;
44
+ const sessionId = follower.sessionId
45
+ ? Threads.normalizeTelegramSessionId(follower.sessionId)
46
+ : undefined;
47
+ const nowMs = getNowMs();
48
+ if (intent.continuity !== "workspace-thread" ||
49
+ typeof intent.target.threadId !== "number" ||
50
+ follower.target?.chatId !== intent.target.chatId ||
51
+ follower.target.threadId !== intent.target.threadId ||
52
+ !cwd || cwd !== intent.cwd || !sessionId ||
53
+ intent.profileName !== (deps.getTelegramProfile?.() ?? "default")) {
54
+ throw new Error("Telegram session replacement does not match the current follower registration.");
55
+ }
56
+ if (intent.expiresAtMs <= nowMs || intent.expiresAtMs > nowMs + ttlMs) {
57
+ throw new Error("Telegram session replacement intent is expired or unbounded.");
58
+ }
59
+ return { cwd, sessionId };
60
+ };
61
+ return {
62
+ async publish(follower, intent, isCurrent) {
63
+ const { sessionId } = assertCommon(follower, intent);
64
+ if (intent.sourceInstanceId !== follower.instanceId ||
65
+ intent.sourceSessionId !== sessionId) {
66
+ throw new Error("Telegram session replacement source does not match the current follower registration.");
67
+ }
68
+ await deps.store.load();
69
+ if (!isCurrent())
70
+ return false;
71
+ const binding = deps.store.getWorkspaceBindingByTarget(intent.target);
72
+ if (!binding || binding.cwd !== intent.cwd ||
73
+ binding.sessionId !== intent.sourceSessionId ||
74
+ binding.slot !== intent.slot ||
75
+ (binding.manualThreadName ?? binding.threadName) !== intent.threadName) {
76
+ throw new Error("Telegram session replacement binding is unavailable.");
77
+ }
78
+ return deps.store.commitSessionReplacementIntent(intent, isCurrent);
79
+ },
80
+ async settle(follower, intent, isCurrent) {
81
+ const { sessionId } = assertCommon(follower, intent);
82
+ if (!intent.sourceInstanceId ||
83
+ (intent.sourceInstanceId !== follower.instanceId &&
84
+ intent.sourceInstanceId !== follower.previousInstanceId) ||
85
+ intent.sourceSessionId === sessionId) {
86
+ throw new Error("Telegram session replacement successor does not match the current follower registration.");
87
+ }
88
+ await deps.store.load();
89
+ if (!isCurrent())
90
+ return false;
91
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
92
+ intent.cwd) {
93
+ throw new Error("Telegram session replacement successor binding is unavailable.");
94
+ }
95
+ return deps.store.removeSessionReplacementIntent(intent, isCurrent);
96
+ },
97
+ };
98
+ }
30
99
  export function createTelegramBusLeaderRuntimeAssembly(deps) {
31
100
  const runWorkspaceAdmission = deps.getWorkspaceAdmission
32
101
  ? (input, operation) => {
@@ -327,6 +396,11 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
327
396
  }
328
397
  });
329
398
  });
399
+ const followerSessionReplacement = createTelegramBusFollowerSessionReplacementAuthority({
400
+ store: deps.topicTargetStore,
401
+ getTelegramProfile: deps.getTelegramProfile,
402
+ getNowMs: deps.runtime.getNowMs,
403
+ });
330
404
  const runtime = createTelegramBusLeaderRuntime({
331
405
  ...deps.runtime,
332
406
  applyThreadDisplayMode,
@@ -427,6 +501,16 @@ export function createTelegramBusLeaderRuntimeAssembly(deps) {
427
501
  }
428
502
  });
429
503
  }),
504
+ publishFollowerSessionReplacement: (follower, intent, isCurrent) => runWorkspaceOperation({
505
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
506
+ operationKind: "workspace.publish-follower-session-replacement",
507
+ scopes: [{ kind: "profile" }],
508
+ }, () => followerSessionReplacement.publish(follower, intent, isCurrent)),
509
+ settleFollowerSessionReplacement: (follower, intent, isCurrent) => runWorkspaceOperation({
510
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
511
+ operationKind: "workspace.settle-follower-session-replacement",
512
+ scopes: [{ kind: "profile" }],
513
+ }, () => followerSessionReplacement.settle(follower, intent, isCurrent)),
430
514
  onFollowerConfirmedDead: async (follower) => {
431
515
  await runWorkspaceOperation({
432
516
  operationId: createTelegramWorkspaceAdmissionOperationId(),
@@ -1796,6 +1880,63 @@ export function createTelegramBusLeaderEnvelopeHandler(deps) {
1796
1880
  }
1797
1881
  });
1798
1882
  }
1883
+ case "follower.publishSessionReplacement":
1884
+ case "follower.settleSessionReplacement": {
1885
+ const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
1886
+ return runFollowerMutation(registeredFollower ?? { instanceId: envelope.instanceId }, async () => {
1887
+ const publish = envelope.kind === "follower.publishSessionReplacement";
1888
+ const operation = publish
1889
+ ? deps.publishFollowerSessionReplacement
1890
+ : deps.settleFollowerSessionReplacement;
1891
+ const epoch = deps.getCurrentLeaderEpoch?.();
1892
+ const follower = deps.followerRegistry.get(envelope.instanceId);
1893
+ const isCurrent = () => epoch !== undefined &&
1894
+ deps.getCurrentLeaderEpoch?.() === epoch &&
1895
+ deps.followerRegistry.get(envelope.instanceId)?.registrationGeneration ===
1896
+ envelope.registrationGeneration;
1897
+ if (!follower?.registrationGeneration ||
1898
+ follower.registrationGeneration !== envelope.registrationGeneration ||
1899
+ !isCurrent() || !operation ||
1900
+ !hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT) ||
1901
+ !hasTelegramBusCapability(follower.protocol, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT)) {
1902
+ return {
1903
+ kind: "bus.ack",
1904
+ requestId: envelope.requestId,
1905
+ ok: false,
1906
+ message: "Telegram session replacement requires current follower registration and compatible leader authority.",
1907
+ };
1908
+ }
1909
+ try {
1910
+ const committed = await operation(follower, envelope.intent, isCurrent);
1911
+ if (!committed || !isCurrent()) {
1912
+ return {
1913
+ kind: "bus.ack",
1914
+ requestId: envelope.requestId,
1915
+ ok: false,
1916
+ message: publish
1917
+ ? "Telegram session replacement intent was not persisted."
1918
+ : "Telegram session replacement intent was not claimed.",
1919
+ };
1920
+ }
1921
+ return {
1922
+ kind: "bus.ack",
1923
+ requestId: envelope.requestId,
1924
+ ok: true,
1925
+ result: { committed: true },
1926
+ };
1927
+ }
1928
+ catch (error) {
1929
+ return {
1930
+ kind: "bus.ack",
1931
+ requestId: envelope.requestId,
1932
+ ok: false,
1933
+ message: error instanceof Error
1934
+ ? error.message
1935
+ : "Telegram session replacement request failed.",
1936
+ };
1937
+ }
1938
+ });
1939
+ }
1799
1940
  case "follower.disconnect": {
1800
1941
  const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
1801
1942
  return runFollowerMutation(registeredFollower ?? { instanceId: envelope.instanceId }, async () => {
@@ -2303,6 +2444,8 @@ export function createTelegramBusLeaderRuntime(deps) {
2303
2444
  onFollowerDisconnected: deps.onFollowerDisconnected,
2304
2445
  renameFollowerThread: deps.renameFollowerThread,
2305
2446
  resetFollowerThreadName: deps.resetFollowerThreadName,
2447
+ publishFollowerSessionReplacement: deps.publishFollowerSessionReplacement,
2448
+ settleFollowerSessionReplacement: deps.settleFollowerSessionReplacement,
2306
2449
  getFollowerDisplayTitle: deps.getFollowerDisplayTitle,
2307
2450
  onFollowerRegistered: deps.onFollowerRegistered,
2308
2451
  applyThreadDisplayMode: deps.applyThreadDisplayMode,
package/dist/lib/bus.d.ts CHANGED
@@ -8,6 +8,7 @@ import { type TelegramBusTransportEventRecorder, type TelegramBusTransportRetryP
8
8
  import { type TelegramQueueHandoffPayload } from "./queue.ts";
9
9
  import type { TelegramTarget } from "./target.ts";
10
10
  import type { TelegramThreadDisplayMode } from "./config.ts";
11
+ import { type TelegramSessionReplacementIntent } from "./threads.ts";
11
12
  export interface TelegramBusProcessRuntime {
12
13
  instanceId: string;
13
14
  processId: number;
@@ -60,6 +61,7 @@ export declare const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME: "workspace
60
61
  export declare const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE: "thread-display-mode-v1";
61
62
  export declare const TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT: "directory-display-format-v1";
62
63
  export declare const TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT: "workspace-follower-auto-connect-v1";
64
+ export declare const TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT: "session-replacement-intent-v1";
63
65
  export interface TelegramBusProtocolIdentity {
64
66
  protocolVersion: number;
65
67
  runtimeBuild: string;
@@ -249,6 +251,13 @@ export type TelegramBusEnvelope = ({
249
251
  threadId: number;
250
252
  };
251
253
  sentAtMs: number;
254
+ } | {
255
+ kind: "follower.publishSessionReplacement" | "follower.settleSessionReplacement";
256
+ requestId: string;
257
+ instanceId: string;
258
+ registrationGeneration: string;
259
+ intent: TelegramSessionReplacementIntent;
260
+ sentAtMs: number;
252
261
  } | {
253
262
  kind: "leader.forwardCallback";
254
263
  requestId: string;
package/dist/lib/bus.js CHANGED
@@ -15,6 +15,7 @@ import { classifyTelegramBusTransportError, createTelegramBusTransportTimeoutErr
15
15
  import { TELEGRAM_QUEUE_HANDOFF_MAX_RECEIPTS, TELEGRAM_QUEUE_HANDOFF_PAYLOAD_MAX_BYTES, } from "./queue.js";
16
16
  import { isProcessAlive } from "./locks.js";
17
17
  import { resolveAgentDir } from "./paths.js";
18
+ import { normalizeTelegramSessionReplacementIntent, } from "./threads.js";
18
19
  function readDarwinProcessStart(pid) {
19
20
  return execFileSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], {
20
21
  encoding: "utf8",
@@ -124,6 +125,7 @@ export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME = "workspace-thread
124
125
  export const TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE = "thread-display-mode-v1";
125
126
  export const TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT = "directory-display-format-v1";
126
127
  export const TELEGRAM_BUS_CAPABILITY_WORKSPACE_FOLLOWER_AUTO_CONNECT = "workspace-follower-auto-connect-v1";
128
+ export const TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT = "session-replacement-intent-v1";
127
129
  export function createTelegramBusProtocolIdentity(input) {
128
130
  const runtimeBuild = input.runtimeBuild.trim();
129
131
  if (!runtimeBuild || runtimeBuild.length > 128) {
@@ -505,6 +507,23 @@ export function parseTelegramBusEnvelope(line) {
505
507
  }
506
508
  break;
507
509
  }
510
+ case "follower.publishSessionReplacement":
511
+ case "follower.settleSessionReplacement": {
512
+ const intent = normalizeTelegramSessionReplacementIntent(value.intent);
513
+ if (typeof value.instanceId === "string" &&
514
+ typeof value.registrationGeneration === "string" &&
515
+ typeof value.sentAtMs === "number" && intent) {
516
+ envelope = {
517
+ kind,
518
+ requestId,
519
+ instanceId: value.instanceId,
520
+ registrationGeneration: value.registrationGeneration,
521
+ intent,
522
+ sentAtMs: value.sentAtMs,
523
+ };
524
+ }
525
+ break;
526
+ }
508
527
  case "leader.forwardCallback":
509
528
  envelope = parseForwardCallbackEnvelope(value, requestId);
510
529
  break;
@@ -645,6 +645,19 @@ export interface TelegramSessionActionAssemblyDeps {
645
645
  };
646
646
  getProfileName: () => string | undefined;
647
647
  ownsPersistence: () => boolean;
648
+ /**
649
+ * Registered-follower port. A follower cannot persist leader-owned state, so
650
+ * its Workspace Thread intent is published and claimed by the leader over
651
+ * authenticated generation-fenced bus RPC.
652
+ */
653
+ follower?: {
654
+ instanceId: string;
655
+ isRegisteredFor: (target: {
656
+ chatId: number;
657
+ threadId?: number;
658
+ }) => boolean;
659
+ requestSessionReplacement: (operation: "publish" | "settle", intent: TelegramSessionReplacementIntent) => Promise<boolean>;
660
+ };
648
661
  sendResult: (target: {
649
662
  chatId: number;
650
663
  threadId?: number;
@@ -1294,7 +1294,15 @@ export function createTelegramSessionActionAssembly(deps) {
1294
1294
  sendUserMessage: deps.sendUserMessage,
1295
1295
  notifyResult(target, result) { return sendTerminalResult(target, result); },
1296
1296
  async prepareReplacement(ctx, updateId, target) {
1297
- await deps.store.load();
1297
+ const follower = !deps.ownsPersistence() && typeof target.threadId === "number" &&
1298
+ deps.follower?.isRegisteredFor(target)
1299
+ ? deps.follower
1300
+ : undefined;
1301
+ // Follower memory is not authority; reread the leader-published snapshot.
1302
+ if (follower && deps.store.refresh)
1303
+ await deps.store.refresh();
1304
+ else
1305
+ await deps.store.load();
1298
1306
  const sessionId = ctx.sessionManager.getSessionId();
1299
1307
  const binding = typeof target.threadId === "number"
1300
1308
  ? deps.store.getWorkspaceBindingByTarget(target)
@@ -1304,7 +1312,7 @@ export function createTelegramSessionActionAssembly(deps) {
1304
1312
  throw new Error("Telegram session replacement binding is unavailable.");
1305
1313
  }
1306
1314
  const createdAtMs = now();
1307
- if (!await deps.store.commitSessionReplacementIntent({
1315
+ const intent = {
1308
1316
  continuity: binding ? "workspace-thread" : "classic-chat",
1309
1317
  cwd: binding?.cwd ?? ctx.cwd,
1310
1318
  profileName: deps.getProfileName() ?? "default",
@@ -1317,7 +1325,11 @@ export function createTelegramSessionActionAssembly(deps) {
1317
1325
  ? { threadName: binding.manualThreadName ?? binding.threadName } : {}),
1318
1326
  createdAtMs,
1319
1327
  expiresAtMs: createdAtMs + deps.handoffTtlMs,
1320
- }, deps.ownsPersistence)) {
1328
+ ...(follower ? { sourceInstanceId: follower.instanceId } : {}),
1329
+ };
1330
+ if (!await (follower
1331
+ ? follower.requestSessionReplacement("publish", intent)
1332
+ : deps.store.commitSessionReplacementIntent(intent, deps.ownsPersistence))) {
1321
1333
  throw new Error("Telegram session replacement intent was not persisted.");
1322
1334
  }
1323
1335
  },
@@ -1331,11 +1343,22 @@ export function createTelegramSessionActionAssembly(deps) {
1331
1343
  return {
1332
1344
  async getIntent() { await deps.store.refresh?.(); return deps.store.getSessionReplacementIntent(); },
1333
1345
  hasSuccessorContinuity(intent) {
1334
- return intent.continuity === "classic-chat" ||
1335
- deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd === intent.cwd;
1346
+ if (intent.continuity === "classic-chat")
1347
+ return true;
1348
+ if (deps.store.getWorkspaceBindingByTarget(intent.target, sessionId)?.cwd !==
1349
+ intent.cwd)
1350
+ return false;
1351
+ // A follower successor claims only after its own re-registration is live.
1352
+ return intent.sourceInstanceId === undefined || deps.ownsPersistence() ||
1353
+ deps.follower?.isRegisteredFor(intent.target) === true;
1336
1354
  },
1337
1355
  editSuccess(intent) { return deps.sendResult(intent.target, "<b>🆕 New session started.</b>"); },
1338
- clearIntent(intent) { return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence); },
1356
+ async clearIntent(intent) {
1357
+ if (intent.sourceInstanceId === undefined || deps.ownsPersistence()) {
1358
+ return deps.store.removeSessionReplacementIntent(intent, deps.ownsPersistence);
1359
+ }
1360
+ return await deps.follower?.requestSessionReplacement("settle", intent) ?? false;
1361
+ },
1339
1362
  profileName: deps.getProfileName() ?? "default",
1340
1363
  cwd: ctx.cwd,
1341
1364
  sessionId,
@@ -60,6 +60,7 @@ const telegramBusProtocolIdentity = Bus.createTelegramCurrentBusProtocolIdentity
60
60
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_THREAD_RENAME,
61
61
  Bus.TELEGRAM_BUS_CAPABILITY_THREAD_DISPLAY_MODE,
62
62
  Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
63
+ Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
63
64
  ]);
64
65
  // --- Extension Runtime ---
65
66
  export default function (pi) {
@@ -191,6 +192,21 @@ export default function (pi) {
191
192
  store: threadStore,
192
193
  getProfileName: configStore.getActiveProfileName,
193
194
  ownsPersistence: lockRuntime.owns,
195
+ follower: {
196
+ instanceId: telegramInstanceId,
197
+ isRegisteredFor(target) {
198
+ const registered = telegramBusFollowerRegistrationState.getTarget();
199
+ return telegramBusFollowerRegistrationState.isRegistered() &&
200
+ registered?.chatId === target.chatId &&
201
+ registered.threadId === target.threadId;
202
+ },
203
+ async requestSessionReplacement(operation, intent) {
204
+ const request = telegramBusFollowerRegistration.requestSessionReplacement;
205
+ if (!request)
206
+ return false;
207
+ return await request(operation, intent);
208
+ },
209
+ },
194
210
  async sendResult(target, html) {
195
211
  const delivery = await Delivery.sendTelegramView({ text: html, parseMode: "html", replyMarkup: { inline_keyboard: [] } }, { scope: { kind: "target", target } });
196
212
  return delivery.ok ? { ok: true } : { ok: false,
package/dist/lib/locks.js CHANGED
@@ -1171,8 +1171,12 @@ export function createTelegramLockedPollingRuntime(deps) {
1171
1171
  return;
1172
1172
  if (canRestoreRememberedFollower && state?.kind === "active-elsewhere") {
1173
1173
  const restored = await deps.restoreFollowerWithOwner?.(ctx, state.lock);
1174
- if (!isCurrent() || !restored)
1174
+ if (!isCurrent())
1175
1175
  return;
1176
+ if (!restored) {
1177
+ deps.recordRuntimeEvent?.("lock", "Telegram follower auto-connect did not restore this session.", { phase: "follower-auto-connect-unavailable" });
1178
+ return;
1179
+ }
1176
1180
  deps.onTransportAvailabilityChanged?.();
1177
1181
  deps.updateStatus(ctx);
1178
1182
  deps.recordRuntimeEvent?.("bus", "Telegram follower auto-connect completed", { phase: "follower-auto-connect" });
@@ -752,10 +752,14 @@ export function createTelegramThreadAwarePollingPorts(deps) {
752
752
  return deps.registerFollowerWithLeader(ctx, owner);
753
753
  };
754
754
  const restoreFollowerWithOwner = async (ctx, owner) => {
755
- if (!(await refreshFollowerState()))
755
+ if (!(await refreshFollowerState())) {
756
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: Threaded Mode is not enabled.", { phase: "follower-auto-connect-skip", reason: "thread-mode-unavailable" });
756
757
  return undefined;
757
- if (!deps.hasRememberedWorkspaceBinding?.(ctx))
758
+ }
759
+ if (!deps.hasRememberedWorkspaceBinding?.(ctx)) {
760
+ deps.recordEvent("bus", "Telegram follower auto-connect skipped: no binding for this session.", { phase: "follower-auto-connect-skip", reason: "session-binding-unavailable" });
758
761
  return undefined;
762
+ }
759
763
  return deps.restoreFollowerWithLeader?.(ctx, owner);
760
764
  };
761
765
  return {
@@ -4,5 +4,11 @@
4
4
  * Owns source-checkout skill contribution; installed packages use their manifest
5
5
  */
6
6
  import type { ExtensionAPI } from "./pi.ts";
7
+ export declare function getTelegramExtensionPackageRoot(modulePath: string): string;
8
+ export interface TelegramRawExtensionCheckoutOptions {
9
+ agentDir?: string;
10
+ cwd?: string;
11
+ }
12
+ export declare function isRawTelegramExtensionCheckout(modulePath: string, options?: TelegramRawExtensionCheckoutOptions): boolean;
7
13
  export declare const TELEGRAM_SKILLS_PATH: string;
8
- export declare function registerTelegramSkillDiscovery(pi: Pick<ExtensionAPI, "on">, modulePath?: string): boolean;
14
+ export declare function registerTelegramSkillDiscovery(pi: Pick<ExtensionAPI, "on">, modulePath?: string, options?: TelegramRawExtensionCheckoutOptions): boolean;