@llblab/pi-kit 0.20.0 → 0.21.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 (66) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +9 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +4 -4
  6. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -3
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +41 -11
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +6 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +94 -1
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +2 -5
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +11 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +4 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +8 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +6 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +19 -6
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +5 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +27 -3
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +10 -8
  25. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +2 -2
  26. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
  27. package/node_modules/@llblab/pi-state-flow/docs/usage.md +5 -5
  28. package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
  29. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -3
  30. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +41 -9
  31. package/node_modules/@llblab/pi-state-flow/lib/git.ts +83 -1
  32. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +2 -4
  33. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +11 -0
  34. package/node_modules/@llblab/pi-state-flow/lib/status.ts +10 -4
  35. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +23 -6
  36. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +29 -3
  37. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -2
  38. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  39. package/node_modules/@llblab/pi-telegram/AGENTS.md +2 -2
  40. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +5 -0
  41. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -0
  42. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -0
  43. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +3 -1
  44. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +6 -1
  45. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +5 -0
  46. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +9 -0
  47. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +5 -1
  48. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +8 -0
  49. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +63 -11
  50. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +4 -0
  51. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +14 -0
  52. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
  53. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +5 -0
  54. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  55. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  56. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  57. package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -1
  58. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +7 -0
  59. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +13 -2
  60. package/node_modules/@llblab/pi-telegram/lib/commands.ts +19 -0
  61. package/node_modules/@llblab/pi-telegram/lib/extension.ts +9 -0
  62. package/node_modules/@llblab/pi-telegram/lib/queue.ts +75 -16
  63. package/node_modules/@llblab/pi-telegram/lib/replies.ts +18 -0
  64. package/node_modules/@llblab/pi-telegram/lib/routing.ts +7 -0
  65. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  66. package/package.json +3 -3
@@ -25,7 +25,7 @@ import {
25
25
  type SessionAddress,
26
26
  } from "./durable.ts";
27
27
  import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
28
- import { backupCurrentStateFlowFiles } from "./git.ts";
28
+ import { backupCurrentStateFlowFiles, pushCurrentStateFlowBackup } from "./git.ts";
29
29
  import { projectRecentTransitionsWithLimit } from "./history.ts";
30
30
  import { isObject, presentationJson, sameJson, type JsonObject } from "./json.ts";
31
31
  import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
@@ -40,6 +40,7 @@ import { emptySnapshot, migrationFailure, type Snapshot } from "./snapshot.ts";
40
40
  import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ModelState, type ScopedStates, type StateScope } from "./state.ts";
41
41
  import { compactStatus, detailedStatus, STATUS_KEY, type StatusDiagnostics } from "./status.ts";
42
42
  import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader, type StateFlowTelegramScope } from "./telegram.ts";
43
+ import { temporalScopeRevisions, type ScopeRevisions } from "./temporal.ts";
43
44
  import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, type StagedScopedTransition } from "./transition.ts";
44
45
 
45
46
  export interface StateFlowExtensionOptions {
@@ -85,7 +86,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
85
86
  let activeContext: ExtensionContext | undefined;
86
87
  let rehydrationPhase: RehydrationPhase | undefined;
87
88
  const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
88
- const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => activeContext?.ui.notify(message, "warning"));
89
+ const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
89
90
  let backupPending = false;
90
91
  const skillReads = new SkillReadTracker(hashSkillSource, (path) => activeContext
91
92
  ? registeredSkillResolver(activeContext.cwd, pi.getCommands())(path)
@@ -100,6 +101,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
100
101
  return resolveSessionAddress(ctx.sessionManager.getSessionFile(), ctx.sessionManager.getSessionId(), ctx.sessionManager.getHeader()?.timestamp);
101
102
  }
102
103
 
104
+ function notifyActiveContext(message: string): void {
105
+ try {
106
+ activeContext?.ui.notify(message, "warning");
107
+ } catch {
108
+ // An asynchronous push attempt can outlive the Pi context that launched it.
109
+ }
110
+ }
111
+
103
112
  function createRuntime(ctx: ExtensionContext): TemporalRuntime {
104
113
  return new TemporalRuntime(ctx.cwd, sessionAddress(ctx), repositoryRoot, undefined, config.historyLimit);
105
114
  }
@@ -134,8 +143,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
134
143
  if ("boundary" in checkpoint) branchStartsWithoutRuntime = false;
135
144
  }
136
145
 
146
+ function scopeRevisions(): ScopeRevisions {
147
+ return runtime?.view
148
+ ? temporalScopeRevisions(runtime.view)
149
+ : { global: 0, cwd: 0, session: 0 };
150
+ }
151
+
137
152
  function updateUi(ctx: ExtensionContext): void {
138
- ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, (color, text) => ctx.ui.theme.fg(color, text)));
153
+ ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, scopeRevisions(), (color, text) => ctx.ui.theme.fg(color, text)));
139
154
  }
140
155
 
141
156
  function clearRunTransient(): void {
@@ -202,6 +217,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
202
217
  if (Object.keys(removals).length > 0) {
203
218
  const stage = stageAtomicScopePatches(scopeStates, removals, [], runtime.causalBasis());
204
219
  commitStage(stage, ctx, false);
220
+ updateUi(ctx);
205
221
  }
206
222
  refreshArtifactHints();
207
223
  }
@@ -217,10 +233,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
217
233
 
218
234
  function refreshTelegramStateView(scope: StateFlowTelegramScope): void {
219
235
  if (scope === "session" || scope === "effective") assertSelectedBranchAvailable();
220
- if (runtime?.view) return;
221
- if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
222
- runtime ??= createRuntime(activeContext);
223
- runtime.loadPassive();
236
+ if (!runtime?.view) {
237
+ if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
238
+ runtime ??= createRuntime(activeContext);
239
+ runtime.loadPassive();
240
+ } else if (scope !== "session") {
241
+ runtime.refreshShared();
242
+ }
224
243
  installScopeStates();
225
244
  }
226
245
 
@@ -319,6 +338,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
319
338
  head: structuredClone(view.lineage.at(-1)!),
320
339
  historyDepth: view.lineage.length - 1,
321
340
  tailCounts: { global: view.scopes.global.patches.length, cwd: view.scopes.cwd.patches.length, session: view.scopes.session.patches.length },
341
+ revisions: temporalScopeRevisions(view),
322
342
  } }),
323
343
  staleArtifacts,
324
344
  ...(durableStateError === undefined ? {} : { durableStateError }),
@@ -699,6 +719,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
699
719
  snapshot: () => ({
700
720
  enabled: snapshot.config.enabled,
701
721
  step: snapshot.meta.step,
722
+ revisions: scopeRevisions(),
702
723
  bootstrap: snapshot.meta.bootstrap === true,
703
724
  startPending: telegramStartPending,
704
725
  }),
@@ -709,6 +730,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
709
730
  : scopeStates[scope];
710
731
  return { ...projectModelState(selected), lazy: structuredClone(selected.lazy) };
711
732
  },
733
+ revisions: () => scopeRevisions(),
712
734
  canStartNow: () => activeContext === undefined || activeContext.isIdle(),
713
735
  start: () => {
714
736
  if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
@@ -909,7 +931,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
909
931
  if (!backupPending) return;
910
932
  backupPending = false;
911
933
  try {
912
- if (existsSync(join(repositoryRoot, ".git"))) backupCurrentStateFlowFiles(repositoryRoot);
934
+ if (existsSync(join(repositoryRoot, ".git"))) {
935
+ backupCurrentStateFlowFiles(repositoryRoot);
936
+ const pushSessionId = sessionAddress(ctx).id;
937
+ const pushCwd = ctx.cwd;
938
+ void pushCurrentStateFlowBackup(repositoryRoot).catch((error) => {
939
+ const message = error instanceof Error ? error.message : String(error);
940
+ diagnosticWriter.record(pushSessionId, pushCwd, message, "publication-conflict");
941
+ notifyActiveContext(`State Flow accepted canonical state; Git backup push failed: ${message}`);
942
+ });
943
+ }
913
944
  } catch (error) {
914
945
  const message = error instanceof Error ? error.message : String(error);
915
946
  recordDiagnostic(message, "publication-conflict", ctx);
@@ -950,10 +981,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
950
981
  runAnchorTimestamp = undefined;
951
982
  restoreActiveBranch(ctx);
952
983
  });
953
- pi.on("session_shutdown", (_event, ctx) => {
984
+ pi.on("session_shutdown", (_event, _ctx) => {
954
985
  telegramStartPending = false;
955
986
  compactionStopped = true;
956
987
  completedRunAccepted = false;
988
+ activeContext = undefined;
957
989
  telegram.dispose();
958
990
  });
959
991
  }
@@ -2,13 +2,19 @@
2
2
  import { closeSync, constants, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join, relative, resolve, sep } from "node:path";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
6
  import { captureOwnedFileBases, isStateFlowOwnedPath } from "./durable.ts";
7
7
  import { acquirePublicationLock, withStoragePublicationLock } from "./storage.ts";
8
8
 
9
9
  const GIT_TIMEOUT_MS = 15_000;
10
10
  const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
11
11
 
12
+ function redactGitDiagnostic(value: string): string {
13
+ return value
14
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@")
15
+ .replace(/([?&](?:access_token|auth|password|token)=)[^&\s]+/gi, "$1***");
16
+ }
17
+
12
18
  interface GitResult {
13
19
  status: number;
14
20
  stdout: string;
@@ -134,6 +140,22 @@ function currentBranchRef(repositoryRoot: string): string {
134
140
  return result.stdout.trim();
135
141
  }
136
142
 
143
+ function configuredPushDestination(repositoryRoot: string): { remote: string; ref: string } | undefined {
144
+ const branchRef = currentBranchRef(repositoryRoot);
145
+ const branch = branchRef.slice("refs/heads/".length);
146
+ const configuredRemote = git(repositoryRoot, ["config", "--get", `branch.${branch}.remote`], { allowFailure: true });
147
+ if (configuredRemote.status > 1) throw new Error(configuredRemote.stderr || "Cannot inspect State Flow backup remote configuration");
148
+ if (configuredRemote.status !== 0 || configuredRemote.stdout.trim().length === 0) return undefined;
149
+ const remote = configuredRemote.stdout.trim();
150
+ if (remote === ".") throw new Error("State Flow backup replication requires a non-local Git remote");
151
+ const configuredMerge = git(repositoryRoot, ["config", "--get", `branch.${branch}.merge`], { allowFailure: true });
152
+ if (configuredMerge.status > 1) throw new Error(configuredMerge.stderr || "Cannot inspect State Flow backup branch configuration");
153
+ const ref = configuredMerge.status === 0 && configuredMerge.stdout.trim().length > 0
154
+ ? configuredMerge.stdout.trim()
155
+ : branchRef;
156
+ return { remote, ref };
157
+ }
158
+
137
159
  function commitCurrentOwnedFiles(repositoryRoot: string, expectedHead: string | undefined): string | undefined {
138
160
  const branchRef = currentBranchRef(repositoryRoot);
139
161
  if (currentHead(repositoryRoot) !== expectedHead) throw new Error("State Flow backup Git base changed concurrently");
@@ -183,3 +205,63 @@ function commitCurrentOwnedFiles(repositoryRoot: string, expectedHead: string |
183
205
  export function backupCurrentStateFlowFiles(repositoryRoot: string): string | undefined {
184
206
  return withBackupLock(repositoryRoot, (root) => commitCurrentOwnedFiles(root, currentHead(root)));
185
207
  }
208
+
209
+ /** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
210
+ export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ commit: string; remote: string; ref: string } | undefined> {
211
+ return new Promise((resolvePush, rejectPush) => {
212
+ let root: string;
213
+ let commit: string | undefined;
214
+ let destination: { remote: string; ref: string } | undefined;
215
+ try {
216
+ root = assertRepositoryRoot(repositoryRoot);
217
+ commit = currentHead(root);
218
+ destination = configuredPushDestination(root);
219
+ } catch (error) {
220
+ rejectPush(error);
221
+ return;
222
+ }
223
+ if (commit === undefined || destination === undefined) {
224
+ resolvePush(undefined);
225
+ return;
226
+ }
227
+ const child = spawn("git", ["-C", root, "push", "--porcelain", "--", destination.remote, `${commit}:${destination.ref}`], {
228
+ detached: process.platform !== "win32",
229
+ stdio: ["ignore", "ignore", "pipe"],
230
+ windowsHide: true,
231
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
232
+ });
233
+ let stderr = "";
234
+ let failure: Error | undefined;
235
+ let settled = false;
236
+ function terminate(error: Error): void {
237
+ failure ??= error;
238
+ if (child.exitCode !== null || child.signalCode !== null) {
239
+ child.stderr?.destroy();
240
+ return;
241
+ }
242
+ if (!child.pid) return;
243
+ try {
244
+ if (process.platform === "win32") child.kill("SIGKILL");
245
+ else process.kill(-child.pid, "SIGKILL");
246
+ } catch {
247
+ // Keep waiting for close: signalling failure is not proof that the process ended.
248
+ }
249
+ }
250
+ const timeout = setTimeout(() => terminate(new Error(`Git backup push timed out after ${GIT_TIMEOUT_MS}ms`)), GIT_TIMEOUT_MS);
251
+ function finish(error?: Error): void {
252
+ if (settled) return;
253
+ settled = true;
254
+ clearTimeout(timeout);
255
+ if (error) rejectPush(error);
256
+ else resolvePush({ commit: commit!, ...destination! });
257
+ }
258
+ child.stderr?.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString("utf8")).slice(-1000); });
259
+ child.on("error", (error) => {
260
+ failure ??= error;
261
+ if (!child.pid) finish(error);
262
+ });
263
+ child.once("exit", () => { if (failure) child.stderr?.destroy(); });
264
+ child.once("close", (code, endedBy) => finish(failure ?? (code === 0 ? undefined
265
+ : new Error(`Git backup push failed (${endedBy ?? code}): ${redactGitDiagnostic(stderr.trim()) || "no diagnostic output"}`))));
266
+ });
267
+ }
@@ -56,7 +56,7 @@ READ: Use read_state for concrete scope/history gaps. lazy_navigation lists boun
56
56
 
57
57
  WRITE: patch_state is the sole model-authored semantic mutation mechanism. Supply global/cwd/session patches in any combination; all supplied scopes are validated and durably accepted as one atomic transition. Call it alone in an assistant response, then continue only after its acknowledgement.
58
58
 
59
- RESPONSE: Ordinary assistant completion needs no finalization patch. Runtime reconciles the accepted non-empty answer into response at turn_end without another inference.
59
+ RESPONSE: Ordinary assistant completion needs no finalization patch. At turn_end, runtime stores the exact accepted answer; empty becomes response "".
60
60
 
61
61
  INTENTS: Keep chosen actions; detail may stay lazy. State refs use {"$ref":"cwd.lazy.plan"} or \`$cwd.lazy.plan\` in text. Resolve only when needed; infer no authority, hydration, execution, or completion. If that resolution proves a dangling state ref, fix/drop it in owning text; never scan for broken refs.
62
62
 
@@ -88,10 +88,8 @@ export function finalizedAssistantResponse(message: AgentMessage): string {
88
88
  if (message.content.some((block) => block.type === "toolCall")) {
89
89
  throw new Error("Accepted State Flow response cannot contain a tool call");
90
90
  }
91
- const response = message.content
91
+ return message.content
92
92
  .filter((block) => block.type === "text")
93
93
  .map((block) => block.text)
94
94
  .join("");
95
- if (response.trim().length === 0) throw new Error("Finalized State Flow response must contain non-empty text");
96
- return response;
97
95
  }
@@ -415,6 +415,17 @@ export class TemporalRuntime {
415
415
  return { view: reconciledView(), base: captured, provenance };
416
416
  }
417
417
 
418
+ /** Refresh live shared scopes in memory without publishing or advancing private semantic history. */
419
+ refreshShared(): boolean {
420
+ if (!this.view || !this.base) throw new Error("State Flow shared refresh requires a selected temporal runtime");
421
+ const before = this.view;
422
+ const reconciled = this.reconcileSharedDrift(new Set());
423
+ this.view = reconciled.view;
424
+ this.base = reconciled.base;
425
+ this.provenanceByScope = reconciled.provenance;
426
+ return before !== this.view;
427
+ }
428
+
418
429
  /** Canonically accept a prepared retained-boundary origin before lifecycle-only persistence. */
419
430
  acceptRestoredOrigin(snapshot: Snapshot): RuntimePublication {
420
431
  if (!this.restoredOriginPending) throw new Error("State Flow has no prepared restored origin to accept");
@@ -3,7 +3,7 @@ import { projectRecentTransitionsWithLimit, type RecentTransitionWindow } from "
3
3
  import { retainedMemoryScopes } from "./memory.ts";
4
4
  import type { Snapshot } from "./snapshot.ts";
5
5
  import { overlayStates, type ScopedStates, type StateScope } from "./state.ts";
6
- import type { TransitionBoundary } from "./temporal.ts";
6
+ import type { ScopeRevisions, TransitionBoundary } from "./temporal.ts";
7
7
 
8
8
  export const STATUS_KEY = "state-flow";
9
9
 
@@ -24,13 +24,18 @@ export interface StatusDiagnostics {
24
24
  scopeStates: ScopedStates;
25
25
  recent: RecentTransitionWindow;
26
26
  historyLimit: number;
27
- temporal?: { head: TransitionBoundary; historyDepth: number; tailCounts: Record<StateScope, number> };
27
+ temporal?: { head: TransitionBoundary; historyDepth: number; tailCounts: Record<StateScope, number>; revisions: ScopeRevisions };
28
28
  staleArtifacts: readonly StaleArtifactDiagnostic[];
29
29
  durableStateError?: string;
30
30
  }
31
31
 
32
- export function compactStatus(snapshot: Snapshot, colorize: Colorize): string {
33
- return `${colorize("accent", "state-flow")} ${colorize("dim", `#${snapshot.meta.step}`)}`;
32
+ export function formatScopeRevisionVector(revisions: ScopeRevisions): string {
33
+ return `G${revisions.global}/C${revisions.cwd}/S${revisions.session}`;
34
+ }
35
+
36
+ export function compactStatus(snapshot: Snapshot, revisions: ScopeRevisions, colorize: Colorize): string | undefined {
37
+ if (!snapshot.config.enabled) return undefined;
38
+ return `${colorize("accent", "state-flow")} ${colorize("dim", formatScopeRevisionVector(revisions))}`;
34
39
  }
35
40
 
36
41
  function countArtifacts(states: ScopedStates, scope: StateScope): number {
@@ -62,6 +67,7 @@ export function detailedStatus(snapshot: Snapshot, diagnostics: StatusDiagnostic
62
67
  `Hot history: unavailable; configured maximum depth ${diagnostics.historyLimit}`,
63
68
  "Retained patch tails: unavailable"]
64
69
  : [`Temporal head: ${JSON.stringify(temporal.head.id)}; branch-local position ${temporal.head.position}`,
70
+ `Scope revisions: global #${temporal.revisions.global}; CWD #${temporal.revisions.cwd}; session #${temporal.revisions.session}; effective ${formatScopeRevisionVector(temporal.revisions)}`,
65
71
  `Hot history: offsets 0..${temporal.historyDepth}; maximum depth ${diagnostics.historyLimit}`,
66
72
  `Retained patch tails: global ${temporal.tailCounts.global}; CWD ${temporal.tailCounts.cwd}; session ${temporal.tailCounts.session}`];
67
73
  const artifacts = (scope: StateScope) => available ? countArtifacts(diagnostics.scopeStates, scope) : "unknown";
@@ -3,6 +3,9 @@
3
3
  // This is a leaf adapter. Core semantics, storage, and inference never depend on it; when
4
4
  // pi-telegram is absent or its registry is not ready, registration fails open and retries.
5
5
 
6
+ import { formatScopeRevisionVector } from "./status.ts";
7
+ import type { ScopeRevisions } from "./temporal.ts";
8
+
6
9
  export const STATE_FLOW_TELEGRAM_ID = "@llblab/pi-state-flow";
7
10
 
8
11
  /** Resolve the package export or the compiled sibling-extension layout used in local development. */
@@ -15,7 +18,9 @@ export function stateFlowTelegramSectionSpecifiers(moduleUrl = import.meta.url):
15
18
 
16
19
  export interface StateFlowTelegramSnapshot {
17
20
  enabled: boolean;
21
+ /** Legacy branch step retained for existing adapter ports; current runtime ports also supply owner revisions. */
18
22
  step: number;
23
+ revisions?: ScopeRevisions;
19
24
  bootstrap: boolean;
20
25
  startPending: boolean;
21
26
  }
@@ -93,6 +98,8 @@ export interface StateFlowTelegramControlResult {
93
98
  export interface StateFlowTelegramPort {
94
99
  snapshot(): StateFlowTelegramSnapshot;
95
100
  state(scope: StateFlowTelegramScope): StateFlowTelegramState;
101
+ /** Optional additive capability; absent legacy ports retain their branch-step presentation. */
102
+ revisions?(): ScopeRevisions;
96
103
  canStartNow(): boolean;
97
104
  start(): StateFlowTelegramControlResult;
98
105
  stop(): StateFlowTelegramControlResult;
@@ -107,12 +114,14 @@ export interface StateFlowTelegramAdapter {
107
114
 
108
115
  /** Main-menu section label doubles as the live status value: the spiral identity is constant, the value is not. */
109
116
  export function formatStateFlowSectionLabel(snapshot: StateFlowTelegramSnapshot): string {
110
- return `🌀 State Flow: ${stateFlowLabelValue(snapshot)}`;
117
+ if (!snapshot.enabled) return "🌀 State Flow: off";
118
+ return `🌀 State Flow: ${snapshot.revisions ? formatScopeRevisionVector(snapshot.revisions) : `#${snapshot.step}`}`;
111
119
  }
112
120
 
113
121
  /** Shared live value: plain in the button label, monospaced in the submenu state line. */
114
122
  function stateFlowLabelValue(snapshot: StateFlowTelegramSnapshot): string {
115
- return `#${snapshot.step}`;
123
+ if (!snapshot.enabled) return "off";
124
+ return snapshot.revisions ? formatScopeRevisionVector(snapshot.revisions) : `#${snapshot.step}`;
116
125
  }
117
126
 
118
127
  /** Submenu state line: the same identity as the button label, with the live value in monospace. */
@@ -191,13 +200,18 @@ function renderStateFlowTelegramField(value: unknown): string {
191
200
  return rendered;
192
201
  }
193
202
 
194
- export function renderStateFlowRichState(scope: StateFlowTelegramScope, step: number, state: StateFlowTelegramState): StateFlowTelegramRichMessage {
195
- const fields = ["intents", "contract", "working", "artifacts", "response", "lazy"] as const;
203
+ export function renderStateFlowRichState(scope: StateFlowTelegramScope, revisions: ScopeRevisions, state: StateFlowTelegramState): StateFlowTelegramRichMessage {
204
+ const fields: Array<keyof StateFlowTelegramState> = scope === "global" || scope === "cwd"
205
+ ? ["intents", "contract", "working", "artifacts", "lazy"]
206
+ : ["intents", "contract", "working", "artifacts", "response", "lazy"];
207
+ const revision = scope === "effective"
208
+ ? formatScopeRevisionVector(revisions)
209
+ : `#${revisions[scope]}`;
196
210
  return {
197
211
  blocks: [
198
212
  {
199
213
  type: "heading",
200
- text: [`${STATE_FLOW_SCOPE_LABELS[scope]}: `, { type: "code", text: `#${step}` }],
214
+ text: [`${STATE_FLOW_SCOPE_LABELS[scope]}: `, { type: "code", text: revision }],
201
215
  size: 3,
202
216
  },
203
217
  ...fields.map((field) => ({
@@ -233,7 +247,10 @@ function buildStateFlowTelegramSection(port: StateFlowTelegramPort) {
233
247
  }
234
248
  if (ctx.action === "inspect") {
235
249
  if (!isStateFlowTelegramScope(ctx.payload)) throw new Error("Unknown State Flow scope");
236
- await ctx.openRich(renderStateFlowRichState(ctx.payload, port.snapshot().step, port.state(ctx.payload)));
250
+ const state = port.state(ctx.payload);
251
+ const live = port.snapshot();
252
+ const revisions = port.revisions?.() ?? live.revisions ?? { global: live.step, cwd: live.step, session: live.step };
253
+ await ctx.openRich(renderStateFlowRichState(ctx.payload, revisions, state));
237
254
  await ctx.answerCallback();
238
255
  return "handled" as const;
239
256
  }
@@ -21,6 +21,8 @@ export interface TemporalPatch {
21
21
  }
22
22
 
23
23
  export interface ScopeStream {
24
+ /** Monotonic semantic revision owned by this scope; independent of branch-local boundary positions. */
25
+ revision: number;
24
26
  checkpoint: ScopeCheckpoint;
25
27
  patches: TemporalPatch[];
26
28
  }
@@ -32,6 +34,7 @@ export interface TemporalState {
32
34
  }
33
35
 
34
36
  const SCOPES: StateScope[] = ["global", "cwd", "session"];
37
+ export type ScopeRevisions = Record<StateScope, number>;
35
38
 
36
39
  function validateHistoryLimit(limit: number): void {
37
40
  if (!Number.isSafeInteger(limit) || limit < 0 || limit > MAX_HISTORY_LIMIT) {
@@ -69,7 +72,8 @@ function sameBoundary(left: TransitionBoundary, right: TransitionBoundary): bool
69
72
  export function validateScopeStream(value: unknown, scope: StateScope, historyLimit = DEFAULT_HISTORY_LIMIT): asserts value is ScopeStream {
70
73
  validateHistoryLimit(historyLimit);
71
74
  if (!SCOPES.includes(scope)) throw new Error("Unknown temporal scope");
72
- if (!isJsonValue(value) || !isObject(value) || Object.keys(value).sort().join(",") !== "checkpoint,patches"
75
+ if (!isJsonValue(value) || !isObject(value) || Object.keys(value).sort().join(",") !== "checkpoint,patches,revision"
76
+ || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0
73
77
  || !isObject(value.checkpoint) || Object.keys(value.checkpoint).sort().join(",") !== "state,through"
74
78
  || !Array.isArray(value.patches)) {
75
79
  throw new Error("Invalid temporal checkpoint/tail envelope");
@@ -78,6 +82,7 @@ export function validateScopeStream(value: unknown, scope: StateScope, historyLi
78
82
  validateBoundary(stream.checkpoint.through);
79
83
  validateState(stream.checkpoint.state);
80
84
  if (stream.patches.length > historyLimit) throw new Error(`Temporal scope tail exceeds configured history limit ${historyLimit}`);
85
+ if (stream.revision < stream.patches.length) throw new Error("Temporal scope revision predates its retained patch tail");
81
86
  let previous = stream.checkpoint.through;
82
87
  let state = stream.checkpoint.state;
83
88
  const identities = new Set([previous.id]);
@@ -191,6 +196,7 @@ export function adoptTemporalStreams(scopes: Record<StateScope, ScopeStream>, id
191
196
  export function createTemporalState(states: ScopedStates, id: string, historyLimit = DEFAULT_HISTORY_LIMIT): TemporalState {
192
197
  const through: TransitionBoundary = { id, position: 0, parent: null };
193
198
  const stream = (scope: StateScope): ScopeStream => ({
199
+ revision: 0,
194
200
  checkpoint: { through: structuredClone(through), state: structuredClone(states[scope]) },
195
201
  patches: [],
196
202
  });
@@ -234,7 +240,9 @@ export function selectScopeStreamAtBoundary(stream: ScopeStream, scope: StateSco
234
240
  throw new Error("Selected State Flow history boundary predates the retained scope checkpoint");
235
241
  }
236
242
  const selected = structuredClone(stream);
237
- selected.patches = selected.patches.filter(({ transition }) => transition.position <= boundary.position);
243
+ const retained = selected.patches.filter(({ transition }) => transition.position <= boundary.position);
244
+ selected.revision -= selected.patches.length - retained.length;
245
+ selected.patches = retained;
238
246
  validateScopeStream(selected, scope, historyLimit);
239
247
  return selected;
240
248
  }
@@ -250,12 +258,28 @@ export function selectTemporalStateBoundary(view: TemporalState, boundaryId: str
250
258
  const selected = structuredClone(view);
251
259
  selected.lineage = selected.lineage.slice(0, index + 1);
252
260
  for (const scope of SCOPES) {
253
- selected.scopes[scope].patches = selected.scopes[scope].patches.filter(({ transition }) => transition.position <= target.position);
261
+ const stream = selected.scopes[scope];
262
+ const retained = stream.patches.filter(({ transition }) => transition.position <= target.position);
263
+ stream.revision -= stream.patches.length - retained.length;
264
+ stream.patches = retained;
254
265
  }
255
266
  validateTemporalState(selected, historyLimit);
256
267
  return selected;
257
268
  }
258
269
 
270
+ /** Current independent scope revisions; Effective uses this vector rather than inventing a scalar owner. */
271
+ export function temporalScopeRevisions(view: TemporalState): ScopeRevisions {
272
+ const revisions = {
273
+ global: view.scopes.global.revision,
274
+ cwd: view.scopes.cwd.revision,
275
+ session: view.scopes.session.revision,
276
+ };
277
+ if (Object.values(revisions).some((revision) => !Number.isSafeInteger(revision) || revision < 0)) {
278
+ throw new Error("Invalid State Flow scope revision vector");
279
+ }
280
+ return revisions;
281
+ }
282
+
259
283
  /** Lazy scope/effective read at one shared transition boundary, never by local patch count. */
260
284
  export function readTemporalState(view: TemporalState, offset = 0, scope?: StateScope, historyLimit = DEFAULT_HISTORY_LIMIT): MaterializedState {
261
285
  validateHistoryLimit(historyLimit);
@@ -298,6 +322,8 @@ export function advanceTemporalState(
298
322
  const next = structuredClone(view);
299
323
  for (const { scope, patch } of changes) {
300
324
  const stream = next.scopes[scope];
325
+ if (stream.revision >= Number.MAX_SAFE_INTEGER) throw new Error(`State Flow ${scope} scope revision is exhausted`);
326
+ stream.revision += 1;
301
327
  if (historyLimit === 0) {
302
328
  stream.checkpoint = { through: structuredClone(boundary), state: apply(scopeAt(stream, head), patch) };
303
329
  stream.patches = [];
@@ -257,8 +257,8 @@ export function stageScopedTransition(
257
257
  causalBasis: string,
258
258
  successfulArtifactReads: Iterable<SuccessfulArtifactRead> = [],
259
259
  ): StagedScopedTransition {
260
- if (typeof transition.response !== "string" || transition.response.trim().length === 0) {
261
- throw new Error("Accepted State Flow response body must be non-empty");
260
+ if (typeof transition.response !== "string") {
261
+ throw new Error("Accepted State Flow response body must be a string");
262
262
  }
263
263
  return stageScopedSemanticTransition(
264
264
  currentStates,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-state-flow",
3
- "version": "0.17.4",
3
+ "version": "0.18.0",
4
4
  "private": false,
5
5
  "description": "Incremental scoped state/context/memory compiler for Pi, inspired by SKILL.state",
6
6
  "keywords": [
@@ -74,7 +74,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
74
74
  - Threaded Mode has exactly one live leader per bot profile. Followers are real operator-started Pi processes and must authenticate/register over local IPC; Telegram never spawns hidden Pi processes. A live but unreachable owner does not authorize split-brain polling.
75
75
  - Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings. Follower registration identity may prepare a binding, but inbound generation authority is published only after successful preparation for the same generation and current context; pending or failed startup cannot append into a retained old journal. Readiness also binds the active Pi context and supplied session generation. Session refresh awaits binding preparation without re-registering; reusing a context object cannot carry readiness across a session-generation change. Registration requests capture session authority before asynchronous startup and fence every publication/finalization against the current attempt. Stop or supersession invalidates that attempt; obsolete cleanup cannot erase a newer registration or a refreshed context.
76
76
  - Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register` and capability-gated restore-only `follower.restoreWorkspace` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader owns their serialized profile preference and title application.
77
- - Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
77
+ - Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Follower heartbeat response deadlines align with the eight-second leader stale-liveness window rather than the generic one-second local-RPC default; an ordinary multi-second Pi/TUI event-loop stall is not registration loss. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
78
78
  - Runtime state is event-driven reconciliation of local assumptions against Telegram signals, not a complete bot read-model and not permission to query Telegram on every action. Destructive thread cleanup goes through `thread-reconciler` with current proof and leader fencing. Fresh Workspace Thread creation derives its initial Bot API title from the active display mode before issuance; the stable generated `threadName` remains separate from the acknowledged `displayTitle`.
79
79
 
80
80
  ### 4.3 Durable Admission And Settlement
@@ -95,7 +95,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
95
95
  ### 4.4 Queue, Delivery, And User Surfaces
96
96
 
97
97
  - Queue lane/kind admission is explicit. Dispatch waits for active-turn, pending-dispatch, control, compaction, `ctx.isIdle()`, and Pi pending-message guards; a dispatched prompt stays queued until `agent_start` consumes it. The terminal `+N` suffix is a yellow count of executable prompts still waiting, excludes the dispatched head immediately, and never counts current agent work from any source. Each prompt is one object with one active lane and no reserved return slot. Normal and Priority are separate FIFO lanes: crossing lanes removes it from the source and appends it at the destination tail, while Keep/Skip and same-category emoji changes preserve lane position. Complete reaction sets independently derive Priority from recognized positive emoji and Skip from recognized negative emoji; both may coexist, suppressed turns retain durable receipts while waiting, and Skip settles them only when the prompt reaches dispatch before dropping it without inference. Suppressed turns remain visible at a struck-through physical ordinal without contributing to executable queue counters, while graceful session shutdown discards all remaining queue authority before clearing memory.
98
- - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. Abort-history folding applies only to Telegram-owned active turns.
98
+ - `/stop`, `/abort`, `/next`, and `/continue` respectively reset+abort, abort while preserving queue, force the next turn, and enqueue a control-lane continuation. Abort-history folding applies only to Telegram-owned active turns. A busy `/next` marks only its exact active Telegram turn: terminal abort settlement attempts one explicit abort notice before the exact selected queued prompt receives its dispatch notice. Notice failure is diagnostic and cannot block dispatch; a later `/abort` or `/stop` cancels both pending transition notices before taking ownership. Target command adapters must forward these transition ports. A successful dispatch notice keeps the queued prompt's one reply-header claim across agent start, so later messages in that turn never repeat it.
99
99
  - Telegram extension side effects must not hold Pi's core lifecycle hostage after semantic completion. Preserve ordering in extension-owned background work, record failures, and fence target/profile/transport/session authority.
100
100
  - Complete assistant/guest model answers use Telegram-native Rich Markdown. Harness-owned menus, status, diagnostics, thinking, and tool evidence remain explicit HTML/plain or their documented native surface. Before Telegram preview or final delivery, strip every assistant-authored HTML comment regardless of Markdown position while keeping action activation top-level-only; a comment-only result sends no text message. Preserve literal code outside comments and structurally safe chunking; never split invalid markup.
101
101
  - `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for active preview flushes and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
@@ -4,6 +4,11 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.51.1: Queue transitions and follower heartbeat stability
8
+
9
+ - `/next lifecycle notices`: Target command composition now forwards request, exact-turn marker, and cancellation ports. Busy `/next` orders abort then selected-prompt dispatch notices; failures cannot block dispatch, and `/abort`/`/stop` cancel stale notices. A successful dispatch notice keeps first-reply ownership through agent start, so later answer messages do not repeat the queued-prompt reply. Rapid ordinary messages remain distinct turns.
10
+ - `Follower heartbeat stability`: Follower heartbeats now use a dedicated eight-second response deadline aligned with leader stale-liveness policy instead of the generic one-second local-RPC default. Ordinary multi-second TUI/event-loop stalls therefore retain registration and stable `follower` status rather than destroying the socket, producing leader EPIPE noise, and entering repeated reconnect cycles.
11
+
7
12
  ## 0.51.0: Safe Workspace rotation and follower readiness
8
13
 
9
14
  - `Workspace slot rotation`: Fresh allocation reclaims the oldest proven inactive slot only at full A–Z pressure. Exact grouped receipts may be journal-CAS discarded only after complete source inspection proves every process-birth owner dead. Live/unknown owners, partial groups, handoffs and unrelated work stay protected. Fresh protection precedes Thread/history deletion and slot reuse. Pi sessions/files/memory/journals remain; restore-only never evicts; ambiguous deletion never replays.
@@ -34,6 +34,7 @@ export interface TelegramQueueBindingRuntime<TContext> {
34
34
  mutation: Queue.TelegramQueueMutationController<TContext>;
35
35
  dispatchNext: (ctx: TContext) => void;
36
36
  requestNextDispatchAnnouncement: () => void;
37
+ cancelNextDispatchAnnouncement: () => void;
37
38
  watchdog: Queue.TelegramQueueDispatchWatchdogRuntime<TContext>;
38
39
  }
39
40
  export declare function createTelegramQueueBindingRuntime<TContext>(deps: {
@@ -58,6 +59,7 @@ export declare function createTelegramQueueBindingRuntime<TContext>(deps: {
58
59
  updateStatus: (ctx: TContext, error?: string) => void;
59
60
  sendTextReply: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendTextReply"];
60
61
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
62
+ reconcileNextDispatchAnnouncementReplyOwnership?: (item: Queue.PendingTelegramTurn) => void;
61
63
  recordRuntimeEvent?: TelegramRuntimeEventRecorder;
62
64
  }): TelegramQueueBindingRuntime<TContext>;
63
65
  export interface TelegramGenerativeAppLiveSurfaceBinding {
@@ -79,6 +79,7 @@ export function createTelegramQueueBindingRuntime(deps) {
79
79
  },
80
80
  updateStatus: deps.updateStatus,
81
81
  sendTextReply: deps.sendTextReply,
82
+ reconcileNextDispatchAnnouncementReplyOwnership: deps.reconcileNextDispatchAnnouncementReplyOwnership,
82
83
  recordRuntimeEvent: deps.recordRuntimeEvent,
83
84
  ...deps.promptDispatch,
84
85
  sendUserMessage: deps.sendUserMessage,
@@ -87,6 +88,7 @@ export function createTelegramQueueBindingRuntime(deps) {
87
88
  mutation,
88
89
  dispatchNext: dispatch.dispatchNext,
89
90
  requestNextDispatchAnnouncement: dispatch.requestNextDispatchAnnouncement,
91
+ cancelNextDispatchAnnouncement: dispatch.cancelNextDispatchAnnouncement,
90
92
  watchdog: Queue.createTelegramQueueDispatchWatchdogRuntime({
91
93
  hasQueuedItems: deps.store.hasQueuedItems,
92
94
  dispatchNextQueuedTelegramTurn: dispatch.dispatchNext,
@@ -8,7 +8,7 @@
8
8
  import * as Sync from "./sync.ts";
9
9
  import * as Threads from "./threads.ts";
10
10
  import { type TelegramUpdateJournalStoreOptions } from "./journal.ts";
11
- import type { TelegramLockEntry, TelegramLockState } from "./locks.ts";
11
+ import { type TelegramLockEntry, type TelegramLockState } from "./locks.ts";
12
12
  import type { TelegramQueueHandoffPayload, TelegramQueueHandoffStageResult } from "./queue.ts";
13
13
  import type { TelegramTarget } from "./target.ts";
14
14
  import { type TelegramBusAgentMessage, type TelegramBusAgentTargetSelector, type TelegramBusEnvelope, type TelegramBusForwardOwnership, type TelegramBusProtocolIdentity, type TelegramBusSocketPathSource } from "./bus.ts";
@@ -18,6 +18,7 @@ export declare const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2500;
18
18
  export declare const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30000;
19
19
  export declare const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30000;
20
20
  export declare const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30000;
21
+ export declare const TELEGRAM_BUS_FOLLOWER_HEARTBEAT_TIMEOUT_MS = 8000;
21
22
  export declare const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS: number;
22
23
  export declare const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS: number;
23
24
  export interface TelegramFollowerSessionHandoff {
@@ -170,6 +171,7 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<TContext extends {
170
171
  registrationRetryAttempts?: number;
171
172
  registrationRetryDelayMs?: number;
172
173
  heartbeatMs?: number;
174
+ heartbeatTimeoutMs?: number;
173
175
  recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
174
176
  onHeartbeatFailure?: (error: unknown, ctx: TContext) => Promise<void> | void;
175
177
  onRegistered?: (ctx: TContext) => Promise<void> | void;