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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +979 -28
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +23 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CodexResumeMap.d.ts +95 -0
- package/dist/core/CodexResumeMap.d.ts.map +1 -0
- package/dist/core/CodexResumeMap.js +283 -0
- package/dist/core/CodexResumeMap.js.map +1 -0
- package/dist/core/MeshRpc.d.ts +3 -0
- package/dist/core/MeshRpc.d.ts.map +1 -1
- package/dist/core/MeshRpc.js +5 -0
- package/dist/core/MeshRpc.js.map +1 -1
- package/dist/core/ModelSwapService.d.ts +26 -11
- package/dist/core/ModelSwapService.d.ts.map +1 -1
- package/dist/core/ModelSwapService.js +59 -21
- package/dist/core/ModelSwapService.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +16 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +62 -2
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +4 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +3 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SessionRefresh.d.ts +36 -7
- package/dist/core/SessionRefresh.d.ts.map +1 -1
- package/dist/core/SessionRefresh.js +90 -29
- package/dist/core/SessionRefresh.js.map +1 -1
- package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
- package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
- package/dist/core/TopicProfileOrchestrator.js +1404 -0
- package/dist/core/TopicProfileOrchestrator.js.map +1 -0
- package/dist/core/TopicProfileResolver.d.ts +104 -0
- package/dist/core/TopicProfileResolver.d.ts.map +1 -0
- package/dist/core/TopicProfileResolver.js +231 -0
- package/dist/core/TopicProfileResolver.js.map +1 -0
- package/dist/core/TopicProfileStore.d.ts +308 -0
- package/dist/core/TopicProfileStore.d.ts.map +1 -0
- package/dist/core/TopicProfileStore.js +781 -0
- package/dist/core/TopicProfileStore.js.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.js +533 -0
- package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
- package/dist/core/TopicResumeMap.d.ts +67 -1
- package/dist/core/TopicResumeMap.d.ts.map +1 -1
- package/dist/core/TopicResumeMap.js +114 -1
- package/dist/core/TopicResumeMap.js.map +1 -1
- package/dist/core/classifyProfileChange.d.ts +83 -0
- package/dist/core/classifyProfileChange.d.ts.map +1 -0
- package/dist/core/classifyProfileChange.js +274 -0
- package/dist/core/classifyProfileChange.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/frameworkSessionLaunch.d.ts +26 -0
- package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
- package/dist/core/frameworkSessionLaunch.js +58 -0
- package/dist/core/frameworkSessionLaunch.js.map +1 -1
- package/dist/core/slackRefreshBinding.d.ts +83 -0
- package/dist/core/slackRefreshBinding.d.ts.map +1 -0
- package/dist/core/slackRefreshBinding.js +54 -0
- package/dist/core/slackRefreshBinding.js.map +1 -0
- package/dist/core/topicProfileIngress.d.ts +128 -0
- package/dist/core/topicProfileIngress.d.ts.map +1 -0
- package/dist/core/topicProfileIngress.js +249 -0
- package/dist/core/topicProfileIngress.js.map +1 -0
- package/dist/core/topicProfileValidation.d.ts +109 -0
- package/dist/core/topicProfileValidation.d.ts.map +1 -0
- package/dist/core/topicProfileValidation.js +247 -0
- package/dist/core/topicProfileValidation.js.map +1 -0
- package/dist/core/topicProfileWriteSurface.d.ts +222 -0
- package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
- package/dist/core/topicProfileWriteSurface.js +631 -0
- package/dist/core/topicProfileWriteSurface.js.map +1 -0
- package/dist/core/types.d.ts +37 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +6 -0
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +10 -1
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +41 -1
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +9 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +32 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +52 -3
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +17 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +271 -1
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +66 -66
- package/src/scaffold/templates.ts +9 -0
- package/upgrades/1.3.490.md +34 -0
- package/upgrades/side-effects/topic-profile.md +142 -0
package/dist/commands/server.js
CHANGED
|
@@ -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
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
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
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
|
|
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 —
|
|
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}".
|
|
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.
|
|
1042
|
-
: `This topic is on "${fw}", which doesn't support the local-model path.
|
|
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})` : ''}.
|
|
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
|
|
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
|
-
|
|
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; //
|
|
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
|
-
|
|
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
|
-
|
|
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();
|