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