@llblab/pi-kit 0.6.0 → 0.7.1

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-actors/README.md +1 -1
  6. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +3 -0
  7. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +14 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +45 -3
  9. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +1 -1
  10. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +16 -3
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +92 -7
  12. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +0 -1
  13. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +15 -24
  14. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.d.ts +17 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery-lineage.js +44 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +4 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +102 -4
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +58 -39
  19. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +14 -6
  20. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-actors/docs/async-runs.md +1 -1
  22. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +18 -23
  23. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +18 -1
  24. package/node_modules/@llblab/pi-actors/lib/command-templates.ts +41 -3
  25. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +1 -1
  26. package/node_modules/@llblab/pi-actors/lib/observability.ts +119 -5
  27. package/node_modules/@llblab/pi-actors/lib/pi.ts +15 -28
  28. package/node_modules/@llblab/pi-actors/lib/run-delivery-lineage.ts +68 -0
  29. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +120 -4
  30. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +69 -44
  31. package/node_modules/@llblab/pi-actors/lib/runtime.ts +17 -6
  32. package/node_modules/@llblab/pi-actors/package.json +1 -1
  33. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +1 -1
  34. package/node_modules/@llblab/pi-state-flow/AGENTS.md +33 -12
  35. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +125 -2
  36. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +87 -45
  37. package/node_modules/@llblab/pi-state-flow/README.md +203 -107
  38. package/node_modules/@llblab/pi-state-flow/docs/README.md +4 -0
  39. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +188 -0
  40. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +42 -0
  41. package/node_modules/@llblab/pi-state-flow/index.ts +164 -2
  42. package/node_modules/@llblab/pi-state-flow/lib/acquisition.ts +138 -0
  43. package/node_modules/@llblab/pi-state-flow/lib/artifact.ts +273 -0
  44. package/node_modules/@llblab/pi-state-flow/lib/config.ts +48 -0
  45. package/node_modules/@llblab/pi-state-flow/lib/context.ts +18 -5
  46. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +268 -0
  47. package/node_modules/@llblab/pi-state-flow/lib/discovery.ts +117 -0
  48. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +562 -0
  49. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +24 -12
  50. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +606 -70
  51. package/node_modules/@llblab/pi-state-flow/lib/git.ts +666 -0
  52. package/node_modules/@llblab/pi-state-flow/lib/history.ts +95 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/json.ts +24 -0
  54. package/node_modules/@llblab/pi-state-flow/lib/maintenance.ts +141 -0
  55. package/node_modules/@llblab/pi-state-flow/lib/memory.ts +52 -0
  56. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +88 -0
  57. package/node_modules/@llblab/pi-state-flow/lib/publication.ts +296 -0
  58. package/node_modules/@llblab/pi-state-flow/lib/recovery.ts +23 -7
  59. package/node_modules/@llblab/pi-state-flow/lib/rehydration.ts +79 -0
  60. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +264 -0
  61. package/node_modules/@llblab/pi-state-flow/lib/session.ts +6 -0
  62. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +99 -7
  63. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +305 -48
  64. package/node_modules/@llblab/pi-state-flow/lib/state.ts +74 -7
  65. package/node_modules/@llblab/pi-state-flow/lib/status.ts +125 -6
  66. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +196 -0
  67. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +233 -0
  68. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +70 -24
  69. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +254 -29
  70. package/node_modules/@llblab/pi-state-flow/package.json +8 -2
  71. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +128 -0
  72. package/node_modules/@llblab/pi-telegram/AGENTS.md +14 -9
  73. package/node_modules/@llblab/pi-telegram/BACKLOG.md +23 -5
  74. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  75. package/node_modules/@llblab/pi-telegram/README.md +13 -9
  76. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -0
  77. package/node_modules/@llblab/pi-telegram/docs/architecture.md +227 -23
  78. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +70 -19
  80. package/node_modules/@llblab/pi-telegram/docs/outbound.md +6 -6
  81. package/node_modules/@llblab/pi-telegram/docs/public-api.md +13 -6
  82. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +3 -1
  83. package/node_modules/@llblab/pi-telegram/index.ts +4 -1418
  84. package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +6 -3
  85. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +46 -1
  86. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +32 -19
  87. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +600 -135
  88. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +962 -55
  89. package/node_modules/@llblab/pi-telegram/lib/bus.ts +355 -26
  90. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +718 -0
  91. package/node_modules/@llblab/pi-telegram/lib/commands.ts +237 -11
  92. package/node_modules/@llblab/pi-telegram/lib/config.ts +242 -26
  93. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1851 -0
  94. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +20 -2
  95. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2184 -126
  96. package/node_modules/@llblab/pi-telegram/lib/locks.ts +44 -2
  97. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +152 -13
  98. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +97 -10
  99. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -0
  100. package/node_modules/@llblab/pi-telegram/lib/polling.ts +85 -17
  101. package/node_modules/@llblab/pi-telegram/lib/preview.ts +17 -0
  102. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +6 -2
  103. package/node_modules/@llblab/pi-telegram/lib/queue.ts +118 -26
  104. package/node_modules/@llblab/pi-telegram/lib/rendering.ts +4 -1
  105. package/node_modules/@llblab/pi-telegram/lib/replies.ts +21 -2
  106. package/node_modules/@llblab/pi-telegram/lib/routing.ts +344 -112
  107. package/node_modules/@llblab/pi-telegram/lib/setup.ts +44 -4
  108. package/node_modules/@llblab/pi-telegram/lib/status.ts +51 -4
  109. package/node_modules/@llblab/pi-telegram/lib/sync.ts +308 -39
  110. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +353 -22
  111. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +664 -0
  112. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +226 -0
  113. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +118 -0
  114. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1686 -129
  115. package/node_modules/@llblab/pi-telegram/lib/turns.ts +7 -0
  116. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1319 -97
  117. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +1643 -0
  118. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +968 -0
  119. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +84 -0
  120. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  121. package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
  122. package/node_modules/@llblab/pi-telegram/scripts/measure-bus.mjs +83 -0
  123. package/node_modules/@llblab/pi-telegram/scripts/measure-workspace.mjs +101 -0
  124. package/node_modules/@llblab/{skills → pi-telegram/skills}/show-me/SKILL.md +28 -6
  125. package/node_modules/@llblab/pi-telegram/skills/show-me/references/telegram-surfaces.md +43 -0
  126. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +1 -1
  127. package/node_modules/@llblab/skills/package.json +2 -3
  128. package/package.json +6 -5
  129. /package/node_modules/@llblab/pi-telegram/lib/{logs.ts → logging.ts} +0 -0
@@ -4,7 +4,7 @@
4
4
  * Owns persisted bot/session pairing state, local config storage, live config controls, authorization policy, and first-user pairing side effects
5
5
  */
6
6
 
7
- import { randomUUID } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import {
9
9
  chmodSync,
10
10
  existsSync,
@@ -62,6 +62,59 @@ function getConfigPath(): string {
62
62
  return resolveTelegramConfigPath();
63
63
  }
64
64
 
65
+ const TELEGRAM_BOT_TOKEN_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
66
+
67
+ /** Parsed stored bot-token form: a literal secret or one environment-variable reference. */
68
+ export type TelegramBotTokenReference =
69
+ | { kind: "literal"; token: string }
70
+ | { kind: "environment"; variable: string }
71
+ | { kind: "malformed" };
72
+
73
+ /**
74
+ * Parse a persisted bot-token value. `$NAME` and `${NAME}` are exact
75
+ * environment-variable references. Any other `$`-prefixed value is malformed
76
+ * rather than a literal secret so a broken reference fails closed.
77
+ */
78
+ export function getTelegramBotTokenReference(
79
+ value: string | undefined,
80
+ ): TelegramBotTokenReference | undefined {
81
+ const trimmed = value?.trim();
82
+ if (!trimmed) return undefined;
83
+ if (!trimmed.startsWith("$")) return { kind: "literal", token: trimmed };
84
+ const body =
85
+ trimmed.startsWith("${") && trimmed.endsWith("}")
86
+ ? trimmed.slice(2, -1)
87
+ : trimmed.slice(1);
88
+ return TELEGRAM_BOT_TOKEN_ENV_NAME_PATTERN.test(body)
89
+ ? { kind: "environment", variable: body }
90
+ : { kind: "malformed" };
91
+ }
92
+
93
+ /** Resolve a persisted token at a validation/activation boundary. */
94
+ export function resolveTelegramBotToken(
95
+ value: string | undefined,
96
+ env: NodeJS.ProcessEnv = process.env,
97
+ ): string | undefined {
98
+ const reference = getTelegramBotTokenReference(value);
99
+ if (reference?.kind === "literal") return reference.token;
100
+ if (reference?.kind !== "environment") return undefined;
101
+ return env[reference.variable]?.trim() || undefined;
102
+ }
103
+
104
+ /** Redacted diagnostic for an unresolved or malformed token reference. */
105
+ export function getTelegramBotTokenDiagnostic(
106
+ value: string | undefined,
107
+ env: NodeJS.ProcessEnv = process.env,
108
+ ): string | undefined {
109
+ const reference = getTelegramBotTokenReference(value);
110
+ if (reference?.kind === "malformed") {
111
+ return "Telegram bot token environment reference is malformed; use $NAME or ${NAME}.";
112
+ }
113
+ if (reference?.kind !== "environment") return undefined;
114
+ if (resolveTelegramBotToken(value, env)) return undefined;
115
+ return `Telegram bot token environment variable ${reference.variable} is not set.`;
116
+ }
117
+
65
118
  export type TelegramOutboundCommandTemplateConfig =
66
119
  string | CommandTemplateObjectConfig;
67
120
  export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
@@ -83,6 +136,35 @@ export interface ResolvedTelegramTimeConfig {
83
136
  timezone: string;
84
137
  }
85
138
 
139
+ export type TelegramThreadDisplayMode = "letters" | "names" | "directories";
140
+
141
+ export function resolveTelegramThreadDisplayMode(
142
+ config: Pick<TelegramConfig, "threadDisplayMode">,
143
+ ): TelegramThreadDisplayMode {
144
+ return config.threadDisplayMode === "directories"
145
+ ? "directories"
146
+ : "letters";
147
+ }
148
+
149
+ export async function setTelegramThreadDisplayMode(
150
+ store: TelegramConfigStore,
151
+ mode: TelegramThreadDisplayMode,
152
+ isCurrent: () => boolean,
153
+ ): Promise<void> {
154
+ if (!["letters", "directories"].includes(mode)) {
155
+ throw new Error("Invalid Telegram Thread display mode.");
156
+ }
157
+ const profile = store.getActiveProfileName();
158
+ const current = () => isCurrent() && store.getActiveProfileName() === profile;
159
+ if (!current()) throw new Error("Telegram Thread display setting lost authority.");
160
+ await store.load();
161
+ if (!current() || !store.hasBotToken()) {
162
+ throw new Error("Telegram Thread display setting lost its configured profile.");
163
+ }
164
+ await store.persist({ ...store.get(), threadDisplayMode: mode }, { isCurrent: current });
165
+ if (!current()) throw new Error("Telegram Thread display setting changed during persistence.");
166
+ }
167
+
86
168
  export type TelegramAssistantRenderingMode = "rich" | "html";
87
169
  export type TelegramActivityVerbosity =
88
170
  | "quiet"
@@ -99,6 +181,8 @@ export interface TelegramConfig {
99
181
  botId?: number;
100
182
  /** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
101
183
  allowedUserId?: number;
184
+ /** Effective view; persisted under profiles.<name>. */
185
+ threadDisplayMode?: TelegramThreadDisplayMode;
102
186
  inboundHandlers?: TelegramInboundHandlerConfig[];
103
187
  attachmentHandlers?: TelegramInboundHandlerConfig[];
104
188
  outboundHandlers?: TelegramOutboundHandlerConfig[];
@@ -130,7 +214,7 @@ export interface TelegramConfig {
130
214
  }
131
215
 
132
216
  /**
133
- * Per-profile bot/session identity fields.
217
+ * Per-profile bot/session identity and Thread display preference.
134
218
  * Stored under `profiles.<name>` in telegram.json.
135
219
  * Shared bridge settings (inboundHandlers, outboundHandlers, voice, time,
136
220
  * assistant) stay at the top level.
@@ -140,6 +224,7 @@ export interface TelegramBotProfile {
140
224
  botUsername?: string;
141
225
  botId?: number;
142
226
  allowedUserId?: number;
227
+ threadDisplayMode?: TelegramThreadDisplayMode;
143
228
  }
144
229
 
145
230
  interface TelegramLegacyCursorCarrier {
@@ -174,6 +259,7 @@ export interface TelegramConfigStore {
174
259
  activateProfile: (profileName: string | undefined) => boolean;
175
260
  getActiveProfileName: () => string | undefined;
176
261
  getBotToken: () => string | undefined;
262
+ getBotTokenDiagnostic: () => string | undefined;
177
263
  hasBotToken: () => boolean;
178
264
  getAllowedUserId: () => number | undefined;
179
265
  getLegacyPollingCursor: () => number | undefined;
@@ -182,9 +268,35 @@ export interface TelegramConfigStore {
182
268
  getAttachmentHandlers: () => TelegramInboundHandlerConfig[] | undefined;
183
269
  getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined;
184
270
  setAllowedUserId: (userId: number) => void;
271
+ /** Publish an unpaired profile owner atomically; true only for the resulting exact owner. */
272
+ persistAllowedUserId: (
273
+ userId: number,
274
+ assertExecutionCurrent?: () => void,
275
+ commitIfOwned?: (commit: () => void) => boolean,
276
+ ) => Promise<boolean>;
277
+ /** Lock-only serialization for trusted synchronous source operations, not authorization.
278
+ * Does not read/adopt config. Acquire required Workspace admission first; never
279
+ * acquire owners or nest config admission here. Do not pass async callbacks:
280
+ * returned promises are not protected after their synchronous prefix.
281
+ */
282
+ withSourceSerialization: <T>(operation: () => T) => T;
283
+ /** Trusted synchronous publication only; caller acquires Workspace admission before this config transaction. */
284
+ withPairingAdmission: <T>(
285
+ profileName: string,
286
+ tokenSha256: string,
287
+ publish: (preApprovalExcluded: boolean) => T,
288
+ ) => T;
289
+ /** Observe an existing exact owner and refresh an unpaired cache; never create an owner. Callback must be synchronous. */
290
+ withPairedUserAdmission: <T>(
291
+ profileName: string,
292
+ tokenSha256: string,
293
+ userId: number,
294
+ publish: () => T,
295
+ assertExecutionCurrent?: () => void,
296
+ ) => { admitted: false } | { admitted: true; value: T };
185
297
  load: () => Promise<void>;
186
298
  didLastLoadRecoverInvalidConfig: () => boolean;
187
- persist: (config?: TelegramConfig) => Promise<void>;
299
+ persist: (config?: TelegramConfig, options?: { isCurrent?: () => boolean }) => Promise<void>;
188
300
  }
189
301
 
190
302
  export function createTelegramConfigBotIdGetter(
@@ -203,6 +315,8 @@ export interface TelegramConfigStoreOptions {
203
315
  initialConfig?: TelegramConfig;
204
316
  agentDir?: string;
205
317
  configPath?: string;
318
+ /** Environment used to resolve `$NAME` token references; defaults to process.env. */
319
+ env?: NodeJS.ProcessEnv;
206
320
  recordRuntimeEvent?: (
207
321
  category: string,
208
322
  error: unknown,
@@ -437,6 +551,9 @@ export function getTelegramProfileFields(
437
551
  ...(config.allowedUserId !== undefined
438
552
  ? { allowedUserId: config.allowedUserId }
439
553
  : {}),
554
+ ...(config.threadDisplayMode !== undefined
555
+ ? { threadDisplayMode: config.threadDisplayMode }
556
+ : {}),
440
557
  ...(legacyCursor !== undefined ? { lastUpdateId: legacyCursor } : {}),
441
558
  };
442
559
  }
@@ -447,6 +564,7 @@ function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
447
564
  botUsername: _botUsername,
448
565
  botId: _botId,
449
566
  allowedUserId: _allowedUserId,
567
+ threadDisplayMode: _threadDisplayMode,
450
568
  lastUpdateId: _lastUpdateId,
451
569
  ...sharedConfig
452
570
  } = config as TelegramConfig & TelegramLegacyCursorCarrier;
@@ -484,6 +602,7 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
484
602
  "botUsername",
485
603
  "botId",
486
604
  "allowedUserId",
605
+ "threadDisplayMode",
487
606
  "lastUpdateId",
488
607
  ].some((field) => Object.hasOwn(config, field));
489
608
  if (!hasLegacyRootProfile) {
@@ -504,6 +623,9 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
504
623
  ...(config.allowedUserId !== undefined
505
624
  ? { allowedUserId: config.allowedUserId }
506
625
  : {}),
626
+ ...(config.threadDisplayMode !== undefined
627
+ ? { threadDisplayMode: config.threadDisplayMode }
628
+ : {}),
507
629
  ...((config as TelegramConfig & TelegramLegacyCursorCarrier)
508
630
  .lastUpdateId !== undefined
509
631
  ? {
@@ -587,6 +709,7 @@ export function createTelegramConfigStore(
587
709
  let lastLoadRecoveredInvalidConfig = false;
588
710
  const agentDir = options.agentDir ?? resolveAgentDir();
589
711
  const configPath = options.configPath ?? getConfigPath();
712
+ const env = options.env ?? process.env;
590
713
  const getEffectiveConfig = () =>
591
714
  applyTelegramProfile(config, activeProfileName);
592
715
  const setEffectiveConfig = (nextConfig: TelegramConfig) => {
@@ -597,6 +720,39 @@ export function createTelegramConfigStore(
597
720
  );
598
721
  mutationVersion += 1;
599
722
  };
723
+ const adoptPersistedConfig = (merged: TelegramConfig, preserveLocalChanges: boolean) => {
724
+ // Local edits are relative to the latest observation, not a queued write's older request baseline.
725
+ const nextConfig = preserveLocalChanges
726
+ ? mergeTelegramConfigDelta(persistedConfig as Record<string, unknown>, config as Record<string, unknown>,
727
+ merged as Record<string, unknown>) as TelegramConfig
728
+ : cloneTelegramConfig(merged);
729
+ persistedConfig = cloneTelegramConfig(merged);
730
+ config = nextConfig;
731
+ };
732
+ const withPersistedPairingProfile = <T>(
733
+ profileName: string, tokenSha256: string,
734
+ observe: (latest: TelegramConfig, profile: TelegramBotProfile) => T,
735
+ ): T => {
736
+ if ((profileName !== TELEGRAM_DEFAULT_PROFILE_NAME && !isValidTelegramProfileName(profileName)) ||
737
+ !/^[a-f0-9]{64}$/u.test(tokenSha256)) {
738
+ throw new Error("Invalid Telegram pairing admission identity.");
739
+ }
740
+ return withTelegramFileTransaction(`${configPath}.transaction`, () => {
741
+ const latest = readTelegramConfigForTransaction(configPath);
742
+ const profile = latest.profiles?.[profileName];
743
+ const resolvedToken =
744
+ typeof profile?.botToken === "string"
745
+ ? resolveTelegramBotToken(profile.botToken, env)
746
+ : undefined;
747
+ if (!profile || !resolvedToken ||
748
+ createHash("sha256").update(resolvedToken).digest("hex") !== tokenSha256 ||
749
+ (profile.allowedUserId !== undefined &&
750
+ (!Number.isSafeInteger(profile.allowedUserId) || profile.allowedUserId <= 0))) {
751
+ throw new Error("Telegram pairing admission authority is unavailable or changed.");
752
+ }
753
+ return observe(latest, profile);
754
+ });
755
+ };
600
756
  return {
601
757
  get: getEffectiveConfig,
602
758
  getStoredConfig: () => config,
@@ -628,8 +784,10 @@ export function createTelegramConfigStore(
628
784
  return true;
629
785
  },
630
786
  getActiveProfileName: () => activeProfileName,
631
- getBotToken: () => getEffectiveConfig().botToken,
632
- hasBotToken: () => !!getEffectiveConfig().botToken,
787
+ getBotToken: () => resolveTelegramBotToken(getEffectiveConfig().botToken, env),
788
+ getBotTokenDiagnostic: () =>
789
+ getTelegramBotTokenDiagnostic(getEffectiveConfig().botToken, env),
790
+ hasBotToken: () => !!resolveTelegramBotToken(getEffectiveConfig().botToken, env),
633
791
  getAllowedUserId: () => getEffectiveConfig().allowedUserId,
634
792
  getLegacyPollingCursor: () =>
635
793
  (getEffectiveConfig() as TelegramConfig & TelegramLegacyCursorCarrier)
@@ -652,6 +810,73 @@ export function createTelegramConfigStore(
652
810
  nextConfig.allowedUserId = userId;
653
811
  setEffectiveConfig(nextConfig);
654
812
  },
813
+ withSourceSerialization: (operation) =>
814
+ withTelegramFileTransaction(`${configPath}.transaction`, operation),
815
+ withPairingAdmission: (profileName, tokenSha256, publish) =>
816
+ withPersistedPairingProfile(profileName, tokenSha256, (_latest, profile) =>
817
+ publish(profile.allowedUserId === undefined)),
818
+ withPairedUserAdmission: (profileName, tokenSha256, userId, publish, assertExecutionCurrent) => {
819
+ if (!Number.isSafeInteger(userId) || userId <= 0) return { admitted: false };
820
+ assertExecutionCurrent?.();
821
+ return withPersistedPairingProfile(profileName, tokenSha256, (latest, profile) => {
822
+ if (profile.allowedUserId !== userId) return { admitted: false };
823
+ assertExecutionCurrent?.();
824
+ const current = getEffectiveConfig();
825
+ const previousOwner = persistedConfig.profiles?.[profileName]?.allowedUserId;
826
+ if ((activeProfileName ?? TELEGRAM_DEFAULT_PROFILE_NAME) !== profileName ||
827
+ current.botToken !== profile.botToken ||
828
+ (current.allowedUserId !== undefined && current.allowedUserId !== userId) ||
829
+ (current.allowedUserId === undefined && previousOwner !== undefined)) {
830
+ throw new Error("Telegram paired admission lost local profile authority.");
831
+ }
832
+ // Observation is not a local edit; queued persistence still adopts its own fresh disk result.
833
+ adoptPersistedConfig(latest, true);
834
+ return { admitted: true, value: publish() };
835
+ });
836
+ },
837
+ persistAllowedUserId: (userId, assertExecutionCurrent, commitIfOwned) => {
838
+ const profileName = activeProfileName;
839
+ const profileKey = profileName ?? TELEGRAM_DEFAULT_PROFILE_NAME;
840
+ const botToken = getEffectiveConfig().botToken;
841
+ const previousOwner = getEffectiveConfig().allowedUserId;
842
+ const assertCurrent = () => {
843
+ assertExecutionCurrent?.();
844
+ if (activeProfileName !== profileName || getEffectiveConfig().botToken !== botToken ||
845
+ getEffectiveConfig().allowedUserId !== previousOwner) {
846
+ throw new Error("Telegram pairing lost its originating profile authority.");
847
+ }
848
+ };
849
+ const pairing = persistQueue.then(() => {
850
+ assertCurrent();
851
+ if (!Number.isSafeInteger(userId) || userId <= 0) throw new Error("Invalid Telegram pairing user ID.");
852
+ let merged: TelegramConfig | undefined;
853
+ const publish = () => {
854
+ merged = withTelegramFileTransaction(`${configPath}.transaction`, () => {
855
+ const latest = readTelegramConfigForTransaction(configPath);
856
+ const profile = latest.profiles?.[profileKey];
857
+ if (!botToken || profile?.botToken !== botToken) {
858
+ throw new Error("Telegram pairing profile is unavailable or changed.");
859
+ }
860
+ assertCurrent();
861
+ if (profile.allowedUserId !== undefined) return latest;
862
+ const next = { ...latest, profiles: { ...latest.profiles,
863
+ [profileKey]: { ...profile, allowedUserId: userId } } };
864
+ writeTelegramConfigInTransaction(agentDir, configPath, next);
865
+ return next;
866
+ });
867
+ };
868
+ if (commitIfOwned) {
869
+ if (!commitIfOwned(publish)) throw new Error("Telegram pairing lost transport ownership before publication.");
870
+ } else {
871
+ publish();
872
+ }
873
+ if (!merged) throw new Error("Telegram pairing publication did not execute.");
874
+ adoptPersistedConfig(merged, true);
875
+ return merged.profiles?.[profileKey]?.allowedUserId === userId;
876
+ });
877
+ persistQueue = pairing.then(() => undefined, () => undefined);
878
+ return pairing;
879
+ },
655
880
  load: async () => {
656
881
  lastLoadRecoveredInvalidConfig = false;
657
882
  const loadedConfig = await readTelegramConfig(configPath, {
@@ -693,7 +918,7 @@ export function createTelegramConfigStore(
693
918
  mutationVersion += 1;
694
919
  },
695
920
  didLastLoadRecoverInvalidConfig: () => lastLoadRecoveredInvalidConfig,
696
- persist: (nextConfig = getEffectiveConfig()) => {
921
+ persist: (nextConfig = getEffectiveConfig(), options) => {
697
922
  const profileName = activeProfileName;
698
923
  const desiredConfig = storeTelegramEffectiveConfig(
699
924
  config,
@@ -706,6 +931,9 @@ export function createTelegramConfigStore(
706
931
  const mergedConfig = withTelegramFileTransaction(
707
932
  `${configPath}.transaction`,
708
933
  () => {
934
+ if (options?.isCurrent && !options.isCurrent()) {
935
+ throw new Error("Telegram config update lost its originating authority.");
936
+ }
709
937
  const latestConfig = readTelegramConfigForTransaction(configPath);
710
938
  const merged = mergeTelegramConfigDelta(
711
939
  baseConfig as Record<string, unknown>,
@@ -718,16 +946,7 @@ export function createTelegramConfigStore(
718
946
  return merged;
719
947
  },
720
948
  );
721
- persistedConfig = cloneTelegramConfig(mergedConfig);
722
- if (mutationVersion === capturedMutationVersion) {
723
- config = cloneTelegramConfig(mergedConfig);
724
- } else {
725
- config = mergeTelegramConfigDelta(
726
- baseConfig as Record<string, unknown>,
727
- config as Record<string, unknown>,
728
- mergedConfig as Record<string, unknown>,
729
- ) as TelegramConfig;
730
- }
949
+ adoptPersistedConfig(mergedConfig, mutationVersion !== capturedMutationVersion);
731
950
  });
732
951
  persistQueue = persist.catch(() => undefined);
733
952
  return persist;
@@ -1054,20 +1273,19 @@ export type TelegramAuthorizationState =
1054
1273
  export interface TelegramUserPairingDeps<TContext> {
1055
1274
  allowedUserId?: number;
1056
1275
  ctx: TContext;
1057
- setAllowedUserId: (userId: number) => void;
1058
- persistConfig: () => Promise<void>;
1276
+ persistAllowedUserId: TelegramConfigStore["persistAllowedUserId"];
1059
1277
  updateStatus: (ctx: TContext) => void;
1060
1278
  assertExecutionCurrent?: () => void;
1061
1279
  }
1062
1280
 
1063
1281
  export interface TelegramUserPairingRuntimeDeps<TContext> {
1064
1282
  getAllowedUserId: () => number | undefined;
1065
- setAllowedUserId: (userId: number) => void;
1066
- persistConfig: () => Promise<void>;
1283
+ persistAllowedUserId: TelegramConfigStore["persistAllowedUserId"];
1067
1284
  updateStatus: (ctx: TContext) => void;
1068
1285
  }
1069
1286
 
1070
1287
  export interface TelegramUserPairingRuntime<TContext> {
1288
+ /** True means this user is authorized, whether newly paired or already configured. */
1071
1289
  pairIfNeeded: (
1072
1290
  userId: number,
1073
1291
  ctx: TContext,
@@ -1104,12 +1322,11 @@ export async function pairTelegramUserIfNeeded<TContext>(
1104
1322
  userId,
1105
1323
  deps.allowedUserId,
1106
1324
  );
1107
- if (authorization.kind !== "pair") return false;
1108
- deps.assertExecutionCurrent?.();
1109
- deps.setAllowedUserId(authorization.userId);
1325
+ if (authorization.kind !== "pair") return authorization.kind === "allow";
1110
1326
  deps.assertExecutionCurrent?.();
1111
- await deps.persistConfig();
1327
+ const allowed = await deps.persistAllowedUserId(authorization.userId, deps.assertExecutionCurrent);
1112
1328
  deps.assertExecutionCurrent?.();
1329
+ if (!allowed) return false;
1113
1330
  try {
1114
1331
  deps.updateStatus(deps.ctx);
1115
1332
  } catch (error) {
@@ -1126,8 +1343,7 @@ export function createTelegramUserPairingRuntime<TContext>(
1126
1343
  pairTelegramUserIfNeeded(userId, {
1127
1344
  allowedUserId: deps.getAllowedUserId(),
1128
1345
  ctx,
1129
- setAllowedUserId: deps.setAllowedUserId,
1130
- persistConfig: deps.persistConfig,
1346
+ persistAllowedUserId: deps.persistAllowedUserId,
1131
1347
  updateStatus: deps.updateStatus,
1132
1348
  assertExecutionCurrent,
1133
1349
  }),