instar 1.3.489 → 1.3.491

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 (175) hide show
  1. package/dashboard/index.html +30 -0
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +1124 -28
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  6. package/dist/config/ConfigDefaults.js +23 -0
  7. package/dist/config/ConfigDefaults.js.map +1 -1
  8. package/dist/core/CodexResumeMap.d.ts +95 -0
  9. package/dist/core/CodexResumeMap.d.ts.map +1 -0
  10. package/dist/core/CodexResumeMap.js +283 -0
  11. package/dist/core/CodexResumeMap.js.map +1 -0
  12. package/dist/core/GuardPostureStore.d.ts +38 -0
  13. package/dist/core/GuardPostureStore.d.ts.map +1 -0
  14. package/dist/core/GuardPostureStore.js +87 -0
  15. package/dist/core/GuardPostureStore.js.map +1 -0
  16. package/dist/core/MachinePoolRegistry.d.ts +16 -0
  17. package/dist/core/MachinePoolRegistry.d.ts.map +1 -1
  18. package/dist/core/MachinePoolRegistry.js +24 -1
  19. package/dist/core/MachinePoolRegistry.js.map +1 -1
  20. package/dist/core/MeshRpc.d.ts +3 -0
  21. package/dist/core/MeshRpc.d.ts.map +1 -1
  22. package/dist/core/MeshRpc.js +5 -0
  23. package/dist/core/MeshRpc.js.map +1 -1
  24. package/dist/core/ModelSwapService.d.ts +26 -11
  25. package/dist/core/ModelSwapService.d.ts.map +1 -1
  26. package/dist/core/ModelSwapService.js +59 -21
  27. package/dist/core/ModelSwapService.js.map +1 -1
  28. package/dist/core/PeerPresencePuller.d.ts +9 -0
  29. package/dist/core/PeerPresencePuller.d.ts.map +1 -1
  30. package/dist/core/PeerPresencePuller.js +1 -1
  31. package/dist/core/PeerPresencePuller.js.map +1 -1
  32. package/dist/core/PostUpdateMigrator.d.ts +16 -0
  33. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  34. package/dist/core/PostUpdateMigrator.js +92 -4
  35. package/dist/core/PostUpdateMigrator.js.map +1 -1
  36. package/dist/core/SessionManager.d.ts +4 -0
  37. package/dist/core/SessionManager.d.ts.map +1 -1
  38. package/dist/core/SessionManager.js +3 -0
  39. package/dist/core/SessionManager.js.map +1 -1
  40. package/dist/core/SessionRefresh.d.ts +36 -7
  41. package/dist/core/SessionRefresh.d.ts.map +1 -1
  42. package/dist/core/SessionRefresh.js +90 -29
  43. package/dist/core/SessionRefresh.js.map +1 -1
  44. package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
  45. package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
  46. package/dist/core/TopicProfileOrchestrator.js +1404 -0
  47. package/dist/core/TopicProfileOrchestrator.js.map +1 -0
  48. package/dist/core/TopicProfileResolver.d.ts +104 -0
  49. package/dist/core/TopicProfileResolver.d.ts.map +1 -0
  50. package/dist/core/TopicProfileResolver.js +231 -0
  51. package/dist/core/TopicProfileResolver.js.map +1 -0
  52. package/dist/core/TopicProfileStore.d.ts +308 -0
  53. package/dist/core/TopicProfileStore.d.ts.map +1 -0
  54. package/dist/core/TopicProfileStore.js +781 -0
  55. package/dist/core/TopicProfileStore.js.map +1 -0
  56. package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
  57. package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
  58. package/dist/core/TopicProfileTransferCarrier.js +533 -0
  59. package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
  60. package/dist/core/TopicResumeMap.d.ts +67 -1
  61. package/dist/core/TopicResumeMap.d.ts.map +1 -1
  62. package/dist/core/TopicResumeMap.js +114 -1
  63. package/dist/core/TopicResumeMap.js.map +1 -1
  64. package/dist/core/classifyProfileChange.d.ts +83 -0
  65. package/dist/core/classifyProfileChange.d.ts.map +1 -0
  66. package/dist/core/classifyProfileChange.js +274 -0
  67. package/dist/core/classifyProfileChange.js.map +1 -0
  68. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  69. package/dist/core/devGatedFeatures.js +6 -0
  70. package/dist/core/devGatedFeatures.js.map +1 -1
  71. package/dist/core/frameworkSessionLaunch.d.ts +26 -0
  72. package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
  73. package/dist/core/frameworkSessionLaunch.js +58 -0
  74. package/dist/core/frameworkSessionLaunch.js.map +1 -1
  75. package/dist/core/slackRefreshBinding.d.ts +83 -0
  76. package/dist/core/slackRefreshBinding.d.ts.map +1 -0
  77. package/dist/core/slackRefreshBinding.js +54 -0
  78. package/dist/core/slackRefreshBinding.js.map +1 -0
  79. package/dist/core/topicProfileIngress.d.ts +128 -0
  80. package/dist/core/topicProfileIngress.d.ts.map +1 -0
  81. package/dist/core/topicProfileIngress.js +249 -0
  82. package/dist/core/topicProfileIngress.js.map +1 -0
  83. package/dist/core/topicProfileValidation.d.ts +109 -0
  84. package/dist/core/topicProfileValidation.d.ts.map +1 -0
  85. package/dist/core/topicProfileValidation.js +247 -0
  86. package/dist/core/topicProfileValidation.js.map +1 -0
  87. package/dist/core/topicProfileWriteSurface.d.ts +222 -0
  88. package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
  89. package/dist/core/topicProfileWriteSurface.js +631 -0
  90. package/dist/core/topicProfileWriteSurface.js.map +1 -0
  91. package/dist/core/types.d.ts +66 -0
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/types.js.map +1 -1
  94. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  95. package/dist/lifeline/TelegramLifeline.js +6 -0
  96. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  97. package/dist/messaging/TelegramAdapter.d.ts +10 -1
  98. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  99. package/dist/messaging/TelegramAdapter.js +41 -1
  100. package/dist/messaging/TelegramAdapter.js.map +1 -1
  101. package/dist/monitoring/ActiveWorkSilenceSentinel.d.ts +8 -0
  102. package/dist/monitoring/ActiveWorkSilenceSentinel.d.ts.map +1 -1
  103. package/dist/monitoring/ActiveWorkSilenceSentinel.js +8 -0
  104. package/dist/monitoring/ActiveWorkSilenceSentinel.js.map +1 -1
  105. package/dist/monitoring/ContextWedgeSentinel.d.ts +8 -0
  106. package/dist/monitoring/ContextWedgeSentinel.d.ts.map +1 -1
  107. package/dist/monitoring/ContextWedgeSentinel.js +8 -0
  108. package/dist/monitoring/ContextWedgeSentinel.js.map +1 -1
  109. package/dist/monitoring/GuardPostureTripwire.d.ts +3 -27
  110. package/dist/monitoring/GuardPostureTripwire.d.ts.map +1 -1
  111. package/dist/monitoring/GuardPostureTripwire.js +8 -76
  112. package/dist/monitoring/GuardPostureTripwire.js.map +1 -1
  113. package/dist/monitoring/GuardRegistry.d.ts +48 -0
  114. package/dist/monitoring/GuardRegistry.d.ts.map +1 -0
  115. package/dist/monitoring/GuardRegistry.js +49 -0
  116. package/dist/monitoring/GuardRegistry.js.map +1 -0
  117. package/dist/monitoring/SessionReaper.d.ts +7 -0
  118. package/dist/monitoring/SessionReaper.d.ts.map +1 -1
  119. package/dist/monitoring/SessionReaper.js +9 -0
  120. package/dist/monitoring/SessionReaper.js.map +1 -1
  121. package/dist/monitoring/SessionWatchdog.d.ts +8 -0
  122. package/dist/monitoring/SessionWatchdog.d.ts.map +1 -1
  123. package/dist/monitoring/SessionWatchdog.js +8 -0
  124. package/dist/monitoring/SessionWatchdog.js.map +1 -1
  125. package/dist/monitoring/SocketDisconnectSentinel.d.ts +8 -0
  126. package/dist/monitoring/SocketDisconnectSentinel.d.ts.map +1 -1
  127. package/dist/monitoring/SocketDisconnectSentinel.js +8 -0
  128. package/dist/monitoring/SocketDisconnectSentinel.js.map +1 -1
  129. package/dist/monitoring/guardManifest.d.ts +67 -0
  130. package/dist/monitoring/guardManifest.d.ts.map +1 -0
  131. package/dist/monitoring/guardManifest.js +536 -0
  132. package/dist/monitoring/guardManifest.js.map +1 -0
  133. package/dist/monitoring/guardPosture.d.ts +77 -0
  134. package/dist/monitoring/guardPosture.d.ts.map +1 -0
  135. package/dist/monitoring/guardPosture.js +198 -0
  136. package/dist/monitoring/guardPosture.js.map +1 -0
  137. package/dist/monitoring/guardPostureView.d.ts +100 -0
  138. package/dist/monitoring/guardPostureView.d.ts.map +1 -0
  139. package/dist/monitoring/guardPostureView.js +294 -0
  140. package/dist/monitoring/guardPostureView.js.map +1 -0
  141. package/dist/monitoring/probes/GuardPostureProbe.d.ts +82 -0
  142. package/dist/monitoring/probes/GuardPostureProbe.d.ts.map +1 -0
  143. package/dist/monitoring/probes/GuardPostureProbe.js +384 -0
  144. package/dist/monitoring/probes/GuardPostureProbe.js.map +1 -0
  145. package/dist/scaffold/templates.d.ts.map +1 -1
  146. package/dist/scaffold/templates.js +18 -0
  147. package/dist/scaffold/templates.js.map +1 -1
  148. package/dist/scheduler/JobScheduler.d.ts +10 -0
  149. package/dist/scheduler/JobScheduler.d.ts.map +1 -1
  150. package/dist/scheduler/JobScheduler.js +12 -0
  151. package/dist/scheduler/JobScheduler.js.map +1 -1
  152. package/dist/server/AgentServer.d.ts +40 -0
  153. package/dist/server/AgentServer.d.ts.map +1 -1
  154. package/dist/server/AgentServer.js +54 -3
  155. package/dist/server/AgentServer.js.map +1 -1
  156. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  157. package/dist/server/CapabilityIndex.js +12 -0
  158. package/dist/server/CapabilityIndex.js.map +1 -1
  159. package/dist/server/peerUrlGuard.d.ts +35 -0
  160. package/dist/server/peerUrlGuard.d.ts.map +1 -0
  161. package/dist/server/peerUrlGuard.js +93 -0
  162. package/dist/server/peerUrlGuard.js.map +1 -0
  163. package/dist/server/routes.d.ts +31 -0
  164. package/dist/server/routes.d.ts.map +1 -1
  165. package/dist/server/routes.js +407 -1
  166. package/dist/server/routes.js.map +1 -1
  167. package/package.json +3 -2
  168. package/scripts/lint-guard-manifest.js +264 -0
  169. package/src/data/builtin-manifest.json +67 -67
  170. package/src/data/state-coherence-registry.json +54 -18
  171. package/src/scaffold/templates.ts +18 -0
  172. package/upgrades/1.3.490.md +34 -0
  173. package/upgrades/1.3.491.md +37 -0
  174. package/upgrades/side-effects/guard-posture-endpoint.md +101 -0
  175. package/upgrades/side-effects/topic-profile.md +142 -0
@@ -17,6 +17,13 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
17
  import { loadConfig, ensureStateDir, detectTmuxPath, detectGeminiPath } from '../core/Config.js';
18
18
  import { isNonFatalUncaught, shouldLogStackForUncaught } from '../core/uncaughtExceptionPolicy.js';
19
19
  import { resolveDevAgentGate } from '../core/devAgentGate.js';
20
+ import { parseProfileTrigger, platformMessageIdFrom } from '../core/topicProfileIngress.js';
21
+ import { TopicProfileOrchestrator, resolvedToApplied, } from '../core/TopicProfileOrchestrator.js';
22
+ import { CodexResumeMap } from '../core/CodexResumeMap.js';
23
+ import { paneIdleWithEmptyInput } from '../core/ModelSwapService.js';
24
+ import { escalatedModelIds } from '../core/ModelTierEscalation.js';
25
+ import { activeAutonomousJobs } from '../core/AutonomousSessions.js';
26
+ import { TopicProfileTransferCarrier, createTopicProfilePullHandler } from '../core/TopicProfileTransferCarrier.js';
20
27
  import { closeAllSqlite } from '../core/SqliteRegistry.js';
21
28
  import { SessionManager } from '../core/SessionManager.js';
22
29
  import { StateManager } from '../core/StateManager.js';
@@ -58,6 +65,12 @@ import { QuotaNotifier } from '../monitoring/QuotaNotifier.js';
58
65
  import { QuotaManager } from '../monitoring/QuotaManager.js';
59
66
  import { classifySessionDeath, detectContextExhaustion } from '../monitoring/QuotaExhaustionDetector.js';
60
67
  import { SessionWatchdog } from '../monitoring/SessionWatchdog.js';
68
+ import { GuardRegistry } from '../monitoring/GuardRegistry.js';
69
+ import { resolveGuardConfigSnapshot, readGuardPostureBootSnapshot } from '../monitoring/guardPosture.js';
70
+ import { buildGuardInventory, buildHeartbeatPostureBlock } from '../monitoring/guardPostureView.js';
71
+ import { createGuardPostureProbes } from '../monitoring/probes/GuardPostureProbe.js';
72
+ import { GuardPostureStore } from '../core/GuardPostureStore.js';
73
+ import { isPeerUrlAllowedForCredentials } from '../server/peerUrlGuard.js';
61
74
  import { formatWatchdogUserMessage } from '../monitoring/watchdog-notifications.js';
62
75
  import { StallTriageNurse } from '../monitoring/StallTriageNurse.js';
63
76
  import { TriageOrchestrator } from '../monitoring/TriageOrchestrator.js';
@@ -365,6 +378,7 @@ let _resolveRouterUrl = null;
365
378
  /** Every OTHER active machine with a known URL — backs GET /sessions?scope=pool
366
379
  * (pool-wide session aggregation for the dashboard). Set in the same mesh block. */
367
380
  let _resolvePeerUrls = null;
381
+ let _listPoolMachines = null;
368
382
  /** Multi-Machine Session Pool §L4: per-topic placement pin store ("move this to <nickname>"). */
369
383
  let _topicPinStore = null;
370
384
  /** Cross-machine secret-sync (spec Phase 4): route-facing handle (push lever + read-only status). */
@@ -388,9 +402,58 @@ let _topicFrameworks = {};
388
402
  * Initialized in startServer(); consulted by resolveTopicFramework on every spawn. */
389
403
  let _topicFrameworksStore = null;
390
404
  let _topicLocalModelStore = null;
405
+ /** Topic Profile (§5.1): the sticky per-topic profile store. The framework
406
+ * arm of resolution reads THIS (the legacy topic-frameworks file is a
407
+ * one-directional seed + store-written mirror). Initialized in startServer(). */
408
+ let _topicProfileStore = null;
409
+ /** Topic Profile (§5.2): the single resolution point feeding spawn launch
410
+ * params. Initialized in startServer() alongside the store. */
411
+ let _topicProfileResolver = null;
412
+ /** Topic Profile (§5.2/§10): the ONE write engine behind every write surface
413
+ * (conversational / /topic / /route / HTTP / recovery writes). Initialized in
414
+ * startServer() alongside the store + resolver. */
415
+ let _topicProfileWriteSurface = null;
416
+ /** Topic Profile (§10.1/§10.4): the shared armed-confirm slot manager —
417
+ * propose-confirm / switch-now / re-apply-cooldown all share ONE slot per
418
+ * topic. */
419
+ let _topicProfileConfirmSlots = null;
420
+ /** §5.2(d) legacy respawn hook — bound in wireTelegramCallbacks (it needs the
421
+ * telegram adapter + session manager in scope). Today's exact /route
422
+ * behavior: drop the resume UUID, kill, CONTINUATION respawn. */
423
+ let _profileLegacyRespawn = null;
424
+ /** §8 disclosure hook — bound in wireTelegramCallbacks (platform adapter send). */
425
+ let _profileDisclose = null;
426
+ /** Topic Profile §8 (TOPIC-PROFILE-SPEC): the orchestration core — debounce
427
+ * slots, idle-gated kill/respawn, resume-writer gates, §10.4 breaker, §14
428
+ * dry-run regime. Constructed in startServer() beside the write surface. */
429
+ let _topicProfileOrchestrator = null;
430
+ /** Topic Profile §7: per-topic codex rollout-id capture-at-kill (the
431
+ * CodexResumeMap prerequisite sub-task). Constructed beside the orchestrator. */
432
+ let _codexResumeMap = null;
433
+ /** Topic Profile §7: the codex spawn fence recorded at launch (spawn
434
+ * timestamp + pane cwd) — capture-at-kill validates candidates against it. */
435
+ const _codexSpawnFences = new Map();
436
+ /** Topic Profile §5.3: the transfer-follow carrier (pull-at-acquire).
437
+ * Constructed in the mesh block; null on single-machine installs. */
438
+ let _topicProfileCarrier = null;
439
+ /** Topics whose CURRENT spawn attempt was initiated by the orchestrator's own
440
+ * respawn phase — the spawn chokepoint must not double-report the failure to
441
+ * the §10.4 breaker (the orchestrator records its own spawn outcome). */
442
+ const _orchestratorSpawnInFlight = new Set();
443
+ /** §5.3 "possibly stale" read disclosure — once per (topic, process). */
444
+ const _pendingPullStaleDisclosed = new Set();
391
445
  /** Default framework for sessions when no per-topic override is set. */
392
446
  let _defaultFramework = 'claude-code';
393
447
  function resolveTopicFramework(topicId) {
448
+ // Topic Profile §5.1 rewire: the profile store's framework field is the
449
+ // authoritative per-topic layer (it was seeded one-directionally from the
450
+ // legacy store at first load). The resolver adds the §5.2 launchability
451
+ // fallback. Legacy layers remain below for the not-yet-wired boot window.
452
+ if (topicId !== undefined && _topicProfileResolver) {
453
+ const fw = _topicProfileResolver.resolve(topicId).framework;
454
+ if (fw === 'claude-code' || fw === 'codex-cli')
455
+ return fw;
456
+ }
394
457
  if (topicId !== undefined && _topicFrameworksStore) {
395
458
  const stored = _topicFrameworksStore.get(topicId);
396
459
  if (stored === 'claude-code' || stored === 'codex-cli')
@@ -401,6 +464,39 @@ function resolveTopicFramework(topicId) {
401
464
  }
402
465
  return _defaultFramework;
403
466
  }
467
+ /**
468
+ * Topic Profile §10.3 — append one structured line to the profile audit
469
+ * trail (logs/topic-profile-changes.jsonl). Size-capped like sibling audit
470
+ * logs (simple head-truncation rotation at ~5MB). Never the triggering turn
471
+ * text or any message content — structured deltas + verified principals only.
472
+ */
473
+ let _topicProfileAuditSeq = 0;
474
+ function appendTopicProfileAudit(stateDir, event) {
475
+ // The audit sequence stamp is included in rendered disclosures (§8 — so the
476
+ // relay's exact-duplicate window can never silently swallow a repeat notice).
477
+ const seq = `${Date.now().toString(36)}.${++_topicProfileAuditSeq}`;
478
+ try {
479
+ const logsDir = path.join(stateDir, '..', 'logs');
480
+ fs.mkdirSync(logsDir, { recursive: true });
481
+ const auditPath = path.join(logsDir, 'topic-profile-changes.jsonl');
482
+ try {
483
+ const stat = fs.statSync(auditPath);
484
+ if (stat.size > 5 * 1024 * 1024) {
485
+ // Keep the newest half on overflow — same pragmatic cap as siblings.
486
+ const lines = fs.readFileSync(auditPath, 'utf-8').split('\n');
487
+ fs.writeFileSync(auditPath, lines.slice(Math.floor(lines.length / 2)).join('\n'));
488
+ }
489
+ }
490
+ catch { /* @silent-fallback-ok: no audit file yet — the trim is a best-effort size cap, the append below recreates it (TOPIC-PROFILE-SPEC §10.3) */ }
491
+ fs.appendFileSync(auditPath, `${JSON.stringify({ ts: new Date().toISOString(), seq, ...event })}\n`);
492
+ }
493
+ catch {
494
+ // @silent-fallback-ok: the topic-profile change audit is best-effort —
495
+ // resolution/writes must NEVER fail on an audit-sink error (a full disk or
496
+ // a transient fs fault can't break profile resolution) (TOPIC-PROFILE-SPEC §10.3).
497
+ }
498
+ return seq;
499
+ }
404
500
  let _projectDir = process.cwd();
405
501
  let _sharedIntelligence = null;
406
502
  let _selfKnowledgeTree = null;
@@ -586,10 +682,25 @@ accountSwap) {
586
682
  bootstrapMessage = `[telegram:${topicId}] ${msg}`;
587
683
  }
588
684
  }
589
- // Resolve framework EARLY needed for the inline Telegram-relay block
590
- // (the block is the same for both frameworks today, but the helper accepts
591
- // framework so future divergence stays structural rather than ad-hoc).
592
- const framework = resolveTopicFramework(topicId);
685
+ // Resolve the FULL topic profile EARLY (Topic Profile §5.2 the single
686
+ // resolution point: framework + model + thinking mode, with the read-time
687
+ // clamp + launchability fallback already applied). resolveTopicFramework
688
+ // delegates to the same resolver, so both reads agree by construction.
689
+ const resolvedProfile = _topicProfileResolver?.resolve(topicId) ?? null;
690
+ const framework = (resolvedProfile?.framework === 'claude-code' || resolvedProfile?.framework === 'codex-cli'
691
+ || resolvedProfile?.framework === 'gemini-cli' || resolvedProfile?.framework === 'pi-cli')
692
+ ? resolvedProfile.framework
693
+ : resolveTopicFramework(topicId);
694
+ // §5.2 fallback notices are once-per-transition deduped by the resolver —
695
+ // surface any that fired on this resolution to the topic.
696
+ if (resolvedProfile && resolvedProfile.notices.length > 0) {
697
+ for (const notice of resolvedProfile.notices) {
698
+ try {
699
+ await telegram.sendToTopic(topicId, notice);
700
+ }
701
+ catch { /* notice delivery is best-effort */ }
702
+ }
703
+ }
593
704
  // Large bootstrap messages (e.g. CONTINUATION context with full thread history)
594
705
  // can exceed tmux send-keys limits. Write to a temp file and inject a reference,
595
706
  // same pattern as injectTelegramMessage's FILE_THRESHOLD.
@@ -655,12 +766,23 @@ accountSwap) {
655
766
  const localEntry = _topicLocalModelStore?.get(topicId) ?? null;
656
767
  const codexLocalProvider = framework === 'codex-cli' ? localEntry?.provider : undefined;
657
768
  const codexLocalModelOverride = framework === 'codex-cli' && localEntry?.model ? localEntry.model : undefined;
769
+ // Topic Profile §5.2 precedence on the model arm: an active local-model
770
+ // binding wins; otherwise the resolved profile model (pin > topicProfiles
771
+ // config default > frameworkDefaultModels — already clamped) flows through
772
+ // as the launch default. Undefined = account default, today's behavior.
773
+ const profileModel = !codexLocalModelOverride && resolvedProfile?.model
774
+ && resolvedProfile.sources.model !== 'local-model-binding'
775
+ ? resolvedProfile.model
776
+ : undefined;
777
+ const profileThinkingMode = resolvedProfile?.thinkingMode;
658
778
  const newSessionName = await sessionManager.spawnInteractiveSession(bootstrapMessage, sessionName, {
659
779
  telegramTopicId: topicId,
660
780
  resumeSessionId,
661
781
  framework,
662
782
  ...(codexLocalProvider ? { codexLocalProvider } : {}),
663
783
  ...(codexLocalModelOverride ? { defaultModel: codexLocalModelOverride } : {}),
784
+ ...(profileModel ? { defaultModel: profileModel } : {}),
785
+ ...(profileThinkingMode ? { thinkingMode: profileThinkingMode } : {}),
664
786
  // Subscription & Auth Standard P1.3 (additive): account-swap — launch under
665
787
  // this account's config home + record its id. Unset = unchanged.
666
788
  ...(accountSwap?.configHome ? { configHome: accountSwap.configHome } : {}),
@@ -670,6 +792,16 @@ accountSwap) {
670
792
  if (resumeSessionId) {
671
793
  _topicResumeMap?.remove(topicId);
672
794
  }
795
+ // TOPIC-PROFILE-SPEC §10.4 — record the successful spawn so the §10.4 breaker
796
+ // resets + the codex same-cwd fence window opens. SKIPPED when the orchestrator
797
+ // initiated THIS respawn: it records its own spawn outcome (the spawn port),
798
+ // and double-recording would reset the breaker it is mid-evaluating.
799
+ if (_topicProfileOrchestrator && resolvedProfile && !_orchestratorSpawnInFlight.has(String(topicId))) {
800
+ try {
801
+ _topicProfileOrchestrator.recordSpawnSuccess(topicId, resolvedToApplied(resolvedProfile), { cwd: _projectDir });
802
+ }
803
+ catch { /* @silent-fallback-ok: recordSpawnSuccess is breaker-reset bookkeeping — a failure leaves a slightly-stale breaker count that the next attributable failure/success corrects; never fails the spawn (TOPIC-PROFILE-SPEC §10.4) */ }
804
+ }
673
805
  // Proactive UUID save — schedule discovery after spawn.
674
806
  // ONLY uses authoritative claudeSessionId from hook events.
675
807
  // Never falls back to mtime-based JSONL scan — that can pick up a UUID from
@@ -982,35 +1114,74 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
982
1114
  await telegram.sendToTopic(replyTopicId, 'Login didn\'t complete successfully. Try again, or if this keeps happening, the authentication service may be down.');
983
1115
  }
984
1116
  };
985
- // /route get or set the framework for this topic. Persists via
986
- // TopicFrameworksStore (atomic write) and triggers a respawn so the
987
- // new framework binding takes effect on the next session for this topic.
988
- telegram.onRouteCommand = async (topicId, framework) => {
1117
+ // Topic Profile §5.2(d) the legacy respawn + disclosure hooks the write
1118
+ // surface late-binds (the surface is constructed before the adapter exists).
1119
+ // The respawn is BYTE-FOR-BYTE today's /route behavior: drop the stored
1120
+ // resume UUID (created under the previous framework's session-id scheme —
1121
+ // meaningless to the new one), then the immediate kill + CONTINUATION
1122
+ // respawn via the existing respawn path.
1123
+ _profileLegacyRespawn = async (topicKey) => {
1124
+ if (!/^\d+$/.test(topicKey))
1125
+ return { respawned: false };
1126
+ const topicId = Number(topicKey);
1127
+ _topicResumeMap?.remove(topicId);
1128
+ const existingSession = telegram.getSessionForTopic(topicId);
1129
+ if (!existingSession)
1130
+ return { respawned: false };
1131
+ try {
1132
+ await respawnSessionForTopic(sessionManager, telegram, existingSession, topicId, undefined, topicMemory, undefined, undefined, { silent: true });
1133
+ return { respawned: true };
1134
+ }
1135
+ catch (err) {
1136
+ return { respawned: false, error: err instanceof Error ? err.message : String(err) };
1137
+ }
1138
+ };
1139
+ _profileDisclose = async (topicKey, text) => {
1140
+ if (!/^\d+$/.test(topicKey))
1141
+ return;
1142
+ await telegram.sendToTopic(Number(topicKey), text);
1143
+ };
1144
+ // /route — get or set the framework for this topic. REWIRED into the Topic
1145
+ // Profile store (§5.1 — the legacy topic-frameworks file is now a
1146
+ // store-written mirror), with the §5.2(d) exemption honored INSIDE the
1147
+ // write surface: a framework write lands LIVE regardless of the enabled /
1148
+ // dryRun knobs and is served by the legacy immediate respawn wherever the
1149
+ // new orchestration is not fully live. §10.1: the authenticated sender uid
1150
+ // is forwarded and checked against the topic's bound operator.
1151
+ telegram.onRouteCommand = async (topicId, framework, userId) => {
989
1152
  if (framework === null) {
990
- // Status query — read current resolved framework
1153
+ // Status query — conversational register (§12, B2: never instruct the
1154
+ // operator to type a command; slash syntax is the power-user aside).
991
1155
  const current = resolveTopicFramework(topicId);
992
- return { ok: true, message: `This topic is using "${current}". Run /route claude-code or /route codex-cli to switch.` };
1156
+ return { ok: true, message: `This topic is using "${current}". Just tell me to switch (e.g. "use codex here") — or /route codex-cli works too.` };
993
1157
  }
994
1158
  const valid = ['claude-code', 'codex-cli'];
995
1159
  if (!valid.includes(framework)) {
996
1160
  return { ok: false, message: `Unknown framework "${framework}". Supported: ${valid.join(', ')}.` };
997
1161
  }
998
- if (!_topicFrameworksStore) {
999
- return { ok: false, message: 'Routing store not initialized — server boot was incomplete. Restart the server.' };
1000
- }
1001
1162
  const prev = resolveTopicFramework(topicId);
1002
1163
  if (prev === framework) {
1003
1164
  return { ok: true, message: `This topic is already on "${framework}". Nothing to change.` };
1004
1165
  }
1166
+ if (_topicProfileWriteSurface && userId) {
1167
+ const result = await _topicProfileWriteSurface.applyWrite({
1168
+ topicKey: String(topicId),
1169
+ patch: { framework },
1170
+ principal: { kind: 'operator', platform: 'telegram', uid: String(userId) },
1171
+ origin: 'slash-route',
1172
+ // The command reply IS the disclosure-of-record for an exempted write
1173
+ // (§8 round-12/13) — it carries the audit stamp.
1174
+ discloseInReply: true,
1175
+ });
1176
+ return { ok: result.ok, message: result.reply };
1177
+ }
1178
+ // Fallback (surface unavailable / no authenticated uid forwarded by an
1179
+ // older caller): the pre-profile legacy write path, unchanged.
1180
+ if (!_topicFrameworksStore) {
1181
+ return { ok: false, message: 'Routing store not initialized — server boot was incomplete. Restart the server.' };
1182
+ }
1005
1183
  _topicFrameworksStore.set(topicId, framework);
1006
- // Drop any stored resume UUID — it was created under the previous
1007
- // framework's session-id scheme and is meaningless to the new one
1008
- // (Claude UUIDs ≠ Codex session ids). Without this, the new
1009
- // session's --resume flag gets a wrong-shape id, which at best
1010
- // emits a warning and at worst dies during startup.
1011
1184
  _topicResumeMap?.remove(topicId);
1012
- // Trigger a respawn so the new framework takes effect immediately.
1013
- // Re-use the existing respawn path which builds context from TopicMemory.
1014
1185
  const existingSession = telegram.getSessionForTopic(topicId);
1015
1186
  if (existingSession) {
1016
1187
  try {
@@ -1022,6 +1193,71 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
1022
1193
  }
1023
1194
  return { ok: true, message: `Routed this topic to "${framework}". ${existingSession ? 'Session respawned.' : 'Will take effect when a session starts for this topic.'}` };
1024
1195
  };
1196
+ // /topic — the power-user surface for the full Topic Profile (§10.1; the
1197
+ // conversational surface is PRIMARY). Forwards the authenticated sender uid
1198
+ // down to the write — the store stamps updatedBy from it and refuses a
1199
+ // non-bound-operator.
1200
+ telegram.onTopicProfileCommand = async (topicId, argText, userId) => {
1201
+ const surface = _topicProfileWriteSurface;
1202
+ if (!surface) {
1203
+ return { ok: false, message: 'Topic profiles aren\'t initialized on this server.' };
1204
+ }
1205
+ const arg = argText.trim();
1206
+ if (arg === '' || arg.toLowerCase() === 'status') {
1207
+ return { ok: true, message: surface.renderReadout(String(topicId)) };
1208
+ }
1209
+ const principal = { kind: 'operator', platform: 'telegram', uid: String(userId) };
1210
+ const topicKey = String(topicId);
1211
+ const parts = arg.split(/\s+/);
1212
+ const head = parts[0].toLowerCase();
1213
+ if (head === 'clear') {
1214
+ const result = await surface.clear({ topicKey, principal, origin: 'slash-topic', discloseInReply: true });
1215
+ return { ok: result.ok, message: result.reply };
1216
+ }
1217
+ if (head === 'undo') {
1218
+ const result = await surface.undo({ topicKey, principal, origin: 'slash-topic', discloseInReply: true });
1219
+ return { ok: result.ok, message: result.reply };
1220
+ }
1221
+ if (head === 're-apply' || head === 'reapply') {
1222
+ const result = await surface.reapply({ topicKey, principal, origin: 'slash-topic', discloseInReply: true });
1223
+ if (result.needsConfirm && _topicProfileConfirmSlots) {
1224
+ const armed = _topicProfileConfirmSlots.arm(topicKey, 'reapply-cooldown', {}, result.reply, 'ingress');
1225
+ if (armed.ok) {
1226
+ return { ok: false, message: `${result.reply} (reply "yes" to apply it anyway)` };
1227
+ }
1228
+ }
1229
+ return { ok: result.ok, message: result.reply };
1230
+ }
1231
+ let patch = null;
1232
+ if (['claude-code', 'codex-cli', 'gemini-cli', 'pi-cli'].includes(head) && parts.length === 1) {
1233
+ patch = { framework: head };
1234
+ }
1235
+ else if (head === 'framework' && parts.length === 2) {
1236
+ patch = { framework: parts[1].toLowerCase() };
1237
+ }
1238
+ else if (head === 'model' && parts.length === 2) {
1239
+ patch = { model: parts[1], modelTier: null };
1240
+ }
1241
+ else if (head === 'tier' && parts.length === 2) {
1242
+ patch = { modelTier: parts[1].toLowerCase(), model: null };
1243
+ }
1244
+ else if (head === 'thinking' && parts.length === 2) {
1245
+ patch = { thinkingMode: parts[1].toLowerCase() };
1246
+ }
1247
+ else if (head === 'escalation' && parts.length === 2) {
1248
+ patch = { escalationOverride: parts[1].toLowerCase() };
1249
+ }
1250
+ if (!patch) {
1251
+ return {
1252
+ ok: false,
1253
+ message: 'Usage: /topic [status] · /topic <framework> · /topic model <id> · /topic tier <default|escalated> · /topic thinking <off|low|medium|high|max> · /topic escalation <inherit|suppress> · /topic clear · /topic undo · /topic re-apply — or just tell me in plain words.',
1254
+ };
1255
+ }
1256
+ const result = await surface.applyWrite({
1257
+ topicKey, patch, principal, origin: 'slash-topic', discloseInReply: true,
1258
+ });
1259
+ return { ok: result.ok, message: result.reply };
1260
+ };
1025
1261
  // /local-model — conversational counterpart of editing config.json.
1026
1262
  // Justin's rule: every config change must be reachable via Telegram.
1027
1263
  // Validates the requested provider is reachable before flipping the
@@ -1038,11 +1274,11 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
1038
1274
  return {
1039
1275
  ok: true,
1040
1276
  message: fw === 'codex-cli'
1041
- ? 'This topic is on Codex with the cloud model. Run /local-model ollama [model] to switch to a local model (Ollama / LM Studio supported).'
1042
- : `This topic is on "${fw}", which doesn't support the local-model path. Run /route codex-cli first, then /local-model ollama [model].`,
1277
+ ? 'This topic is on Codex with the cloud model. Just tell me to use a local model (Ollama / LM Studio supported) — or /local-model ollama [model] works too.'
1278
+ : `This topic is on "${fw}", which doesn't support the local-model path. Tell me to switch this topic to Codex first, then ask for the local model.`,
1043
1279
  };
1044
1280
  }
1045
- return { ok: true, message: `This topic is on Codex via local ${current.provider}${current.model ? ` (model: ${current.model})` : ''}. Run /local-model off to revert to cloud Codex.` };
1281
+ return { ok: true, message: `This topic is on Codex via local ${current.provider}${current.model ? ` (model: ${current.model})` : ''}. Tell me to go back to the cloud model when you want — or /local-model off.` };
1046
1282
  }
1047
1283
  // Disable
1048
1284
  if (provider === 'off' || provider === 'none' || provider === 'disable') {
@@ -1070,7 +1306,7 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
1070
1306
  // Topic must be on codex-cli — local-model goes through Codex --oss.
1071
1307
  const fw = resolveTopicFramework(topicId);
1072
1308
  if (fw !== 'codex-cli') {
1073
- return { ok: false, message: `This topic is on "${fw}". Local models route through Codex CLI's --oss flag, so the topic must be on codex-cli first. Run /route codex-cli, then re-run this command.` };
1309
+ return { ok: false, message: `This topic is on "${fw}". Local models route through Codex CLI's --oss flag, so the topic must be on Codex first tell me to switch it over, then ask again.` };
1074
1310
  }
1075
1311
  // Pre-flight: provider reachability + model availability (best-effort).
1076
1312
  try {
@@ -1126,6 +1362,182 @@ function messageToPipeline(msg, topicName) {
1126
1362
  timestamp: msg.receivedAt,
1127
1363
  };
1128
1364
  }
1365
+ /**
1366
+ * Topic Profile §10.1 — the SERVER-SIDE conversational ingress. The parse
1367
+ * runs in the message-ingress pipeline where `telegramUserId` is first-party,
1368
+ * so the authenticated sender uid reaches the store through code, never
1369
+ * through a body the agent composed. Returns true when the message was a
1370
+ * profile trigger/confirm and was fully handled (do NOT route it to the
1371
+ * session); false otherwise (normal routing proceeds).
1372
+ *
1373
+ * Forwarded content never matches ANY ingress recognition (§10.1 round-5):
1374
+ * a message carrying platform forward metadata falls through as normal
1375
+ * conversation regardless of sender.
1376
+ */
1377
+ async function handleTopicProfileIngress(telegram, topicId, text, telegramUserId, msg) {
1378
+ const surface = _topicProfileWriteSurface;
1379
+ const slots = _topicProfileConfirmSlots;
1380
+ if (!surface || !telegramUserId)
1381
+ return false;
1382
+ // The trust floor: only an authorized sender's turn can ever be a trigger
1383
+ // (the bound-operator check inside the surface is the refusal tier).
1384
+ let authorized = false;
1385
+ try {
1386
+ authorized = telegram.isAuthorizedSender(telegramUserId);
1387
+ }
1388
+ catch { /* @silent-fallback-ok: a trust-floor read fault fails toward NOT-a-trigger (deny-by-default) — the conversational profile parse never runs for an unauthorized turn (TOPIC-PROFILE-SPEC §10.1) */ }
1389
+ if (!authorized)
1390
+ return false;
1391
+ const trigger = parseProfileTrigger(text);
1392
+ if (!trigger)
1393
+ return false;
1394
+ const forwarded = msg.metadata?.forwarded === true;
1395
+ if (forwarded)
1396
+ return false;
1397
+ const topicKey = String(topicId);
1398
+ const principal = { kind: 'operator', platform: 'telegram', uid: String(telegramUserId) };
1399
+ const send = async (reply) => {
1400
+ try {
1401
+ await telegram.sendToTopic(topicId, reply);
1402
+ }
1403
+ catch { /* @silent-fallback-ok: a profile-ingress disclosure send is best-effort — a transient Telegram send fault must not throw out of the ingress handler; the armed slot TTL / next turn re-surfaces it (TOPIC-PROFILE-SPEC §10.1) */ }
1404
+ };
1405
+ switch (trigger.kind) {
1406
+ case 'write': {
1407
+ const result = await surface.applyWrite({
1408
+ topicKey, patch: trigger.patch, principal, origin: 'conversational', discloseInReply: true,
1409
+ });
1410
+ await send(result.reply);
1411
+ return true;
1412
+ }
1413
+ case 'readout': {
1414
+ await send(surface.renderReadout(topicKey));
1415
+ return true;
1416
+ }
1417
+ case 'undo': {
1418
+ const result = await surface.undo({ topicKey, principal, origin: 'conversational', discloseInReply: true });
1419
+ await send(result.reply);
1420
+ return true;
1421
+ }
1422
+ case 'clear': {
1423
+ const result = await surface.clear({ topicKey, principal, origin: 'conversational', discloseInReply: true });
1424
+ await send(result.reply);
1425
+ return true;
1426
+ }
1427
+ case 'reapply': {
1428
+ const result = await surface.reapply({ topicKey, principal, origin: 'conversational', discloseInReply: true });
1429
+ if (result.needsConfirm && slots) {
1430
+ // §10.4 cooldown confirm — rides the SAME shared armed slot as the
1431
+ // other confirm surfaces, with the server-rendered consequence echo.
1432
+ const armed = slots.arm(topicKey, 'reapply-cooldown', {}, result.reply, 'ingress');
1433
+ if (armed.ok) {
1434
+ try {
1435
+ const sent = await telegram.sendToTopic(topicId, `${result.reply} Reply "yes" to apply it anyway.`);
1436
+ slots.recordEchoMessageId(topicKey, sent.messageId);
1437
+ }
1438
+ catch { /* echo undelivered — the confirm refuses toward re-echo */ }
1439
+ return true;
1440
+ }
1441
+ await send('Too many back-to-back proposals here — give it a few minutes, then say it again fresh.');
1442
+ return true;
1443
+ }
1444
+ await send(result.reply);
1445
+ return true;
1446
+ }
1447
+ case 'switch-now': {
1448
+ const armedSlot = slots?.peek(topicKey) ?? null;
1449
+ if (!armedSlot) {
1450
+ // No WRITE-SURFACE confirm armed. The orchestrator runs a SEPARATE §8
1451
+ // confirm surface: on a busy framework switch it tells the operator
1452
+ // "say 'switch now' to interrupt" and arms its OWN switch-now slot
1453
+ // (orchestrator.armConfirm → executeSwitchNow). Bridge the operator's
1454
+ // reply to it so that disclosed instruction is not a dead end. This is
1455
+ // purely the empty-slot fallback — write-surface propose-confirm/reapply
1456
+ // slots keep precedence (handled below), so no existing confirm flow
1457
+ // changes behavior. (TOPIC-PROFILE-SPEC §8)
1458
+ if (_topicProfileOrchestrator) {
1459
+ const r = await _topicProfileOrchestrator.handleSwitchNow(topicKey);
1460
+ if (r.fired) {
1461
+ await send(r.reply);
1462
+ return true;
1463
+ }
1464
+ }
1465
+ // §8: a "switch now" with no armed pending switch (either surface) is a
1466
+ // no-op with a plain reply.
1467
+ await send('Nothing is pending a switch right now.');
1468
+ return true;
1469
+ }
1470
+ return handleProfileConfirm(telegram, surface, slots, topicId, principal, msg);
1471
+ }
1472
+ case 'confirm': {
1473
+ if (!slots || !slots.peek(topicKey))
1474
+ return false; // normal conversation
1475
+ return handleProfileConfirm(telegram, surface, slots, topicId, principal, msg);
1476
+ }
1477
+ }
1478
+ return false;
1479
+ }
1480
+ /** Fire the topic's armed confirm (shared slot — §10.1/§8/§10.4). */
1481
+ async function handleProfileConfirm(telegram, surface, slots, topicId, principal, msg) {
1482
+ const topicKey = String(topicId);
1483
+ const send = async (reply) => {
1484
+ try {
1485
+ await telegram.sendToTopic(topicId, reply);
1486
+ }
1487
+ catch { /* @silent-fallback-ok: a profile-ingress disclosure send is best-effort — a transient Telegram send fault must not throw out of the ingress handler; the armed slot TTL / next turn re-surfaces it (TOPIC-PROFILE-SPEC §10.1) */ }
1488
+ };
1489
+ const match = slots.matchConfirm(topicKey, {
1490
+ platformMessageId: platformMessageIdFrom(msg.id),
1491
+ forwarded: msg.metadata?.forwarded === true,
1492
+ });
1493
+ if (!match.ok) {
1494
+ if (match.reason === 'none-armed' || match.reason === 'forwarded')
1495
+ return false;
1496
+ if (match.reason === 'expired') {
1497
+ await send('That proposal has expired — say what you want again.');
1498
+ return true;
1499
+ }
1500
+ // stale-order / no-echo-id: the confirm must answer the LATEST echo —
1501
+ // re-issue it and record the fresh ordering anchor (§10.1(c)).
1502
+ const slot = slots.peek(topicKey);
1503
+ if (slot) {
1504
+ try {
1505
+ const sent = await telegram.sendToTopic(topicId, `Please confirm this version:\n${slot.echoText}`);
1506
+ slots.recordEchoMessageId(topicKey, sent.messageId);
1507
+ }
1508
+ catch { /* best-effort */ }
1509
+ }
1510
+ return true;
1511
+ }
1512
+ const armed = match.armed;
1513
+ if (armed.kind === 'reapply-cooldown') {
1514
+ const result = await surface.reapply({
1515
+ topicKey, principal, origin: 'propose-confirm', confirmed: true, discloseInReply: true,
1516
+ });
1517
+ await send(result.reply);
1518
+ return true;
1519
+ }
1520
+ if (armed.kind === 'propose-confirm') {
1521
+ const result = await surface.applyWrite({
1522
+ topicKey,
1523
+ patch: armed.patch,
1524
+ principal,
1525
+ origin: 'propose-confirm',
1526
+ agentComposedPayload: armed.origin === 'agent-composed',
1527
+ discloseInReply: true,
1528
+ });
1529
+ await send(result.reply);
1530
+ return true;
1531
+ }
1532
+ // 'switch-now' — the orchestrator's armed-confirm slot (orchestrator.armConfirm
1533
+ // / handleSwitchNow) is a SEPARATE mechanism from this write-surface confirm
1534
+ // slot; bridging the two ingress confirm systems is the remaining stage-3
1535
+ // ingress hook (integrating session). Until then this branch replies honestly
1536
+ // (the §8 switch-now path is exercised through the orchestrator's own confirm
1537
+ // surface, not this write-surface ProfileConfirmSlots match).
1538
+ await send('That switch is no longer pending.');
1539
+ return true;
1540
+ }
1129
1541
  export function wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManager, fixCommandHandler,
1130
1542
  // Late-bound: the threadline hub deps are constructed AFTER this is wired, so
1131
1543
  // resolve them at message-time (CMT-529 deterministic "open this" intercept).
@@ -1187,6 +1599,20 @@ getAttentionTopicId) {
1187
1599
  if (handled)
1188
1600
  return;
1189
1601
  }
1602
+ // ── Topic Profile §10.1: server-side conversational ingress ──────────
1603
+ // The PRIMARY write surface: "use codex here", "set high thinking on this
1604
+ // topic", undo/clear/re-apply, and the shared confirm ("yes" / "switch
1605
+ // now"). Parsed HERE — where the authenticated sender uid is first-party —
1606
+ // never by the agent. Non-triggers fall through to normal routing.
1607
+ try {
1608
+ if (await handleTopicProfileIngress(telegram, topicId, text, telegramUserId, msg)) {
1609
+ return;
1610
+ }
1611
+ }
1612
+ catch (err) {
1613
+ // Fail toward normal routing — the ingress must never eat a message.
1614
+ console.error(`[telegram] topic-profile ingress error (routing normally): ${err instanceof Error ? err.message : err}`);
1615
+ }
1190
1616
  // /new — create a new topic thread. Does NOT spawn a session immediately.
1191
1617
  // Sessions are spawned on-demand when the user sends their first real message
1192
1618
  // in the new topic (via the auto-spawn path below). This avoids premature
@@ -3116,8 +3542,101 @@ export async function startServer(options) {
3116
3542
  });
3117
3543
  }
3118
3544
  catch (err) {
3545
+ // @silent-fallback-ok: TopicLocalModelStore init is a per-topic /local-model
3546
+ // override layer — a construction fault leaves it null and resolution falls
3547
+ // through to the config/global model defaults (the override is additive, never
3548
+ // the only model source) (TOPIC-PROFILE-SPEC §5.2).
3119
3549
  console.warn(`[server] TopicLocalModelStore failed to initialize: ${err}`);
3120
3550
  }
3551
+ // Topic Profile (§5.1/§5.2): the sticky per-topic profile store + the
3552
+ // single resolution point. The store seeds one-directionally from the
3553
+ // legacy topic-frameworks file and regenerates it as a mirror; the
3554
+ // resolver layers profile pin > config default > global default with
3555
+ // the read-time enum re-validation + launchability fallback. Reads
3556
+ // HONOR existing pins regardless of the topicProfiles enabled flag
3557
+ // (§5.2 disabled-flag semantics — the flag gates writes, not reads).
3558
+ try {
3559
+ const { TopicProfileStore } = await import('../core/TopicProfileStore.js');
3560
+ const { TopicProfileResolver } = await import('../core/TopicProfileResolver.js');
3561
+ const { normalizeTierEscalationConfig } = await import('../core/ModelTierEscalation.js');
3562
+ const topicProfilesCfg = config.topicProfiles;
3563
+ _topicProfileStore = new TopicProfileStore({
3564
+ stateFilePath: path.join(config.stateDir, 'state', 'topic-profiles.json'),
3565
+ legacyFrameworksPath: path.join(config.stateDir, 'state', 'topic-frameworks.json'),
3566
+ isDryRun: () => topicProfilesCfg?.dryRun !== false,
3567
+ });
3568
+ _topicProfileResolver = new TopicProfileResolver({
3569
+ store: _topicProfileStore,
3570
+ defaultFramework: () => _defaultFramework,
3571
+ configTopicFrameworks: () => _topicFrameworks,
3572
+ configProfileDefaults: () => topicProfilesCfg?.defaults ?? {},
3573
+ frameworkDefaultModels: () => config.sessions?.frameworkDefaultModels ?? {},
3574
+ tierEscalationConfig: () => normalizeTierEscalationConfig(config.models?.tierEscalation),
3575
+ localModelBinding: (topicKey) => _topicLocalModelStore?.get(Number(topicKey)) ?? null,
3576
+ // Mirrors SessionManager's spawn-path binary resolution exactly
3577
+ // (frameworkBinaryPaths[fw] ?? claudePath, pi-cli → bare 'pi') so the
3578
+ // launchability signal answers "would the REAL spawn find a binary",
3579
+ // never a stricter question that false-fallbacks a valid pin.
3580
+ frameworkBinaryPath: (fw) => config.sessions?.frameworkBinaryPaths?.[fw]
3581
+ ?? (fw === 'pi-cli' ? 'pi' : (config.sessions?.claudePath ?? null)),
3582
+ audit: (event) => {
3583
+ appendTopicProfileAudit(config.stateDir, event);
3584
+ },
3585
+ });
3586
+ // Topic Profile (§5.2/§10): the write surface + the shared confirm
3587
+ // slots. Regime knobs resolve LIVE on every write: `enabled` rides the
3588
+ // dev-agent dark gate (DEV_GATED_FEATURES — never a written literal);
3589
+ // dryRun ships true (§14 shadow canary). §5.2(d): framework-arm writes
3590
+ // bypass BOTH knobs inside the surface itself.
3591
+ const { TopicProfileWriteSurface } = await import('../core/topicProfileWriteSurface.js');
3592
+ const { ProfileConfirmSlots } = await import('../core/topicProfileIngress.js');
3593
+ _topicProfileConfirmSlots = new ProfileConfirmSlots({
3594
+ ttlMs: () => topicProfilesCfg?.switchNowConfirmTtlMs ?? 300_000,
3595
+ audit: (event) => { appendTopicProfileAudit(config.stateDir, event); },
3596
+ });
3597
+ _topicProfileWriteSurface = new TopicProfileWriteSurface({
3598
+ store: _topicProfileStore,
3599
+ resolver: _topicProfileResolver,
3600
+ regime: () => ({
3601
+ enabled: resolveDevAgentGate(topicProfilesCfg?.enabled, config),
3602
+ dryRun: topicProfilesCfg?.dryRun !== false,
3603
+ }),
3604
+ // Late-bound: the AgentServer's TopicOperatorStore is the SAME
3605
+ // instance the routes' auto-bind writes (a second instance on the
3606
+ // same file would lose updates between two in-memory caches).
3607
+ boundOperator: (topicKey) => {
3608
+ const op = _agentServerRef?.getTopicOperatorStore()?.getOperator(topicKey) ?? null;
3609
+ return op ? { platform: op.platform, uid: op.uid } : null;
3610
+ },
3611
+ localModelBinding: (topicKey) => /^\d+$/.test(topicKey) ? (_topicLocalModelStore?.get(Number(topicKey)) ?? null) : null,
3612
+ // Late-bound (wireTelegramCallbacks owns the adapter): §5.2(d)
3613
+ // legacy immediate respawn + the §8 disclosure send.
3614
+ legacyFrameworkRespawn: async (topicKey) => _profileLegacyRespawn
3615
+ ? _profileLegacyRespawn(topicKey)
3616
+ : { respawned: false },
3617
+ // §8 orchestration seam — late-bound: the TopicProfileOrchestrator is
3618
+ // constructed AFTER the AgentServer exists (it late-binds the
3619
+ // EscalationGovernor + ModelSwapService off the server). This thin
3620
+ // ProfileOrchestratorLike forwards the surface's post-write signal to
3621
+ // the orchestrator's debounced, idle-gated respawn once it is wired;
3622
+ // a no-op while null (the surface's keep-working fallback serves).
3623
+ orchestrator: {
3624
+ onProfileWrite: (topicKey, info) => _topicProfileOrchestrator?.onProfileWrite(topicKey, info),
3625
+ },
3626
+ // §5.3 transfer-carrier cancel marker — late-bound (the carrier is
3627
+ // built in the mesh block, after this surface). Cancels a pending
3628
+ // transfer-pull REPLACE for the topic the moment a local write lands.
3629
+ onLocalWriteDurable: (topicKey, origin) => _topicProfileCarrier?.onLocalWriteDurable(topicKey, origin),
3630
+ disclose: async (topicKey, text) => {
3631
+ if (_profileDisclose)
3632
+ await _profileDisclose(topicKey, text);
3633
+ },
3634
+ audit: (event) => appendTopicProfileAudit(config.stateDir, event),
3635
+ });
3636
+ }
3637
+ catch (err) {
3638
+ console.warn(`[server] TopicProfileStore/Resolver failed to initialize: ${err}`);
3639
+ }
3121
3640
  // June-15 subscription-path routing (spec 04 Rule 1). Built ONCE here;
3122
3641
  // reused by the main provider below AND the per-component
3123
3642
  // IntelligenceRouter's claude-code builds, so a component routed to
@@ -3517,9 +4036,15 @@ export async function startServer(options) {
3517
4036
  : 'detect-only';
3518
4037
  console.log(pc.green(` Prompt Gate: enabled (mode: ${mode})`));
3519
4038
  }
4039
+ // ── GuardRegistry (GUARD-POSTURE-ENDPOINT-SPEC §2.1) ──────────────
4040
+ // Constructed guard components self-register SYNC runtime getters at
4041
+ // their construction sites below; GET /guards reconciles registrations
4042
+ // against the declared manifest (missing = a state, never an omission).
4043
+ const guardRegistry = new GuardRegistry();
3520
4044
  let scheduler;
3521
4045
  if (config.scheduler.enabled && coordinator.isAwake) {
3522
4046
  scheduler = new JobScheduler(config.scheduler, sessionManager, state, config.stateDir);
4047
+ guardRegistry.register('scheduler.enabled', () => scheduler.guardStatus());
3523
4048
  // Wire machine identity for machine-scoped job filtering
3524
4049
  if (coordinator.identity) {
3525
4050
  scheduler.setMachineIdentity(coordinator.identity.machineId, coordinator.identity.name);
@@ -5293,6 +5818,7 @@ export async function startServer(options) {
5293
5818
  if (config.monitoring.watchdog?.enabled) {
5294
5819
  watchdog = new SessionWatchdog(config, sessionManager, state);
5295
5820
  watchdog.intelligence = sharedIntelligence ?? null;
5821
+ guardRegistry.register('monitoring.watchdog.enabled', () => watchdog.guardStatus());
5296
5822
  watchdog.on('intervention', (event) => {
5297
5823
  // Routine recovery (Ctrl+C, SIGTERM) stays as console diagnostics only.
5298
5824
  // The user only hears when we had to force-kill (SIGKILL / session-kill) —
@@ -6478,6 +7004,7 @@ export async function startServer(options) {
6478
7004
  socketSentinel.on('recovered', (n) => notifier.record('recovered', 'socket-disconnect', n));
6479
7005
  socketSentinel.on('recovery-error', (e) => notifier.record('recovery-error', 'socket-disconnect', e.sessionName, e.err instanceof Error ? e.err.message : String(e.err)));
6480
7006
  socketSentinel.start();
7007
+ guardRegistry.register('monitoring.socketDisconnectSentinel.enabled', () => socketSentinel.guardStatus());
6481
7008
  socketRecoveryActive = (s) => socketSentinel.isRecoveryActive(s);
6482
7009
  console.log(pc.green(' SocketDisconnectSentinel enabled (connection-drop recovery)'));
6483
7010
  }
@@ -6532,6 +7059,7 @@ export async function startServer(options) {
6532
7059
  silenceSentinel.on('recover-error', (e) => notifier.record('recovery-error', 'active-silence', e.sessionName, e.err instanceof Error ? e.err.message : String(e.err)));
6533
7060
  silenceSentinel.on('nudge-error', (e) => notifier.record('nudge-error', 'active-silence', e.sessionName, e.err instanceof Error ? e.err.message : String(e.err)));
6534
7061
  silenceSentinel.start();
7062
+ guardRegistry.register('monitoring.activeWorkSilenceSentinel.enabled', () => silenceSentinel.guardStatus());
6535
7063
  silenceRecoveryActive = (s) => silenceSentinel.isRecoveryActive(s);
6536
7064
  const silenceMode = silenceAutoRecover ? ' — auto-heal LIVE' : '';
6537
7065
  console.log(pc.green(telegramEscalation
@@ -6574,6 +7102,16 @@ export async function startServer(options) {
6574
7102
  wedgeSentinel.on('false-alarm', (e) => notifier.record('false-alarm', 'context-wedge', e.sessionName, 'signature scrolled out of tail'));
6575
7103
  wedgeSentinel.on('recovery-error', (e) => notifier.record('recovery-error', 'context-wedge', e.sessionName, e.err instanceof Error ? e.err.message : String(e.err)));
6576
7104
  wedgeSentinel.start();
7105
+ guardRegistry.register('monitoring.contextWedgeSentinel.enabled', () => wedgeSentinel.guardStatus());
7106
+ // Sub-guard row (spec §2.1): the destructive auto-recovery arm reports
7107
+ // its OWN posture so 'autoRecovery silently off inside an on-confirmed
7108
+ // sentinel' cannot hide.
7109
+ guardRegistry.register('monitoring.contextWedgeSentinel.autoRecovery.enabled', () => ({
7110
+ enabled: autoRecovery.enabled === true,
7111
+ dryRun: autoRecovery.dryRun !== false,
7112
+ // Deliberately no lastTickAt: the autoRecovery arm is config-derived
7113
+ // (no tick loop of its own); the parent sentinel row carries liveness.
7114
+ }));
6577
7115
  wedgeRecoveryActive = (s) => wedgeSentinel.isRecoveryActive(s);
6578
7116
  const mode = autoRecovery.enabled ? (autoRecovery.dryRun ? 'auto-recover dry-run' : 'auto-recover LIVE') : 'detect-only';
6579
7117
  console.log(pc.green(` ContextWedgeSentinel enabled (thinking-block-400 + aup-rejection wedges — ${mode})`));
@@ -8718,6 +9256,68 @@ export async function startServer(options) {
8718
9256
  lockFilePath: path.join(config.stateDir, 'lifeline.lock'),
8719
9257
  isEnabled: () => fs.existsSync(path.join(config.stateDir, 'lifeline.lock')),
8720
9258
  }),
9259
+ ...createGuardPostureProbes({
9260
+ // Same one-read inventory GET /guards serves; null on read failure.
9261
+ getLocalPosture: () => {
9262
+ try {
9263
+ const snap = resolveGuardConfigSnapshot(config.projectDir);
9264
+ if (snap.readError)
9265
+ return null;
9266
+ return buildGuardInventory({
9267
+ snapshot: snap,
9268
+ bootSnapshot: readGuardPostureBootSnapshot(config.stateDir),
9269
+ registry: guardRegistry,
9270
+ });
9271
+ }
9272
+ catch {
9273
+ return null; /* @silent-fallback-ok — probe degrades to no-local-posture for this tick; the route surfaces config errors loudly */
9274
+ }
9275
+ },
9276
+ // Heartbeat-sourced (durable last-known for offline peers) — never a
9277
+ // doomed fan-out for a dark peer (spec §2.4 data-source rule).
9278
+ getPeerPostures: () => {
9279
+ try {
9280
+ return (machinePoolRegistry?.getCapacities() ?? [])
9281
+ .filter((m) => m.machineId !== (_meshSelfId ?? ''))
9282
+ .map((m) => ({
9283
+ machineId: m.machineId,
9284
+ nickname: m.nickname,
9285
+ online: m.online,
9286
+ posture: m.guardPosture ?? null,
9287
+ postureAgeMs: m.guardPostureReceivedAt ? Date.now() - Date.parse(m.guardPostureReceivedAt) : null,
9288
+ }));
9289
+ }
9290
+ catch {
9291
+ return []; /* @silent-fallback-ok — pool not wired yet (boot order): peers none this tick, next tick reads the live registry */
9292
+ }
9293
+ },
9294
+ // Spec §2.4 deep-read fallback: ONLY for an ONLINE peer whose
9295
+ // heartbeat posture block is missing/stale — a plain GET /guards
9296
+ // (never ?scope=pool), token attached only past the URL guard.
9297
+ deepReadPeer: async (machineId) => {
9298
+ if (!_listPoolMachines)
9299
+ return null; // pool not wired on this host — explicit, not incidental
9300
+ const m = _listPoolMachines().find((x) => x.machineId === machineId);
9301
+ if (!m?.lastKnownUrl)
9302
+ return null;
9303
+ const extra = config.multiMachine?.peerUrlAllowlist;
9304
+ if (!isPeerUrlAllowedForCredentials(m.lastKnownUrl, extra).ok)
9305
+ return null;
9306
+ const r = await fetch(`${m.lastKnownUrl}/guards`, {
9307
+ headers: { Authorization: `Bearer ${config.authToken}` },
9308
+ signal: AbortSignal.timeout(5000),
9309
+ });
9310
+ if (!r.ok)
9311
+ return null;
9312
+ return r.json();
9313
+ },
9314
+ emitAttention: async (item) => {
9315
+ if (!telegram)
9316
+ return;
9317
+ await telegram.createAttentionItem(item);
9318
+ },
9319
+ stateDir: config.stateDir,
9320
+ }),
8721
9321
  ...createPlatformProbes({
8722
9322
  tmuxPath,
8723
9323
  }),
@@ -9700,14 +10300,50 @@ export async function startServer(options) {
9700
10300
  // adapter (v1 scope: Telegram-bound sessions only). The respawner closure
9701
10301
  // captures `topicMemory` by reference, so even if topicMemory is wired up
9702
10302
  // after this point it will be resolved at refresh-time.
9703
- if (telegram) {
10303
+ // §10.5: SessionRefresh is available for a Slack-only server too (the
10304
+ // Slack respawner sub-task). The construction is hoisted to (telegram ||
10305
+ // slack); the Telegram respawner refuses honestly when telegram is null,
10306
+ // and the Slack arm is served by the slackRespawner closure below.
10307
+ if (telegram || _slackAdapter) {
9704
10308
  const { SessionRefresh } = await import('../core/SessionRefresh.js');
9705
- const telegramRef = telegram; // narrow for closure
10309
+ const telegramRef = telegram ?? null; // may be null on a Slack-only server
10310
+ const slackRef = _slackAdapter; // narrow for closures
9706
10311
  _sessionRefresh = new SessionRefresh({
9707
10312
  sessionManager,
9708
10313
  state,
9709
10314
  telegram: telegramRef,
9710
10315
  topicResumeMap: _topicResumeMap,
10316
+ // §10.5 Slack binding — SlackAdapter satisfies SlackRefreshBinding
10317
+ // structurally (getChannelForSession / removeChannelResume /
10318
+ // resolveChannelForSessionFromDisk). Null ⇒ Telegram-only, unchanged.
10319
+ slack: slackRef,
10320
+ // §10.5 Slack respawner — mirrors the Slack message-handler spawn path
10321
+ // (getChannelResume → removeChannelResume → spawnInteractiveSession with
10322
+ // the parsed channel/thread → registerChannelSession). SessionRefresh
10323
+ // kills first; for a fresh respawn it already removed the resume entry.
10324
+ slackRespawner: slackRef
10325
+ ? async (sessionName, routingKey, followUpPrompt, accountSwap) => {
10326
+ const resumeInfo = slackRef.getChannelResume(routingKey);
10327
+ const resumeSessionId = resumeInfo?.uuid ?? undefined;
10328
+ if (resumeInfo)
10329
+ slackRef.removeChannelResume(routingKey);
10330
+ // routingKey = `<channelId>[:<thread_ts>]`.
10331
+ const sep = routingKey.indexOf(':');
10332
+ const slackChannelId = sep === -1 ? routingKey : routingKey.slice(0, sep);
10333
+ const slackThreadTs = sep === -1 ? undefined : routingKey.slice(sep + 1);
10334
+ const newSessionName = await sessionManager.spawnInteractiveSession(followUpPrompt ?? 'Session refreshed — continue where you left off.', undefined, {
10335
+ resumeSessionId,
10336
+ slackChannelId,
10337
+ slackThreadTs,
10338
+ ...(accountSwap?.configHome ? { configHome: accountSwap.configHome } : {}),
10339
+ ...(accountSwap?.accountId ? { subscriptionAccountId: accountSwap.accountId } : {}),
10340
+ });
10341
+ if (newSessionName) {
10342
+ slackRef.registerChannelSession(routingKey, newSessionName, slackThreadTs ? `${slackChannelId} (thread ${slackThreadTs})` : undefined);
10343
+ }
10344
+ return newSessionName || sessionName;
10345
+ }
10346
+ : null,
9711
10347
  respawner: async (sessionName, topicId, followUpPrompt, accountSwap) => {
9712
10348
  // killSession (called inside SessionRefresh) has already fired
9713
10349
  // beforeSessionKill (UUID persisted) and destroyed the tmux
@@ -9716,11 +10352,21 @@ export async function startServer(options) {
9716
10352
  // P1.3: accountSwap (when present) re-launches the resume under a
9717
10353
  // different account's config home — the --resume uuid is account-
9718
10354
  // agnostic, so the conversation is preserved across the swap.
10355
+ if (!telegramRef) {
10356
+ // Telegram-only respawn path on a Slack-only server — never reached
10357
+ // (a Slack-bound session routes through slackRespawner above), but
10358
+ // the contract requires a value.
10359
+ return sessionName;
10360
+ }
9719
10361
  await respawnSessionForTopic(sessionManager, telegramRef, sessionName, topicId, followUpPrompt, topicMemory, undefined, undefined, accountSwap ? { configHome: accountSwap.configHome, accountId: accountSwap.accountId } : undefined);
9720
10362
  return telegramRef.getSessionForTopic(topicId) ?? sessionName;
9721
10363
  },
9722
10364
  });
9723
- // ── QuotaAwareScheduler (Subscription & Auth Standard P1.3) ──
10365
+ }
10366
+ // ── QuotaAwareScheduler (Subscription & Auth Standard P1.3) ──
10367
+ // Telegram-specific (createAttentionItem + subscription-pool wiring).
10368
+ if (telegram) {
10369
+ const telegramRef = telegram; // narrow for closure
9724
10370
  // Selects the optimal account + enforces the continuity guarantee: on
9725
10371
  // quota pressure it resumes the session under another account (via the
9726
10372
  // SessionRefresh account-swap path), never letting it die. Auto-trigger
@@ -10197,6 +10843,7 @@ export async function startServer(options) {
10197
10843
  console.log(pc.green(' Unkillability backstop enabled (§P5 — signal-only, never auto-kills)'));
10198
10844
  }
10199
10845
  }
10846
+ guardRegistry.register('monitoring.sessionReaper.enabled', () => sessionReaper.guardStatus());
10200
10847
  if (config.monitoring?.sessionReaper?.enabled) {
10201
10848
  console.log(pc.green(config.monitoring.sessionReaper.dryRun === false
10202
10849
  ? ' SessionReaper enabled (idle-session reaper — LIVE)'
@@ -10378,6 +11025,9 @@ export async function startServer(options) {
10378
11025
  const failoverThresholdMs = (config.multiMachine?.failoverTimeoutMinutes ?? 15) * 60_000;
10379
11026
  const clockSkewToleranceMs = config.multiMachine?.sessionPool?.clockSkewToleranceMs ?? 300_000;
10380
11027
  machinePoolRegistry = new poolMod.MachinePoolRegistry({
11028
+ // Durable last-known posture (GUARD-POSTURE-ENDPOINT-SPEC §2.3(c)) —
11029
+ // survives local restarts so a dark peer renders with its real age.
11030
+ postureStore: new GuardPostureStore(config.stateDir),
10381
11031
  listMachines: () => poolIdMgr.getActiveMachines().map(({ machineId, entry }) => ({
10382
11032
  machineId,
10383
11033
  nickname: entry.nickname,
@@ -10420,6 +11070,47 @@ export async function startServer(options) {
10420
11070
  return undefined; /* unknown ≠ blocked */
10421
11071
  }
10422
11072
  };
11073
+ // Self guard-posture block riding the capacity heartbeat (spec §2.3).
11074
+ // Computed per beat from the same one-read snapshot GET /guards uses;
11075
+ // a failed compute omits the block (older-peer semantics), never throws.
11076
+ // The resolved-config snapshot is the expensive half (defaults clone
11077
+ // + deep merge); cache it keyed on config.json mtime so the 30s
11078
+ // beat pays one cheap fs.stat instead (perf review 2026-06-12 #1).
11079
+ // The INVENTORY still rebuilds every beat — runtime states
11080
+ // (lastTickAt staleness, self-reported enabled) must stay live.
11081
+ let _postureComputeWarned = false;
11082
+ let _postureSnapCache = null;
11083
+ const selfGuardPosture = () => {
11084
+ try {
11085
+ let mtimeMs = -1;
11086
+ try {
11087
+ mtimeMs = fs.statSync(path.join(config.stateDir, 'config.json')).mtimeMs;
11088
+ }
11089
+ catch { /* @silent-fallback-ok — absent config file: mtime -1 still caches the defaults-only snapshot */ }
11090
+ if (!_postureSnapCache || _postureSnapCache.mtimeMs !== mtimeMs) {
11091
+ _postureSnapCache = { mtimeMs, snap: resolveGuardConfigSnapshot(config.projectDir) };
11092
+ }
11093
+ const snap = _postureSnapCache.snap;
11094
+ if (snap.readError)
11095
+ return undefined;
11096
+ const inv = buildGuardInventory({
11097
+ snapshot: snap,
11098
+ bootSnapshot: readGuardPostureBootSnapshot(config.stateDir),
11099
+ registry: guardRegistry,
11100
+ });
11101
+ return buildHeartbeatPostureBlock(inv, new Date().toISOString());
11102
+ }
11103
+ catch (err) {
11104
+ // @silent-fallback-ok — posture is optional on a beat (the pool
11105
+ // renders "unknown" honestly), and a PERSISTENT compute failure
11106
+ // is not invisible: the first occurrence logs below.
11107
+ if (!_postureComputeWarned) {
11108
+ _postureComputeWarned = true;
11109
+ console.log(pc.yellow(` [guards] heartbeat posture compute failed (beats will omit posture until it recovers): ${err instanceof Error ? err.message : String(err)}`));
11110
+ }
11111
+ return undefined;
11112
+ }
11113
+ };
10423
11114
  const refreshPool = () => {
10424
11115
  try {
10425
11116
  machinePoolRegistry.recordHeartbeat({
@@ -10427,6 +11118,7 @@ export async function startServer(options) {
10427
11118
  selfReportedLastSeen: new Date().toISOString(),
10428
11119
  loadAvg: osMod.loadavg()[0],
10429
11120
  quotaState: selfQuotaState(),
11121
+ guardPosture: selfGuardPosture(),
10430
11122
  });
10431
11123
  const hbApi = machineHeartbeat?.api;
10432
11124
  if (hbApi) {
@@ -10579,6 +11271,12 @@ export async function startServer(options) {
10579
11271
  const wsTopic = Number(cmd.session);
10580
11272
  if (Number.isFinite(wsTopic))
10581
11273
  workingSetPullCoordinator?.onTopicAccepted(wsTopic);
11274
+ // TOPIC-PROFILE-SPEC §5.3 acquire seam (1/3): this machine just
11275
+ // accepted ownership of the topic. Fire-and-forget pull of the
11276
+ // pin from the previous owner (resolved from the journal when not
11277
+ // named here). Never blocks message delivery.
11278
+ if (Number.isFinite(wsTopic))
11279
+ _topicProfileCarrier?.onTopicAcquired(wsTopic);
10582
11280
  }
10583
11281
  if (_sessionPoolStage() === 'dark' || !telegram)
10584
11282
  return;
@@ -10778,6 +11476,17 @@ export async function startServer(options) {
10778
11476
  return { ok: false, reason: 'working-set disabled' };
10779
11477
  return workingSetPullServer.handle(cmd);
10780
11478
  },
11479
+ // TOPIC-PROFILE-SPEC §5.3 — the pull-at-acquire serve verb. The
11480
+ // previous owner answers a follower's pull with the current +
11481
+ // §14-shadow profile entries for the named topics (present:false
11482
+ // for absent, 500-topic cap). Stateless read over the in-memory
11483
+ // store; answers 'disabled' until the store is constructed. Joins
11484
+ // the read/observe RBAC class beside working-set-pull (MeshRpc.ts).
11485
+ 'topic-profile-pull': (cmd) => {
11486
+ if (!_topicProfileStore)
11487
+ return { ok: false, reason: 'topic-profile disabled' };
11488
+ return createTopicProfilePullHandler({ store: _topicProfileStore })(cmd);
11489
+ },
10781
11490
  // COMMITMENTS-COHERENCE-SPEC §3.4 — owner-side apply for the
10782
11491
  // owner-routed mutation. opKey window first (replay returns the
10783
11492
  // recorded verdict, applies nothing); the UNCHANGED state machine
@@ -10852,6 +11561,14 @@ export async function startServer(options) {
10852
11561
  .getActiveMachines()
10853
11562
  .filter((m) => m.machineId !== meshSelfId && !!m.entry.lastKnownUrl)
10854
11563
  .map((m) => ({ machineId: m.machineId, url: m.entry.lastKnownUrl }));
11564
+ // EVERY registered non-revoked machine — URL or not — so the /guards
11565
+ // pool view can account for each by name (no-known-url is a NAMED row,
11566
+ // never a silent omission — GUARD-POSTURE-ENDPOINT-SPEC §2.3).
11567
+ _listPoolMachines = () => meshIdMgr.getActiveMachines().map((m) => ({
11568
+ machineId: m.machineId,
11569
+ nickname: m.entry.nickname,
11570
+ lastKnownUrl: m.entry.lastKnownUrl ?? null,
11571
+ }));
10855
11572
  // Pool Dashboard Streaming requesting side (§2.2): build the connector
10856
11573
  // the WebSocketManager uses to open an upstream /pool-stream to a peer.
10857
11574
  // connect() is synchronous (PeerStreamProxy contract) but the mint +
@@ -10974,6 +11691,93 @@ export async function startServer(options) {
10974
11691
  const selfNickTimer = setInterval(() => { void convergeSelfNickname(); }, 60_000);
10975
11692
  if (typeof selfNickTimer.unref === 'function')
10976
11693
  selfNickTimer.unref();
11694
+ // ── Topic-profile transfer carrier (TOPIC-PROFILE-SPEC §5.3) ──
11695
+ // Pull-at-acquire follow: when THIS machine acquires a topic, pull
11696
+ // its per-topic profile from the previous owner so the pin follows
11697
+ // the conversation across machines. Constructed at the mesh level
11698
+ // (after meshClient + peerUrl exist) — NOT gated by the working-set
11699
+ // replication gate; it has its own durable retry ledger. Null on a
11700
+ // single-machine install (no acquires from a peer ever fire).
11701
+ if (_topicProfileStore) {
11702
+ try {
11703
+ const tpcReaderMod = await import('../core/CoherenceJournalReader.js');
11704
+ const tpcReader = new tpcReaderMod.CoherenceJournalReader({ stateDir: config.stateDir });
11705
+ _topicProfileCarrier = new TopicProfileTransferCarrier({
11706
+ stateDir: config.stateDir,
11707
+ selfMachineId: meshSelfId,
11708
+ store: _topicProfileStore,
11709
+ effectiveFramework: () => _defaultFramework,
11710
+ ownerOf: (topicKey) => ({ owner: ownReg.ownerOf(topicKey) }),
11711
+ // Previous-owner evidence from the journal's topic-placement
11712
+ // history (the most-recent entry naming a prevOwner). Used only
11713
+ // when an acquire seam could not name the previous owner.
11714
+ prevOwnerOf: (topicKey) => {
11715
+ const topicNum = Number(topicKey);
11716
+ if (!Number.isFinite(topicNum))
11717
+ return null;
11718
+ try {
11719
+ const entries = tpcReader.query({ kind: 'topic-placement', topic: topicNum, limit: 20 }).entries;
11720
+ for (const e of entries) {
11721
+ const data = e.data;
11722
+ if (typeof data.prevOwner === 'string' && data.prevOwner !== meshSelfId)
11723
+ return data.prevOwner;
11724
+ }
11725
+ }
11726
+ catch { /* @silent-fallback-ok: missing placement evidence means no previous owner to pull from — the local entry stays authoritative (TOPIC-PROFILE-SPEC §5.3) */ }
11727
+ return null;
11728
+ },
11729
+ sendPull: async (peerMachineId, topics) => {
11730
+ const url = peerUrl(peerMachineId);
11731
+ if (!url)
11732
+ return { kind: 'unreachable', detail: 'no peer url' };
11733
+ const r = await meshClient.send({ machineId: peerMachineId, url }, { type: 'topic-profile-pull', topics }, 0, { timeoutMs: 15_000 });
11734
+ if (r.ok) {
11735
+ const res = r.result;
11736
+ if (res && res.ok)
11737
+ return { kind: 'ok', entries: res.entries };
11738
+ return { kind: 'unreachable', detail: res?.reason ?? 'serve-error' };
11739
+ }
11740
+ // A peer whose instar predates the verb answers no-handler (501) — PARK.
11741
+ if (r.status === 501 || r.reason === 'no-handler')
11742
+ return { kind: 'protocol-unsupported' };
11743
+ return { kind: 'unreachable', detail: r.reason ?? `status ${r.status}` };
11744
+ },
11745
+ // Rolling-update skew: the pool advertises each machine's verb
11746
+ // capabilities. Undefined ⇒ unknown ⇒ attempt (no-handler parks).
11747
+ peerSupportsPull: (peerMachineId) => {
11748
+ const caps = machinePoolRegistry?.getCapacity(peerMachineId)?.capabilities;
11749
+ if (!caps)
11750
+ return undefined;
11751
+ return caps.includes('topic-profile-pull');
11752
+ },
11753
+ audit: (event) => appendTopicProfileAudit(config.stateDir, event),
11754
+ // §5.3 round-5: ONE aggregated reconciliation notice per (peer,
11755
+ // landing). Routed to the system (lifeline) topic — it spans
11756
+ // topics, so it is not a single conversation's disclosure.
11757
+ notify: (text) => {
11758
+ const sysTopic = telegram?.getLifelineTopicId();
11759
+ if (sysTopic)
11760
+ void telegram.sendToTopic(sysTopic, text).catch(() => { });
11761
+ },
11762
+ logger: (m) => console.log(pc.dim(` [topic-profile-pull] ${m}`)),
11763
+ });
11764
+ // §5.3(e) drain: the slow retry tick (10 min) — retries pending
11765
+ // pulls whose backoff is due and drops expired (7d) records.
11766
+ const tpcTickTimer = setInterval(() => {
11767
+ void _topicProfileCarrier?.tick().catch(() => { });
11768
+ }, 600_000);
11769
+ if (typeof tpcTickTimer.unref === 'function')
11770
+ tpcTickTimer.unref();
11771
+ console.log(pc.dim(' [topic-profile-pull] transfer carrier wired'));
11772
+ }
11773
+ catch (err) {
11774
+ // @silent-fallback-ok: carrier construction failure leaves the
11775
+ // pull-at-acquire follow disabled — pins simply do not follow a
11776
+ // cross-machine move (the local entry stays authoritative); never
11777
+ // a boot failure (TOPIC-PROFILE-SPEC §5.3).
11778
+ console.warn(`[server] TopicProfileTransferCarrier failed to initialize: ${err instanceof Error ? err.message : err}`);
11779
+ }
11780
+ }
10977
11781
  // ── Working-set pull coordinator (WORKING-SET-HANDOFF §3.3/§3.4) ──
10978
11782
  // Constructed here (after meshClient + peerUrl exist) ONLY when the
10979
11783
  // serve side was constructed above — i.e. the same explicit
@@ -11403,6 +12207,13 @@ export async function startServer(options) {
11403
12207
  const prevOwner = ownReg.read(sk)?.ownerMachineId;
11404
12208
  const r = ownReg.cas({ type: 'place', machineId }, { sessionKey: sk, sender: meshSelfId, nonce: `${meshSelfId}:c:${++routerNonce}` });
11405
12209
  emitPlacement(sk, r, 'placed', prevOwner);
12210
+ // TOPIC-PROFILE-SPEC §5.3 acquire seam (2/3): when the claim places
12211
+ // THIS machine as owner (and there was a real previous owner), pull
12212
+ // the topic's pin from that owner. Self-placement / no-prior-owner
12213
+ // is a no-op inside the carrier.
12214
+ if (r.ok && machineId === meshSelfId && prevOwner && prevOwner !== meshSelfId) {
12215
+ _topicProfileCarrier?.onTopicAcquired(sk, prevOwner);
12216
+ }
11406
12217
  return { ok: r.ok, epoch: ownReg.read(sk)?.ownershipEpoch ?? 0 };
11407
12218
  },
11408
12219
  // bug #11: confirm the remote owner (placing → active) after the spawn is
@@ -11501,6 +12312,9 @@ export async function startServer(options) {
11501
12312
  const onPeerBack = (machineId) => {
11502
12313
  workingSetPullCoordinator?.onPeerRecorded(machineId);
11503
12314
  void _commitmentReFire?.(machineId).catch(() => { });
12315
+ // §5.3(e): a returning peer drains any pending topic-profile pulls
12316
+ // that were parked for-protocol or backed-off against it.
12317
+ void _topicProfileCarrier?.onPeerOnline(machineId).catch(() => { });
11504
12318
  };
11505
12319
  const peerPresencePuller = new presenceMod.PeerPresencePuller({
11506
12320
  selfMachineId: meshSelfId,
@@ -11531,6 +12345,9 @@ export async function startServer(options) {
11531
12345
  journalAdvert,
11532
12346
  ...(cap.commitmentsAdvert ? { commitmentsAdvert: cap.commitmentsAdvert } : {}),
11533
12347
  ...(cap.quotaState ? { quotaState: cap.quotaState } : {}),
12348
+ // Guard posture rides the same pass-through (the A2 narrowing
12349
+ // lesson): dropping it here would blind the pool to peers' posture.
12350
+ ...(cap.guardPosture ? { guardPosture: cap.guardPosture } : {}),
11534
12351
  };
11535
12352
  }
11536
12353
  return null;
@@ -11810,11 +12627,286 @@ export async function startServer(options) {
11810
12627
  catch (err) {
11811
12628
  console.log(pc.dim(` [session-pool] rollout gate not wired: ${err instanceof Error ? err.message : String(err)}`));
11812
12629
  }
11813
- const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, feedbackAnomalyDetector, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, quotaManager, publisher, viewer, tunnel, evolution, watchdog, topicMemory, triageNurse, projectMapper, cartographer: cartographer ?? undefined, coherenceGate: scopeVerifier, contextHierarchy, canonicalState, operationGate, sentinel, adaptiveTrust, memoryMonitor, orphanReaper, coherenceMonitor, commitmentTracker, subscriptionPool, quotaPoller, quotaAwareScheduler: _quotaAwareScheduler ?? undefined, proactiveSwapMonitor: _proactiveSwapMonitor ?? undefined, inUseAccountResolver, enrollmentWizard, semanticMemory, activitySentinel, rateLimitSentinel, releaseReadinessSentinel: releaseReadinessSentinel ?? undefined, messageRouter, summarySentinel, spawnManager, systemReviewer, capabilityMapper, selfKnowledgeTree, coverageAuditor, topicResumeMap: _topicResumeMap ?? undefined, sessionRefresh: _sessionRefresh ?? undefined, autonomyManager, trustElevationTracker, autonomousEvolution, coordinator: coordinator.enabled ? coordinator : undefined, localSigningKeyPem, leaseTransport, onLeasePullRequest: () => leaseCoordinatorRef?.currentLease() ?? null, liveTailReceiver, handoffWireTransport, onHandoffBegin, onHandoffInitiate: handoffInitiate, handoffInProgress: handoffSentinelInProgress, messageLedger, currentInboundByTopic, replyMarkerTransport, onReplyMarker: messageLedger ? (marker) => { const m = marker; messageLedger.applyRemoteReplyMarker(m.dedupeKey, { platform: m.platform, replyIdempotencyKey: m.replyIdempotencyKey, epoch: m.epoch, topic: m.topic ?? null }); } : undefined, whatsapp: whatsappAdapter, slack: slackAdapter, imessage: imessageAdapter, whatsappBusinessBackend, messageBridge, hookEventReceiver, worktreeMonitor, subagentTracker, instructionsVerifier, handshakeManager: threadlineHandshake, threadlineRouter, conversationStore, warrantsReplyGate, collaborationSurfacer, threadResumeMap, topicLinkageHandler: topicLinkageHandler ?? undefined, threadlineRelayClient, threadlineReplyWaiters, listenerManager: listenerManager ?? undefined, a2aDeliveryTracker: a2aDeliveryTracker ?? undefined, responseReviewGate, messagingToneGate, outboundDedupGate, telemetryHeartbeat, pasteManager, featureRegistry, discoveryEvaluator, completionEvaluator, unifiedTrust, liveConfig, sharedStateLedger, ledgerSessionRegistry, worktreeManager, oidcEnrolledRepos: parallelDevConfig?.oidcEnrolledRepos, initiativeTracker, projectRoundRunner, projectDriftChecker, machineHeartbeat, machinePoolRegistry, meshRpcDispatcher, workingSetPullCoordinator, commitmentReplicaStore, forwardCommitmentMutate, sessionOwnershipRegistry, topicPinStore: _topicPinStore ?? undefined, streamTicketStore: _streamTicketStore ?? undefined, poolStreamAllowRemoteInput: config.dashboard?.poolStream?.allowRemoteInput ?? false, poolStreamConnector: _poolStreamConnector ?? undefined, secretSync: _secretSyncHandle ?? undefined, meshSelfId: _meshSelfId ?? undefined, resolveRouterUrl: _resolveRouterUrl ?? undefined, resolvePeerUrls: _resolvePeerUrls ?? undefined, sessionPoolE2EResultStore, proxyCoordinator, topicIntentStore, topicIntentArcCheck, usherSignalStore, intelligence: sharedIntelligence ?? undefined, telegramBridgeConfig, telegramBridge: telegramBridge ?? undefined, threadlineObservability, briefDeps, workingMemory, taskFlowRegistry, threadlineFlowBridge, sessionReaper, agentWorktreeReaper, mcpProcessReaper, geminiLoopRunner, sleepController, agentActivityState, reapLog, sleepWakeDetector, unjustifiedStopGate, stopGateDb, stopNotifier });
12630
+ // Topic Profile (§8/§5.3): the routes ctx object is shared BY REFERENCE
12631
+ // into AgentServer → routes, so the orchestrator + carrier (which late-bind
12632
+ // off the just-constructed AgentServer's governors / the mesh block) are
12633
+ // attached to THIS object after construction and reach the routes live.
12634
+ const _topicProfileCtx = (_topicProfileStore && _topicProfileResolver && _topicProfileWriteSurface && _topicProfileConfirmSlots)
12635
+ ? {
12636
+ store: _topicProfileStore,
12637
+ resolver: _topicProfileResolver,
12638
+ surface: _topicProfileWriteSurface,
12639
+ confirmSlots: _topicProfileConfirmSlots,
12640
+ orchestrator: null,
12641
+ carrier: _topicProfileCarrier,
12642
+ }
12643
+ : null;
12644
+ const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, feedbackAnomalyDetector, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, quotaManager, publisher, viewer, tunnel, evolution, watchdog, topicMemory, triageNurse, projectMapper, cartographer: cartographer ?? undefined, coherenceGate: scopeVerifier, contextHierarchy, canonicalState, operationGate, sentinel, adaptiveTrust, memoryMonitor, orphanReaper, coherenceMonitor, commitmentTracker, subscriptionPool, quotaPoller, quotaAwareScheduler: _quotaAwareScheduler ?? undefined, proactiveSwapMonitor: _proactiveSwapMonitor ?? undefined, inUseAccountResolver, enrollmentWizard, semanticMemory, activitySentinel, rateLimitSentinel, releaseReadinessSentinel: releaseReadinessSentinel ?? undefined, messageRouter, summarySentinel, spawnManager, systemReviewer, capabilityMapper, selfKnowledgeTree, coverageAuditor, topicResumeMap: _topicResumeMap ?? undefined, topicProfile: _topicProfileCtx ?? undefined, sessionRefresh: _sessionRefresh ?? undefined, autonomyManager, trustElevationTracker, autonomousEvolution, coordinator: coordinator.enabled ? coordinator : undefined, localSigningKeyPem, leaseTransport, onLeasePullRequest: () => leaseCoordinatorRef?.currentLease() ?? null, liveTailReceiver, handoffWireTransport, onHandoffBegin, onHandoffInitiate: handoffInitiate, handoffInProgress: handoffSentinelInProgress, messageLedger, currentInboundByTopic, replyMarkerTransport, onReplyMarker: messageLedger ? (marker) => { const m = marker; messageLedger.applyRemoteReplyMarker(m.dedupeKey, { platform: m.platform, replyIdempotencyKey: m.replyIdempotencyKey, epoch: m.epoch, topic: m.topic ?? null }); } : undefined, whatsapp: whatsappAdapter, slack: slackAdapter, imessage: imessageAdapter, whatsappBusinessBackend, messageBridge, hookEventReceiver, worktreeMonitor, subagentTracker, instructionsVerifier, handshakeManager: threadlineHandshake, threadlineRouter, conversationStore, warrantsReplyGate, collaborationSurfacer, threadResumeMap, topicLinkageHandler: topicLinkageHandler ?? undefined, threadlineRelayClient, threadlineReplyWaiters, listenerManager: listenerManager ?? undefined, a2aDeliveryTracker: a2aDeliveryTracker ?? undefined, responseReviewGate, messagingToneGate, outboundDedupGate, telemetryHeartbeat, pasteManager, featureRegistry, discoveryEvaluator, completionEvaluator, unifiedTrust, liveConfig, sharedStateLedger, ledgerSessionRegistry, worktreeManager, oidcEnrolledRepos: parallelDevConfig?.oidcEnrolledRepos, initiativeTracker, projectRoundRunner, projectDriftChecker, machineHeartbeat, machinePoolRegistry, meshRpcDispatcher, workingSetPullCoordinator, commitmentReplicaStore, forwardCommitmentMutate, sessionOwnershipRegistry, topicPinStore: _topicPinStore ?? undefined, streamTicketStore: _streamTicketStore ?? undefined, poolStreamAllowRemoteInput: config.dashboard?.poolStream?.allowRemoteInput ?? false, poolStreamConnector: _poolStreamConnector ?? undefined, secretSync: _secretSyncHandle ?? undefined, meshSelfId: _meshSelfId ?? undefined, resolveRouterUrl: _resolveRouterUrl ?? undefined, resolvePeerUrls: _resolvePeerUrls ?? undefined, guardRegistry, listPoolMachines: _listPoolMachines ?? undefined, sessionPoolE2EResultStore, proxyCoordinator, topicIntentStore, topicIntentArcCheck, usherSignalStore, intelligence: sharedIntelligence ?? undefined, telegramBridgeConfig, telegramBridge: telegramBridge ?? undefined, threadlineObservability, briefDeps, workingMemory, taskFlowRegistry, threadlineFlowBridge, sessionReaper, agentWorktreeReaper, mcpProcessReaper, geminiLoopRunner, sleepController, agentActivityState, reapLog, sleepWakeDetector, unjustifiedStopGate, stopGateDb, stopNotifier });
11814
12645
  // Resolve the late-bound topic-operator getter (increment 2e): routing was
11815
12646
  // wired before the server existed; from here on inbound binds use the
11816
12647
  // server's own store instance.
11817
12648
  _agentServerRef = server;
12649
+ // ── Topic Profile §8 orchestrator (TOPIC-PROFILE-SPEC) ──
12650
+ // Constructed HERE (after the AgentServer) because two of its ports late-bind
12651
+ // off the server: the §9 EscalationGovernor (server.getEscalationGovernor())
12652
+ // and the §7 in-flight ModelSwapService (server.getModelTierSwap()). The
12653
+ // orchestrator owns the debounced, idle-gated kill/respawn, the resume-writer
12654
+ // gates, the §10.4 breaker, and the §14 dry-run regime. Ships dark behind the
12655
+ // dev-agent gate (the `enabled` knob is resolved LIVE per write — never a
12656
+ // literal). Attached onto the shared _topicProfileCtx so the routes see it.
12657
+ if (_topicProfileCtx && _topicProfileStore && _topicProfileResolver) {
12658
+ try {
12659
+ const tpStore = _topicProfileStore;
12660
+ const tpResolver = _topicProfileResolver;
12661
+ const tpProjectDir = config.projectDir;
12662
+ // §7 codex resume map — the per-topic rollout-id capture-at-kill store.
12663
+ if (!_codexResumeMap) {
12664
+ _codexResumeMap = new CodexResumeMap(path.join(config.stateDir, 'state'));
12665
+ }
12666
+ const tpCodexResume = _codexResumeMap;
12667
+ // The §9 marker reader needs the escalated-model-id set, derived from the
12668
+ // live tier-escalation config (synchronously normalized at read time).
12669
+ const { normalizeTierEscalationConfig: normTierEsc } = await import('../core/ModelTierEscalation.js');
12670
+ const escIds = () => {
12671
+ try {
12672
+ return escalatedModelIds(normTierEsc(config.models?.tierEscalation));
12673
+ }
12674
+ catch { /* @silent-fallback-ok: an unparseable tier-escalation config yields an empty escalated-id set — the §9 marker reader then reports "no escalation marker", the safe direction (a profile kill never inherits a phantom escalation) (TOPIC-PROFILE-SPEC §9) */
12675
+ return new Set();
12676
+ }
12677
+ };
12678
+ // topic → live tmux session name (or null).
12679
+ const sessionNameForTopic = (topicKey) => {
12680
+ const n = Number(topicKey);
12681
+ if (!Number.isFinite(n))
12682
+ return null;
12683
+ return telegram?.getSessionForTopic(n) ?? null;
12684
+ };
12685
+ const orchDeps = {
12686
+ store: tpStore,
12687
+ resolveProfile: (topicKey) => tpResolver.resolve(topicKey),
12688
+ sessions: {
12689
+ getSessionForTopic: (topicKey) => {
12690
+ const name = sessionNameForTopic(topicKey);
12691
+ if (!name)
12692
+ return null;
12693
+ return { sessionName: name, cwd: tpProjectDir };
12694
+ },
12695
+ listTopicSessions: () => {
12696
+ // Every running session that is bound to a numeric (telegram) topic.
12697
+ const out = [];
12698
+ for (const s of sessionManager.listRunningSessions()) {
12699
+ const topic = telegram?.getTopicForSession?.(s.tmuxSession);
12700
+ if (topic != null && Number.isFinite(Number(topic))) {
12701
+ out.push({ topicKey: String(topic), sessionName: s.tmuxSession });
12702
+ }
12703
+ }
12704
+ return out;
12705
+ },
12706
+ readIdle: (sessionName) => {
12707
+ // FABLE three-valued idle read off the live pane (the §8 kill-time
12708
+ // re-confirm). null tail ⇒ unconfirmed; idle+empty-input ⇒
12709
+ // confirmed-idle; any other live content ⇒ busy (fail toward busy).
12710
+ const tail = sessionManager.captureMeaningfulTail(sessionName, 8);
12711
+ if (tail === null)
12712
+ return 'unconfirmed';
12713
+ return paneIdleWithEmptyInput(tail) ? 'confirmed-idle' : 'busy';
12714
+ },
12715
+ killForResume: async (sessionName) => sessionManager.killSession(sessionName),
12716
+ killFresh: async (sessionName) => {
12717
+ // Fresh respawn: clear the resume entry first so the next spawn does
12718
+ // NOT --resume the (possibly cross-framework) transcript, then kill.
12719
+ const topic = telegram?.getTopicForSession?.(sessionName);
12720
+ if (topic != null && Number.isFinite(Number(topic)))
12721
+ _topicResumeMap?.remove(Number(topic));
12722
+ return sessionManager.killSession(sessionName);
12723
+ },
12724
+ spawn: async (topicKey, _resolved, _directive) => {
12725
+ // The orchestrator already killed; respawn re-resolves the (now
12726
+ // updated) pin via spawnSessionForTopic and picks up the resume id
12727
+ // from the resume map. We mark the spawn in-flight so the spawn-path
12728
+ // chokepoint does not double-report a failure to the §10.4 breaker.
12729
+ const n = Number(topicKey);
12730
+ if (!Number.isFinite(n) || !telegram)
12731
+ return { ok: false, failureClass: 'unknown' };
12732
+ const topicName = telegram.getTopicName(n) || `topic-${n}`;
12733
+ _orchestratorSpawnInFlight.add(topicKey);
12734
+ try {
12735
+ await spawnSessionForTopic(sessionManager, telegram, topicName, n, undefined, topicMemory);
12736
+ return { ok: true };
12737
+ }
12738
+ catch (err) {
12739
+ const msg = err instanceof Error ? err.message : String(err);
12740
+ let cls = 'unknown';
12741
+ if (/not found|ENOENT|command not found/i.test(msg))
12742
+ cls = 'cli-not-found';
12743
+ else if (/quota|rate.?limit|usage limit/i.test(msg))
12744
+ cls = 'quota';
12745
+ else if (/tmux/i.test(msg))
12746
+ cls = 'tmux';
12747
+ else if (/model|account|rejected/i.test(msg))
12748
+ cls = 'model-rejected-by-account';
12749
+ return { ok: false, failureClass: cls };
12750
+ }
12751
+ finally {
12752
+ _orchestratorSpawnInFlight.delete(topicKey);
12753
+ }
12754
+ },
12755
+ },
12756
+ claudeResume: {
12757
+ // hook-provenance resume = none-loss readiness (§8 pre-kill predicate).
12758
+ ready: (topicKey) => {
12759
+ const n = Number(topicKey);
12760
+ return Number.isFinite(n) ? _topicResumeMap?.getProvenance(n) === 'hook' : false;
12761
+ },
12762
+ resumeId: (topicKey) => {
12763
+ const n = Number(topicKey);
12764
+ return Number.isFinite(n) ? (_topicResumeMap?.get(n) ?? null) : null;
12765
+ },
12766
+ park: (topicKey, reason) => {
12767
+ const n = Number(topicKey);
12768
+ if (Number.isFinite(n))
12769
+ _topicResumeMap?.park(n, reason);
12770
+ },
12771
+ unpark: (topicKey) => {
12772
+ const n = Number(topicKey);
12773
+ return Number.isFinite(n) ? (_topicResumeMap?.unpark(n) ?? false) : false;
12774
+ },
12775
+ },
12776
+ codexResume: tpCodexResume,
12777
+ escalation: {
12778
+ // §9 FABLE marker = the live escalated model id on the topic's session.
12779
+ activeMarker: (topicKey) => {
12780
+ const name = sessionNameForTopic(topicKey);
12781
+ if (!name)
12782
+ return null;
12783
+ const s = sessionManager.listRunningSessions().find((x) => x.tmuxSession === name);
12784
+ const model = s?.model;
12785
+ if (model && escIds().has(String(model)))
12786
+ return { model: String(model) };
12787
+ return null;
12788
+ },
12789
+ listMarkerTopics: () => {
12790
+ const ids = escIds();
12791
+ const out = [];
12792
+ for (const s of sessionManager.listRunningSessions()) {
12793
+ if (s.model && ids.has(String(s.model))) {
12794
+ const topic = telegram?.getTopicForSession?.(s.tmuxSession);
12795
+ if (topic != null)
12796
+ out.push(String(topic));
12797
+ }
12798
+ }
12799
+ return out;
12800
+ },
12801
+ clearMarkerAndReleaseLease: (topicKey) => {
12802
+ const name = sessionNameForTopic(topicKey);
12803
+ if (!name)
12804
+ return;
12805
+ const s = sessionManager.listRunningSessions().find((x) => x.tmuxSession === name);
12806
+ if (s)
12807
+ server.getEscalationGovernor()?.releaseLease(s.id);
12808
+ },
12809
+ },
12810
+ inFlightSwap: {
12811
+ // §7 in-flight row — delegate to the SAME ModelSwapService the
12812
+ // /sessions/:name/model-swap route uses (closed-enum + cost-guard +
12813
+ // idle disciplines apply identically).
12814
+ swap: async (sessionName, tier) => {
12815
+ const svc = server.getModelTierSwap();
12816
+ if (!svc)
12817
+ return { status: 'noop', reason: 'model-swap-unavailable' };
12818
+ // ModelSwapService.swap does its own exact-match session lookup.
12819
+ const r = await svc.swap(sessionName, tier);
12820
+ return { status: r.status, reason: r.reason };
12821
+ },
12822
+ },
12823
+ autonomousActive: (topicKey) => {
12824
+ try {
12825
+ return activeAutonomousJobs(config.stateDir).some((j) => String(j.topic) === String(topicKey));
12826
+ }
12827
+ catch { /* @silent-fallback-ok: an unreadable autonomous-jobs dir reports "not autonomous" — the §8 idle re-confirm then proceeds on the live tmux idle reading (FABLE capture-pane), which is the stricter gate anyway; a busy autonomous session still reads busy and is left alone (TOPIC-PROFILE-SPEC §8) */
12828
+ return false;
12829
+ }
12830
+ },
12831
+ isProtectedSession: (sessionName) => {
12832
+ // FAIL-CLOSED: a protected-set read fault treats the session AS
12833
+ // protected (true) — "protected is never profile-killed" is a hard
12834
+ // §8 invariant, so the safe direction on an unreadable set is to
12835
+ // refuse the kill, not risk killing a protected session.
12836
+ try {
12837
+ return sessionManager.getProtectedSessions().includes(sessionName);
12838
+ }
12839
+ catch { /* @silent-fallback-ok: fail-closed to protected — see comment above (TOPIC-PROFILE-SPEC §8) */
12840
+ return true;
12841
+ }
12842
+ },
12843
+ codexFence: (topicKey) => _codexSpawnFences.get(String(topicKey)) ?? null,
12844
+ verification: () => ({
12845
+ inFlightSwapConfirmedRecently: false,
12846
+ thinkingOffOnResumeVerified: false,
12847
+ thinkingLevelResumeVerified: false,
12848
+ crossModelResumeVerified: false,
12849
+ claudeThinkingControlAvailable: false,
12850
+ }),
12851
+ getConfig: () => {
12852
+ const cfg = config.topicProfiles;
12853
+ return {
12854
+ enabled: resolveDevAgentGate(cfg?.enabled, config),
12855
+ dryRun: cfg?.dryRun !== false,
12856
+ respawnDebounceMs: 4_000,
12857
+ frameworkSwitchDebounceMs: 8_000,
12858
+ maxConcurrentProfileRespawns: 2,
12859
+ spawnFailureBreakerThreshold: 3,
12860
+ switchNowConfirmTtlMs: cfg?.switchNowConfirmTtlMs ?? 300_000,
12861
+ };
12862
+ },
12863
+ disclose: (topicKey, text, meta) => {
12864
+ // §8 disclosure-of-record. The orchestrator stamps each notice with
12865
+ // an audit sequence ([#N]) so consecutive notices are never byte-
12866
+ // identical; direct adapter sends bypass the /telegram/reply relay's
12867
+ // exact-duplicate window anyway, so a delta-carrying notice can never
12868
+ // be swallowed. meta.allowDuplicate is forwarded as kind metadata for
12869
+ // any relayed (cross-machine) hop that DOES consult the window.
12870
+ const n = Number(topicKey);
12871
+ if (Number.isFinite(n) && telegram) {
12872
+ void telegram
12873
+ .sendToTopic(n, text, { kindMetadata: { allowDuplicate: meta.allowDuplicate } })
12874
+ .catch(() => { });
12875
+ }
12876
+ },
12877
+ audit: (event) => appendTopicProfileAudit(config.stateDir, event),
12878
+ stateFilePath: path.join(config.stateDir, 'state', 'topic-profile-orchestrator.json'),
12879
+ };
12880
+ _topicProfileOrchestrator = new TopicProfileOrchestrator(orchDeps);
12881
+ _topicProfileCtx.orchestrator = _topicProfileOrchestrator;
12882
+ // §8(2): gate ALL claude resume-map writers at the single chokepoint.
12883
+ _topicResumeMap?.setWriteGate((topicId) => _topicProfileOrchestrator.claudeResumeWriteGate(topicId));
12884
+ // §8(4): boot reconcile sweep (audits divergence; no kill in a gated regime).
12885
+ try {
12886
+ _topicProfileOrchestrator.bootReconcileSweep();
12887
+ }
12888
+ catch { /* @silent-fallback-ok: the boot sweep is observe-only divergence audit — a sweep fault never blocks boot; divergence resolves at the next natural spawn (TOPIC-PROFILE-SPEC §8) */ }
12889
+ // §8(4): periodic tick — retries busy-aborted (deferred) respawns and
12890
+ // re-checks the §14 dry-run flip lever. Piggybacks no per-topic poller;
12891
+ // a single slow interval beside the reaper/watchdog cadence (~30s).
12892
+ const tpOrchTickTimer = setInterval(() => {
12893
+ try {
12894
+ _topicProfileOrchestrator?.tick();
12895
+ }
12896
+ catch { /* @silent-fallback-ok: a tick fault is best-effort retry of deferred respawns — the next tick re-attempts; never throws from a timer (TOPIC-PROFILE-SPEC §8) */ }
12897
+ }, 30_000);
12898
+ if (typeof tpOrchTickTimer.unref === 'function')
12899
+ tpOrchTickTimer.unref();
12900
+ console.log(pc.dim(' [topic-profile] §8 orchestrator wired'));
12901
+ }
12902
+ catch (err) {
12903
+ // @silent-fallback-ok: orchestrator construction failure leaves the §8
12904
+ // machinery disabled — the write surface's keep-working fallback (legacy
12905
+ // respawn / apply-at-next-spawn) still serves every write; never a boot
12906
+ // failure (TOPIC-PROFILE-SPEC §8).
12907
+ console.warn(`[server] TopicProfileOrchestrator failed to initialize: ${err instanceof Error ? err.message : err}`);
12908
+ }
12909
+ }
11818
12910
  // Boot-recovery (tunnel-failure-resilience spec Part 6): if the agent
11819
12911
  // died mid-relay-episode, the persisted tunnel.json carries
11820
12912
  // rotationPending=true. Rotate the dashboard PIN + authToken BEFORE
@@ -12220,6 +13312,10 @@ export async function startServer(options) {
12220
13312
  clearInterval(warmReapTimer);
12221
13313
  warmReapTimer = null;
12222
13314
  } // Warm-Session A2A reap tick
13315
+ try {
13316
+ _topicProfileOrchestrator?.dispose();
13317
+ }
13318
+ catch { /* @silent-fallback-ok: orchestrator dispose clears timers/locks on shutdown — a dispose fault must not block the shutdown sequence (TOPIC-PROFILE-SPEC §8) */ } // §8 dispose
12223
13319
  spawnManager.dispose(); // §4.4: stop drain loop + clear DRR state
12224
13320
  summarySentinel.stop();
12225
13321
  memoryMonitor.stop();