@llblab/pi-telegram 0.51.4 → 0.51.6
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 +1 -1
- package/BACKLOG.md +1 -0
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/dist/lib/bindings.d.ts +4 -1
- package/dist/lib/bindings.js +16 -12
- package/dist/lib/bus-follower.d.ts +3 -1
- package/dist/lib/bus-follower.js +62 -3
- package/dist/lib/bus-leader.d.ts +21 -0
- package/dist/lib/bus-leader.js +144 -1
- package/dist/lib/bus.d.ts +9 -0
- package/dist/lib/bus.js +19 -0
- package/dist/lib/commands.d.ts +25 -3
- package/dist/lib/commands.js +166 -89
- package/dist/lib/extension.js +42 -0
- package/dist/lib/lifecycle.d.ts +57 -2
- package/dist/lib/lifecycle.js +109 -4
- package/dist/lib/locks.js +5 -1
- package/dist/lib/model.js +2 -4
- package/dist/lib/polling.js +6 -2
- package/dist/lib/status.d.ts +3 -1
- package/dist/lib/status.js +31 -1
- package/dist/lib/sync.js +4 -4
- package/dist/lib/threads.d.ts +8 -0
- package/dist/lib/threads.js +30 -9
- package/dist/lib/workspace-retirement.d.ts +2 -2
- package/dist/lib/workspace-retirement.js +48 -12
- package/dist/package.json +1 -1
- package/docs/architecture.md +6 -5
- package/docs/multi-instance-bus.md +4 -2
- package/docs/public-api.md +3 -3
- package/docs/ui-style.md +4 -0
- package/lib/bindings.ts +17 -10
- package/lib/bus-follower.ts +87 -3
- package/lib/bus-leader.ts +197 -0
- package/lib/bus.ts +33 -0
- package/lib/commands.ts +173 -103
- package/lib/extension.ts +40 -0
- package/lib/lifecycle.ts +140 -4
- package/lib/locks.ts +6 -1
- package/lib/model.ts +2 -4
- package/lib/polling.ts +10 -2
- package/lib/status.ts +30 -1
- package/lib/sync.ts +4 -4
- package/lib/threads.ts +40 -8
- package/lib/workspace-retirement.ts +46 -13
- package/package.json +1 -1
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
|
|
package/BACKLOG.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
_This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
|
|
4
4
|
|
|
5
|
+
- [ ] `Connect intent across resume` (`post-release acceptance`): The operator approved 0.51.6 release on the successful smoke and green Linux/macOS/Windows CI; this expanded live matrix is not a release blocker. Run live acceptance after rebuilding/reloading: start bare Pi, invoke `/telegram-connect` (default and named profile), immediately `/resume` a session with/without a binding. Check both leader and follower; confirm one connected destination, its correct retained/new slot, no source-target transplant, stale-context error or duplicate notice. Repeat a switch during startup and confirm disconnect cancels it. Native composition, destination-binding, cancellation, and late-completion regressions pass locally. The operator observed one immediate-resume connection complete after a short wait without error; the full profile/role/binding and cancellation matrix remains unverified live.
|
|
5
6
|
- [ ] [`Workspace Thread recovery`](./docs/multi-instance-bus.md) (`environment-gated`): Complete native-Windows coverage for follower restore, inaccessible callbacks, stale targets, and Singleton↔Threaded transitions while preserving accepted work and current ownership.
|
|
6
7
|
- [ ] Select an approved exact-absence observation before implementing ambiguous `deletion-issued` recovery. Never replay an unknown deletion or infer absence from cache, heartbeat silence, empty `editForumTopic`, or `sendChatAction` success.
|
|
7
8
|
- [ ] [`Automatic pairing and follower input custody`](./docs/architecture.md#automatic-pairing-confirmation-design) (`activation-gated`): Keep production custody disabled until historical sources and physical references are reconciled, all legacy writers/consumers are retired or excluded under operator authority, migration is complete, and every participating peer supports the final protocol. Preserve outcome-unknown running work, exact receipt/group authority, authenticated handoff, and rollback/downgrade safety. Then compose the prepared v3 lifecycle/bus ports, add bounded operator disposition for retained legacy retry state, and run real-process plus native-Windows acceptance.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.51.6: Connection resume and Workspace recovery hotfix
|
|
8
|
+
|
|
9
|
+
- `Workspace slot recovery`: Confirmed pressure-retirement deletion invalidates the exact stale active-target record before binding removal. Same-process and successor retries finish a retained `commit-ready` fence without repeating Telegram deletion, preventing exhausted A–Z slots from deadlocking on `protection-changed`. Includes [#305](https://github.com/llblab/pi-telegram/pull/305).
|
|
10
|
+
- `Connect lifecycle`: `/telegram-connect` checks plain session generation before reading Pi context getters after awaited work. Replaced commands cannot publish stale connection notices or retry startup against the old session; config, startup, recovery and takeover confirmation discard obsolete results.
|
|
11
|
+
- `Resume connection`: In-flight `/telegram-connect` or an already connected bridge carries bounded same-process intent into the resumed session, retaining the selected profile but not the source Thread. Startup restores the destination's exact binding or provisions its own slot; cancellation, expiry and late completion cannot revive obsolete work.
|
|
12
|
+
- `Thread binding isolation`: Session-aware startup no longer reuses another session's active target or legacy instance binding. Pending Thread creation records its Workspace binding key; unproven or foreign-session recovery is blocked without consuming evidence or repeating creation.
|
|
13
|
+
- `Connection feedback`: Manual/resumed connection failures use compact cause-and-action notices rather than raw exception guidance. Detailed errors remain in redacted diagnostics; recovery success produces one short notice, and failed disconnect keeps Pi open without a duplicate exception banner. Delayed disconnect completion cannot touch a replaced Pi context.
|
|
14
|
+
- `Model continuation`: In-flight model switching injects one compact line with the resume instruction, selected model and optional thinking level, preserving the control lane and exact reply target.
|
|
15
|
+
- `Lifecycle ownership`: Connect intent and resume scheduling belong to the existing session lifecycle domain; compact failure formatting belongs to status. Transport authority, durable bindings and queue custody keep their existing owners, without introducing a separate connection domain.
|
|
16
|
+
|
|
17
|
+
## 0.51.5: Follower Thread new-session hotfix
|
|
18
|
+
|
|
19
|
+
- `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.
|
|
20
|
+
- `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.
|
|
21
|
+
|
|
7
22
|
## 0.51.4: Resolver-owned Skills and drift-safe Git installs
|
|
8
23
|
|
|
9
24
|
- `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.
|
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ Paste the bot token. If `~/.pi/agent/telegram.json` already contains a saved tok
|
|
|
57
57
|
/telegram-connect
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers. Reopening or resuming the same Pi session restores its remembered Thread at session startup
|
|
60
|
+
The connected Pi instance owns Telegram polling. Use `/telegram-connect <profile>` to activate a named profile. Each profile is a parallel bot runtime with isolated polling, diagnostics, Threaded Mode state, and local bus transport; the `default` profile keeps unsuffixed runtime paths. In classic mode each profile uses a singleton lock. When Telegram private-chat Threaded Mode is available, one live instance becomes the profile's leader and later visible Pi instances register as followers. Reopening or resuming the same Pi session restores its remembered Thread at session startup. If `/resume` immediately follows `/telegram-connect`, or the bridge is already connected, connection intent follows the switch: the destination restores its own Thread/slot or receives a new binding, never the source session's Thread. Otherwise, a distinct unbound session still requires explicit `/telegram-connect`.
|
|
61
61
|
|
|
62
62
|
After an unclean computer shutdown, `/telegram-connect` detects truncated or structurally invalid temporary ownership/routing files, quarantines only the damaged files under `tmp/telegram/recovery/`, and retries once. A journal snapshot removed by older broad temp cleanup is rebuilt when its complete segment history proves an empty result, while a revisionless snapshot is repaired from the first surviving segment's exact predecessor when the reconstructed tail validates. Otherwise the snapshot and segments are quarantined as recovery evidence, a fresh journal is published, and startup continues with an informational diagnostic instead of requiring manual JSON repair. Unsupported journal versions block recovery without rewriting or quarantining the retained files; use a compatible runtime rather than deleting journals. Saved `telegram.json` configuration and runtime diagnostics remain intact. Recovery never replaces a verifiable live owner; if safe automatic recovery cannot complete, the command gives one explicit Pi-restart instruction instead of requiring deletion of the whole `tmp/` directory.
|
|
63
63
|
|
package/dist/lib/bindings.d.ts
CHANGED
|
@@ -194,9 +194,12 @@ interface TelegramCommandsAndToolsBindingDeps {
|
|
|
194
194
|
canSendDirect: () => boolean;
|
|
195
195
|
setGenerativeAppLiveSurfaceRuntime?: (runtime: GenerativeApps.GenerativeAppLiveSurfaceRuntime<GenerativeApps.TelegramBindLiveHandle> | undefined) => void;
|
|
196
196
|
updateStatus: TelegramBridgeStatusUpdater;
|
|
197
|
+
isContextCurrent: (ctx: Pi.ExtensionContext) => boolean;
|
|
198
|
+
getSessionGeneration: () => number;
|
|
199
|
+
connectionIntent: NonNullable<Commands.TelegramBridgeCommandRegistrationDeps["connectionIntent"]>;
|
|
197
200
|
recordRuntimeEvent: TelegramRuntimeEventRecorder;
|
|
198
201
|
}
|
|
199
|
-
export declare function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, }: TelegramCommandsAndToolsBindingDeps): void;
|
|
202
|
+
export declare function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }: TelegramCommandsAndToolsBindingDeps): void;
|
|
200
203
|
interface TelegramLifecycleBindingDeps {
|
|
201
204
|
pi: Pi.ExtensionAPI;
|
|
202
205
|
publicationRuntime: TelegramBridgePublicationRuntime;
|
package/dist/lib/bindings.js
CHANGED
|
@@ -323,7 +323,7 @@ export function createTelegramActivityBindingRuntime(deps) {
|
|
|
323
323
|
},
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
|
-
export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, }) {
|
|
326
|
+
export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, persistConfig, setup, activeTurnRuntime, lockedPollingRuntime, stopPolling, recoverPollingStart, getDisconnectThreadName, onTransportChanged, getStatusLines, buttonActionStore, sendMarkdownReply, sendChannelMarkdownMessage, sendChannelMediaMessage, listChannelPosts, mutateChannelPost, callMultipart, getDefaultChatId, getDefaultTarget, resolveAgentTarget, routeAgentMessage, canSendDirect, setGenerativeAppLiveSurfaceRuntime, recordRuntimeEvent, updateStatus, isContextCurrent, getSessionGeneration, connectionIntent, }) {
|
|
327
327
|
GenerativeApps.registerTelegramBindTool(pi, {
|
|
328
328
|
agentDir,
|
|
329
329
|
getActiveProfileName: configStore.getActiveProfileName,
|
|
@@ -452,33 +452,35 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
|
|
|
452
452
|
reloadConfig: configStore.load,
|
|
453
453
|
hasBotToken: configStore.hasBotToken,
|
|
454
454
|
getBotTokenDiagnostic: configStore.getBotTokenDiagnostic,
|
|
455
|
-
startPolling:
|
|
456
|
-
|
|
457
|
-
return await lockedPollingRuntime.start(ctx, options);
|
|
458
|
-
}
|
|
459
|
-
catch (error) {
|
|
460
|
-
recordRuntimeEvent("recovery", error, { phase: "polling-start" });
|
|
461
|
-
throw error;
|
|
462
|
-
}
|
|
463
|
-
},
|
|
455
|
+
startPolling: lockedPollingRuntime.start,
|
|
456
|
+
recordConnectionEvent: (error, phase) => recordRuntimeEvent("connection", error, { phase }),
|
|
464
457
|
stopPolling: stopPolling ?? lockedPollingRuntime.stop,
|
|
465
458
|
recoverPollingStart,
|
|
466
459
|
getDisconnectThreadName,
|
|
467
460
|
queueAgentConnectionContext,
|
|
468
461
|
updateStatus,
|
|
462
|
+
isContextCurrent,
|
|
463
|
+
getSessionGeneration,
|
|
464
|
+
connectionIntent,
|
|
469
465
|
getProfileNames: () => Config.getTelegramProfileNames(configStore.getStoredConfig()),
|
|
470
|
-
activateDefaultProfileConfig: async () => {
|
|
466
|
+
activateDefaultProfileConfig: async (_ctx, isCurrent) => {
|
|
471
467
|
const previousProfileName = configStore.getActiveProfileName();
|
|
472
468
|
await configStore.load();
|
|
469
|
+
if (!isCurrent())
|
|
470
|
+
return;
|
|
473
471
|
if (previousProfileName) {
|
|
474
472
|
await (stopPolling ?? lockedPollingRuntime.stop)();
|
|
473
|
+
if (!isCurrent())
|
|
474
|
+
return;
|
|
475
475
|
}
|
|
476
476
|
configStore.activateProfile(undefined);
|
|
477
477
|
await onTransportChanged?.();
|
|
478
478
|
},
|
|
479
|
-
activateProfileConfig: async (_ctx, profileName) => {
|
|
479
|
+
activateProfileConfig: async (_ctx, profileName, isCurrent) => {
|
|
480
480
|
const previousProfileName = configStore.getActiveProfileName();
|
|
481
481
|
await configStore.load();
|
|
482
|
+
if (!isCurrent())
|
|
483
|
+
return false;
|
|
482
484
|
if (!Config.isValidTelegramProfileName(profileName))
|
|
483
485
|
return false;
|
|
484
486
|
const storedConfig = configStore.getStoredConfig();
|
|
@@ -486,6 +488,8 @@ export function registerTelegramCommandsAndTools({ pi, agentDir, configStore, pe
|
|
|
486
488
|
return false;
|
|
487
489
|
if (previousProfileName !== profileName) {
|
|
488
490
|
await (stopPolling ?? lockedPollingRuntime.stop)();
|
|
491
|
+
if (!isCurrent())
|
|
492
|
+
return false;
|
|
489
493
|
}
|
|
490
494
|
if (!configStore.activateProfile(profileName))
|
|
491
495
|
return false;
|
|
@@ -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 {
|
|
@@ -369,7 +371,7 @@ export declare function createTelegramBusAgentMessageClient(deps: TelegramBusFol
|
|
|
369
371
|
routeMessage: (message: TelegramBusAgentMessage) => Promise<void>;
|
|
370
372
|
};
|
|
371
373
|
export declare function createTelegramBusFollowerApiCaller(deps: TelegramBusFollowerApiCallerDeps): (method: string, args: unknown[]) => Promise<unknown>;
|
|
372
|
-
export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): () => Promise<void>;
|
|
374
|
+
export declare function createTelegramBusFollowerSessionReplacementSuspender(deps: TelegramBusFollowerSessionReplacementSuspenderDeps): (preserveTarget?: boolean) => Promise<void>;
|
|
373
375
|
export declare function createTelegramBusFollowerSessionRefreshHook<TContext>(deps: TelegramBusFollowerSessionRefreshHookDeps<TContext>): (_event: unknown, ctx: TContext) => Promise<void>;
|
|
374
376
|
export declare function createTelegramBusFollowerControlState(): TelegramBusFollowerControlState;
|
|
375
377
|
export declare function createTelegramBusFollowerRegistrationState(options?: {
|
package/dist/lib/bus-follower.js
CHANGED
|
@@ -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;
|
|
@@ -519,7 +519,13 @@ function isTelegramStaleContextError(error) {
|
|
|
519
519
|
export function createTelegramBusFollowerSessionReplacementSuspender(deps) {
|
|
520
520
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
521
521
|
const getPid = deps.getPid ?? (() => process.pid);
|
|
522
|
-
return async () => {
|
|
522
|
+
return async (preserveTarget = true) => {
|
|
523
|
+
if (!preserveTarget) {
|
|
524
|
+
setTelegramFollowerSessionHandoff(undefined);
|
|
525
|
+
Threads.setTelegramLeaderSessionHandoff(undefined);
|
|
526
|
+
await deps.suspendPolling();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
523
529
|
const target = deps.registrationState.getTarget();
|
|
524
530
|
if (deps.registrationState.isRegistered() && target) {
|
|
525
531
|
setTelegramFollowerSessionHandoff({
|
|
@@ -1214,8 +1220,13 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
|
|
|
1214
1220
|
deps.setActiveAuthSecret?.(undefined);
|
|
1215
1221
|
await deps.stopReceiving?.();
|
|
1216
1222
|
if (registrationOptions?.restoreWorkspace &&
|
|
1217
|
-
response.error?.code === "workspace-binding-unavailable")
|
|
1223
|
+
response.error?.code === "workspace-binding-unavailable") {
|
|
1224
|
+
deps.recordRuntimeEvent?.("bus", "Telegram follower auto-connect refused by leader: Workspace binding unavailable.", {
|
|
1225
|
+
phase: "follower-auto-connect-skip",
|
|
1226
|
+
reason: "leader-binding-unavailable",
|
|
1227
|
+
});
|
|
1218
1228
|
return false;
|
|
1229
|
+
}
|
|
1219
1230
|
throw new Error(response.message ??
|
|
1220
1231
|
"Telegram bus follower registration was rejected.");
|
|
1221
1232
|
}
|
|
@@ -1426,6 +1437,54 @@ export function createTelegramBusFollowerRegistrationRuntime(deps) {
|
|
|
1426
1437
|
}
|
|
1427
1438
|
return resetName;
|
|
1428
1439
|
},
|
|
1440
|
+
async requestSessionReplacement(operation, intent) {
|
|
1441
|
+
if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
|
|
1442
|
+
throw new Error("Telegram follower is not registered with the leader.");
|
|
1443
|
+
}
|
|
1444
|
+
if (!hasTelegramBusCapability(deps.protocolIdentity, TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT) ||
|
|
1445
|
+
!hasTelegramBusCapability(deps.registrationState?.getLeaderProtocol(), TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT)) {
|
|
1446
|
+
throw new Error("The active Telegram leader does not support follower session replacement. Update or restart that Pi instance.");
|
|
1447
|
+
}
|
|
1448
|
+
const expectedLeaderSocketPath = activeLeaderSocketPath;
|
|
1449
|
+
const expectedRegistrationGeneration = activeRegistrationGeneration;
|
|
1450
|
+
const expectedAuthSecret = activeAuthSecret;
|
|
1451
|
+
const requestId = deps.createRequestId();
|
|
1452
|
+
const response = await sendTelegramBusLocalEnvelope({
|
|
1453
|
+
socketPath: expectedLeaderSocketPath,
|
|
1454
|
+
timeoutMs: registrationTimeoutMs,
|
|
1455
|
+
retry: getTelegramBusTransportRetryPolicy({
|
|
1456
|
+
endpoint: expectedLeaderSocketPath,
|
|
1457
|
+
operation: "operation",
|
|
1458
|
+
}),
|
|
1459
|
+
envelope: {
|
|
1460
|
+
kind: operation === "publish"
|
|
1461
|
+
? "follower.publishSessionReplacement"
|
|
1462
|
+
: "follower.settleSessionReplacement",
|
|
1463
|
+
requestId,
|
|
1464
|
+
auth: expectedAuthSecret,
|
|
1465
|
+
instanceId: deps.instanceId,
|
|
1466
|
+
registrationGeneration: expectedRegistrationGeneration,
|
|
1467
|
+
intent,
|
|
1468
|
+
sentAtMs: getNowMs(),
|
|
1469
|
+
},
|
|
1470
|
+
});
|
|
1471
|
+
if (response?.kind !== "bus.ack" || !response.ok ||
|
|
1472
|
+
response.requestId !== requestId ||
|
|
1473
|
+
!isRecord(response.result) || response.result.committed !== true) {
|
|
1474
|
+
throw new Error(response?.kind === "bus.ack"
|
|
1475
|
+
? (response.message ?? "Telegram session replacement was rejected.")
|
|
1476
|
+
: "Telegram session replacement was not acknowledged.");
|
|
1477
|
+
}
|
|
1478
|
+
if (activeLeaderSocketPath !== expectedLeaderSocketPath ||
|
|
1479
|
+
activeRegistrationGeneration !== expectedRegistrationGeneration ||
|
|
1480
|
+
activeAuthSecret !== expectedAuthSecret ||
|
|
1481
|
+
(deps.registrationState &&
|
|
1482
|
+
deps.registrationState.getGeneration() !==
|
|
1483
|
+
expectedRegistrationGeneration)) {
|
|
1484
|
+
throw new Error("Telegram session replacement completed for a stale follower registration.");
|
|
1485
|
+
}
|
|
1486
|
+
return true;
|
|
1487
|
+
},
|
|
1429
1488
|
async disconnectFromLeader() {
|
|
1430
1489
|
if (!activeLeaderSocketPath || !activeRegistrationGeneration) {
|
|
1431
1490
|
return false;
|
package/dist/lib/bus-leader.d.ts
CHANGED
|
@@ -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>;
|
package/dist/lib/bus-leader.js
CHANGED
|
@@ -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;
|
package/dist/lib/commands.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { type TelegramConfigStore } from "./config.ts";
|
|
7
7
|
import type * as Pi from "./pi.ts";
|
|
8
8
|
import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
|
|
9
|
-
import type
|
|
9
|
+
import { type TelegramBridgeStatusLineOptions } from "./status.ts";
|
|
10
10
|
import type { TelegramSessionReplacementIntent } from "./threads.ts";
|
|
11
11
|
import { type PendingTelegramControlItem, type TelegramQueueAdmissionReceipt } from "./queue.ts";
|
|
12
12
|
export interface ParsedTelegramCommand {
|
|
@@ -112,12 +112,21 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
112
112
|
startPolling: (ctx: ExtensionCommandContext, options?: TelegramBridgeCommandStartPollingOptions) => void | Promise<void | TelegramBridgeCommandStartPollingResult> | TelegramBridgeCommandStartPollingResult;
|
|
113
113
|
stopPolling: () => Promise<void | string>;
|
|
114
114
|
recoverPollingStart?: (error: unknown) => Promise<TelegramPollingStartRecoveryResult>;
|
|
115
|
+
recordConnectionEvent?: (error: unknown, phase: string) => void;
|
|
115
116
|
getDisconnectThreadName?: () => string | undefined;
|
|
116
117
|
queueAgentConnectionContext?: (connected: boolean) => void;
|
|
117
118
|
updateStatus: (ctx: ExtensionCommandContext) => void;
|
|
119
|
+
isContextCurrent?: (ctx: ExtensionCommandContext) => boolean;
|
|
120
|
+
getSessionGeneration?: () => number;
|
|
121
|
+
connectionIntent?: {
|
|
122
|
+
begin(cwd: string, profileName?: string): string;
|
|
123
|
+
finish(id: string): void;
|
|
124
|
+
isActive(id: string): boolean;
|
|
125
|
+
cancel(): void;
|
|
126
|
+
};
|
|
118
127
|
getProfileNames?: () => string[];
|
|
119
|
-
activateDefaultProfileConfig?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
120
|
-
activateProfileConfig?: (ctx: ExtensionCommandContext, profileName: string) => Promise<boolean>;
|
|
128
|
+
activateDefaultProfileConfig?: (ctx: ExtensionCommandContext, isCurrent: () => boolean) => Promise<void>;
|
|
129
|
+
activateProfileConfig?: (ctx: ExtensionCommandContext, profileName: string, isCurrent: () => boolean) => Promise<boolean>;
|
|
121
130
|
}
|
|
122
131
|
export type TelegramThreadDisplayNameRenamePort = (target: {
|
|
123
132
|
chatId: number;
|
|
@@ -645,6 +654,19 @@ export interface TelegramSessionActionAssemblyDeps {
|
|
|
645
654
|
};
|
|
646
655
|
getProfileName: () => string | undefined;
|
|
647
656
|
ownsPersistence: () => boolean;
|
|
657
|
+
/**
|
|
658
|
+
* Registered-follower port. A follower cannot persist leader-owned state, so
|
|
659
|
+
* its Workspace Thread intent is published and claimed by the leader over
|
|
660
|
+
* authenticated generation-fenced bus RPC.
|
|
661
|
+
*/
|
|
662
|
+
follower?: {
|
|
663
|
+
instanceId: string;
|
|
664
|
+
isRegisteredFor: (target: {
|
|
665
|
+
chatId: number;
|
|
666
|
+
threadId?: number;
|
|
667
|
+
}) => boolean;
|
|
668
|
+
requestSessionReplacement: (operation: "publish" | "settle", intent: TelegramSessionReplacementIntent) => Promise<boolean>;
|
|
669
|
+
};
|
|
648
670
|
sendResult: (target: {
|
|
649
671
|
chatId: number;
|
|
650
672
|
threadId?: number;
|