instar 1.3.489 → 1.3.490

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 (106) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +979 -28
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +23 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CodexResumeMap.d.ts +95 -0
  8. package/dist/core/CodexResumeMap.d.ts.map +1 -0
  9. package/dist/core/CodexResumeMap.js +283 -0
  10. package/dist/core/CodexResumeMap.js.map +1 -0
  11. package/dist/core/MeshRpc.d.ts +3 -0
  12. package/dist/core/MeshRpc.d.ts.map +1 -1
  13. package/dist/core/MeshRpc.js +5 -0
  14. package/dist/core/MeshRpc.js.map +1 -1
  15. package/dist/core/ModelSwapService.d.ts +26 -11
  16. package/dist/core/ModelSwapService.d.ts.map +1 -1
  17. package/dist/core/ModelSwapService.js +59 -21
  18. package/dist/core/ModelSwapService.js.map +1 -1
  19. package/dist/core/PostUpdateMigrator.d.ts +16 -0
  20. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  21. package/dist/core/PostUpdateMigrator.js +62 -2
  22. package/dist/core/PostUpdateMigrator.js.map +1 -1
  23. package/dist/core/SessionManager.d.ts +4 -0
  24. package/dist/core/SessionManager.d.ts.map +1 -1
  25. package/dist/core/SessionManager.js +3 -0
  26. package/dist/core/SessionManager.js.map +1 -1
  27. package/dist/core/SessionRefresh.d.ts +36 -7
  28. package/dist/core/SessionRefresh.d.ts.map +1 -1
  29. package/dist/core/SessionRefresh.js +90 -29
  30. package/dist/core/SessionRefresh.js.map +1 -1
  31. package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
  32. package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
  33. package/dist/core/TopicProfileOrchestrator.js +1404 -0
  34. package/dist/core/TopicProfileOrchestrator.js.map +1 -0
  35. package/dist/core/TopicProfileResolver.d.ts +104 -0
  36. package/dist/core/TopicProfileResolver.d.ts.map +1 -0
  37. package/dist/core/TopicProfileResolver.js +231 -0
  38. package/dist/core/TopicProfileResolver.js.map +1 -0
  39. package/dist/core/TopicProfileStore.d.ts +308 -0
  40. package/dist/core/TopicProfileStore.d.ts.map +1 -0
  41. package/dist/core/TopicProfileStore.js +781 -0
  42. package/dist/core/TopicProfileStore.js.map +1 -0
  43. package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
  44. package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
  45. package/dist/core/TopicProfileTransferCarrier.js +533 -0
  46. package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
  47. package/dist/core/TopicResumeMap.d.ts +67 -1
  48. package/dist/core/TopicResumeMap.d.ts.map +1 -1
  49. package/dist/core/TopicResumeMap.js +114 -1
  50. package/dist/core/TopicResumeMap.js.map +1 -1
  51. package/dist/core/classifyProfileChange.d.ts +83 -0
  52. package/dist/core/classifyProfileChange.d.ts.map +1 -0
  53. package/dist/core/classifyProfileChange.js +274 -0
  54. package/dist/core/classifyProfileChange.js.map +1 -0
  55. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  56. package/dist/core/devGatedFeatures.js +6 -0
  57. package/dist/core/devGatedFeatures.js.map +1 -1
  58. package/dist/core/frameworkSessionLaunch.d.ts +26 -0
  59. package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
  60. package/dist/core/frameworkSessionLaunch.js +58 -0
  61. package/dist/core/frameworkSessionLaunch.js.map +1 -1
  62. package/dist/core/slackRefreshBinding.d.ts +83 -0
  63. package/dist/core/slackRefreshBinding.d.ts.map +1 -0
  64. package/dist/core/slackRefreshBinding.js +54 -0
  65. package/dist/core/slackRefreshBinding.js.map +1 -0
  66. package/dist/core/topicProfileIngress.d.ts +128 -0
  67. package/dist/core/topicProfileIngress.d.ts.map +1 -0
  68. package/dist/core/topicProfileIngress.js +249 -0
  69. package/dist/core/topicProfileIngress.js.map +1 -0
  70. package/dist/core/topicProfileValidation.d.ts +109 -0
  71. package/dist/core/topicProfileValidation.d.ts.map +1 -0
  72. package/dist/core/topicProfileValidation.js +247 -0
  73. package/dist/core/topicProfileValidation.js.map +1 -0
  74. package/dist/core/topicProfileWriteSurface.d.ts +222 -0
  75. package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
  76. package/dist/core/topicProfileWriteSurface.js +631 -0
  77. package/dist/core/topicProfileWriteSurface.js.map +1 -0
  78. package/dist/core/types.d.ts +37 -0
  79. package/dist/core/types.d.ts.map +1 -1
  80. package/dist/core/types.js.map +1 -1
  81. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  82. package/dist/lifeline/TelegramLifeline.js +6 -0
  83. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  84. package/dist/messaging/TelegramAdapter.d.ts +10 -1
  85. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  86. package/dist/messaging/TelegramAdapter.js +41 -1
  87. package/dist/messaging/TelegramAdapter.js.map +1 -1
  88. package/dist/scaffold/templates.d.ts.map +1 -1
  89. package/dist/scaffold/templates.js +9 -0
  90. package/dist/scaffold/templates.js.map +1 -1
  91. package/dist/server/AgentServer.d.ts +32 -0
  92. package/dist/server/AgentServer.d.ts.map +1 -1
  93. package/dist/server/AgentServer.js +52 -3
  94. package/dist/server/AgentServer.js.map +1 -1
  95. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  96. package/dist/server/CapabilityIndex.js +1 -0
  97. package/dist/server/CapabilityIndex.js.map +1 -1
  98. package/dist/server/routes.d.ts +17 -0
  99. package/dist/server/routes.d.ts.map +1 -1
  100. package/dist/server/routes.js +271 -1
  101. package/dist/server/routes.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/data/builtin-manifest.json +66 -66
  104. package/src/scaffold/templates.ts +9 -0
  105. package/upgrades/1.3.490.md +34 -0
  106. 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';
@@ -388,9 +395,58 @@ let _topicFrameworks = {};
388
395
  * Initialized in startServer(); consulted by resolveTopicFramework on every spawn. */
389
396
  let _topicFrameworksStore = null;
390
397
  let _topicLocalModelStore = null;
398
+ /** Topic Profile (§5.1): the sticky per-topic profile store. The framework
399
+ * arm of resolution reads THIS (the legacy topic-frameworks file is a
400
+ * one-directional seed + store-written mirror). Initialized in startServer(). */
401
+ let _topicProfileStore = null;
402
+ /** Topic Profile (§5.2): the single resolution point feeding spawn launch
403
+ * params. Initialized in startServer() alongside the store. */
404
+ let _topicProfileResolver = null;
405
+ /** Topic Profile (§5.2/§10): the ONE write engine behind every write surface
406
+ * (conversational / /topic / /route / HTTP / recovery writes). Initialized in
407
+ * startServer() alongside the store + resolver. */
408
+ let _topicProfileWriteSurface = null;
409
+ /** Topic Profile (§10.1/§10.4): the shared armed-confirm slot manager —
410
+ * propose-confirm / switch-now / re-apply-cooldown all share ONE slot per
411
+ * topic. */
412
+ let _topicProfileConfirmSlots = null;
413
+ /** §5.2(d) legacy respawn hook — bound in wireTelegramCallbacks (it needs the
414
+ * telegram adapter + session manager in scope). Today's exact /route
415
+ * behavior: drop the resume UUID, kill, CONTINUATION respawn. */
416
+ let _profileLegacyRespawn = null;
417
+ /** §8 disclosure hook — bound in wireTelegramCallbacks (platform adapter send). */
418
+ let _profileDisclose = null;
419
+ /** Topic Profile §8 (TOPIC-PROFILE-SPEC): the orchestration core — debounce
420
+ * slots, idle-gated kill/respawn, resume-writer gates, §10.4 breaker, §14
421
+ * dry-run regime. Constructed in startServer() beside the write surface. */
422
+ let _topicProfileOrchestrator = null;
423
+ /** Topic Profile §7: per-topic codex rollout-id capture-at-kill (the
424
+ * CodexResumeMap prerequisite sub-task). Constructed beside the orchestrator. */
425
+ let _codexResumeMap = null;
426
+ /** Topic Profile §7: the codex spawn fence recorded at launch (spawn
427
+ * timestamp + pane cwd) — capture-at-kill validates candidates against it. */
428
+ const _codexSpawnFences = new Map();
429
+ /** Topic Profile §5.3: the transfer-follow carrier (pull-at-acquire).
430
+ * Constructed in the mesh block; null on single-machine installs. */
431
+ let _topicProfileCarrier = null;
432
+ /** Topics whose CURRENT spawn attempt was initiated by the orchestrator's own
433
+ * respawn phase — the spawn chokepoint must not double-report the failure to
434
+ * the §10.4 breaker (the orchestrator records its own spawn outcome). */
435
+ const _orchestratorSpawnInFlight = new Set();
436
+ /** §5.3 "possibly stale" read disclosure — once per (topic, process). */
437
+ const _pendingPullStaleDisclosed = new Set();
391
438
  /** Default framework for sessions when no per-topic override is set. */
392
439
  let _defaultFramework = 'claude-code';
393
440
  function resolveTopicFramework(topicId) {
441
+ // Topic Profile §5.1 rewire: the profile store's framework field is the
442
+ // authoritative per-topic layer (it was seeded one-directionally from the
443
+ // legacy store at first load). The resolver adds the §5.2 launchability
444
+ // fallback. Legacy layers remain below for the not-yet-wired boot window.
445
+ if (topicId !== undefined && _topicProfileResolver) {
446
+ const fw = _topicProfileResolver.resolve(topicId).framework;
447
+ if (fw === 'claude-code' || fw === 'codex-cli')
448
+ return fw;
449
+ }
394
450
  if (topicId !== undefined && _topicFrameworksStore) {
395
451
  const stored = _topicFrameworksStore.get(topicId);
396
452
  if (stored === 'claude-code' || stored === 'codex-cli')
@@ -401,6 +457,39 @@ function resolveTopicFramework(topicId) {
401
457
  }
402
458
  return _defaultFramework;
403
459
  }
460
+ /**
461
+ * Topic Profile §10.3 — append one structured line to the profile audit
462
+ * trail (logs/topic-profile-changes.jsonl). Size-capped like sibling audit
463
+ * logs (simple head-truncation rotation at ~5MB). Never the triggering turn
464
+ * text or any message content — structured deltas + verified principals only.
465
+ */
466
+ let _topicProfileAuditSeq = 0;
467
+ function appendTopicProfileAudit(stateDir, event) {
468
+ // The audit sequence stamp is included in rendered disclosures (§8 — so the
469
+ // relay's exact-duplicate window can never silently swallow a repeat notice).
470
+ const seq = `${Date.now().toString(36)}.${++_topicProfileAuditSeq}`;
471
+ try {
472
+ const logsDir = path.join(stateDir, '..', 'logs');
473
+ fs.mkdirSync(logsDir, { recursive: true });
474
+ const auditPath = path.join(logsDir, 'topic-profile-changes.jsonl');
475
+ try {
476
+ const stat = fs.statSync(auditPath);
477
+ if (stat.size > 5 * 1024 * 1024) {
478
+ // Keep the newest half on overflow — same pragmatic cap as siblings.
479
+ const lines = fs.readFileSync(auditPath, 'utf-8').split('\n');
480
+ fs.writeFileSync(auditPath, lines.slice(Math.floor(lines.length / 2)).join('\n'));
481
+ }
482
+ }
483
+ 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) */ }
484
+ fs.appendFileSync(auditPath, `${JSON.stringify({ ts: new Date().toISOString(), seq, ...event })}\n`);
485
+ }
486
+ catch {
487
+ // @silent-fallback-ok: the topic-profile change audit is best-effort —
488
+ // resolution/writes must NEVER fail on an audit-sink error (a full disk or
489
+ // a transient fs fault can't break profile resolution) (TOPIC-PROFILE-SPEC §10.3).
490
+ }
491
+ return seq;
492
+ }
404
493
  let _projectDir = process.cwd();
405
494
  let _sharedIntelligence = null;
406
495
  let _selfKnowledgeTree = null;
@@ -586,10 +675,25 @@ accountSwap) {
586
675
  bootstrapMessage = `[telegram:${topicId}] ${msg}`;
587
676
  }
588
677
  }
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);
678
+ // Resolve the FULL topic profile EARLY (Topic Profile §5.2 the single
679
+ // resolution point: framework + model + thinking mode, with the read-time
680
+ // clamp + launchability fallback already applied). resolveTopicFramework
681
+ // delegates to the same resolver, so both reads agree by construction.
682
+ const resolvedProfile = _topicProfileResolver?.resolve(topicId) ?? null;
683
+ const framework = (resolvedProfile?.framework === 'claude-code' || resolvedProfile?.framework === 'codex-cli'
684
+ || resolvedProfile?.framework === 'gemini-cli' || resolvedProfile?.framework === 'pi-cli')
685
+ ? resolvedProfile.framework
686
+ : resolveTopicFramework(topicId);
687
+ // §5.2 fallback notices are once-per-transition deduped by the resolver —
688
+ // surface any that fired on this resolution to the topic.
689
+ if (resolvedProfile && resolvedProfile.notices.length > 0) {
690
+ for (const notice of resolvedProfile.notices) {
691
+ try {
692
+ await telegram.sendToTopic(topicId, notice);
693
+ }
694
+ catch { /* notice delivery is best-effort */ }
695
+ }
696
+ }
593
697
  // Large bootstrap messages (e.g. CONTINUATION context with full thread history)
594
698
  // can exceed tmux send-keys limits. Write to a temp file and inject a reference,
595
699
  // same pattern as injectTelegramMessage's FILE_THRESHOLD.
@@ -655,12 +759,23 @@ accountSwap) {
655
759
  const localEntry = _topicLocalModelStore?.get(topicId) ?? null;
656
760
  const codexLocalProvider = framework === 'codex-cli' ? localEntry?.provider : undefined;
657
761
  const codexLocalModelOverride = framework === 'codex-cli' && localEntry?.model ? localEntry.model : undefined;
762
+ // Topic Profile §5.2 precedence on the model arm: an active local-model
763
+ // binding wins; otherwise the resolved profile model (pin > topicProfiles
764
+ // config default > frameworkDefaultModels — already clamped) flows through
765
+ // as the launch default. Undefined = account default, today's behavior.
766
+ const profileModel = !codexLocalModelOverride && resolvedProfile?.model
767
+ && resolvedProfile.sources.model !== 'local-model-binding'
768
+ ? resolvedProfile.model
769
+ : undefined;
770
+ const profileThinkingMode = resolvedProfile?.thinkingMode;
658
771
  const newSessionName = await sessionManager.spawnInteractiveSession(bootstrapMessage, sessionName, {
659
772
  telegramTopicId: topicId,
660
773
  resumeSessionId,
661
774
  framework,
662
775
  ...(codexLocalProvider ? { codexLocalProvider } : {}),
663
776
  ...(codexLocalModelOverride ? { defaultModel: codexLocalModelOverride } : {}),
777
+ ...(profileModel ? { defaultModel: profileModel } : {}),
778
+ ...(profileThinkingMode ? { thinkingMode: profileThinkingMode } : {}),
664
779
  // Subscription & Auth Standard P1.3 (additive): account-swap — launch under
665
780
  // this account's config home + record its id. Unset = unchanged.
666
781
  ...(accountSwap?.configHome ? { configHome: accountSwap.configHome } : {}),
@@ -670,6 +785,16 @@ accountSwap) {
670
785
  if (resumeSessionId) {
671
786
  _topicResumeMap?.remove(topicId);
672
787
  }
788
+ // TOPIC-PROFILE-SPEC §10.4 — record the successful spawn so the §10.4 breaker
789
+ // resets + the codex same-cwd fence window opens. SKIPPED when the orchestrator
790
+ // initiated THIS respawn: it records its own spawn outcome (the spawn port),
791
+ // and double-recording would reset the breaker it is mid-evaluating.
792
+ if (_topicProfileOrchestrator && resolvedProfile && !_orchestratorSpawnInFlight.has(String(topicId))) {
793
+ try {
794
+ _topicProfileOrchestrator.recordSpawnSuccess(topicId, resolvedToApplied(resolvedProfile), { cwd: _projectDir });
795
+ }
796
+ 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) */ }
797
+ }
673
798
  // Proactive UUID save — schedule discovery after spawn.
674
799
  // ONLY uses authoritative claudeSessionId from hook events.
675
800
  // Never falls back to mtime-based JSONL scan — that can pick up a UUID from
@@ -982,35 +1107,74 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
982
1107
  await telegram.sendToTopic(replyTopicId, 'Login didn\'t complete successfully. Try again, or if this keeps happening, the authentication service may be down.');
983
1108
  }
984
1109
  };
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) => {
1110
+ // Topic Profile §5.2(d) the legacy respawn + disclosure hooks the write
1111
+ // surface late-binds (the surface is constructed before the adapter exists).
1112
+ // The respawn is BYTE-FOR-BYTE today's /route behavior: drop the stored
1113
+ // resume UUID (created under the previous framework's session-id scheme —
1114
+ // meaningless to the new one), then the immediate kill + CONTINUATION
1115
+ // respawn via the existing respawn path.
1116
+ _profileLegacyRespawn = async (topicKey) => {
1117
+ if (!/^\d+$/.test(topicKey))
1118
+ return { respawned: false };
1119
+ const topicId = Number(topicKey);
1120
+ _topicResumeMap?.remove(topicId);
1121
+ const existingSession = telegram.getSessionForTopic(topicId);
1122
+ if (!existingSession)
1123
+ return { respawned: false };
1124
+ try {
1125
+ await respawnSessionForTopic(sessionManager, telegram, existingSession, topicId, undefined, topicMemory, undefined, undefined, { silent: true });
1126
+ return { respawned: true };
1127
+ }
1128
+ catch (err) {
1129
+ return { respawned: false, error: err instanceof Error ? err.message : String(err) };
1130
+ }
1131
+ };
1132
+ _profileDisclose = async (topicKey, text) => {
1133
+ if (!/^\d+$/.test(topicKey))
1134
+ return;
1135
+ await telegram.sendToTopic(Number(topicKey), text);
1136
+ };
1137
+ // /route — get or set the framework for this topic. REWIRED into the Topic
1138
+ // Profile store (§5.1 — the legacy topic-frameworks file is now a
1139
+ // store-written mirror), with the §5.2(d) exemption honored INSIDE the
1140
+ // write surface: a framework write lands LIVE regardless of the enabled /
1141
+ // dryRun knobs and is served by the legacy immediate respawn wherever the
1142
+ // new orchestration is not fully live. §10.1: the authenticated sender uid
1143
+ // is forwarded and checked against the topic's bound operator.
1144
+ telegram.onRouteCommand = async (topicId, framework, userId) => {
989
1145
  if (framework === null) {
990
- // Status query — read current resolved framework
1146
+ // Status query — conversational register (§12, B2: never instruct the
1147
+ // operator to type a command; slash syntax is the power-user aside).
991
1148
  const current = resolveTopicFramework(topicId);
992
- return { ok: true, message: `This topic is using "${current}". Run /route claude-code or /route codex-cli to switch.` };
1149
+ 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
1150
  }
994
1151
  const valid = ['claude-code', 'codex-cli'];
995
1152
  if (!valid.includes(framework)) {
996
1153
  return { ok: false, message: `Unknown framework "${framework}". Supported: ${valid.join(', ')}.` };
997
1154
  }
998
- if (!_topicFrameworksStore) {
999
- return { ok: false, message: 'Routing store not initialized — server boot was incomplete. Restart the server.' };
1000
- }
1001
1155
  const prev = resolveTopicFramework(topicId);
1002
1156
  if (prev === framework) {
1003
1157
  return { ok: true, message: `This topic is already on "${framework}". Nothing to change.` };
1004
1158
  }
1159
+ if (_topicProfileWriteSurface && userId) {
1160
+ const result = await _topicProfileWriteSurface.applyWrite({
1161
+ topicKey: String(topicId),
1162
+ patch: { framework },
1163
+ principal: { kind: 'operator', platform: 'telegram', uid: String(userId) },
1164
+ origin: 'slash-route',
1165
+ // The command reply IS the disclosure-of-record for an exempted write
1166
+ // (§8 round-12/13) — it carries the audit stamp.
1167
+ discloseInReply: true,
1168
+ });
1169
+ return { ok: result.ok, message: result.reply };
1170
+ }
1171
+ // Fallback (surface unavailable / no authenticated uid forwarded by an
1172
+ // older caller): the pre-profile legacy write path, unchanged.
1173
+ if (!_topicFrameworksStore) {
1174
+ return { ok: false, message: 'Routing store not initialized — server boot was incomplete. Restart the server.' };
1175
+ }
1005
1176
  _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
1177
  _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
1178
  const existingSession = telegram.getSessionForTopic(topicId);
1015
1179
  if (existingSession) {
1016
1180
  try {
@@ -1022,6 +1186,71 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
1022
1186
  }
1023
1187
  return { ok: true, message: `Routed this topic to "${framework}". ${existingSession ? 'Session respawned.' : 'Will take effect when a session starts for this topic.'}` };
1024
1188
  };
1189
+ // /topic — the power-user surface for the full Topic Profile (§10.1; the
1190
+ // conversational surface is PRIMARY). Forwards the authenticated sender uid
1191
+ // down to the write — the store stamps updatedBy from it and refuses a
1192
+ // non-bound-operator.
1193
+ telegram.onTopicProfileCommand = async (topicId, argText, userId) => {
1194
+ const surface = _topicProfileWriteSurface;
1195
+ if (!surface) {
1196
+ return { ok: false, message: 'Topic profiles aren\'t initialized on this server.' };
1197
+ }
1198
+ const arg = argText.trim();
1199
+ if (arg === '' || arg.toLowerCase() === 'status') {
1200
+ return { ok: true, message: surface.renderReadout(String(topicId)) };
1201
+ }
1202
+ const principal = { kind: 'operator', platform: 'telegram', uid: String(userId) };
1203
+ const topicKey = String(topicId);
1204
+ const parts = arg.split(/\s+/);
1205
+ const head = parts[0].toLowerCase();
1206
+ if (head === 'clear') {
1207
+ const result = await surface.clear({ topicKey, principal, origin: 'slash-topic', discloseInReply: true });
1208
+ return { ok: result.ok, message: result.reply };
1209
+ }
1210
+ if (head === 'undo') {
1211
+ const result = await surface.undo({ topicKey, principal, origin: 'slash-topic', discloseInReply: true });
1212
+ return { ok: result.ok, message: result.reply };
1213
+ }
1214
+ if (head === 're-apply' || head === 'reapply') {
1215
+ const result = await surface.reapply({ topicKey, principal, origin: 'slash-topic', discloseInReply: true });
1216
+ if (result.needsConfirm && _topicProfileConfirmSlots) {
1217
+ const armed = _topicProfileConfirmSlots.arm(topicKey, 'reapply-cooldown', {}, result.reply, 'ingress');
1218
+ if (armed.ok) {
1219
+ return { ok: false, message: `${result.reply} (reply "yes" to apply it anyway)` };
1220
+ }
1221
+ }
1222
+ return { ok: result.ok, message: result.reply };
1223
+ }
1224
+ let patch = null;
1225
+ if (['claude-code', 'codex-cli', 'gemini-cli', 'pi-cli'].includes(head) && parts.length === 1) {
1226
+ patch = { framework: head };
1227
+ }
1228
+ else if (head === 'framework' && parts.length === 2) {
1229
+ patch = { framework: parts[1].toLowerCase() };
1230
+ }
1231
+ else if (head === 'model' && parts.length === 2) {
1232
+ patch = { model: parts[1], modelTier: null };
1233
+ }
1234
+ else if (head === 'tier' && parts.length === 2) {
1235
+ patch = { modelTier: parts[1].toLowerCase(), model: null };
1236
+ }
1237
+ else if (head === 'thinking' && parts.length === 2) {
1238
+ patch = { thinkingMode: parts[1].toLowerCase() };
1239
+ }
1240
+ else if (head === 'escalation' && parts.length === 2) {
1241
+ patch = { escalationOverride: parts[1].toLowerCase() };
1242
+ }
1243
+ if (!patch) {
1244
+ return {
1245
+ ok: false,
1246
+ 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.',
1247
+ };
1248
+ }
1249
+ const result = await surface.applyWrite({
1250
+ topicKey, patch, principal, origin: 'slash-topic', discloseInReply: true,
1251
+ });
1252
+ return { ok: result.ok, message: result.reply };
1253
+ };
1025
1254
  // /local-model — conversational counterpart of editing config.json.
1026
1255
  // Justin's rule: every config change must be reachable via Telegram.
1027
1256
  // Validates the requested provider is reachable before flipping the
@@ -1038,11 +1267,11 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
1038
1267
  return {
1039
1268
  ok: true,
1040
1269
  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].`,
1270
+ ? '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.'
1271
+ : `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
1272
  };
1044
1273
  }
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.` };
1274
+ 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
1275
  }
1047
1276
  // Disable
1048
1277
  if (provider === 'off' || provider === 'none' || provider === 'disable') {
@@ -1070,7 +1299,7 @@ function wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, ac
1070
1299
  // Topic must be on codex-cli — local-model goes through Codex --oss.
1071
1300
  const fw = resolveTopicFramework(topicId);
1072
1301
  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.` };
1302
+ 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
1303
  }
1075
1304
  // Pre-flight: provider reachability + model availability (best-effort).
1076
1305
  try {
@@ -1126,6 +1355,182 @@ function messageToPipeline(msg, topicName) {
1126
1355
  timestamp: msg.receivedAt,
1127
1356
  };
1128
1357
  }
1358
+ /**
1359
+ * Topic Profile §10.1 — the SERVER-SIDE conversational ingress. The parse
1360
+ * runs in the message-ingress pipeline where `telegramUserId` is first-party,
1361
+ * so the authenticated sender uid reaches the store through code, never
1362
+ * through a body the agent composed. Returns true when the message was a
1363
+ * profile trigger/confirm and was fully handled (do NOT route it to the
1364
+ * session); false otherwise (normal routing proceeds).
1365
+ *
1366
+ * Forwarded content never matches ANY ingress recognition (§10.1 round-5):
1367
+ * a message carrying platform forward metadata falls through as normal
1368
+ * conversation regardless of sender.
1369
+ */
1370
+ async function handleTopicProfileIngress(telegram, topicId, text, telegramUserId, msg) {
1371
+ const surface = _topicProfileWriteSurface;
1372
+ const slots = _topicProfileConfirmSlots;
1373
+ if (!surface || !telegramUserId)
1374
+ return false;
1375
+ // The trust floor: only an authorized sender's turn can ever be a trigger
1376
+ // (the bound-operator check inside the surface is the refusal tier).
1377
+ let authorized = false;
1378
+ try {
1379
+ authorized = telegram.isAuthorizedSender(telegramUserId);
1380
+ }
1381
+ 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) */ }
1382
+ if (!authorized)
1383
+ return false;
1384
+ const trigger = parseProfileTrigger(text);
1385
+ if (!trigger)
1386
+ return false;
1387
+ const forwarded = msg.metadata?.forwarded === true;
1388
+ if (forwarded)
1389
+ return false;
1390
+ const topicKey = String(topicId);
1391
+ const principal = { kind: 'operator', platform: 'telegram', uid: String(telegramUserId) };
1392
+ const send = async (reply) => {
1393
+ try {
1394
+ await telegram.sendToTopic(topicId, reply);
1395
+ }
1396
+ 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) */ }
1397
+ };
1398
+ switch (trigger.kind) {
1399
+ case 'write': {
1400
+ const result = await surface.applyWrite({
1401
+ topicKey, patch: trigger.patch, principal, origin: 'conversational', discloseInReply: true,
1402
+ });
1403
+ await send(result.reply);
1404
+ return true;
1405
+ }
1406
+ case 'readout': {
1407
+ await send(surface.renderReadout(topicKey));
1408
+ return true;
1409
+ }
1410
+ case 'undo': {
1411
+ const result = await surface.undo({ topicKey, principal, origin: 'conversational', discloseInReply: true });
1412
+ await send(result.reply);
1413
+ return true;
1414
+ }
1415
+ case 'clear': {
1416
+ const result = await surface.clear({ topicKey, principal, origin: 'conversational', discloseInReply: true });
1417
+ await send(result.reply);
1418
+ return true;
1419
+ }
1420
+ case 'reapply': {
1421
+ const result = await surface.reapply({ topicKey, principal, origin: 'conversational', discloseInReply: true });
1422
+ if (result.needsConfirm && slots) {
1423
+ // §10.4 cooldown confirm — rides the SAME shared armed slot as the
1424
+ // other confirm surfaces, with the server-rendered consequence echo.
1425
+ const armed = slots.arm(topicKey, 'reapply-cooldown', {}, result.reply, 'ingress');
1426
+ if (armed.ok) {
1427
+ try {
1428
+ const sent = await telegram.sendToTopic(topicId, `${result.reply} Reply "yes" to apply it anyway.`);
1429
+ slots.recordEchoMessageId(topicKey, sent.messageId);
1430
+ }
1431
+ catch { /* echo undelivered — the confirm refuses toward re-echo */ }
1432
+ return true;
1433
+ }
1434
+ await send('Too many back-to-back proposals here — give it a few minutes, then say it again fresh.');
1435
+ return true;
1436
+ }
1437
+ await send(result.reply);
1438
+ return true;
1439
+ }
1440
+ case 'switch-now': {
1441
+ const armedSlot = slots?.peek(topicKey) ?? null;
1442
+ if (!armedSlot) {
1443
+ // No WRITE-SURFACE confirm armed. The orchestrator runs a SEPARATE §8
1444
+ // confirm surface: on a busy framework switch it tells the operator
1445
+ // "say 'switch now' to interrupt" and arms its OWN switch-now slot
1446
+ // (orchestrator.armConfirm → executeSwitchNow). Bridge the operator's
1447
+ // reply to it so that disclosed instruction is not a dead end. This is
1448
+ // purely the empty-slot fallback — write-surface propose-confirm/reapply
1449
+ // slots keep precedence (handled below), so no existing confirm flow
1450
+ // changes behavior. (TOPIC-PROFILE-SPEC §8)
1451
+ if (_topicProfileOrchestrator) {
1452
+ const r = await _topicProfileOrchestrator.handleSwitchNow(topicKey);
1453
+ if (r.fired) {
1454
+ await send(r.reply);
1455
+ return true;
1456
+ }
1457
+ }
1458
+ // §8: a "switch now" with no armed pending switch (either surface) is a
1459
+ // no-op with a plain reply.
1460
+ await send('Nothing is pending a switch right now.');
1461
+ return true;
1462
+ }
1463
+ return handleProfileConfirm(telegram, surface, slots, topicId, principal, msg);
1464
+ }
1465
+ case 'confirm': {
1466
+ if (!slots || !slots.peek(topicKey))
1467
+ return false; // normal conversation
1468
+ return handleProfileConfirm(telegram, surface, slots, topicId, principal, msg);
1469
+ }
1470
+ }
1471
+ return false;
1472
+ }
1473
+ /** Fire the topic's armed confirm (shared slot — §10.1/§8/§10.4). */
1474
+ async function handleProfileConfirm(telegram, surface, slots, topicId, principal, msg) {
1475
+ const topicKey = String(topicId);
1476
+ const send = async (reply) => {
1477
+ try {
1478
+ await telegram.sendToTopic(topicId, reply);
1479
+ }
1480
+ 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) */ }
1481
+ };
1482
+ const match = slots.matchConfirm(topicKey, {
1483
+ platformMessageId: platformMessageIdFrom(msg.id),
1484
+ forwarded: msg.metadata?.forwarded === true,
1485
+ });
1486
+ if (!match.ok) {
1487
+ if (match.reason === 'none-armed' || match.reason === 'forwarded')
1488
+ return false;
1489
+ if (match.reason === 'expired') {
1490
+ await send('That proposal has expired — say what you want again.');
1491
+ return true;
1492
+ }
1493
+ // stale-order / no-echo-id: the confirm must answer the LATEST echo —
1494
+ // re-issue it and record the fresh ordering anchor (§10.1(c)).
1495
+ const slot = slots.peek(topicKey);
1496
+ if (slot) {
1497
+ try {
1498
+ const sent = await telegram.sendToTopic(topicId, `Please confirm this version:\n${slot.echoText}`);
1499
+ slots.recordEchoMessageId(topicKey, sent.messageId);
1500
+ }
1501
+ catch { /* best-effort */ }
1502
+ }
1503
+ return true;
1504
+ }
1505
+ const armed = match.armed;
1506
+ if (armed.kind === 'reapply-cooldown') {
1507
+ const result = await surface.reapply({
1508
+ topicKey, principal, origin: 'propose-confirm', confirmed: true, discloseInReply: true,
1509
+ });
1510
+ await send(result.reply);
1511
+ return true;
1512
+ }
1513
+ if (armed.kind === 'propose-confirm') {
1514
+ const result = await surface.applyWrite({
1515
+ topicKey,
1516
+ patch: armed.patch,
1517
+ principal,
1518
+ origin: 'propose-confirm',
1519
+ agentComposedPayload: armed.origin === 'agent-composed',
1520
+ discloseInReply: true,
1521
+ });
1522
+ await send(result.reply);
1523
+ return true;
1524
+ }
1525
+ // 'switch-now' — the orchestrator's armed-confirm slot (orchestrator.armConfirm
1526
+ // / handleSwitchNow) is a SEPARATE mechanism from this write-surface confirm
1527
+ // slot; bridging the two ingress confirm systems is the remaining stage-3
1528
+ // ingress hook (integrating session). Until then this branch replies honestly
1529
+ // (the §8 switch-now path is exercised through the orchestrator's own confirm
1530
+ // surface, not this write-surface ProfileConfirmSlots match).
1531
+ await send('That switch is no longer pending.');
1532
+ return true;
1533
+ }
1129
1534
  export function wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManager, fixCommandHandler,
1130
1535
  // Late-bound: the threadline hub deps are constructed AFTER this is wired, so
1131
1536
  // resolve them at message-time (CMT-529 deterministic "open this" intercept).
@@ -1187,6 +1592,20 @@ getAttentionTopicId) {
1187
1592
  if (handled)
1188
1593
  return;
1189
1594
  }
1595
+ // ── Topic Profile §10.1: server-side conversational ingress ──────────
1596
+ // The PRIMARY write surface: "use codex here", "set high thinking on this
1597
+ // topic", undo/clear/re-apply, and the shared confirm ("yes" / "switch
1598
+ // now"). Parsed HERE — where the authenticated sender uid is first-party —
1599
+ // never by the agent. Non-triggers fall through to normal routing.
1600
+ try {
1601
+ if (await handleTopicProfileIngress(telegram, topicId, text, telegramUserId, msg)) {
1602
+ return;
1603
+ }
1604
+ }
1605
+ catch (err) {
1606
+ // Fail toward normal routing — the ingress must never eat a message.
1607
+ console.error(`[telegram] topic-profile ingress error (routing normally): ${err instanceof Error ? err.message : err}`);
1608
+ }
1190
1609
  // /new — create a new topic thread. Does NOT spawn a session immediately.
1191
1610
  // Sessions are spawned on-demand when the user sends their first real message
1192
1611
  // in the new topic (via the auto-spawn path below). This avoids premature
@@ -3116,8 +3535,101 @@ export async function startServer(options) {
3116
3535
  });
3117
3536
  }
3118
3537
  catch (err) {
3538
+ // @silent-fallback-ok: TopicLocalModelStore init is a per-topic /local-model
3539
+ // override layer — a construction fault leaves it null and resolution falls
3540
+ // through to the config/global model defaults (the override is additive, never
3541
+ // the only model source) (TOPIC-PROFILE-SPEC §5.2).
3119
3542
  console.warn(`[server] TopicLocalModelStore failed to initialize: ${err}`);
3120
3543
  }
3544
+ // Topic Profile (§5.1/§5.2): the sticky per-topic profile store + the
3545
+ // single resolution point. The store seeds one-directionally from the
3546
+ // legacy topic-frameworks file and regenerates it as a mirror; the
3547
+ // resolver layers profile pin > config default > global default with
3548
+ // the read-time enum re-validation + launchability fallback. Reads
3549
+ // HONOR existing pins regardless of the topicProfiles enabled flag
3550
+ // (§5.2 disabled-flag semantics — the flag gates writes, not reads).
3551
+ try {
3552
+ const { TopicProfileStore } = await import('../core/TopicProfileStore.js');
3553
+ const { TopicProfileResolver } = await import('../core/TopicProfileResolver.js');
3554
+ const { normalizeTierEscalationConfig } = await import('../core/ModelTierEscalation.js');
3555
+ const topicProfilesCfg = config.topicProfiles;
3556
+ _topicProfileStore = new TopicProfileStore({
3557
+ stateFilePath: path.join(config.stateDir, 'state', 'topic-profiles.json'),
3558
+ legacyFrameworksPath: path.join(config.stateDir, 'state', 'topic-frameworks.json'),
3559
+ isDryRun: () => topicProfilesCfg?.dryRun !== false,
3560
+ });
3561
+ _topicProfileResolver = new TopicProfileResolver({
3562
+ store: _topicProfileStore,
3563
+ defaultFramework: () => _defaultFramework,
3564
+ configTopicFrameworks: () => _topicFrameworks,
3565
+ configProfileDefaults: () => topicProfilesCfg?.defaults ?? {},
3566
+ frameworkDefaultModels: () => config.sessions?.frameworkDefaultModels ?? {},
3567
+ tierEscalationConfig: () => normalizeTierEscalationConfig(config.models?.tierEscalation),
3568
+ localModelBinding: (topicKey) => _topicLocalModelStore?.get(Number(topicKey)) ?? null,
3569
+ // Mirrors SessionManager's spawn-path binary resolution exactly
3570
+ // (frameworkBinaryPaths[fw] ?? claudePath, pi-cli → bare 'pi') so the
3571
+ // launchability signal answers "would the REAL spawn find a binary",
3572
+ // never a stricter question that false-fallbacks a valid pin.
3573
+ frameworkBinaryPath: (fw) => config.sessions?.frameworkBinaryPaths?.[fw]
3574
+ ?? (fw === 'pi-cli' ? 'pi' : (config.sessions?.claudePath ?? null)),
3575
+ audit: (event) => {
3576
+ appendTopicProfileAudit(config.stateDir, event);
3577
+ },
3578
+ });
3579
+ // Topic Profile (§5.2/§10): the write surface + the shared confirm
3580
+ // slots. Regime knobs resolve LIVE on every write: `enabled` rides the
3581
+ // dev-agent dark gate (DEV_GATED_FEATURES — never a written literal);
3582
+ // dryRun ships true (§14 shadow canary). §5.2(d): framework-arm writes
3583
+ // bypass BOTH knobs inside the surface itself.
3584
+ const { TopicProfileWriteSurface } = await import('../core/topicProfileWriteSurface.js');
3585
+ const { ProfileConfirmSlots } = await import('../core/topicProfileIngress.js');
3586
+ _topicProfileConfirmSlots = new ProfileConfirmSlots({
3587
+ ttlMs: () => topicProfilesCfg?.switchNowConfirmTtlMs ?? 300_000,
3588
+ audit: (event) => { appendTopicProfileAudit(config.stateDir, event); },
3589
+ });
3590
+ _topicProfileWriteSurface = new TopicProfileWriteSurface({
3591
+ store: _topicProfileStore,
3592
+ resolver: _topicProfileResolver,
3593
+ regime: () => ({
3594
+ enabled: resolveDevAgentGate(topicProfilesCfg?.enabled, config),
3595
+ dryRun: topicProfilesCfg?.dryRun !== false,
3596
+ }),
3597
+ // Late-bound: the AgentServer's TopicOperatorStore is the SAME
3598
+ // instance the routes' auto-bind writes (a second instance on the
3599
+ // same file would lose updates between two in-memory caches).
3600
+ boundOperator: (topicKey) => {
3601
+ const op = _agentServerRef?.getTopicOperatorStore()?.getOperator(topicKey) ?? null;
3602
+ return op ? { platform: op.platform, uid: op.uid } : null;
3603
+ },
3604
+ localModelBinding: (topicKey) => /^\d+$/.test(topicKey) ? (_topicLocalModelStore?.get(Number(topicKey)) ?? null) : null,
3605
+ // Late-bound (wireTelegramCallbacks owns the adapter): §5.2(d)
3606
+ // legacy immediate respawn + the §8 disclosure send.
3607
+ legacyFrameworkRespawn: async (topicKey) => _profileLegacyRespawn
3608
+ ? _profileLegacyRespawn(topicKey)
3609
+ : { respawned: false },
3610
+ // §8 orchestration seam — late-bound: the TopicProfileOrchestrator is
3611
+ // constructed AFTER the AgentServer exists (it late-binds the
3612
+ // EscalationGovernor + ModelSwapService off the server). This thin
3613
+ // ProfileOrchestratorLike forwards the surface's post-write signal to
3614
+ // the orchestrator's debounced, idle-gated respawn once it is wired;
3615
+ // a no-op while null (the surface's keep-working fallback serves).
3616
+ orchestrator: {
3617
+ onProfileWrite: (topicKey, info) => _topicProfileOrchestrator?.onProfileWrite(topicKey, info),
3618
+ },
3619
+ // §5.3 transfer-carrier cancel marker — late-bound (the carrier is
3620
+ // built in the mesh block, after this surface). Cancels a pending
3621
+ // transfer-pull REPLACE for the topic the moment a local write lands.
3622
+ onLocalWriteDurable: (topicKey, origin) => _topicProfileCarrier?.onLocalWriteDurable(topicKey, origin),
3623
+ disclose: async (topicKey, text) => {
3624
+ if (_profileDisclose)
3625
+ await _profileDisclose(topicKey, text);
3626
+ },
3627
+ audit: (event) => appendTopicProfileAudit(config.stateDir, event),
3628
+ });
3629
+ }
3630
+ catch (err) {
3631
+ console.warn(`[server] TopicProfileStore/Resolver failed to initialize: ${err}`);
3632
+ }
3121
3633
  // June-15 subscription-path routing (spec 04 Rule 1). Built ONCE here;
3122
3634
  // reused by the main provider below AND the per-component
3123
3635
  // IntelligenceRouter's claude-code builds, so a component routed to
@@ -9700,14 +10212,50 @@ export async function startServer(options) {
9700
10212
  // adapter (v1 scope: Telegram-bound sessions only). The respawner closure
9701
10213
  // captures `topicMemory` by reference, so even if topicMemory is wired up
9702
10214
  // after this point it will be resolved at refresh-time.
9703
- if (telegram) {
10215
+ // §10.5: SessionRefresh is available for a Slack-only server too (the
10216
+ // Slack respawner sub-task). The construction is hoisted to (telegram ||
10217
+ // slack); the Telegram respawner refuses honestly when telegram is null,
10218
+ // and the Slack arm is served by the slackRespawner closure below.
10219
+ if (telegram || _slackAdapter) {
9704
10220
  const { SessionRefresh } = await import('../core/SessionRefresh.js');
9705
- const telegramRef = telegram; // narrow for closure
10221
+ const telegramRef = telegram ?? null; // may be null on a Slack-only server
10222
+ const slackRef = _slackAdapter; // narrow for closures
9706
10223
  _sessionRefresh = new SessionRefresh({
9707
10224
  sessionManager,
9708
10225
  state,
9709
10226
  telegram: telegramRef,
9710
10227
  topicResumeMap: _topicResumeMap,
10228
+ // §10.5 Slack binding — SlackAdapter satisfies SlackRefreshBinding
10229
+ // structurally (getChannelForSession / removeChannelResume /
10230
+ // resolveChannelForSessionFromDisk). Null ⇒ Telegram-only, unchanged.
10231
+ slack: slackRef,
10232
+ // §10.5 Slack respawner — mirrors the Slack message-handler spawn path
10233
+ // (getChannelResume → removeChannelResume → spawnInteractiveSession with
10234
+ // the parsed channel/thread → registerChannelSession). SessionRefresh
10235
+ // kills first; for a fresh respawn it already removed the resume entry.
10236
+ slackRespawner: slackRef
10237
+ ? async (sessionName, routingKey, followUpPrompt, accountSwap) => {
10238
+ const resumeInfo = slackRef.getChannelResume(routingKey);
10239
+ const resumeSessionId = resumeInfo?.uuid ?? undefined;
10240
+ if (resumeInfo)
10241
+ slackRef.removeChannelResume(routingKey);
10242
+ // routingKey = `<channelId>[:<thread_ts>]`.
10243
+ const sep = routingKey.indexOf(':');
10244
+ const slackChannelId = sep === -1 ? routingKey : routingKey.slice(0, sep);
10245
+ const slackThreadTs = sep === -1 ? undefined : routingKey.slice(sep + 1);
10246
+ const newSessionName = await sessionManager.spawnInteractiveSession(followUpPrompt ?? 'Session refreshed — continue where you left off.', undefined, {
10247
+ resumeSessionId,
10248
+ slackChannelId,
10249
+ slackThreadTs,
10250
+ ...(accountSwap?.configHome ? { configHome: accountSwap.configHome } : {}),
10251
+ ...(accountSwap?.accountId ? { subscriptionAccountId: accountSwap.accountId } : {}),
10252
+ });
10253
+ if (newSessionName) {
10254
+ slackRef.registerChannelSession(routingKey, newSessionName, slackThreadTs ? `${slackChannelId} (thread ${slackThreadTs})` : undefined);
10255
+ }
10256
+ return newSessionName || sessionName;
10257
+ }
10258
+ : null,
9711
10259
  respawner: async (sessionName, topicId, followUpPrompt, accountSwap) => {
9712
10260
  // killSession (called inside SessionRefresh) has already fired
9713
10261
  // beforeSessionKill (UUID persisted) and destroyed the tmux
@@ -9716,11 +10264,21 @@ export async function startServer(options) {
9716
10264
  // P1.3: accountSwap (when present) re-launches the resume under a
9717
10265
  // different account's config home — the --resume uuid is account-
9718
10266
  // agnostic, so the conversation is preserved across the swap.
10267
+ if (!telegramRef) {
10268
+ // Telegram-only respawn path on a Slack-only server — never reached
10269
+ // (a Slack-bound session routes through slackRespawner above), but
10270
+ // the contract requires a value.
10271
+ return sessionName;
10272
+ }
9719
10273
  await respawnSessionForTopic(sessionManager, telegramRef, sessionName, topicId, followUpPrompt, topicMemory, undefined, undefined, accountSwap ? { configHome: accountSwap.configHome, accountId: accountSwap.accountId } : undefined);
9720
10274
  return telegramRef.getSessionForTopic(topicId) ?? sessionName;
9721
10275
  },
9722
10276
  });
9723
- // ── QuotaAwareScheduler (Subscription & Auth Standard P1.3) ──
10277
+ }
10278
+ // ── QuotaAwareScheduler (Subscription & Auth Standard P1.3) ──
10279
+ // Telegram-specific (createAttentionItem + subscription-pool wiring).
10280
+ if (telegram) {
10281
+ const telegramRef = telegram; // narrow for closure
9724
10282
  // Selects the optimal account + enforces the continuity guarantee: on
9725
10283
  // quota pressure it resumes the session under another account (via the
9726
10284
  // SessionRefresh account-swap path), never letting it die. Auto-trigger
@@ -10579,6 +11137,12 @@ export async function startServer(options) {
10579
11137
  const wsTopic = Number(cmd.session);
10580
11138
  if (Number.isFinite(wsTopic))
10581
11139
  workingSetPullCoordinator?.onTopicAccepted(wsTopic);
11140
+ // TOPIC-PROFILE-SPEC §5.3 acquire seam (1/3): this machine just
11141
+ // accepted ownership of the topic. Fire-and-forget pull of the
11142
+ // pin from the previous owner (resolved from the journal when not
11143
+ // named here). Never blocks message delivery.
11144
+ if (Number.isFinite(wsTopic))
11145
+ _topicProfileCarrier?.onTopicAcquired(wsTopic);
10582
11146
  }
10583
11147
  if (_sessionPoolStage() === 'dark' || !telegram)
10584
11148
  return;
@@ -10778,6 +11342,17 @@ export async function startServer(options) {
10778
11342
  return { ok: false, reason: 'working-set disabled' };
10779
11343
  return workingSetPullServer.handle(cmd);
10780
11344
  },
11345
+ // TOPIC-PROFILE-SPEC §5.3 — the pull-at-acquire serve verb. The
11346
+ // previous owner answers a follower's pull with the current +
11347
+ // §14-shadow profile entries for the named topics (present:false
11348
+ // for absent, 500-topic cap). Stateless read over the in-memory
11349
+ // store; answers 'disabled' until the store is constructed. Joins
11350
+ // the read/observe RBAC class beside working-set-pull (MeshRpc.ts).
11351
+ 'topic-profile-pull': (cmd) => {
11352
+ if (!_topicProfileStore)
11353
+ return { ok: false, reason: 'topic-profile disabled' };
11354
+ return createTopicProfilePullHandler({ store: _topicProfileStore })(cmd);
11355
+ },
10781
11356
  // COMMITMENTS-COHERENCE-SPEC §3.4 — owner-side apply for the
10782
11357
  // owner-routed mutation. opKey window first (replay returns the
10783
11358
  // recorded verdict, applies nothing); the UNCHANGED state machine
@@ -10974,6 +11549,93 @@ export async function startServer(options) {
10974
11549
  const selfNickTimer = setInterval(() => { void convergeSelfNickname(); }, 60_000);
10975
11550
  if (typeof selfNickTimer.unref === 'function')
10976
11551
  selfNickTimer.unref();
11552
+ // ── Topic-profile transfer carrier (TOPIC-PROFILE-SPEC §5.3) ──
11553
+ // Pull-at-acquire follow: when THIS machine acquires a topic, pull
11554
+ // its per-topic profile from the previous owner so the pin follows
11555
+ // the conversation across machines. Constructed at the mesh level
11556
+ // (after meshClient + peerUrl exist) — NOT gated by the working-set
11557
+ // replication gate; it has its own durable retry ledger. Null on a
11558
+ // single-machine install (no acquires from a peer ever fire).
11559
+ if (_topicProfileStore) {
11560
+ try {
11561
+ const tpcReaderMod = await import('../core/CoherenceJournalReader.js');
11562
+ const tpcReader = new tpcReaderMod.CoherenceJournalReader({ stateDir: config.stateDir });
11563
+ _topicProfileCarrier = new TopicProfileTransferCarrier({
11564
+ stateDir: config.stateDir,
11565
+ selfMachineId: meshSelfId,
11566
+ store: _topicProfileStore,
11567
+ effectiveFramework: () => _defaultFramework,
11568
+ ownerOf: (topicKey) => ({ owner: ownReg.ownerOf(topicKey) }),
11569
+ // Previous-owner evidence from the journal's topic-placement
11570
+ // history (the most-recent entry naming a prevOwner). Used only
11571
+ // when an acquire seam could not name the previous owner.
11572
+ prevOwnerOf: (topicKey) => {
11573
+ const topicNum = Number(topicKey);
11574
+ if (!Number.isFinite(topicNum))
11575
+ return null;
11576
+ try {
11577
+ const entries = tpcReader.query({ kind: 'topic-placement', topic: topicNum, limit: 20 }).entries;
11578
+ for (const e of entries) {
11579
+ const data = e.data;
11580
+ if (typeof data.prevOwner === 'string' && data.prevOwner !== meshSelfId)
11581
+ return data.prevOwner;
11582
+ }
11583
+ }
11584
+ catch { /* @silent-fallback-ok: missing placement evidence means no previous owner to pull from — the local entry stays authoritative (TOPIC-PROFILE-SPEC §5.3) */ }
11585
+ return null;
11586
+ },
11587
+ sendPull: async (peerMachineId, topics) => {
11588
+ const url = peerUrl(peerMachineId);
11589
+ if (!url)
11590
+ return { kind: 'unreachable', detail: 'no peer url' };
11591
+ const r = await meshClient.send({ machineId: peerMachineId, url }, { type: 'topic-profile-pull', topics }, 0, { timeoutMs: 15_000 });
11592
+ if (r.ok) {
11593
+ const res = r.result;
11594
+ if (res && res.ok)
11595
+ return { kind: 'ok', entries: res.entries };
11596
+ return { kind: 'unreachable', detail: res?.reason ?? 'serve-error' };
11597
+ }
11598
+ // A peer whose instar predates the verb answers no-handler (501) — PARK.
11599
+ if (r.status === 501 || r.reason === 'no-handler')
11600
+ return { kind: 'protocol-unsupported' };
11601
+ return { kind: 'unreachable', detail: r.reason ?? `status ${r.status}` };
11602
+ },
11603
+ // Rolling-update skew: the pool advertises each machine's verb
11604
+ // capabilities. Undefined ⇒ unknown ⇒ attempt (no-handler parks).
11605
+ peerSupportsPull: (peerMachineId) => {
11606
+ const caps = machinePoolRegistry?.getCapacity(peerMachineId)?.capabilities;
11607
+ if (!caps)
11608
+ return undefined;
11609
+ return caps.includes('topic-profile-pull');
11610
+ },
11611
+ audit: (event) => appendTopicProfileAudit(config.stateDir, event),
11612
+ // §5.3 round-5: ONE aggregated reconciliation notice per (peer,
11613
+ // landing). Routed to the system (lifeline) topic — it spans
11614
+ // topics, so it is not a single conversation's disclosure.
11615
+ notify: (text) => {
11616
+ const sysTopic = telegram?.getLifelineTopicId();
11617
+ if (sysTopic)
11618
+ void telegram.sendToTopic(sysTopic, text).catch(() => { });
11619
+ },
11620
+ logger: (m) => console.log(pc.dim(` [topic-profile-pull] ${m}`)),
11621
+ });
11622
+ // §5.3(e) drain: the slow retry tick (10 min) — retries pending
11623
+ // pulls whose backoff is due and drops expired (7d) records.
11624
+ const tpcTickTimer = setInterval(() => {
11625
+ void _topicProfileCarrier?.tick().catch(() => { });
11626
+ }, 600_000);
11627
+ if (typeof tpcTickTimer.unref === 'function')
11628
+ tpcTickTimer.unref();
11629
+ console.log(pc.dim(' [topic-profile-pull] transfer carrier wired'));
11630
+ }
11631
+ catch (err) {
11632
+ // @silent-fallback-ok: carrier construction failure leaves the
11633
+ // pull-at-acquire follow disabled — pins simply do not follow a
11634
+ // cross-machine move (the local entry stays authoritative); never
11635
+ // a boot failure (TOPIC-PROFILE-SPEC §5.3).
11636
+ console.warn(`[server] TopicProfileTransferCarrier failed to initialize: ${err instanceof Error ? err.message : err}`);
11637
+ }
11638
+ }
10977
11639
  // ── Working-set pull coordinator (WORKING-SET-HANDOFF §3.3/§3.4) ──
10978
11640
  // Constructed here (after meshClient + peerUrl exist) ONLY when the
10979
11641
  // serve side was constructed above — i.e. the same explicit
@@ -11403,6 +12065,13 @@ export async function startServer(options) {
11403
12065
  const prevOwner = ownReg.read(sk)?.ownerMachineId;
11404
12066
  const r = ownReg.cas({ type: 'place', machineId }, { sessionKey: sk, sender: meshSelfId, nonce: `${meshSelfId}:c:${++routerNonce}` });
11405
12067
  emitPlacement(sk, r, 'placed', prevOwner);
12068
+ // TOPIC-PROFILE-SPEC §5.3 acquire seam (2/3): when the claim places
12069
+ // THIS machine as owner (and there was a real previous owner), pull
12070
+ // the topic's pin from that owner. Self-placement / no-prior-owner
12071
+ // is a no-op inside the carrier.
12072
+ if (r.ok && machineId === meshSelfId && prevOwner && prevOwner !== meshSelfId) {
12073
+ _topicProfileCarrier?.onTopicAcquired(sk, prevOwner);
12074
+ }
11406
12075
  return { ok: r.ok, epoch: ownReg.read(sk)?.ownershipEpoch ?? 0 };
11407
12076
  },
11408
12077
  // bug #11: confirm the remote owner (placing → active) after the spawn is
@@ -11501,6 +12170,9 @@ export async function startServer(options) {
11501
12170
  const onPeerBack = (machineId) => {
11502
12171
  workingSetPullCoordinator?.onPeerRecorded(machineId);
11503
12172
  void _commitmentReFire?.(machineId).catch(() => { });
12173
+ // §5.3(e): a returning peer drains any pending topic-profile pulls
12174
+ // that were parked for-protocol or backed-off against it.
12175
+ void _topicProfileCarrier?.onPeerOnline(machineId).catch(() => { });
11504
12176
  };
11505
12177
  const peerPresencePuller = new presenceMod.PeerPresencePuller({
11506
12178
  selfMachineId: meshSelfId,
@@ -11810,11 +12482,286 @@ export async function startServer(options) {
11810
12482
  catch (err) {
11811
12483
  console.log(pc.dim(` [session-pool] rollout gate not wired: ${err instanceof Error ? err.message : String(err)}`));
11812
12484
  }
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 });
12485
+ // Topic Profile (§8/§5.3): the routes ctx object is shared BY REFERENCE
12486
+ // into AgentServer → routes, so the orchestrator + carrier (which late-bind
12487
+ // off the just-constructed AgentServer's governors / the mesh block) are
12488
+ // attached to THIS object after construction and reach the routes live.
12489
+ const _topicProfileCtx = (_topicProfileStore && _topicProfileResolver && _topicProfileWriteSurface && _topicProfileConfirmSlots)
12490
+ ? {
12491
+ store: _topicProfileStore,
12492
+ resolver: _topicProfileResolver,
12493
+ surface: _topicProfileWriteSurface,
12494
+ confirmSlots: _topicProfileConfirmSlots,
12495
+ orchestrator: null,
12496
+ carrier: _topicProfileCarrier,
12497
+ }
12498
+ : null;
12499
+ 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, 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
12500
  // Resolve the late-bound topic-operator getter (increment 2e): routing was
11815
12501
  // wired before the server existed; from here on inbound binds use the
11816
12502
  // server's own store instance.
11817
12503
  _agentServerRef = server;
12504
+ // ── Topic Profile §8 orchestrator (TOPIC-PROFILE-SPEC) ──
12505
+ // Constructed HERE (after the AgentServer) because two of its ports late-bind
12506
+ // off the server: the §9 EscalationGovernor (server.getEscalationGovernor())
12507
+ // and the §7 in-flight ModelSwapService (server.getModelTierSwap()). The
12508
+ // orchestrator owns the debounced, idle-gated kill/respawn, the resume-writer
12509
+ // gates, the §10.4 breaker, and the §14 dry-run regime. Ships dark behind the
12510
+ // dev-agent gate (the `enabled` knob is resolved LIVE per write — never a
12511
+ // literal). Attached onto the shared _topicProfileCtx so the routes see it.
12512
+ if (_topicProfileCtx && _topicProfileStore && _topicProfileResolver) {
12513
+ try {
12514
+ const tpStore = _topicProfileStore;
12515
+ const tpResolver = _topicProfileResolver;
12516
+ const tpProjectDir = config.projectDir;
12517
+ // §7 codex resume map — the per-topic rollout-id capture-at-kill store.
12518
+ if (!_codexResumeMap) {
12519
+ _codexResumeMap = new CodexResumeMap(path.join(config.stateDir, 'state'));
12520
+ }
12521
+ const tpCodexResume = _codexResumeMap;
12522
+ // The §9 marker reader needs the escalated-model-id set, derived from the
12523
+ // live tier-escalation config (synchronously normalized at read time).
12524
+ const { normalizeTierEscalationConfig: normTierEsc } = await import('../core/ModelTierEscalation.js');
12525
+ const escIds = () => {
12526
+ try {
12527
+ return escalatedModelIds(normTierEsc(config.models?.tierEscalation));
12528
+ }
12529
+ 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) */
12530
+ return new Set();
12531
+ }
12532
+ };
12533
+ // topic → live tmux session name (or null).
12534
+ const sessionNameForTopic = (topicKey) => {
12535
+ const n = Number(topicKey);
12536
+ if (!Number.isFinite(n))
12537
+ return null;
12538
+ return telegram?.getSessionForTopic(n) ?? null;
12539
+ };
12540
+ const orchDeps = {
12541
+ store: tpStore,
12542
+ resolveProfile: (topicKey) => tpResolver.resolve(topicKey),
12543
+ sessions: {
12544
+ getSessionForTopic: (topicKey) => {
12545
+ const name = sessionNameForTopic(topicKey);
12546
+ if (!name)
12547
+ return null;
12548
+ return { sessionName: name, cwd: tpProjectDir };
12549
+ },
12550
+ listTopicSessions: () => {
12551
+ // Every running session that is bound to a numeric (telegram) topic.
12552
+ const out = [];
12553
+ for (const s of sessionManager.listRunningSessions()) {
12554
+ const topic = telegram?.getTopicForSession?.(s.tmuxSession);
12555
+ if (topic != null && Number.isFinite(Number(topic))) {
12556
+ out.push({ topicKey: String(topic), sessionName: s.tmuxSession });
12557
+ }
12558
+ }
12559
+ return out;
12560
+ },
12561
+ readIdle: (sessionName) => {
12562
+ // FABLE three-valued idle read off the live pane (the §8 kill-time
12563
+ // re-confirm). null tail ⇒ unconfirmed; idle+empty-input ⇒
12564
+ // confirmed-idle; any other live content ⇒ busy (fail toward busy).
12565
+ const tail = sessionManager.captureMeaningfulTail(sessionName, 8);
12566
+ if (tail === null)
12567
+ return 'unconfirmed';
12568
+ return paneIdleWithEmptyInput(tail) ? 'confirmed-idle' : 'busy';
12569
+ },
12570
+ killForResume: async (sessionName) => sessionManager.killSession(sessionName),
12571
+ killFresh: async (sessionName) => {
12572
+ // Fresh respawn: clear the resume entry first so the next spawn does
12573
+ // NOT --resume the (possibly cross-framework) transcript, then kill.
12574
+ const topic = telegram?.getTopicForSession?.(sessionName);
12575
+ if (topic != null && Number.isFinite(Number(topic)))
12576
+ _topicResumeMap?.remove(Number(topic));
12577
+ return sessionManager.killSession(sessionName);
12578
+ },
12579
+ spawn: async (topicKey, _resolved, _directive) => {
12580
+ // The orchestrator already killed; respawn re-resolves the (now
12581
+ // updated) pin via spawnSessionForTopic and picks up the resume id
12582
+ // from the resume map. We mark the spawn in-flight so the spawn-path
12583
+ // chokepoint does not double-report a failure to the §10.4 breaker.
12584
+ const n = Number(topicKey);
12585
+ if (!Number.isFinite(n) || !telegram)
12586
+ return { ok: false, failureClass: 'unknown' };
12587
+ const topicName = telegram.getTopicName(n) || `topic-${n}`;
12588
+ _orchestratorSpawnInFlight.add(topicKey);
12589
+ try {
12590
+ await spawnSessionForTopic(sessionManager, telegram, topicName, n, undefined, topicMemory);
12591
+ return { ok: true };
12592
+ }
12593
+ catch (err) {
12594
+ const msg = err instanceof Error ? err.message : String(err);
12595
+ let cls = 'unknown';
12596
+ if (/not found|ENOENT|command not found/i.test(msg))
12597
+ cls = 'cli-not-found';
12598
+ else if (/quota|rate.?limit|usage limit/i.test(msg))
12599
+ cls = 'quota';
12600
+ else if (/tmux/i.test(msg))
12601
+ cls = 'tmux';
12602
+ else if (/model|account|rejected/i.test(msg))
12603
+ cls = 'model-rejected-by-account';
12604
+ return { ok: false, failureClass: cls };
12605
+ }
12606
+ finally {
12607
+ _orchestratorSpawnInFlight.delete(topicKey);
12608
+ }
12609
+ },
12610
+ },
12611
+ claudeResume: {
12612
+ // hook-provenance resume = none-loss readiness (§8 pre-kill predicate).
12613
+ ready: (topicKey) => {
12614
+ const n = Number(topicKey);
12615
+ return Number.isFinite(n) ? _topicResumeMap?.getProvenance(n) === 'hook' : false;
12616
+ },
12617
+ resumeId: (topicKey) => {
12618
+ const n = Number(topicKey);
12619
+ return Number.isFinite(n) ? (_topicResumeMap?.get(n) ?? null) : null;
12620
+ },
12621
+ park: (topicKey, reason) => {
12622
+ const n = Number(topicKey);
12623
+ if (Number.isFinite(n))
12624
+ _topicResumeMap?.park(n, reason);
12625
+ },
12626
+ unpark: (topicKey) => {
12627
+ const n = Number(topicKey);
12628
+ return Number.isFinite(n) ? (_topicResumeMap?.unpark(n) ?? false) : false;
12629
+ },
12630
+ },
12631
+ codexResume: tpCodexResume,
12632
+ escalation: {
12633
+ // §9 FABLE marker = the live escalated model id on the topic's session.
12634
+ activeMarker: (topicKey) => {
12635
+ const name = sessionNameForTopic(topicKey);
12636
+ if (!name)
12637
+ return null;
12638
+ const s = sessionManager.listRunningSessions().find((x) => x.tmuxSession === name);
12639
+ const model = s?.model;
12640
+ if (model && escIds().has(String(model)))
12641
+ return { model: String(model) };
12642
+ return null;
12643
+ },
12644
+ listMarkerTopics: () => {
12645
+ const ids = escIds();
12646
+ const out = [];
12647
+ for (const s of sessionManager.listRunningSessions()) {
12648
+ if (s.model && ids.has(String(s.model))) {
12649
+ const topic = telegram?.getTopicForSession?.(s.tmuxSession);
12650
+ if (topic != null)
12651
+ out.push(String(topic));
12652
+ }
12653
+ }
12654
+ return out;
12655
+ },
12656
+ clearMarkerAndReleaseLease: (topicKey) => {
12657
+ const name = sessionNameForTopic(topicKey);
12658
+ if (!name)
12659
+ return;
12660
+ const s = sessionManager.listRunningSessions().find((x) => x.tmuxSession === name);
12661
+ if (s)
12662
+ server.getEscalationGovernor()?.releaseLease(s.id);
12663
+ },
12664
+ },
12665
+ inFlightSwap: {
12666
+ // §7 in-flight row — delegate to the SAME ModelSwapService the
12667
+ // /sessions/:name/model-swap route uses (closed-enum + cost-guard +
12668
+ // idle disciplines apply identically).
12669
+ swap: async (sessionName, tier) => {
12670
+ const svc = server.getModelTierSwap();
12671
+ if (!svc)
12672
+ return { status: 'noop', reason: 'model-swap-unavailable' };
12673
+ // ModelSwapService.swap does its own exact-match session lookup.
12674
+ const r = await svc.swap(sessionName, tier);
12675
+ return { status: r.status, reason: r.reason };
12676
+ },
12677
+ },
12678
+ autonomousActive: (topicKey) => {
12679
+ try {
12680
+ return activeAutonomousJobs(config.stateDir).some((j) => String(j.topic) === String(topicKey));
12681
+ }
12682
+ 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) */
12683
+ return false;
12684
+ }
12685
+ },
12686
+ isProtectedSession: (sessionName) => {
12687
+ // FAIL-CLOSED: a protected-set read fault treats the session AS
12688
+ // protected (true) — "protected is never profile-killed" is a hard
12689
+ // §8 invariant, so the safe direction on an unreadable set is to
12690
+ // refuse the kill, not risk killing a protected session.
12691
+ try {
12692
+ return sessionManager.getProtectedSessions().includes(sessionName);
12693
+ }
12694
+ catch { /* @silent-fallback-ok: fail-closed to protected — see comment above (TOPIC-PROFILE-SPEC §8) */
12695
+ return true;
12696
+ }
12697
+ },
12698
+ codexFence: (topicKey) => _codexSpawnFences.get(String(topicKey)) ?? null,
12699
+ verification: () => ({
12700
+ inFlightSwapConfirmedRecently: false,
12701
+ thinkingOffOnResumeVerified: false,
12702
+ thinkingLevelResumeVerified: false,
12703
+ crossModelResumeVerified: false,
12704
+ claudeThinkingControlAvailable: false,
12705
+ }),
12706
+ getConfig: () => {
12707
+ const cfg = config.topicProfiles;
12708
+ return {
12709
+ enabled: resolveDevAgentGate(cfg?.enabled, config),
12710
+ dryRun: cfg?.dryRun !== false,
12711
+ respawnDebounceMs: 4_000,
12712
+ frameworkSwitchDebounceMs: 8_000,
12713
+ maxConcurrentProfileRespawns: 2,
12714
+ spawnFailureBreakerThreshold: 3,
12715
+ switchNowConfirmTtlMs: cfg?.switchNowConfirmTtlMs ?? 300_000,
12716
+ };
12717
+ },
12718
+ disclose: (topicKey, text, meta) => {
12719
+ // §8 disclosure-of-record. The orchestrator stamps each notice with
12720
+ // an audit sequence ([#N]) so consecutive notices are never byte-
12721
+ // identical; direct adapter sends bypass the /telegram/reply relay's
12722
+ // exact-duplicate window anyway, so a delta-carrying notice can never
12723
+ // be swallowed. meta.allowDuplicate is forwarded as kind metadata for
12724
+ // any relayed (cross-machine) hop that DOES consult the window.
12725
+ const n = Number(topicKey);
12726
+ if (Number.isFinite(n) && telegram) {
12727
+ void telegram
12728
+ .sendToTopic(n, text, { kindMetadata: { allowDuplicate: meta.allowDuplicate } })
12729
+ .catch(() => { });
12730
+ }
12731
+ },
12732
+ audit: (event) => appendTopicProfileAudit(config.stateDir, event),
12733
+ stateFilePath: path.join(config.stateDir, 'state', 'topic-profile-orchestrator.json'),
12734
+ };
12735
+ _topicProfileOrchestrator = new TopicProfileOrchestrator(orchDeps);
12736
+ _topicProfileCtx.orchestrator = _topicProfileOrchestrator;
12737
+ // §8(2): gate ALL claude resume-map writers at the single chokepoint.
12738
+ _topicResumeMap?.setWriteGate((topicId) => _topicProfileOrchestrator.claudeResumeWriteGate(topicId));
12739
+ // §8(4): boot reconcile sweep (audits divergence; no kill in a gated regime).
12740
+ try {
12741
+ _topicProfileOrchestrator.bootReconcileSweep();
12742
+ }
12743
+ 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) */ }
12744
+ // §8(4): periodic tick — retries busy-aborted (deferred) respawns and
12745
+ // re-checks the §14 dry-run flip lever. Piggybacks no per-topic poller;
12746
+ // a single slow interval beside the reaper/watchdog cadence (~30s).
12747
+ const tpOrchTickTimer = setInterval(() => {
12748
+ try {
12749
+ _topicProfileOrchestrator?.tick();
12750
+ }
12751
+ 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) */ }
12752
+ }, 30_000);
12753
+ if (typeof tpOrchTickTimer.unref === 'function')
12754
+ tpOrchTickTimer.unref();
12755
+ console.log(pc.dim(' [topic-profile] §8 orchestrator wired'));
12756
+ }
12757
+ catch (err) {
12758
+ // @silent-fallback-ok: orchestrator construction failure leaves the §8
12759
+ // machinery disabled — the write surface's keep-working fallback (legacy
12760
+ // respawn / apply-at-next-spawn) still serves every write; never a boot
12761
+ // failure (TOPIC-PROFILE-SPEC §8).
12762
+ console.warn(`[server] TopicProfileOrchestrator failed to initialize: ${err instanceof Error ? err.message : err}`);
12763
+ }
12764
+ }
11818
12765
  // Boot-recovery (tunnel-failure-resilience spec Part 6): if the agent
11819
12766
  // died mid-relay-episode, the persisted tunnel.json carries
11820
12767
  // rotationPending=true. Rotate the dashboard PIN + authToken BEFORE
@@ -12220,6 +13167,10 @@ export async function startServer(options) {
12220
13167
  clearInterval(warmReapTimer);
12221
13168
  warmReapTimer = null;
12222
13169
  } // Warm-Session A2A reap tick
13170
+ try {
13171
+ _topicProfileOrchestrator?.dispose();
13172
+ }
13173
+ 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
13174
  spawnManager.dispose(); // §4.4: stop drain loop + clear DRR state
12224
13175
  summarySentinel.stop();
12225
13176
  memoryMonitor.stop();