@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/AGENTS.md +4 -4
- package/BACKLOG.md +6 -8
- package/CHANGELOG.md +131 -274
- package/README.md +4 -5
- package/docs/README.md +0 -1
- package/docs/architecture.md +26 -15
- package/docs/multi-instance-bus.md +18 -12
- package/docs/public-api.md +10 -2
- package/docs/sections.md +1 -1
- package/docs/updates.md +1 -1
- package/lib/bindings.ts +1 -9
- package/lib/bus-transport.ts +7 -14
- package/lib/bus.ts +48 -19
- package/lib/command-templates.ts +36 -20
- package/lib/commands.ts +5 -2
- package/lib/config.ts +157 -65
- package/lib/inbound.ts +22 -26
- package/lib/lifecycle.ts +0 -13
- package/lib/locks.ts +59 -40
- package/lib/logs.ts +76 -18
- package/lib/media.ts +0 -9
- package/lib/menu-model.ts +5 -12
- package/lib/menu.ts +0 -19
- package/lib/outbound.ts +2 -12
- package/lib/paths.ts +9 -6
- package/lib/pi.ts +0 -4
- package/lib/queue.ts +0 -5
- package/lib/replies.ts +0 -23
- package/lib/runtime.ts +1 -2
- package/lib/sections.ts +1 -48
- package/lib/status.ts +26 -13
- package/lib/telegram-api.ts +0 -4
- package/lib/threads.ts +41 -8
- package/package.json +1 -1
- package/docs/locks.md +0 -156
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
|
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
|
-
},
|
|
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
|
|
1070
|
-
|
|
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${
|
|
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
|
|
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
|
|
1146
|
+
...(state.activeProfileName
|
|
1147
|
+
? [`- profile: ${state.activeProfileName}`]
|
|
1148
|
+
: []),
|
|
1136
1149
|
`- allowed user: ${state.allowedUserId ?? "not paired"}`,
|
|
1137
1150
|
...(state.botThreadMode
|
|
1138
1151
|
? [
|
package/lib/telegram-api.ts
CHANGED
|
@@ -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
|
-
|
|
|
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) =>
|
|
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:
|
|
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
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.
|