instar 1.3.800 → 1.3.802

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dashboard/index.html +25 -0
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +63 -1
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/BackupManager.d.ts.map +1 -1
  6. package/dist/core/BackupManager.js +8 -0
  7. package/dist/core/BackupManager.js.map +1 -1
  8. package/dist/core/PostUpdateMigrator.d.ts +8 -0
  9. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  10. package/dist/core/PostUpdateMigrator.js +45 -0
  11. package/dist/core/PostUpdateMigrator.js.map +1 -1
  12. package/dist/core/ProactiveSwapMonitor.d.ts +10 -0
  13. package/dist/core/ProactiveSwapMonitor.d.ts.map +1 -1
  14. package/dist/core/ProactiveSwapMonitor.js +39 -0
  15. package/dist/core/ProactiveSwapMonitor.js.map +1 -1
  16. package/dist/core/SessionManager.d.ts +26 -4
  17. package/dist/core/SessionManager.d.ts.map +1 -1
  18. package/dist/core/SessionManager.js +76 -22
  19. package/dist/core/SessionManager.js.map +1 -1
  20. package/dist/core/machineCoherenceManifest.d.ts +10 -0
  21. package/dist/core/machineCoherenceManifest.d.ts.map +1 -1
  22. package/dist/core/machineCoherenceManifest.js +58 -0
  23. package/dist/core/machineCoherenceManifest.js.map +1 -1
  24. package/dist/core/types.d.ts +53 -0
  25. package/dist/core/types.d.ts.map +1 -1
  26. package/dist/core/types.js.map +1 -1
  27. package/dist/monitoring/ExternalHogScanTick.d.ts +9 -0
  28. package/dist/monitoring/ExternalHogScanTick.d.ts.map +1 -1
  29. package/dist/monitoring/ExternalHogScanTick.js +29 -0
  30. package/dist/monitoring/ExternalHogScanTick.js.map +1 -1
  31. package/dist/monitoring/PromiseBeacon.d.ts +6 -0
  32. package/dist/monitoring/PromiseBeacon.d.ts.map +1 -1
  33. package/dist/monitoring/PromiseBeacon.js +42 -6
  34. package/dist/monitoring/PromiseBeacon.js.map +1 -1
  35. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  36. package/dist/monitoring/guardManifest.js +31 -0
  37. package/dist/monitoring/guardManifest.js.map +1 -1
  38. package/dist/monitoring/guardPosture.d.ts.map +1 -1
  39. package/dist/monitoring/guardPosture.js +22 -0
  40. package/dist/monitoring/guardPosture.js.map +1 -1
  41. package/dist/monitoring/selfaction/anchor.d.ts +82 -0
  42. package/dist/monitoring/selfaction/anchor.d.ts.map +1 -0
  43. package/dist/monitoring/selfaction/anchor.js +113 -0
  44. package/dist/monitoring/selfaction/anchor.js.map +1 -0
  45. package/dist/monitoring/selfaction/governor.d.ts +242 -0
  46. package/dist/monitoring/selfaction/governor.d.ts.map +1 -0
  47. package/dist/monitoring/selfaction/governor.js +1475 -0
  48. package/dist/monitoring/selfaction/governor.js.map +1 -0
  49. package/dist/monitoring/selfaction/policies.d.ts +86 -0
  50. package/dist/monitoring/selfaction/policies.d.ts.map +1 -0
  51. package/dist/monitoring/selfaction/policies.js +339 -0
  52. package/dist/monitoring/selfaction/policies.js.map +1 -0
  53. package/dist/monitoring/selfaction/types.d.ts +208 -0
  54. package/dist/monitoring/selfaction/types.d.ts.map +1 -0
  55. package/dist/monitoring/selfaction/types.js +11 -0
  56. package/dist/monitoring/selfaction/types.js.map +1 -0
  57. package/dist/scaffold/templates.d.ts.map +1 -1
  58. package/dist/scaffold/templates.js +8 -1
  59. package/dist/scaffold/templates.js.map +1 -1
  60. package/dist/server/AgentServer.d.ts.map +1 -1
  61. package/dist/server/AgentServer.js +6 -0
  62. package/dist/server/AgentServer.js.map +1 -1
  63. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  64. package/dist/server/CapabilityIndex.js +9 -0
  65. package/dist/server/CapabilityIndex.js.map +1 -1
  66. package/dist/server/routes.d.ts +4 -0
  67. package/dist/server/routes.d.ts.map +1 -1
  68. package/dist/server/routes.js +271 -3
  69. package/dist/server/routes.js.map +1 -1
  70. package/dist/testing/selfActionRegistry.d.ts +16 -0
  71. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  72. package/dist/testing/selfActionRegistry.js +16 -0
  73. package/dist/testing/selfActionRegistry.js.map +1 -1
  74. package/package.json +2 -2
  75. package/scripts/lint-emit-without-admit.js +303 -0
  76. package/src/data/builtin-manifest.json +66 -66
  77. package/src/data/state-coherence-registry.json +1296 -1241
  78. package/src/scaffold/templates.ts +8 -1
  79. package/upgrades/1.3.801.md +38 -0
  80. package/upgrades/1.3.802.md +36 -0
  81. package/upgrades/session-listing-hygiene.eli16.md +26 -0
  82. package/upgrades/side-effects/self-action-governor.md +111 -0
  83. package/upgrades/side-effects/session-listing-hygiene.md +93 -0
@@ -7,6 +7,7 @@
7
7
  import { Router } from 'express';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { createHash, timingSafeEqual, randomUUID } from 'node:crypto';
10
+ import { governor as selfActionGovernorSurface } from '../monitoring/selfaction/governor.js';
10
11
  import { classifyActionClaim } from '../core/action-claim.js';
11
12
  import { sharedG3SoakLedger, decideLeaseGatedSpawn } from '../core/leaseGatedSpawn.js';
12
13
  import { getHostSpawnSemaphore, configuredSpawnAcquireMs, configuredSpawnWaitersMax } from '../core/hostSpawnSemaphore.js';
@@ -6058,9 +6059,24 @@ export function createRoutes(ctx) {
6058
6059
  router.get('/sessions', async (req, res) => {
6059
6060
  const status = req.query.status;
6060
6061
  const validStatuses = ['starting', 'running', 'completed', 'failed', 'killed'];
6061
- const sessions = status && validStatuses.includes(status)
6062
- ? ctx.state.listSessions({ status: status })
6062
+ // Session-listing hygiene (CMT-1936): the DEFAULT listing is ACTIVE
6063
+ // sessions only (starting/running). Finished runs (completed/failed/
6064
+ // killed) stay fully readable behind an explicit opt-in — `?include=all`
6065
+ // for the whole registry, or `?status=<terminal>` for one class — so the
6066
+ // ~50 retained finished mentor/job records never read as "50 running
6067
+ // sessions" on the dashboard / pool / agent surfaces (the 2026-07-05
6068
+ // "duplicate sessions" operator misread).
6069
+ const explicitStatus = status && validStatuses.includes(status)
6070
+ ? status
6071
+ : undefined;
6072
+ const includeAll = req.query.include === 'all';
6073
+ const isActiveStatus = (s) => s === 'running' || s === 'starting';
6074
+ let sessions = explicitStatus
6075
+ ? ctx.state.listSessions({ status: explicitStatus })
6063
6076
  : ctx.state.listSessions();
6077
+ if (!explicitStatus && !includeAll) {
6078
+ sessions = sessions.filter((s) => isActiveStatus(s.status));
6079
+ }
6064
6080
  // Which machine answered — so a session row can say where it runs. Absent
6065
6081
  // on single-machine installs (no pool wired), so the dashboard hides the badge.
6066
6082
  const selfMachineId = ctx.meshSelfId ?? null;
@@ -6138,9 +6154,14 @@ export function createRoutes(ctx) {
6138
6154
  // classifier so an idle peer reads "online — no active sessions" but a
6139
6155
  // dark peer reads "unreachable", never a fabricated state).
6140
6156
  const peerFetchReason = new Map();
6157
+ // Forward the caller's visibility opt-in to each peer so the pool view
6158
+ // keeps ONE semantic (active-only by default, everything on include=all,
6159
+ // one class on ?status=). explicitStatus comes from the validated
6160
+ // allowlist above, so it is URL-safe by construction.
6161
+ const peerQuery = includeAll ? '?include=all' : explicitStatus ? `?status=${explicitStatus}` : '';
6141
6162
  await Promise.all(peers.map(async (p) => {
6142
6163
  try {
6143
- const r = await fetch(`${p.url}/sessions`, {
6164
+ const r = await fetch(`${p.url}/sessions${peerQuery}`, {
6144
6165
  headers: { Authorization: `Bearer ${ctx.config.authToken}` },
6145
6166
  signal: AbortSignal.timeout(5000),
6146
6167
  });
@@ -6153,6 +6174,16 @@ export function createRoutes(ctx) {
6153
6174
  peerFetchReason.set(p.machineId, null);
6154
6175
  const nickname = ctx.machinePoolRegistry?.getCapacity(p.machineId)?.nickname ?? null;
6155
6176
  for (const s of list) {
6177
+ // Defensive mirror of the default-visibility filter for OLDER
6178
+ // peers that still answer the plain route with their FULL registry
6179
+ // (the exact client-side filter dashboard/index.html carries for
6180
+ // the same reason — 2026-06-11: five closed Mac Mini sessions
6181
+ // reappeared as live tiles). Without this, one stale peer
6182
+ // re-introduces the finished-session wall into the pool view.
6183
+ if (!includeAll && !explicitStatus && !isActiveStatus(s.status))
6184
+ continue;
6185
+ if (explicitStatus && s.status !== explicitStatus)
6186
+ continue;
6156
6187
  remote.push({
6157
6188
  ...s,
6158
6189
  machineId: s.machineId ?? p.machineId,
@@ -6218,6 +6249,55 @@ export function createRoutes(ctx) {
6218
6249
  }
6219
6250
  machines.push(entry);
6220
6251
  }
6252
+ // GENUINE cross-machine duplicate detection (CMT-1936 part c): the SAME
6253
+ // conversation (platform + platformId) with a LIVE session on two or
6254
+ // more machines at once is the real incoherency. Each machine running
6255
+ // its OWN copy of a recurring job is benign, BY DESIGN, and never flags
6256
+ // here — job/headless sessions carry no platform binding (they enrich as
6257
+ // platform:'headless', which is excluded), so identical `job-*` names on
6258
+ // two machines stay unflagged. Only ACTIVE rows count: a finished record
6259
+ // on one machine + a live session on another is the normal topic-move
6260
+ // case, not a duplicate.
6261
+ const byConversation = new Map();
6262
+ for (const s of allSessionsMerged) {
6263
+ if (!isActiveStatus(s.status))
6264
+ continue;
6265
+ const platform = typeof s.platform === 'string' ? s.platform : '';
6266
+ const platformId = s.platformId;
6267
+ if (!platform || platform === 'headless' || platformId == null)
6268
+ continue;
6269
+ const key = `${platform}:${String(platformId)}`;
6270
+ const mid = typeof s.machineId === 'string' ? s.machineId : (selfMachineId ?? '__local__');
6271
+ const entry = byConversation.get(key)
6272
+ ?? { platform, platformId: platformId, machineIds: new Set(), sessions: [] };
6273
+ entry.machineIds.add(mid);
6274
+ entry.sessions.push(String(s.name ?? s.tmuxSession ?? s.id ?? ''));
6275
+ byConversation.set(key, entry);
6276
+ }
6277
+ const duplicateTopics = [];
6278
+ const duplicateKeys = new Set();
6279
+ for (const [key, e] of byConversation) {
6280
+ if (e.machineIds.size >= 2) {
6281
+ duplicateKeys.add(key);
6282
+ duplicateTopics.push({
6283
+ platform: e.platform,
6284
+ platformId: e.platformId,
6285
+ machineIds: [...e.machineIds],
6286
+ sessions: e.sessions,
6287
+ });
6288
+ }
6289
+ }
6290
+ if (duplicateKeys.size > 0) {
6291
+ for (const s of allSessionsMerged) {
6292
+ if (!isActiveStatus(s.status))
6293
+ continue;
6294
+ const platform = typeof s.platform === 'string' ? s.platform : '';
6295
+ if (!platform || s.platformId == null)
6296
+ continue;
6297
+ if (duplicateKeys.has(`${platform}:${String(s.platformId)}`))
6298
+ s.duplicateTopic = true;
6299
+ }
6300
+ }
6221
6301
  res.json({
6222
6302
  sessions: allSessionsMerged,
6223
6303
  pool: {
@@ -6232,6 +6312,9 @@ export function createRoutes(ctx) {
6232
6312
  // multi-machine case the dashboard renders; single-machine installs
6233
6313
  // still get the (lone) self row, harmlessly.
6234
6314
  machines,
6315
+ // Always an array (usually empty) — additive, never breaking. Each
6316
+ // entry is one conversation with a live session on >=2 machines.
6317
+ duplicateTopics,
6235
6318
  },
6236
6319
  });
6237
6320
  return;
@@ -6818,6 +6901,25 @@ export function createRoutes(ctx) {
6818
6901
  }, 500 + i * STAGGER_MS);
6819
6902
  });
6820
6903
  });
6904
+ /** FD13 (unified-self-action-backpressure §4): verify the optional
6905
+ * `x-instar-principal-pin` proof and — when it is the operator's dashboard
6906
+ * PIN — record the principal admission on the ALWAYS-ALLOW audited lane.
6907
+ * Never blocks; never throws (a throwing principalAdmit resolves OPEN). */
6908
+ const recordPrincipalKillProvenance = (req, target) => {
6909
+ try {
6910
+ const proof = req.headers['x-instar-principal-pin'];
6911
+ if (typeof proof !== 'string' || !ctx.config.dashboardPin)
6912
+ return;
6913
+ const ha = createHash('sha256').update(proof).digest();
6914
+ const hb = createHash('sha256').update(ctx.config.dashboardPin).digest();
6915
+ if (ha.length === hb.length && timingSafeEqual(ha, hb)) {
6916
+ selfActionGovernorSurface.principalAdmit('dashboard-pin-session', { actionVerb: 'session-kill', target });
6917
+ }
6918
+ }
6919
+ catch {
6920
+ /* provenance recording is observability — never blocks the kill */
6921
+ }
6922
+ };
6821
6923
  router.delete('/sessions/:id', async (req, res) => {
6822
6924
  if (!SESSION_NAME_RE.test(req.params.id)) {
6823
6925
  res.status(400).json({ error: 'Invalid session ID format' });
@@ -6846,6 +6948,14 @@ export function createRoutes(ctx) {
6846
6948
  // those read the route-stamped `origin` below. Sanitized + bounded.
6847
6949
  const viaHeader = req.headers['x-instar-close-via'];
6848
6950
  const via = typeof viaHeader === 'string' && /^[a-z0-9-]{1,40}$/.test(viaHeader) ? viaHeader : undefined;
6951
+ // FD13 principal provenance (unified-self-action-backpressure §4): a
6952
+ // PIN-proof header distinguishes the OPERATOR's dashboard session from a
6953
+ // bare Bearer call (the agent itself holds Bearer, so Bearer alone is
6954
+ // structurally insufficient — Know Your Principal). A valid proof rides
6955
+ // the ALWAYS-ALLOW, always-audited principal lane; absent/invalid stays
6956
+ // agent-origin ('self') and rides the normal governor ceilings once the
6957
+ // kill sink enforces.
6958
+ recordPrincipalKillProvenance(req, req.params.id);
6849
6959
  const result = await ctx.sessionManager.terminateSession(target.id, 'operator-kill', {
6850
6960
  origin: 'operator',
6851
6961
  finalStatus: 'killed',
@@ -6901,6 +7011,9 @@ export function createRoutes(ctx) {
6901
7011
  res.status(502).json({ error: 'url-rejected', machineId });
6902
7012
  return;
6903
7013
  }
7014
+ // FD13 principal provenance (same PIN-proof distinguishability as the
7015
+ // local DELETE route — a bare Bearer relay stays agent-origin 'self').
7016
+ recordPrincipalKillProvenance(req, sessionUuid);
6904
7017
  try {
6905
7018
  // The peer's PLAIN local close — no relay params forwarded (single-hop
6906
7019
  // by construction, §2.1); UUID-targeted (the peer's existing route is
@@ -7598,6 +7711,97 @@ export function createRoutes(ctx) {
7598
7711
  }
7599
7712
  return out;
7600
7713
  };
7714
+ // ── SelfActionGovernor (unified-self-action-backpressure Increment B) ──
7715
+ // LOCK-FREE + WRITE-FREE pure read of the in-memory aggregates (the
7716
+ // /test-runner-limiter PURE-read precedent — never /spawn-limiter's
7717
+ // lock-taking status()). Projection is SCRUBBED: aggregate per-class counts
7718
+ // + verdict/breaker state ONLY — no target identities (account ids,
7719
+ // session/topic names) and no absolute quota values (spec SEC6).
7720
+ // `?scope=pool` merges pool-shared class counters from online peers
7721
+ // (dark-peer-tolerant: a failed peer is a named row, never a 500).
7722
+ const selfActionPoolLimiter = rateLimiter(60_000, 6);
7723
+ router.get('/self-action-governor', (req, res) => {
7724
+ const gov = ctx.selfActionGovernor;
7725
+ if (!gov) {
7726
+ res.status(503).json({ error: 'self-action governor unavailable (not initialized on this build)' });
7727
+ return;
7728
+ }
7729
+ let posture;
7730
+ try {
7731
+ posture = gov.getPosture();
7732
+ }
7733
+ catch (err) {
7734
+ res.status(500).json({ error: `self-action governor read failed: ${err instanceof Error ? err.message : String(err)}` });
7735
+ return;
7736
+ }
7737
+ const selfMachineId = ctx.meshSelfId ?? null;
7738
+ const body = {
7739
+ machineId: selfMachineId,
7740
+ generatedAt: new Date().toISOString(),
7741
+ emergencyDisable: posture.emergencyDisable,
7742
+ initialized: posture.initialized,
7743
+ classes: posture.classes,
7744
+ };
7745
+ if (req.query.scope !== 'pool') {
7746
+ res.json(body);
7747
+ return;
7748
+ }
7749
+ // Pool scope: pool-shared class counters are answerable POOL-WIDE (the
7750
+ // observability boundary matches the resource boundary); hardware-bound
7751
+ // counters stay machine-local. Non-recursive (peers get the plain route),
7752
+ // rate-limited (anti-amplification).
7753
+ selfActionPoolLimiter(req, res, async () => {
7754
+ const known = (ctx.listPoolMachines?.() ?? []).filter((m) => m.machineId !== selfMachineId);
7755
+ const machines = [];
7756
+ const failed = [];
7757
+ let peersQueried = 0;
7758
+ await Promise.all(known.map(async (m) => {
7759
+ const capacity = ctx.machinePoolRegistry?.getCapacity(m.machineId) ?? null;
7760
+ if (!m.lastKnownUrl) {
7761
+ failed.push({ machineId: m.machineId, reason: 'no-known-url' });
7762
+ return;
7763
+ }
7764
+ if (capacity && capacity.online === false) {
7765
+ failed.push({ machineId: m.machineId, reason: 'offline' });
7766
+ return;
7767
+ }
7768
+ const extraAllowlist = ctx.config.multiMachine?.peerUrlAllowlist;
7769
+ const verdict = isPeerUrlAllowedForCredentials(m.lastKnownUrl, extraAllowlist);
7770
+ if (!verdict.ok) {
7771
+ failed.push({ machineId: m.machineId, reason: 'url-rejected' });
7772
+ return;
7773
+ }
7774
+ peersQueried++;
7775
+ try {
7776
+ const r = await fetch(`${m.lastKnownUrl}/self-action-governor`, {
7777
+ headers: { Authorization: `Bearer ${ctx.config.authToken}` },
7778
+ signal: AbortSignal.timeout(5000),
7779
+ });
7780
+ if (r.status === 404) {
7781
+ failed.push({ machineId: m.machineId, reason: 'route-missing' });
7782
+ return;
7783
+ }
7784
+ if (!r.ok) {
7785
+ failed.push({ machineId: m.machineId, reason: r.status === 401 || r.status === 403 ? 'unauthorized' : 'error' });
7786
+ return;
7787
+ }
7788
+ const peerBody = (await r.json());
7789
+ machines.push({
7790
+ machineId: m.machineId,
7791
+ nickname: capacity?.nickname ?? m.nickname,
7792
+ emergencyDisable: peerBody.emergencyDisable ?? null,
7793
+ // Pool-shared classes ONLY (the resource boundary IS the
7794
+ // observability boundary — hardware-bound stays machine-local).
7795
+ classes: (peerBody.classes ?? []).filter((c) => c.resource === 'pool-shared'),
7796
+ });
7797
+ }
7798
+ catch {
7799
+ failed.push({ machineId: m.machineId, reason: 'unreachable' });
7800
+ }
7801
+ }));
7802
+ res.json({ ...body, scope: 'pool', pool: { selfMachineId, peersQueried, peersOk: machines.length, machines, failed } });
7803
+ });
7804
+ });
7601
7805
  router.get('/test-runner-limiter', (_req, res) => {
7602
7806
  try {
7603
7807
  const status = getHostTestRunnerSemaphore().status();
@@ -20667,6 +20871,41 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
20667
20871
  // Single source of truth (module-scope export) so the enableAction-validity
20668
20872
  // test stays in lock-step with what the API actually accepts.
20669
20873
  const allowedKeys = PATCHABLE_CONFIG_KEYS;
20874
+ // ── NESTED-PATH validator for the SelfActionGovernor subtree
20875
+ // (unified-self-action-backpressure INT9-1): top-level `intelligence` is
20876
+ // deliberately NOT allowlisted (that would Bearer-expose spawnCap etc.);
20877
+ // EXACTLY `intelligence.selfActionGovernor` is patchable, with DEEP merge
20878
+ // for this subtree (the generic one-level-deep merge would clobber
20879
+ // sibling per-class overrides — the full-block hazard). The DISABLE
20880
+ // direction (`emergencyDisable: true`) on this API path is
20881
+ // dashboard-PIN-gated (ADV9-4 — two verifier-independent valves remain:
20882
+ // the conversational config edit and the raw file edit, so this costs no
20883
+ // emergency availability); re-enable is Bearer-OK.
20884
+ let sagPatch = null;
20885
+ if ('intelligence' in patch) {
20886
+ const intel = patch.intelligence;
20887
+ const intelKeys = intel && typeof intel === 'object' && !Array.isArray(intel) ? Object.keys(intel) : null;
20888
+ if (!intelKeys || intelKeys.some((k) => k !== 'selfActionGovernor')) {
20889
+ res.status(400).json({
20890
+ error: 'Only intelligence.selfActionGovernor is patchable under `intelligence` via API (nested-path validator; other intelligence.* keys are not Bearer-exposed)',
20891
+ });
20892
+ return;
20893
+ }
20894
+ const sag = intel.selfActionGovernor;
20895
+ if (!sag || typeof sag !== 'object' || Array.isArray(sag)) {
20896
+ res.status(400).json({ error: 'intelligence.selfActionGovernor must be an object' });
20897
+ return;
20898
+ }
20899
+ if (sag.emergencyDisable === true) {
20900
+ // Know Your Principal: disarming the flood brake over the API needs
20901
+ // the operator's PIN — a Bearer token is structurally insufficient.
20902
+ if (!checkMandatePin(req, res))
20903
+ return; // response already sent
20904
+ }
20905
+ sagPatch = sag;
20906
+ delete patch.intelligence;
20907
+ delete patch.pin; // PIN is auth, never config
20908
+ }
20670
20909
  const disallowed = Object.keys(patch).filter(k => !allowedKeys.has(k));
20671
20910
  if (disallowed.length > 0) {
20672
20911
  res.status(400).json({
@@ -20681,6 +20920,35 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
20681
20920
  if (fs.existsSync(configPath)) {
20682
20921
  fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
20683
20922
  }
20923
+ // Deep merge for the validated selfActionGovernor subtree (multi-level
20924
+ // by design — INT9-1's one-level-deep-merge hazard note).
20925
+ if (sagPatch) {
20926
+ const deepMerge = (base, overlay) => {
20927
+ for (const [k, v] of Object.entries(overlay)) {
20928
+ if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
20929
+ deepMerge(base[k], v);
20930
+ }
20931
+ else {
20932
+ base[k] = v;
20933
+ }
20934
+ }
20935
+ return base;
20936
+ };
20937
+ fileConfig.intelligence = fileConfig.intelligence && typeof fileConfig.intelligence === 'object' ? fileConfig.intelligence : {};
20938
+ fileConfig.intelligence.selfActionGovernor = deepMerge(fileConfig.intelligence.selfActionGovernor && typeof fileConfig.intelligence.selfActionGovernor === 'object'
20939
+ ? fileConfig.intelligence.selfActionGovernor
20940
+ : {}, sagPatch);
20941
+ const rt = ctx.config;
20942
+ rt.intelligence = rt.intelligence && typeof rt.intelligence === 'object' ? rt.intelligence : {};
20943
+ rt.intelligence.selfActionGovernor = deepMerge(rt.intelligence.selfActionGovernor && typeof rt.intelligence.selfActionGovernor === 'object'
20944
+ ? rt.intelligence.selfActionGovernor
20945
+ : {}, sagPatch);
20946
+ if (Object.keys(patch).length === 0) {
20947
+ fs.writeFileSync(configPath, JSON.stringify(fileConfig, null, 2) + '\n');
20948
+ res.json({ success: true, patched: ['intelligence.selfActionGovernor'], note: 'emergencyDisable is read live by the governor; per-class overrides apply on its next slow tick.' });
20949
+ return;
20950
+ }
20951
+ }
20684
20952
  // Deep merge (one level deep — sufficient for feature toggles)
20685
20953
  for (const [key, value] of Object.entries(patch)) {
20686
20954
  if (typeof value === 'object' && value !== null && !Array.isArray(value) &&