@llblab/pi-kit 0.14.1 → 0.16.0

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 (119) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +5 -0
  4. package/node_modules/@llblab/pi-codex-usage/README.md +6 -0
  5. package/node_modules/@llblab/pi-codex-usage/index.ts +81 -7
  6. package/node_modules/@llblab/pi-codex-usage/package.json +1 -1
  7. package/node_modules/@llblab/pi-state-flow/AGENTS.md +9 -9
  8. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +0 -13
  9. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +15 -0
  10. package/node_modules/@llblab/pi-state-flow/README.md +7 -6
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/config.d.ts +5 -2
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/config.js +17 -16
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/context.d.ts +8 -0
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/context.js +24 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/continuation.js +4 -3
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +8 -3
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.d.ts +2 -0
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/episode.js +4 -0
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +5 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +93 -35
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +20 -23
  23. package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +8 -4
  24. package/node_modules/@llblab/pi-state-flow/dist/lib/json.js +29 -3
  25. package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +34 -18
  26. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +14 -16
  27. package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +27 -1
  28. package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +182 -10
  29. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
  30. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +34 -5
  31. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.d.ts +5 -5
  32. package/node_modules/@llblab/pi-state-flow/dist/lib/snapshot.js +20 -24
  33. package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +6 -3
  34. package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +5 -3
  35. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +1 -1
  36. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.d.ts +2 -0
  37. package/node_modules/@llblab/pi-state-flow/dist/lib/storage.js +55 -20
  38. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +8 -6
  39. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +10 -3
  40. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +7 -3
  41. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  42. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +11 -3
  43. package/node_modules/@llblab/pi-state-flow/docs/README.md +1 -0
  44. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -3
  45. package/node_modules/@llblab/pi-state-flow/docs/filesystem-recovery.md +3 -2
  46. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +504 -0
  47. package/node_modules/@llblab/pi-state-flow/docs/usage.md +11 -8
  48. package/node_modules/@llblab/pi-state-flow/lib/config.ts +18 -15
  49. package/node_modules/@llblab/pi-state-flow/lib/context.ts +24 -0
  50. package/node_modules/@llblab/pi-state-flow/lib/continuation.ts +4 -3
  51. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -4
  52. package/node_modules/@llblab/pi-state-flow/lib/episode.ts +5 -0
  53. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +86 -34
  54. package/node_modules/@llblab/pi-state-flow/lib/git.ts +23 -22
  55. package/node_modules/@llblab/pi-state-flow/lib/history.ts +8 -4
  56. package/node_modules/@llblab/pi-state-flow/lib/json.ts +31 -3
  57. package/node_modules/@llblab/pi-state-flow/lib/migration.ts +33 -16
  58. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +14 -16
  59. package/node_modules/@llblab/pi-state-flow/lib/query.ts +173 -9
  60. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +32 -4
  61. package/node_modules/@llblab/pi-state-flow/lib/snapshot.ts +22 -25
  62. package/node_modules/@llblab/pi-state-flow/lib/state.ts +11 -6
  63. package/node_modules/@llblab/pi-state-flow/lib/status.ts +1 -1
  64. package/node_modules/@llblab/pi-state-flow/lib/storage.ts +46 -18
  65. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +19 -6
  66. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +7 -3
  67. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  68. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +11 -3
  69. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  70. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -0
  71. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +12 -0
  72. package/node_modules/@llblab/pi-telegram/README.md +1 -0
  73. package/node_modules/@llblab/pi-telegram/dist/lib/activity.d.ts +2 -0
  74. package/node_modules/@llblab/pi-telegram/dist/lib/activity.js +12 -0
  75. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  76. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +7 -2
  77. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +134 -2
  78. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +297 -16
  79. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +2 -0
  80. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +11 -0
  81. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +68 -8
  82. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +4 -3
  83. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +17 -13
  84. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +2 -1
  85. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +1 -0
  86. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +2 -0
  87. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +5 -1
  88. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +1 -0
  89. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -0
  90. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +1 -0
  91. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +59 -7
  92. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.d.ts +23 -0
  93. package/node_modules/@llblab/pi-telegram/dist/lib/thread-display.js +20 -0
  94. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +18 -0
  95. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +152 -6
  96. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +1 -0
  97. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +5 -3
  98. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  99. package/node_modules/@llblab/pi-telegram/docs/architecture.md +3 -3
  100. package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
  101. package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -3
  102. package/node_modules/@llblab/pi-telegram/docs/sections.md +2 -2
  103. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +2 -1
  104. package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
  105. package/node_modules/@llblab/pi-telegram/lib/activity.ts +14 -0
  106. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +10 -2
  107. package/node_modules/@llblab/pi-telegram/lib/commands.ts +466 -21
  108. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +15 -0
  109. package/node_modules/@llblab/pi-telegram/lib/extension.ts +77 -9
  110. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +25 -10
  111. package/node_modules/@llblab/pi-telegram/lib/pi.ts +3 -0
  112. package/node_modules/@llblab/pi-telegram/lib/queue.ts +7 -1
  113. package/node_modules/@llblab/pi-telegram/lib/replies.ts +2 -0
  114. package/node_modules/@llblab/pi-telegram/lib/routing.ts +90 -19
  115. package/node_modules/@llblab/pi-telegram/lib/thread-display.ts +30 -0
  116. package/node_modules/@llblab/pi-telegram/lib/threads.ts +199 -6
  117. package/node_modules/@llblab/pi-telegram/lib/updates.ts +5 -2
  118. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  119. package/package.json +4 -4
@@ -645,6 +645,50 @@ function normalizeWorkspaceBindingRecord(value) {
645
645
  function cloneWorkspaceBinding(binding) {
646
646
  return { ...binding, target: { ...binding.target } };
647
647
  }
648
+ function cloneSessionReplacementIntent(intent) {
649
+ return { ...intent, target: { ...intent.target } };
650
+ }
651
+ function normalizeSessionReplacementIntent(value) {
652
+ if (!value || typeof value !== "object" || Array.isArray(value))
653
+ return undefined;
654
+ const record = value;
655
+ const target = record.target;
656
+ if (typeof record.cwd !== "string" || !normalizeTelegramWorkspacePath(record.cwd) ||
657
+ typeof record.profileName !== "string" || !record.profileName ||
658
+ typeof record.sourceSessionId !== "string" || !normalizeTelegramSessionId(record.sourceSessionId) ||
659
+ typeof record.sourceUpdateId !== "number" || !Number.isSafeInteger(record.sourceUpdateId) ||
660
+ !target || typeof target.chatId !== "number" ||
661
+ (target.threadId !== undefined &&
662
+ (typeof target.threadId !== "number" || !Number.isSafeInteger(target.threadId))) ||
663
+ typeof record.messageId !== "number" || !Number.isSafeInteger(record.messageId) ||
664
+ typeof record.createdAtMs !== "number" || !Number.isSafeInteger(record.createdAtMs) ||
665
+ typeof record.expiresAtMs !== "number" || !Number.isSafeInteger(record.expiresAtMs) ||
666
+ record.expiresAtMs <= record.createdAtMs)
667
+ return undefined;
668
+ const continuity = record.continuity === "workspace-thread" ||
669
+ record.continuity === "classic-chat"
670
+ ? record.continuity
671
+ : target.threadId !== undefined ? "workspace-thread" : "classic-chat";
672
+ if ((continuity === "workspace-thread") !== (target.threadId !== undefined)) {
673
+ return undefined;
674
+ }
675
+ return {
676
+ continuity,
677
+ cwd: normalizeTelegramWorkspacePath(record.cwd),
678
+ profileName: record.profileName,
679
+ sourceSessionId: normalizeTelegramSessionId(record.sourceSessionId),
680
+ sourceUpdateId: record.sourceUpdateId,
681
+ target: {
682
+ chatId: target.chatId,
683
+ ...(typeof target.threadId === "number" ? { threadId: target.threadId } : {}),
684
+ },
685
+ messageId: record.messageId,
686
+ ...(typeof record.slot === "string" ? { slot: record.slot } : {}),
687
+ ...(typeof record.threadName === "string" ? { threadName: record.threadName } : {}),
688
+ createdAtMs: record.createdAtMs,
689
+ expiresAtMs: record.expiresAtMs,
690
+ };
691
+ }
648
692
  function normalizeWorkspaceRetirementIntent(value) {
649
693
  if (!value || typeof value !== "object" || Array.isArray(value))
650
694
  return undefined;
@@ -905,6 +949,7 @@ function parseTopicTargetFile(value) {
905
949
  return normalized ? [normalized] : [];
906
950
  })
907
951
  : [],
952
+ sessionReplacement: normalizeSessionReplacementIntent(file.sessionReplacement),
908
953
  reservations: Array.isArray(file.reservations)
909
954
  ? file.reservations.flatMap((reservation) => {
910
955
  const normalized = normalizeReservation(reservation);
@@ -1026,6 +1071,7 @@ export function createTelegramTopicTargetStore(options) {
1026
1071
  let identities = new Map();
1027
1072
  let workspaceBindings = new Map();
1028
1073
  let workspaceRetirements = [];
1074
+ let sessionReplacement;
1029
1075
  let workspaceRetirementCommitInFlight = false;
1030
1076
  const hasWorkspaceRetirementConflict = (input) => workspaceRetirements.some((intent) => (input.bindingKey !== undefined && intent.binding.bindingKey === input.bindingKey) ||
1031
1077
  (input.cwd !== undefined && intent.binding.cwd === input.cwd) ||
@@ -1150,6 +1196,7 @@ export function createTelegramTopicTargetStore(options) {
1150
1196
  identities = new Map();
1151
1197
  workspaceBindings = new Map();
1152
1198
  workspaceRetirements = [];
1199
+ sessionReplacement = undefined;
1153
1200
  workspaceRetirementCommitInFlight = false;
1154
1201
  workspaceClaims = new Map();
1155
1202
  reservations = [];
@@ -1171,6 +1218,7 @@ export function createTelegramTopicTargetStore(options) {
1171
1218
  identities = new Map();
1172
1219
  workspaceBindings = new Map();
1173
1220
  workspaceRetirements = [];
1221
+ sessionReplacement = undefined;
1174
1222
  workspaceRetirementCommitInFlight = false;
1175
1223
  reservations = [];
1176
1224
  pendingProvisions = [];
@@ -1208,6 +1256,9 @@ export function createTelegramTopicTargetStore(options) {
1208
1256
  cloneWorkspaceBinding(binding),
1209
1257
  ]));
1210
1258
  workspaceRetirements = (file.workspaceRetirements ?? []).map(cloneWorkspaceRetirementIntent);
1259
+ sessionReplacement = file.sessionReplacement
1260
+ ? cloneSessionReplacementIntent(file.sessionReplacement)
1261
+ : undefined;
1211
1262
  reconcileWorkspaceSuffixExposure();
1212
1263
  for (const record of records.values())
1213
1264
  rememberIdentity(record);
@@ -1286,6 +1337,9 @@ export function createTelegramTopicTargetStore(options) {
1286
1337
  identities: Array.from(identities.values()).map(cloneIdentityRecord),
1287
1338
  workspaceBindings: Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding),
1288
1339
  workspaceRetirements: workspaceRetirements.map(cloneWorkspaceRetirementIntent),
1340
+ ...(sessionReplacement
1341
+ ? { sessionReplacement: cloneSessionReplacementIntent(sessionReplacement) }
1342
+ : {}),
1289
1343
  reservations: reservations.map((reservation) => ({ ...reservation })),
1290
1344
  pendingProvisions: pendingProvisions.map((provision) => ({
1291
1345
  ...provision,
@@ -1563,6 +1617,40 @@ export function createTelegramTopicTargetStore(options) {
1563
1617
  listWorkspaceBindings() {
1564
1618
  return Array.from(workspaceBindings.values()).map(cloneWorkspaceBinding);
1565
1619
  },
1620
+ getWorkspaceBindingByTarget(target, sessionId) {
1621
+ const binding = Array.from(workspaceBindings.values()).find((candidate) => targetMatches(candidate.target, target) &&
1622
+ (sessionId === undefined || candidate.sessionId === sessionId));
1623
+ return binding ? cloneWorkspaceBinding(binding) : undefined;
1624
+ },
1625
+ getSessionReplacementIntent() {
1626
+ return sessionReplacement
1627
+ ? cloneSessionReplacementIntent(sessionReplacement)
1628
+ : undefined;
1629
+ },
1630
+ async commitSessionReplacementIntent(intent, isCurrent) {
1631
+ const next = normalizeSessionReplacementIntent(intent);
1632
+ if (!next || !isCurrent())
1633
+ return false;
1634
+ await loadFromDisk();
1635
+ if (!isCurrent())
1636
+ return false;
1637
+ const existing = sessionReplacement;
1638
+ if (existing && existing.expiresAtMs > getNowMs() &&
1639
+ !isDeepStrictEqual(existing, next))
1640
+ return false;
1641
+ sessionReplacement = cloneSessionReplacementIntent(next);
1642
+ markDirty();
1643
+ return await persistSnapshot() && isCurrent();
1644
+ },
1645
+ async removeSessionReplacementIntent(expected, isCurrent) {
1646
+ await loadFromDisk();
1647
+ if (!isCurrent() || !sessionReplacement ||
1648
+ !isDeepStrictEqual(sessionReplacement, expected))
1649
+ return false;
1650
+ sessionReplacement = undefined;
1651
+ markDirty();
1652
+ return await persistSnapshot() && isCurrent();
1653
+ },
1566
1654
  listWorkspaceRetirementIntents() {
1567
1655
  return workspaceRetirements.map(cloneWorkspaceRetirementIntent);
1568
1656
  },
@@ -1959,6 +2047,36 @@ export function createTelegramTopicTargetStore(options) {
1959
2047
  (options?.sessionId !== undefined && !normalizedSessionId) ||
1960
2048
  hasWorkspaceRetirementConflict({ cwd: normalizedCwd }))
1961
2049
  return undefined;
2050
+ let replacementPreviousInstanceId;
2051
+ const replacement = sessionReplacement;
2052
+ if (replacement?.continuity === "workspace-thread" && normalizedSessionId &&
2053
+ replacement.expiresAtMs > getNowMs() &&
2054
+ replacement.profileName === (getTelegramProfile() ?? "default") &&
2055
+ replacement.cwd === normalizedCwd &&
2056
+ replacement.sourceSessionId !== normalizedSessionId) {
2057
+ const sourceEntry = Array.from(workspaceBindings.entries()).find(([, binding]) => binding.cwd === normalizedCwd &&
2058
+ binding.sessionId === replacement.sourceSessionId &&
2059
+ targetMatches(binding.target, replacement.target));
2060
+ if (sourceEntry) {
2061
+ const [sourceKey, sourceBinding] = sourceEntry;
2062
+ const replacementIdentity = Array.from({ length: TELEGRAM_WORKSPACE_SLOTS.length })
2063
+ .map((_, ordinal) => createTelegramWorkspaceBindingIdentityWithKey(sourceBinding.cwd, sourceBinding.workspaceKey, ordinal, normalizedSessionId))
2064
+ .find((identity) => identity?.instanceSlot === sourceBinding.instanceSlot);
2065
+ if (replacementIdentity &&
2066
+ replacementIdentity.instanceSlot === sourceBinding.instanceSlot) {
2067
+ const existingTargetRecord = Array.from(records.values()).find((record) => targetMatches(record.target, replacement.target));
2068
+ replacementPreviousInstanceId = existingTargetRecord?.instanceId;
2069
+ workspaceBindings.delete(sourceKey);
2070
+ workspaceBindings.set(getWorkspaceBindingMapKey(replacementIdentity), {
2071
+ ...sourceBinding,
2072
+ ...replacementIdentity,
2073
+ updatedAtMs: getNowMs(),
2074
+ });
2075
+ markDirty();
2076
+ }
2077
+ }
2078
+ }
2079
+ const effectivePreviousInstanceId = previousInstanceId ?? replacementPreviousInstanceId;
1962
2080
  const externalReservedSlots = captureExternalReservedSlots();
1963
2081
  if (!externalReservedSlots) {
1964
2082
  options?.onCapacityUnavailable?.();
@@ -1995,9 +2113,16 @@ export function createTelegramTopicTargetStore(options) {
1995
2113
  const mapKey = getWorkspaceBindingMapKey(identity);
1996
2114
  const existingClaim = workspaceClaims.get(mapKey);
1997
2115
  if (existingClaim) {
1998
- return existingClaim.instanceId === instanceId
1999
- ? { ...existingClaim.identity }
2000
- : undefined;
2116
+ if (existingClaim.instanceId === instanceId) {
2117
+ return { ...existingClaim.identity };
2118
+ }
2119
+ if (existingClaim.instanceId !== effectivePreviousInstanceId)
2120
+ return undefined;
2121
+ workspaceClaims.set(mapKey, {
2122
+ identity: existingClaim.identity,
2123
+ instanceId,
2124
+ });
2125
+ return { ...existingClaim.identity };
2001
2126
  }
2002
2127
  const binding = workspaceBindings.get(mapKey);
2003
2128
  const liveRecord = binding
@@ -2005,7 +2130,7 @@ export function createTelegramTopicTargetStore(options) {
2005
2130
  : undefined;
2006
2131
  if (liveRecord &&
2007
2132
  liveRecord.instanceId !== instanceId &&
2008
- liveRecord.instanceId !== previousInstanceId) {
2133
+ liveRecord.instanceId !== effectivePreviousInstanceId) {
2009
2134
  return undefined;
2010
2135
  }
2011
2136
  const retainedTarget = binding?.target ?? legacyRecord?.target;
@@ -2147,8 +2272,29 @@ export function createTelegramTopicTargetStore(options) {
2147
2272
  if (!next)
2148
2273
  return undefined;
2149
2274
  const nextMapKey = getWorkspaceBindingMapKey(next);
2150
- const claim = workspaceClaims.get(nextMapKey);
2151
- if (next.slot && Array.from(workspaceBindings.values()).some((existing) => existing.bindingKey !== next.bindingKey && existing.slot === next.slot))
2275
+ let claim = workspaceClaims.get(nextMapKey);
2276
+ const replacedTargetBinding = Array.from(workspaceBindings.values()).find((existing) => existing.bindingKey !== next.bindingKey &&
2277
+ targetMatches(existing.target, next.target));
2278
+ if (claimInstanceId && claim?.instanceId === claimInstanceId &&
2279
+ replacedTargetBinding?.slot) {
2280
+ claim = {
2281
+ ...claim,
2282
+ identity: { ...claim.identity, slot: replacedTargetBinding.slot },
2283
+ };
2284
+ workspaceClaims.set(nextMapKey, claim);
2285
+ next.slot = replacedTargetBinding.slot;
2286
+ if (replacedTargetBinding.threadName && !next.threadName) {
2287
+ next.threadName = replacedTargetBinding.threadName;
2288
+ }
2289
+ if (replacedTargetBinding.manualThreadName && !next.manualThreadName) {
2290
+ next.manualThreadName = replacedTargetBinding.manualThreadName;
2291
+ }
2292
+ if (replacedTargetBinding.displayTitle && !next.displayTitle) {
2293
+ next.displayTitle = replacedTargetBinding.displayTitle;
2294
+ }
2295
+ }
2296
+ if (next.slot && Array.from(workspaceBindings.values()).some((existing) => existing.bindingKey !== next.bindingKey && existing.slot === next.slot &&
2297
+ !targetMatches(existing.target, next.target)))
2152
2298
  return undefined;
2153
2299
  for (const existing of workspaceBindings.values()) {
2154
2300
  if (existing.workspaceKey === next.workspaceKey &&
@@ -1216,6 +1216,7 @@ export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
1216
1216
  isContextCurrent: (ctx: TContext) => boolean;
1217
1217
  dispatchNext: (ctx: TContext) => void;
1218
1218
  requestQueueHandoffReconciliation: (ctx: TContext) => void;
1219
+ afterUpdateCompleted?: (updateId: number) => void;
1219
1220
  }
1220
1221
  export declare function createTelegramUpdateWorkerOwnerRuntime<TContext>(deps: TelegramUpdateWorkerOwnerRuntimeDeps<TContext>): TelegramUpdateWorkerOwnerRuntime<TContext>;
1221
1222
  export interface TelegramUpdateAdmissionRuntimeBinding<TContext> {
@@ -3177,9 +3177,11 @@ export function createTelegramUpdateWorkerOwnerRuntime(deps) {
3177
3177
  deps.dispatchNext(ctx);
3178
3178
  deps.requestQueueHandoffReconciliation(ctx);
3179
3179
  },
3180
- onUpdateCompleted(_updateId, ctx) {
3181
- if (deps.isContextCurrent(ctx))
3182
- deps.dispatchNext(ctx);
3180
+ onUpdateCompleted(updateId, ctx) {
3181
+ if (!deps.isContextCurrent(ctx))
3182
+ return;
3183
+ deps.dispatchNext(ctx);
3184
+ deps.afterUpdateCompleted?.(updateId);
3183
3185
  },
3184
3186
  };
3185
3187
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.48.2",
3
+ "version": "0.49.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -42,7 +42,7 @@ Keep this boundary explicit:
42
42
 
43
43
  ### Instance, Session, And Context Cost
44
44
 
45
- A Telegram destination follows a Pi instance, not an immutable Pi session file. Ordinary Telegram prompts enter whichever session is active in that assigned instance when dispatch occurs. If the operator replaces or resumes a session locally, pi-telegram rebinds its session-scoped runtime state while preserving the instance's Telegram target where supported. Telegram currently exposes compaction for the active session, but not new-session, resume, fork, tree navigation, session switching, or full reload; those operations require safe public Pi extension APIs.
45
+ A Telegram destination follows a Pi instance, not an immutable Pi session file. Ordinary Telegram prompts enter whichever session is active in that assigned instance when dispatch occurs. If the operator replaces or resumes a session locally, pi-telegram rebinds its session-scoped runtime state while preserving the instance's Telegram target where supported. Telegram exposes compaction and new-session replacement for the active session: `/new` first completes and removes its exact durable update, then dispatches an internal registered Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })` so the handler runs with a real `ExtensionCommandContext` and calls the same `AgentSessionRuntime.newSession()` path as the terminal. Before invoking that path, the current transport owner CAS-publishes one expiring replacement intent inside the existing profile target snapshot. The intent discriminates `workspace-thread` from `classic-chat`. A same- or cross-process successor may consume either only for the exact profile, CWD, source session, target, and fresh lifetime: Threaded continuity re-keys the retained Workspace binding and preserves Thread, slot, and display identity, while Classic continuity preserves the exact chat target without creating a Workspace binding or entering topic lifecycle APIs. A confirmed callback is acknowledged and its dialog is deleted before session replacement begins. Once the successor has delivery authority, it atomically claims the intent by CAS-clearing it and then sends one separate terminal success result with bounded in-process transport retry. The old `withSession` path never publishes success, and an unclaimed intent never sends, preventing duplicate terminal notices across same-process startup and cleanup-ack ambiguity. Mismatch or expiry never authorizes binding takeover. Resume, fork, tree navigation, session switching, and full reload remain outside the stable Telegram API until Pi exposes safe public extension hooks for them.
46
46
 
47
47
  `/telegram-connect` never launches a hidden or headless Pi process. A long-lived background Pi process can own Telegram only when something else explicitly launched that process and it satisfies the normal lock/runtime rules. Pi `print` and `json` modes stay passive and exit rather than becoming hidden polling owners.
48
48
 
@@ -70,7 +70,7 @@ The repository uses a **Flat Domain DAG**:
70
70
  - `sync`: demand-driven Telegram reconciliation, mutable sync-slice state, nested provisioning activity, and local assumption policy. It does not own a complete Telegram bot read-model; Bot API lacks a complete topic/thread listing surface. It owns sync slices, invalidation triggers, config-persist invalidation sequencing, exact-target admitted stale-topic API recovery, observation intake, status/debug freshness, paired manual-disconnect/session-restart cleanup assembly, and reconciliation scheduling across bot identity, pairing assumptions, live target bindings, reservations, and transport health after meaningful observable signals. Production topic lifecycle and disconnect/restart cleanup enter profile-wide admission before the shared Workspace operation gate. Lifecycle admission spans observation-driven store settlement; cleanup admission spans intent publication, Telegram cleanup, binding mutation, durable settlement, and transport release. It should call narrower domain primitives rather than letting `index.ts`, `threads`, or `status` accumulate cross-cutting reconciliation policy.
71
71
  - `thread-reconciler`: Threaded Mode control-plane planning for Telegram thread/tab lifecycle. It owns the reconciliation state machine (`stable`, `provisioning`, `sync-required`, `cleanup-required`), pure plans, proof-before-delete rules, pending-provision protection, fresh-creation grace windows, leader-epoch checks, and the single policy authority for destructive thread cleanup actions. It excludes live Telegram API calls, inbound routing, menu rendering, and direct persistence.
72
72
  - `thread-cleanup-manager`: Disconnected proof-only admission planning for manual inactive-Workspace cleanup. It emits exact profile/binding/target snapshots only when durable inactivity exists, all live-owner/accepted-work/delivery evidence is `clear`, identities are unique, and no reservation, provision, or cleanup competes. Missing, malformed, duplicate, or `unknown` evidence returns no candidates; age and ordering never create authority. Its disconnected bounded profile/token-scoped work store atomically persists exact candidate snapshots, records one exact Workspace-issued deletion permit as outcome-unknown, rejects mismatched permits, and confirms deleted state idempotently; strict reads reject malformed/private-file ambiguity. A disconnected executor requires an injected exclusive Workspace deletion boundary across fresh planner evidence, exact retained-snapshot comparison, permit acquisition/recording, delete callback, and confirmation. The future fence owner must acquire and recheck in admission-ledger order; a retirement fence cannot be nested inside an active ordinary admission lease. Regressions prove drift stops before permit, unavailable/already-issued state cannot fabricate authority, and ambiguous delete remains outcome-unknown without replay. A production-shaped adapter preserves full Workspace records through external-protection resolution, then snapshots exact cleanup fields, reservations, provisions, and cleanup intents; protection exceptions become `unknown`, while source failures propagate fail-closed. Production Settings exposes **Review inactive tabs** only: profile-wide admission surrounds fresh evidence capture, one canonical 128-bit-digest work-set is retained, and a separate summary reports proven count, explicit no-deletion state, and Back navigation. The exact confirmation callback fits Telegram's 64-byte bound and accepts only canonical work-set IDs. **Clean inactive tabs** renders only when composition supplies a destructive port; production intentionally omits it, so malformed/stale callbacks fail closed and review remains non-destructive. Permit composition must not call `acquireRetirementFence()` because retirement remains pressure-only. The one admission-ledger fence now carries discriminated `pressure-retirement | manual-thread-cleanup` authority, treats legacy missing kind as pressure, exposes `acquireThreadCleanupFence()`, includes kind in exact comparison/permits, and remains profile-singleton. Cleanup-specific adopt/issue/absence/release/complete APIs preserve existing retirement callers and reject cross-kind use. Review admission releases before cleanup-fence acquisition; that fence then spans exact full-record/protection revalidation, sole permit issuance, work-set recording, one delete attempt, absence confirmation, binding/work-set commit, and fence completion. The v1-compatible schema and kind-specific acquire/adopt/issue/absence/release/complete methods are implemented; pressure methods reject manual fences and cleanup methods reject pressure fences. A disconnected permit runtime acquires the cleanup fence, revalidates under it, releases drifted unissued fences, refuses already-issued replay, and retains `commit-ready` until an injected durable commit succeeds. `threads.commitInactiveWorkspaceCleanup()` now removes only an exact full inactive binding after rechecking local records, claims, reservations, provisions, cleanups, and retirement intents; the retained cleanup candidate carries sufficient exact cwd/workspace/instance/global-slot/binding/target/inactivity/update commit identity, and under the retained fence exact absence closes commit-unknown retry without reconstructing the deleted full binding. Commit composition removes the binding before confirming the work-set, and failure retains `commit-ready`. A hidden coordinator validates canonical review identity, resolves the full binding, records the sole permit before one injected delete call, then commits binding, work-set, and fence. A `commit-ready` retry finishes without another delete; ambiguous deletion remains `deletion-issued` and is never replayed. A typed Settings-port adapter exposes this coordinator only when explicitly supplied and reports deleted, outcome-unknown, and blocked counts. Fake-port tests exercise the callback lifecycle and successor recovery: takeover requires injected proof, exact fence identity is preserved, live/unverifiable predecessors fail closed, and `commit-ready` resumes without deletion replay. Cross-process workers prove stale `prepared` contenders call fake transport once: deletion requires successful work-set permit CAS, and redundant fences over deleted entries settle without replay. Work-set pre-rename failure and lost post-rename acknowledgement retain recoverable fence truth; durable `deleted` completes that fence without another transport call. Binding-snapshot pre-rename ownership loss reloads and restores the exact binding, while post-rename acknowledgement loss reloads durable absence as successful commit. Cleanup runtime composition additionally requires candidate/work-set/runtime/fence profile equality before mutation and captures one stable successor owner snapshot for adoption. The optional Settings port reports only redacted exact-authority recovery classes: commit pending, deletion outcome unknown with no retry, or unavailable authority. It exposes no target, path, token, or transport details. Production still omits `cleanInactiveThreads`, so Bot API activation remains separate.
73
- - `thread-display`: Shared Letters/Names/Directories projection, initial-create title selection, and serialized leader-owned title application. Owns bounded path disambiguation, ambiguous-label rejection, and profile/mode/epoch plus captured live-binding fences. Acknowledged `displayTitle` remains distinct from stable `threadName`; the caller owns triggers and live-owner discovery. Leader provisioning applies the configured projection before creation; registration ACKs deliver the acknowledged title before initial follower status, and heartbeat ACKs carry later changes. Stable runtime names remain restoration identity rather than presentation. Current-thread/TUI display identity is separate from restoration identity. Live bot choosers, notices, prompt labels, and cross-instance agent-target resolution use acknowledged display titles while captured numeric targets and live registrations remain the routing authority. Settings routes direct-owner changes locally and follower changes through an authenticated capability-gated envelope. The leader serializes preference writes and application.
73
+ - `thread-display`: Shared Letters/Names/Directories projection, Settings-mode/manual-override coordination, initial-create title selection, and serialized leader-owned title application. Owns bounded path disambiguation, ambiguous-label rejection, and profile/mode/epoch plus captured live-binding fences. Acknowledged `displayTitle` remains distinct from stable `threadName`; the caller owns triggers and live-owner discovery. Leader provisioning applies the configured projection before creation; registration ACKs deliver the acknowledged title before initial follower status, and heartbeat ACKs carry later changes. Stable runtime names remain restoration identity rather than presentation. Current-thread/TUI display identity is separate from restoration identity. Live bot choosers, notices, prompt labels, and cross-instance agent-target resolution use acknowledged display titles while captured numeric targets and live registrations remain the routing authority. Settings routes direct-owner changes locally and follower changes through an authenticated capability-gated envelope. The leader serializes preference writes and application.
74
74
  - `workspace-slots`: Pure bounded global-letter selection and pressure-reclamation proposals. It consumes explicit protection/inactivity evidence and never discovers owners, persists state, or performs deletion.
75
75
  - `workspace-admission`: Durable profile-scoped reader/writer ledger for cross-process exact-target, chat-wide, and profile-wide admission leases plus one destructive retirement fence. It owns atomic lease/fence transactions, proven-dead process-birth recovery, conservative malformed/ambiguous-state handling, exact successor adoption, retained-slot projection, and the durable `fenced` → `deletion-issued` → `commit-ready` phases that emit at most one deletion permit. Its runtime binding resolves `workspace-admission[.<profile>].json`, stores only the token SHA-256 profile authority, preserves separate named-profile identities across switching, permits changed-token rebind only when the prior ledger is provably empty, and fails closed while foreign leases or a fence remain. Issued fences cannot be released before confirmed absence and durable retirement commit; callers own journal/API/provisioning operations and retirement policy. Production composition supplies admission to journals, JSON/multipart API, leader/follower mutations, topic lifecycle, reroute restoration/reclamation, manual disconnect/session-restart cleanup, exact stale-target recovery, and Thread-store slot reservations; journal-evidence pruning also requires caller-supplied admission. Common async runners and the API adapter reject concurrent reuse of a live operation ID before a second caller can share or release its lease; once the first invocation exits, retry-stable recovery remains available. A 2/2 same-model independent post-fix quorum verified complete production-mutation composition at 0.96 confidence per reviewer. Destructive retirement remains disconnected by release scope; this verification does not authorize live deletion or replace disposable operator smoke.
76
76
  - `workspace-retirement`: Profile/leader-fenced pressure preparation over the store snapshot. It counts standalone reservations, selects one candidate only at full slot capacity, rechecks protection, and persists/resumes an exact durable intent. Workspace bindings accumulate their historical follower-journal routing keys and distinguish complete fresh metadata from incomplete legacy evidence. Its read-only accepted-work policy resolves those binding-specific journals plus the shared leader journal and combines them with local exact targets, failing closed when source coverage or target decoding is incomplete. It can prune a known empty follower-journal key only from complete readable evidence plus explicit writer quiescence under exact binding/profile/epoch fences and an exact-target admission lease held through durable publication. Incomplete legacy bindings consume discovered hashed journals as target-scoped evidence but remain incomplete so every later retirement repeats discovery. The shared Workspace operation runtime serializes topic lifecycle, reroute restoration/reclamation, provisioning, delayed post-provision reconciliation, follower/manual cleanup, rename, and display mutation through one exposed gate. Detached mutation work must reacquire fresh admission rather than inherit a lease already released by its caller. A successor may durably adopt one exact stale-epoch intent after profile/binding/protection revalidation; direct old-epoch execution remains blocked. The isolated executor consumes that gate and requires the durable admission ledger. It acquires or exactly adopts the matching fence, rechecks protection after admissions close, advances to `deletion-issued` before invoking an executor-only `deleteForumTopic` port with the sole permit, and never reissues from that phase. Success or exact absence advances to `commit-ready`; store commit failure retains the fence, and exact completion follows durable binding+intent removal. A successor resolves an issued unknown outcome only through a separate exact-absence probe. Leader composition exposes exact registry, active/queued work, known journals, and profile-exact legacy discovery as protection evidence. Missing queue targets and incomplete reads remain unknown. The common direct Bot API client counts exact JSON/multipart targets until settlement; message-scoped edits/deletes without a thread conservatively protect every binding in their chat. Known historical follower owner keys decode to process-birth identity before liveness checks. Durable intents block matching claims and binding mutations. Preparation/adoption/execution remains disconnected from leader runtime by release scope. Independent review cleared the admission-composition blocker; live deletion and operator acceptance remain separate gates.
@@ -596,7 +596,7 @@ The concrete runtime and wire reference remains in [Generative Apps Runtime For
596
596
  - Voice/STT/TTS providers: [Voice Integration](./voice.md).
597
597
  - Inbound/outbound command-template handlers: [Command Templates](./command-templates.md).
598
598
 
599
- Extension callbacks must avoid `pi-telegram` owned prefixes such as `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`. Workflow-specific Telegram slash commands should use the public command registry instead of becoming new core built-ins unless they are bridge lifecycle, transport ownership, queue safety, or essential operator controls.
599
+ Extension callbacks must avoid `pi-telegram` owned prefixes such as `compact:`, `new:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`. Workflow-specific Telegram slash commands should use the public command registry instead of becoming new core built-ins unless they are bridge lifecycle, transport ownership, queue safety, or essential operator controls.
600
600
 
601
601
  The bridge does not mirror arbitrary `ctx.ui.confirm/input/select/custom` prompts from other extensions into Telegram. Companion extensions that need Telegram operation should expose a Telegram-native command, section, settings row, callback, status line, inbound/update handler, or assistant action-markup path instead of relying on hidden TUI-only prompts.
602
602
 
@@ -20,7 +20,7 @@ myext:page:2
20
20
 
21
21
  - Use a stable extension-owned namespace, preferably the package or extension name without scope punctuation.
22
22
  - Keep the namespace lowercase ASCII: `a-z`, `0-9`, `_`, `-`.
23
- - Do not use `pi-telegram` owned prefixes: `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`. Current app navigation uses `menu:`; `status:` remains reserved for legacy/owned status callbacks but is not emitted by current UI. `compact:` is owned by the manual compaction confirmation dialog. `section:` is owned by the Extension Sections platform (0.10.0+), documented in [Extension Sections](./sections.md). `settings:` is owned for the built-in Settings submenu.
23
+ - Do not use `pi-telegram` owned prefixes: `compact:`, `new:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`. Current app navigation uses `menu:`; `status:` remains reserved for legacy/owned status callbacks but is not emitted by current UI. `compact:` and `new:` are owned by their destructive-action confirmation dialogs. `section:` is owned by the Extension Sections platform (0.10.0+), documented in [Extension Sections](./sections.md). `settings:` is owned for the built-in Settings submenu.
24
24
  - Keep the full `callback_data` within Telegram's 64-byte limit.
25
25
  - Put only opaque ids or small enum values in payloads; do not store secrets, full prompts, or large state.
26
26
  - Treat callbacks as untrusted input. Validate namespace, action, and payload before executing side effects.
@@ -51,6 +51,7 @@ Stable commands inside Pi:
51
51
  Stable commands inside the paired Telegram DM:
52
52
 
53
53
  - `/start` — pair when needed and open the main application menu.
54
+ - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches an internal Pi command through `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Its real `ExtensionCommandContext` calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. Busy, identity-mismatch, and unavailable-host paths fail closed.
54
55
  - `/compact` — open confirmation and compact when idle.
55
56
  - `/next` — abort active work first when needed, let the interrupted prompt receive its abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. The command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
56
57
  - `/continue` — enqueue a priority `continue` prompt.
@@ -59,7 +60,7 @@ Stable commands inside the paired Telegram DM:
59
60
 
60
61
  Hidden compatibility shortcuts may open sections directly: `/help`, `/status`, `/model`, `/thinking`, `/queue`, and `/settings`.
61
62
 
62
- This command surface is a mobile companion subset, not a raw terminal-command bridge or session browser. A Telegram destination follows its assigned Pi instance and sends prompts into that instance's currently active session; it is not permanently bound to one session identity. Compaction operates on the current session, while new-session, resume, fork, tree navigation, session switching, TUI transcript clearing, and arbitrary slash-command dispatch stay out of the stable Telegram API unless Pi exposes safe public extension hooks for them.
63
+ This command surface is a mobile companion subset, not a raw terminal-command bridge or session browser. A Telegram destination follows its assigned Pi instance and sends prompts into that instance's currently active session; it is not permanently bound to one session identity. Compaction and new-session replacement operate on the current session; resume, fork, tree navigation, session switching, TUI transcript clearing, and arbitrary slash-command dispatch stay out of the stable Telegram API unless Pi exposes safe public extension hooks for them.
63
64
 
64
65
  ### Tools and assistant-authored actions
65
66
 
@@ -120,7 +121,7 @@ Bot/session identity always persists under `profiles.<name>`. The ordinary setup
120
121
 
121
122
  The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
122
123
 
123
- Threaded Mode Settings exposes **Thread display** as Letters (default), Names, `directory-snake`, or `directory-title`; retained `directories` is unsupported, resolves to Letters, and is never rewritten automatically. `profiles.<name>.threadDisplayMode` is profile-scoped; absent and invalid values resolve to `letters`. Settings previews the current binding set through the same projector used for fresh titles and reconciliation. Names projects the generated dictionary name for the slot; the two directory modes tokenize Unicode path segments deterministically and render snake case or humanized title case, with `_a` or ` A` slot suffixes when required. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1`, the additional `directory-display-format-v1` capability for either new directory mode, and its exact registration generation. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
124
+ Threaded Mode Settings exposes **Thread display** as Letters (default), Names, `directory-snake`, or `directory-title`; retained `directories` is unsupported, resolves to Letters, and is never rewritten automatically. `profiles.<name>.threadDisplayMode` is profile-scoped; absent and invalid values resolve to `letters`. Settings previews the current binding set through the same projector used for fresh titles and reconciliation. A manual `/name` override appears as `custom`; choosing any automatic Thread display option clears that override for the current Thread and immediately reapplies the selected projection. Names projects the generated dictionary name for the slot; the two directory modes tokenize Unicode path segments deterministically and render snake case or humanized title case, with `_a` or ` A` slot suffixes when required. The leader serializes preference persistence and title reconciliation, while a follower sends an authenticated `follower.setThreadDisplayMode` request gated by `thread-display-mode-v1`, the additional `directory-display-format-v1` capability for either new directory mode, and its exact registration generation. Config writes check the originating authority inside the config transaction; mode changes preserve target IDs, slots, generated recovery names, manual overrides, and queue ownership. `/name` mutations carry their originating target through final binding validation. The caller confirms only after application succeeds. A partial failure may leave the preference saved and some titles updated; Settings reports that state and permits retry. Acknowledged follower titles arrive through heartbeat rather than a new read loop.
124
125
 
125
126
  Hidden/default semantics are represented by absence:
126
127
 
@@ -577,7 +578,7 @@ async function synthesizeDemoOgg(_text: string): Promise<string> {
577
578
 
578
579
  ## Callback Namespaces
579
580
 
580
- Owned prefixes are reserved by `pi-telegram`: `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`.
581
+ Owned prefixes are reserved by `pi-telegram`: `compact:`, `new:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`.
581
582
 
582
583
  Companion extensions should use their own short prefix for raw callbacks or use `ctx.callbackData()` inside sections. Unknown unowned callbacks may be forwarded to Pi as `[callback] <data>` after built-in handlers decline them.
583
584
 
@@ -197,7 +197,7 @@ The token is an implementation detail. Section authors **never** write `section:
197
197
  1. Telegram update arrives through the single `pi-telegram` polling loop
198
198
  2. Update handlers observe/consume (raw update interception)
199
199
  3. Button action store (`tgbtn:*`)
200
- 4. Compact confirmation callbacks (`compact:*`)
200
+ 4. New-session and compact confirmation callbacks (`new:*`, `compact:*`)
201
201
  5. Queue menu callbacks (`queue:*`)
202
202
  6. Settings menu callbacks (`settings:*`)
203
203
  7. Section callbacks (`section:*`)
@@ -372,7 +372,7 @@ section:0:settings:open → open settings root
372
372
  section:0:<action>:<payload> → forwarded to handleCallback
373
373
  ```
374
374
 
375
- `section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `compact:`, `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
375
+ `section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `compact:`, `new:`, `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
376
376
 
377
377
  ### Inline keyboard layout
378
378
 
@@ -48,7 +48,7 @@ Use emoji as stable semantic markers, not decoration. Emoji carry transportable
48
48
  | `▶️` | Play / continue immediately | Idle `/next` result, `/continue` command, and matching menu action | Means work can start or resume directly without first aborting an active turn. |
49
49
  | `⏹️` | Abort current Pi work | `/abort` command description and active `/stop` result | Stops active work; accompanying copy states separately when queued work is cleared. |
50
50
  | `🟥` | Destructive stop command | `/stop` command description | Strong warning at the command/action entrypoint; standalone results use the more precise idle or abort state icon. |
51
- | `🆕` | New session / fresh start | Reserved visible extension command example for `/new`-like flows | Same-thread Telegram `/new` is currently blocked by Pi core API; keep this meaning reserved. |
51
+ | `🆕` | New session / fresh start | `/new`, session replacement notices | `/new` replaces the active Pi session while preserving the current classic chat or Thread target; use it only for a real session reset. |
52
52
  | `🔄` | Refresh | Queue refresh row and future refresh buttons | Re-fetch/re-render current surface, not transport reconnect. |
53
53
  | `↪️` | Reroute to an existing target | Thread chooser buttons that send a captured command/message from one thread to another live thread | Curved arrow means the message arrived here but bends to another target. |
54
54
  | `🔁` | Replace/restore mode | Thread replace/restore chooser entrypoints | Opens a second step for moving a Pi instance binding to the current source thread. |
@@ -228,6 +228,7 @@ Rules:
228
228
  - Format standalone notices as one fully bold line: relevant emoji, one space, concise sentence, and terminal period. Menu or chooser headings use the same fully bold form but end in a colon when controls or detail follow. Empty-queue headings are the deliberate exception: fully bold, with no trailing period or colon.
229
229
  - Keep the emoji and complete sentence or heading inside the single bold span; do not bold only a fragment. A material name or phrase may receive nested italic emphasis without breaking the outer bold hierarchy—for example `<b>📡 Instance <i>Cedar</i> connected.</b>`.
230
230
  - Apply the same hierarchy to success, progress, empty, busy, unavailable, cancellation, and failure notices.
231
+ - Once an action has settled, describe only the completed result in completed-state language. Do not append transitional copy such as “returning” or “starting”; use a separate progress surface only while work is genuinely still pending.
231
232
  - Callback alerts remain plain text because Telegram does not support rich text there, but still keep the relevant emoji and concise sentence.
232
233
  - Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
233
234
  - Explain what the setting does and what the options mean only as much as needed.
@@ -150,7 +150,7 @@ This means:
150
150
 
151
151
  - Extensions can claim callback namespaces that `pi-telegram` would otherwise forward as `[callback] <data>` text.
152
152
  - Extensions can observe updates by always returning `"pass"`.
153
- - Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`) unless they are deliberately replacing that behavior.
153
+ - Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`compact:`, `new:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`) unless they are deliberately replacing that behavior.
154
154
 
155
155
  ## Ownership semantics
156
156
 
@@ -804,7 +804,9 @@ export function createTelegramActivityPublicationRuntime(): TelegramActivityPubl
804
804
 
805
805
  export interface TelegramAssistantOutputRuntime {
806
806
  start: () => void;
807
+ beginTurn: () => void;
807
808
  accept: (event: TelegramAssistantSegmentEvent) => void;
809
+ hasAdmittedTelegramIntermediate: (text: string) => boolean;
808
810
  waitForIdle: () => Promise<void>;
809
811
  stop: () => void;
810
812
  }
@@ -834,6 +836,7 @@ export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(dep
834
836
  let running = false;
835
837
  let tail: Promise<void> = Promise.resolve();
836
838
  const admitted = new Set<string>();
839
+ const admittedTelegramIntermediateText = new Set<string>();
837
840
  const isEligibleEvent = (event: TelegramAssistantSegmentEvent): boolean =>
838
841
  (event.source === "telegram" && event.placement === "intermediate") ||
839
842
  event.source === "local" ||
@@ -845,13 +848,20 @@ export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(dep
845
848
  generation += 1;
846
849
  running = true;
847
850
  admitted.clear();
851
+ admittedTelegramIntermediateText.clear();
848
852
  tail = Promise.resolve();
849
853
  },
854
+ beginTurn() {
855
+ admittedTelegramIntermediateText.clear();
856
+ },
850
857
  accept(event) {
851
858
  if (!running || !isEligibleEvent(event) || !event.text.trim()) return;
852
859
  const key = `${event.activityId}:${event.sequence}`;
853
860
  if (admitted.has(key)) return;
854
861
  admitted.add(key);
862
+ if (event.source === "telegram" && event.placement === "intermediate") {
863
+ admittedTelegramIntermediateText.add(event.text.trim());
864
+ }
855
865
  const admittedGeneration = generation;
856
866
  const admittedAuthority = deps.captureAuthority?.();
857
867
  const preparation = deps.prepareSend?.(event);
@@ -877,6 +887,9 @@ export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(dep
877
887
  }
878
888
  }).finally(() => preparation?.settle());
879
889
  },
890
+ hasAdmittedTelegramIntermediate(text) {
891
+ return admittedTelegramIntermediateText.has(text.trim());
892
+ },
880
893
  waitForIdle() {
881
894
  return tail;
882
895
  },
@@ -884,6 +897,7 @@ export function createTelegramAssistantOutputRuntime<TAuthority = undefined>(dep
884
897
  generation += 1;
885
898
  running = false;
886
899
  admitted.clear();
900
+ admittedTelegramIntermediateText.clear();
887
901
  },
888
902
  };
889
903
  }
@@ -835,7 +835,7 @@ interface TelegramLifecycleBindingDeps {
835
835
  activityVerbosityRuntime?: ActivityVerbosity.TelegramActivityVerbosityRuntime;
836
836
  assistantOutputRuntime: Pick<
837
837
  Activity.TelegramAssistantOutputRuntime,
838
- "start" | "waitForIdle" | "stop"
838
+ "start" | "beginTurn" | "hasAdmittedTelegramIntermediate" | "waitForIdle" | "stop"
839
839
  >;
840
840
  sessionLifecycleRuntime: Pick<
841
841
  Lifecycle.TelegramLifecycleRegistrationDeps,
@@ -862,6 +862,7 @@ interface TelegramLifecycleBindingDeps {
862
862
  deferredQueueDispatchRuntime: Queue.TelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>;
863
863
  modelContextAvailabilityRuntime: Prompts.TelegramModelContextAvailabilityRuntime;
864
864
  disconnectOnQuit?: () => Promise<unknown>;
865
+ onSessionStarted?: (event: Pi.SessionStartEvent, ctx: Pi.ExtensionContext) => void;
865
866
  shutdownGenerativeAppLiveSurfaces?: () => void;
866
867
  resolveAutomaticThreadCleanupEnabled?: () => boolean | Promise<boolean>;
867
868
  buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
@@ -952,6 +953,7 @@ export function registerTelegramLifecycleRuntimeHooks({
952
953
  deferredQueueDispatchRuntime,
953
954
  modelContextAvailabilityRuntime,
954
955
  disconnectOnQuit,
956
+ onSessionStarted,
955
957
  shutdownGenerativeAppLiveSurfaces,
956
958
  resolveAutomaticThreadCleanupEnabled,
957
959
  buttonActionStore,
@@ -1136,7 +1138,10 @@ export function registerTelegramLifecycleRuntimeHooks({
1136
1138
  clearDispatchPending: lifecycle.clearDispatchPending,
1137
1139
  setFoldQueuedPromptsIntoHistory: lifecycle.setFoldQueuedPromptsIntoHistory,
1138
1140
  setActiveTurn: activeTurnRuntime.set,
1139
- onPromptHandedOff,
1141
+ onPromptHandedOff: (turn, ctx) => {
1142
+ assistantOutputRuntime.beginTurn();
1143
+ onPromptHandedOff?.(turn, ctx);
1144
+ },
1140
1145
  createPreviewState: previewRuntime.resetState,
1141
1146
  startTypingLoop: (ctx) => {
1142
1147
  const turn = activeTurnRuntime.get();
@@ -1148,6 +1153,8 @@ export function registerTelegramLifecycleRuntimeHooks({
1148
1153
  getActiveTurn: activeTurnRuntime.get,
1149
1154
  loadConfig: configStore.load,
1150
1155
  extractAssistant: Replies.extractRunAssistantMessage,
1156
+ isRecoveredAssistantAlreadyPublished: (assistant) =>
1157
+ !!assistant.text && assistantOutputRuntime.hasAdmittedTelegramIntermediate(assistant.text),
1151
1158
  getFoldQueuedPromptsIntoHistory:
1152
1159
  lifecycle.shouldFoldQueuedPromptsIntoHistory,
1153
1160
  resetRuntimeState: agentEndResetter,
@@ -1273,6 +1280,7 @@ export function registerTelegramLifecycleRuntimeHooks({
1273
1280
  activityVerbosityRuntime?.reset();
1274
1281
  modelContextAvailabilityRuntime.reconcile();
1275
1282
  await sessionLifecycleRuntime.onSessionStart(event, ctx);
1283
+ onSessionStarted?.(event, ctx);
1276
1284
  },
1277
1285
  async onSessionShutdown(event, ctx) {
1278
1286
  if (!isSessionContextActive(ctx)) return;