@llblab/pi-telegram 0.23.3 → 0.24.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.
package/lib/status.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Builds usage, cost, and context summaries for the interactive Telegram status view
5
5
  */
6
6
 
7
+ const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
8
+
7
9
  export type TelegramStatusQueueLane = "control" | "priority" | "default";
8
10
 
9
11
  export interface TelegramUsageStats {
@@ -260,9 +262,10 @@ export interface TelegramBridgeStatusRuntimeDeps<
260
262
  statusKey?: string;
261
263
  getConfig: () => TelegramBridgeStatusConfig;
262
264
  getActiveProfileName?: () => string | undefined;
263
- getDiagnosticPaths?: (
264
- profileName?: string,
265
- ) => { state: string; logs: string };
265
+ getDiagnosticPaths?: (profileName?: string) => {
266
+ state: string;
267
+ logs: string;
268
+ };
266
269
  isPollingActive: () => boolean;
267
270
  getActiveSourceMessageIds: () => number[] | undefined;
268
271
  hasActiveTurn: () => boolean;
@@ -293,8 +296,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
293
296
  TelegramBridgeStatusSyncSlice | undefined
294
297
  >;
295
298
  getThreadReconciliationState?: () =>
296
- | TelegramBridgeThreadReconciliationState
297
- | undefined;
299
+ TelegramBridgeThreadReconciliationState | undefined;
298
300
  getInstanceSlot?: () => string | undefined;
299
301
  getInstanceThreadName?: () => string | undefined;
300
302
  getNowMs?: () => number;
@@ -624,7 +626,9 @@ export function createTelegramBridgeStatusRuntime<
624
626
  getBridgeStatusLineState: () => {
625
627
  const config = deps.getConfig();
626
628
  const botThreadMode = deps.getBotThreadMode?.();
627
- const activeProfileName = deps.getActiveProfileName?.();
629
+ const activeProfileName = deps.getActiveProfileName
630
+ ? (deps.getActiveProfileName() ?? TELEGRAM_STATUS_DEFAULT_PROFILE_NAME)
631
+ : undefined;
628
632
  return {
629
633
  hasBotToken: Boolean(config.botToken),
630
634
  botUsername: config.botUsername,
@@ -722,6 +726,8 @@ export function createTelegramStatusSnapshot(
722
726
  };
723
727
  }
724
728
 
729
+ const TELEGRAM_DIAGNOSTICS_SNAPSHOT_COALESCE_MS = 100;
730
+
725
731
  export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
726
732
  persistSnapshot: () => Promise<void>;
727
733
  recordError: (error: unknown) => void;
@@ -734,7 +740,7 @@ export function createTelegramRuntimeDiagnosticsSnapshotScheduler(deps: {
734
740
  timer = setTimer(() => {
735
741
  timer = undefined;
736
742
  void deps.persistSnapshot().catch(deps.recordError);
737
- }, 0);
743
+ }, TELEGRAM_DIAGNOSTICS_SNAPSHOT_COALESCE_MS);
738
744
  if (typeof timer !== "number") timer?.unref?.();
739
745
  };
740
746
  }
@@ -1066,18 +1072,23 @@ function buildTelegramBridgeCompactStatusLines(
1066
1072
  : state.activeSourceMessageIds?.length
1067
1073
  ? "active"
1068
1074
  : "idle";
1069
- const profileSuffix = state.activeProfileName
1070
- ? `.${state.activeProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
1075
+ const diagnosticsProfileName =
1076
+ state.activeProfileName === TELEGRAM_STATUS_DEFAULT_PROFILE_NAME
1077
+ ? undefined
1078
+ : state.activeProfileName;
1079
+ const profileSuffix = diagnosticsProfileName
1080
+ ? `.${diagnosticsProfileName.replace(/[^a-zA-Z0-9._-]+/g, "_")}`
1071
1081
  : "";
1072
- const profileSlug = profileSuffix.slice(1);
1073
1082
  const diagnosticsPaths = state.diagnosticPaths ?? {
1074
1083
  state: `~/.pi/agent/tmp/telegram/state${profileSuffix}.json`,
1075
- logs: `~/.pi/agent/tmp/telegram/logs${profileSlug ? `.${profileSlug}` : ""}.jsonl`,
1084
+ logs: `~/.pi/agent/tmp/telegram/logs${profileSuffix}.jsonl`,
1076
1085
  };
1077
1086
  return [
1078
1087
  "connection:",
1079
1088
  `- bot: ${formatTelegramBridgeBotStatus(state)}`,
1080
- ...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
1089
+ ...(state.activeProfileName
1090
+ ? [`- profile: ${state.activeProfileName}`]
1091
+ : []),
1081
1092
  `- user: ${state.allowedUserId ?? "not paired"}`,
1082
1093
  ...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
1083
1094
  ...(state.busRole ? [`- role: ${state.busRole}`] : []),
@@ -1132,7 +1143,9 @@ export function buildTelegramBridgeDiagnosticStatusLines(
1132
1143
  return [
1133
1144
  "connection:",
1134
1145
  `- bot: ${formatTelegramBridgeBotStatus(state)}`,
1135
- ...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
1146
+ ...(state.activeProfileName
1147
+ ? [`- profile: ${state.activeProfileName}`]
1148
+ : []),
1136
1149
  `- allowed user: ${state.allowedUserId ?? "not paired"}`,
1137
1150
  ...(state.botThreadMode
1138
1151
  ? [
@@ -297,10 +297,6 @@ export type TelegramSendRichMessageBody = Record<string, unknown> & {
297
297
  reply_parameters?: TelegramReplyParameters;
298
298
  };
299
299
 
300
- export type TelegramInputRichMessageContent = {
301
- rich_message: TelegramInputRichMessage;
302
- };
303
-
304
300
  export type TelegramEditMessageTextBody = Record<string, unknown> & {
305
301
  chat_id: number;
306
302
  message_id: number;
package/lib/threads.ts CHANGED
@@ -443,8 +443,7 @@ export function getTelegramTopicTargetsPath(
443
443
  return getTelegramStatePath(agentDir, profileName);
444
444
  }
445
445
 
446
- const TELEGRAM_LEADER_SESSION_HANDOFF_KEY =
447
- "__piTelegramLeaderSessionHandoff";
446
+ const TELEGRAM_LEADER_SESSION_HANDOFF_KEY = "__piTelegramLeaderSessionHandoff";
448
447
  export const TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS = 30_000;
449
448
 
450
449
  export interface TelegramLeaderSessionHandoff {
@@ -458,8 +457,7 @@ export interface TelegramLeaderSessionHandoff {
458
457
  }
459
458
 
460
459
  export function getTelegramLeaderSessionHandoff():
461
- | TelegramLeaderSessionHandoff
462
- | undefined {
460
+ TelegramLeaderSessionHandoff | undefined {
463
461
  const value = (globalThis as Record<string, unknown>)[
464
462
  TELEGRAM_LEADER_SESSION_HANDOFF_KEY
465
463
  ];
@@ -981,6 +979,18 @@ function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
981
979
  };
982
980
  }
983
981
 
982
+ function serializeTelegramStateSemanticSnapshot(
983
+ value: unknown,
984
+ ): string | undefined {
985
+ if (!value || typeof value !== "object" || Array.isArray(value))
986
+ return undefined;
987
+ const { writtenAtMs: _writtenAtMs, ...semantic } = value as Record<
988
+ string,
989
+ unknown
990
+ >;
991
+ return JSON.stringify(semantic);
992
+ }
993
+
984
994
  function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
985
995
  return left.chatId === right.chatId && left.threadId === right.threadId;
986
996
  }
@@ -1083,6 +1093,7 @@ export function createTelegramTopicTargetStore(
1083
1093
  let loadedPath: string | undefined;
1084
1094
  let dirty = false;
1085
1095
  let mutationRevision = 0;
1096
+ let statusRevision = 0;
1086
1097
  let persistQueue: Promise<void> = Promise.resolve();
1087
1098
  let statusSnapshot: {
1088
1099
  runtime?: Record<string, unknown>;
@@ -1245,6 +1256,7 @@ export function createTelegramTopicTargetStore(
1245
1256
  ]),
1246
1257
  );
1247
1258
  const persistedRevision = mutationRevision;
1259
+ const persistedStatusRevision = statusRevision;
1248
1260
  const file = {
1249
1261
  version: 1,
1250
1262
  source: "snapshot",
@@ -1266,6 +1278,24 @@ export function createTelegramTopicTargetStore(
1266
1278
  return serialized;
1267
1279
  }),
1268
1280
  };
1281
+ let persistedSemanticSnapshot: string | undefined;
1282
+ try {
1283
+ persistedSemanticSnapshot = serializeTelegramStateSemanticSnapshot(
1284
+ JSON.parse(await readFile(path, "utf8")),
1285
+ );
1286
+ } catch {
1287
+ /* missing or invalid snapshots must be replaced */
1288
+ }
1289
+ if (
1290
+ persistedSemanticSnapshot ===
1291
+ serializeTelegramStateSemanticSnapshot(file) &&
1292
+ mutationRevision === persistedRevision &&
1293
+ statusRevision === persistedStatusRevision
1294
+ ) {
1295
+ loaded = true;
1296
+ dirty = false;
1297
+ return;
1298
+ }
1269
1299
  await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
1270
1300
  encoding: "utf8",
1271
1301
  mode: 0o600,
@@ -1407,6 +1437,7 @@ export function createTelegramTopicTargetStore(
1407
1437
  setStatusSnapshot(snapshot) {
1408
1438
  if (!loadedPath) loadedPath = getPath();
1409
1439
  statusSnapshot = { ...snapshot };
1440
+ statusRevision += 1;
1410
1441
  },
1411
1442
  getByProfileKey(profileKey) {
1412
1443
  const ownerKey = getTelegramThreadOwnerKey(
@@ -2025,13 +2056,16 @@ export async function provisionOwnBusTopic(
2025
2056
  ) {
2026
2057
  const existingHandoffRecord = deps.store
2027
2058
  .list()
2028
- .find((record) => targetMatches(record.target, leaderSessionHandoff.target));
2059
+ .find((record) =>
2060
+ targetMatches(record.target, leaderSessionHandoff.target),
2061
+ );
2029
2062
  deps.store.upsert({
2030
2063
  profileKey,
2031
2064
  owner: currentLeaderOwner,
2032
2065
  target: { ...leaderSessionHandoff.target },
2033
2066
  status: "active",
2034
- createdAtMs: existingHandoffRecord?.createdAtMs ?? leaderSessionHandoff.createdAtMs,
2067
+ createdAtMs:
2068
+ existingHandoffRecord?.createdAtMs ?? leaderSessionHandoff.createdAtMs,
2035
2069
  updatedAtMs: nowMs,
2036
2070
  threadName:
2037
2071
  existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
@@ -2042,8 +2076,7 @@ export async function provisionOwnBusTopic(
2042
2076
  : {}),
2043
2077
  ...(existingHandoffRecord?.lastSyncObservedAtMs !== undefined
2044
2078
  ? {
2045
- lastSyncObservedAtMs:
2046
- existingHandoffRecord.lastSyncObservedAtMs,
2079
+ lastSyncObservedAtMs: existingHandoffRecord.lastSyncObservedAtMs,
2047
2080
  }
2048
2081
  : {}),
2049
2082
  lastReconcileAction: "leader-session-handoff-restored",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.23.3",
3
+ "version": "0.24.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/docs/locks.md DELETED
@@ -1,156 +0,0 @@
1
- # Extension Locks Standard
2
-
3
- **Meta-contract:** transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
4
-
5
- ---
6
-
7
- `locks.json` is a shared registry for singleton pi extensions.
8
-
9
- Path:
10
-
11
- ```text
12
- ~/.pi/agent/locks.json
13
- ```
14
-
15
- ## Shape
16
-
17
- ```json
18
- {
19
- "@scope/pi-singleton": {
20
- "pid": 2590864,
21
- "cwd": "/home/user/project"
22
- }
23
- }
24
- ```
25
-
26
- Top-level keys are extension identities. Values are JSON objects owned by that extension.
27
-
28
- ## Identity key
29
-
30
- Use the most stable available identity:
31
-
32
- 1. `package.json/name` for npm-style pi packages
33
- 2. Directory name when the extension entrypoint is `index.ts` but there is no package name
34
- 3. File basename when the extension is a single file
35
-
36
- For npm-style package extensions, the canonical value is the `package.json` `name`. Implementations may keep that value as a small local constant when it is clearer than runtime package introspection. The fallback rules are only for unpackaged extensions.
37
-
38
- Examples:
39
-
40
- ```text
41
- extensions/pi-singleton/package.json name=@scope/pi-singleton -> @scope/pi-singleton
42
- extensions/pi-singleton/index.ts without package.json -> pi-singleton
43
- extensions/pi-singleton.ts -> pi-singleton
44
- ```
45
-
46
- ## Required fields
47
-
48
- ```json
49
- {
50
- "pid": 2590864
51
- }
52
- ```
53
-
54
- `pid` is the process that currently owns the singleton runtime. `cwd` should be stored when ownership is tied to a pi session directory.
55
-
56
- During a user-initiated start/connect event, an extension should:
57
-
58
- 1. Read its lock entry
59
- 2. If `pid` is stale, replace the entry
60
- 3. If `pid` and `cwd` match the current pi instance, refresh or keep the entry
61
- 4. If a live external owner exists, ask interactively whether to move singleton ownership here
62
-
63
- ## Acquisition timing
64
-
65
- Lock writes must be caused by an explicit user-initiated runtime event, such as a start/connect command or a confirmed takeover prompt.
66
-
67
- Extension initialization and session-start hooks may read `locks.json`, update local status, install ownership watchers, and resume local work when the existing lock already points at the current `pid`/`cwd`. After a full process restart, a session-start hook may replace a stale lock from the same `cwd` to restore explicitly requested ownership. They must not create ownership from an inactive lock, take over a live external owner, or replace a stale lock from another directory by themselves. Such locks should stay visible as state until the user runs the start/connect command. Session replacement should suspend local runtime work and ownership watchers without releasing the lock, so the next session in the same `pid`/`cwd` can resume from explicit ownership.
68
-
69
- ## Optional fields
70
-
71
- Extensions may add compact fields when useful:
72
-
73
- ```json
74
- {
75
- "pid": 2590864,
76
- "cwd": "/repo/project",
77
- "mode": "connected",
78
- "updatedAt": "2026-04-28T00:00:00.000Z"
79
- }
80
- ```
81
-
82
- Do not print optional fields in normal UI unless they help the user act.
83
-
84
- ## Ownership rules
85
-
86
- - One top-level key per singleton extension
87
- - An extension may only mutate its own key
88
- - Other keys must be preserved exactly
89
- - If `cwd` is present, active-here ownership means both `pid` and `cwd` match the current pi instance
90
- - Human-readable diagnostics should say `active here`, `active elsewhere`, or `stale`
91
- - Debug data belongs in `locks.json`, not in normal status output
92
-
93
- ## Runtime status
94
-
95
- Singleton extensions with footer/status presence should expose quiet but explicit local state:
96
-
97
- - `off` when this pi instance does not own the singleton runtime
98
- - `on` when this pi instance owns the runtime but has no pending runtime detail to show
99
- - `[16:32:39]` when the runtime owns scheduled work and can show the next countdown
100
-
101
- Extensions may prefix active states with their own compact name, such as `telegram on` or `wakeup [00:10:00]`. Quiet idle states may be hidden when status-line width is more valuable than an explicit off marker.
102
-
103
- ## Interactive takeover
104
-
105
- Start/connect commands should make singleton moves easy:
106
-
107
- 1. If no live owner exists, take ownership without an extra prompt
108
- 2. If a live external owner exists, ask whether to move singleton ownership to this pi instance
109
- 3. On confirmation, write the current `{ "pid": ..., "cwd": ... }` to this extension's key in `locks.json`
110
- 4. The previous owner must notice that `locks.json` no longer points at its own `pid`/`cwd` and stop local runtime work without deleting the new lock
111
-
112
- Takeover prompts should use the extension name as the dialog title, then the question, a blank line, and source/target lines:
113
-
114
- ```text
115
- pi-singleton
116
- move singleton lock here?
117
-
118
- from: pid 2590864, cwd /old
119
- to: /new
120
- ```
121
-
122
- Avoid repeating the extension name in the body. Color is encouraged: extension title/name accent, question warning, `from:`/`to:` muted.
123
-
124
- The previous owner may use `fs.watch`, mtime polling, or an existing status/timer tick. Long-lived watchers should compare against a snapshotted `pid`/`cwd` identity rather than a live pi context object, because session replacement such as `/new` makes captured contexts stale. The important contract is graceful local shutdown after ownership mismatch.
125
-
126
- ## Reset
127
-
128
- Delete `~/.pi/agent/locks.json` to reset singleton runtime ownership for all participating extensions without deleting their configuration files.
129
-
130
- ## Atomicity
131
-
132
- `locks.json` is one shared registry, so preserving unrelated keys in memory is not sufficient. Every writer must serialize the complete cross-process read/check/write transaction through the same guard. Otherwise two extensions can read the same snapshot, update different keys, and publish snapshots that erase one another.
133
-
134
- The canonical guard path is:
135
-
136
- ```text
137
- ~/.pi/agent/locks.json.transaction
138
- ```
139
-
140
- All participating extensions must follow one compatible protocol:
141
-
142
- - Acquire the guard before every ownership acquisition, refresh, release, takeover, or other registry mutation.
143
- - Publish fully initialized private owner metadata atomically. A portable implementation may stage a non-empty directory containing `owner.<generation>.json`, require filename/payload generation agreement, and rename that directory into the stable guard path.
144
- - Do not depend on hard links or platform-specific advisory locks; the protocol must work on Linux, macOS, native Windows, and Android/Termux filesystems supported by Pi.
145
- - Read and validate the latest complete registry only after guard acquisition, change only the owned extension key, and preserve every unrelated key from that guarded snapshot.
146
- - Publish the JSON payload through a same-directory temporary file and atomic rename. Atomic payload replacement prevents torn JSON but does not replace transaction serialization.
147
- - Release only the exact acquired owner by atomically renaming the stable guard away before cleanup. Stale recovery must prove the observed owner process is dead and must fence delayed recovery against replacement-owner ABA races.
148
- - Fail closed on malformed owner metadata, malformed registry state, unverifiable generations, contention timeout, or unsupported atomic filesystem behavior.
149
-
150
- Lock-free reads remain appropriate for status display when readers tolerate an old-or-new complete snapshot. Any decision that mutates shared ownership must re-read and validate under the transaction.
151
-
152
- Cross-writer safety is compositional: every writer targeting the same registry must participate in the protocol. One compliant writer cannot guarantee lost-update safety against another writer that bypasses the shared transaction.
153
-
154
- ## Migration
155
-
156
- Migrations from legacy lock files or legacy keys should be one-off cleanup work. Runtime ownership should read and write only `locks.json` under the canonical identity key.