@ashlr/hub 3.2.7 → 3.3.2

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 (192) hide show
  1. package/CHANGELOG.md +314 -2
  2. package/README.md +54 -38
  3. package/dist/build-identity.json +2 -2
  4. package/dist/cli/audit.js +18 -0
  5. package/dist/cli/audit.js.map +1 -1
  6. package/dist/cli/backlog.d.ts +0 -5
  7. package/dist/cli/backlog.js +18 -0
  8. package/dist/cli/backlog.js.map +1 -1
  9. package/dist/cli/comms.js +20 -0
  10. package/dist/cli/comms.js.map +1 -1
  11. package/dist/cli/completions.d.ts +6 -1
  12. package/dist/cli/completions.js +33 -10
  13. package/dist/cli/completions.js.map +1 -1
  14. package/dist/cli/daemon.js +10 -3
  15. package/dist/cli/daemon.js.map +1 -1
  16. package/dist/cli/dashboard.js +5 -4
  17. package/dist/cli/dashboard.js.map +1 -1
  18. package/dist/cli/demo.d.ts +0 -27
  19. package/dist/cli/demo.js +17 -0
  20. package/dist/cli/demo.js.map +1 -1
  21. package/dist/cli/help.js +22 -9
  22. package/dist/cli/help.js.map +1 -1
  23. package/dist/cli/inbox.d.ts +14 -2
  24. package/dist/cli/inbox.js +137 -23
  25. package/dist/cli/inbox.js.map +1 -1
  26. package/dist/cli/onboard.d.ts +0 -13
  27. package/dist/cli/onboard.js +20 -0
  28. package/dist/cli/onboard.js.map +1 -1
  29. package/dist/cli/resources.js +15 -0
  30. package/dist/cli/resources.js.map +1 -1
  31. package/dist/cli/run.js +1 -1
  32. package/dist/cli/run.js.map +1 -1
  33. package/dist/cli/sandbox.js +43 -0
  34. package/dist/cli/sandbox.js.map +1 -1
  35. package/dist/cli/serve.js +7 -3
  36. package/dist/cli/serve.js.map +1 -1
  37. package/dist/cli/setup.d.ts +5 -3
  38. package/dist/cli/setup.js +14 -7
  39. package/dist/cli/setup.js.map +1 -1
  40. package/dist/cli/tui.d.ts +0 -6
  41. package/dist/cli/tui.js +14 -0
  42. package/dist/cli/tui.js.map +1 -1
  43. package/dist/cli/update.js +3 -1
  44. package/dist/cli/update.js.map +1 -1
  45. package/dist/core/config.js +70 -0
  46. package/dist/core/config.js.map +1 -1
  47. package/dist/core/daemon/loop.js +83 -3
  48. package/dist/core/daemon/loop.js.map +1 -1
  49. package/dist/core/daemon/runtime-activation-resident-start-protocol.d.ts +141 -0
  50. package/dist/core/daemon/runtime-activation-resident-start-protocol.js +462 -0
  51. package/dist/core/daemon/runtime-activation-resident-start-protocol.js.map +1 -0
  52. package/dist/core/daemon/runtime-activation-resident-start-runtime.d.ts +27 -0
  53. package/dist/core/daemon/runtime-activation-resident-start-runtime.js +28 -0
  54. package/dist/core/daemon/runtime-activation-resident-start-runtime.js.map +1 -0
  55. package/dist/core/daemon/runtime-activation-stopped-consumer.d.ts +139 -0
  56. package/dist/core/daemon/runtime-activation-stopped-consumer.js +995 -0
  57. package/dist/core/daemon/runtime-activation-stopped-consumer.js.map +1 -0
  58. package/dist/core/daemon/runtime-activation-stopped-runtime.d.ts +36 -0
  59. package/dist/core/daemon/runtime-activation-stopped-runtime.js +134 -0
  60. package/dist/core/daemon/runtime-activation-stopped-runtime.js.map +1 -0
  61. package/dist/core/daemon/runtime-activation-transaction.d.ts +41 -17
  62. package/dist/core/daemon/runtime-activation-transaction.js +80 -11
  63. package/dist/core/daemon/runtime-activation-transaction.js.map +1 -1
  64. package/dist/core/daemon/runtime-release-dependency-inventory.js +1 -0
  65. package/dist/core/daemon/runtime-release-dependency-inventory.js.map +1 -1
  66. package/dist/core/daemon/service-install-authority.d.ts +1 -1
  67. package/dist/core/daemon/service-install-authority.js +4 -2
  68. package/dist/core/daemon/service-install-authority.js.map +1 -1
  69. package/dist/core/dashboard.d.ts +10 -0
  70. package/dist/core/dashboard.js +103 -28
  71. package/dist/core/dashboard.js.map +1 -1
  72. package/dist/core/effective-config.js +7 -0
  73. package/dist/core/effective-config.js.map +1 -1
  74. package/dist/core/fleet/automerge-pass.js +57 -73
  75. package/dist/core/fleet/automerge-pass.js.map +1 -1
  76. package/dist/core/fleet/decisions-ledger.js +36 -13
  77. package/dist/core/fleet/decisions-ledger.js.map +1 -1
  78. package/dist/core/fleet/external-skill-git-capture.js +9 -1
  79. package/dist/core/fleet/external-skill-git-capture.js.map +1 -1
  80. package/dist/core/fleet/judge-decision-metadata.d.ts +8 -1
  81. package/dist/core/fleet/judge-decision-metadata.js +14 -1
  82. package/dist/core/fleet/judge-decision-metadata.js.map +1 -1
  83. package/dist/core/fleet/judge-trace.js +2 -1
  84. package/dist/core/fleet/judge-trace.js.map +1 -1
  85. package/dist/core/fleet/manager.d.ts +21 -1
  86. package/dist/core/fleet/manager.js +137 -41
  87. package/dist/core/fleet/manager.js.map +1 -1
  88. package/dist/core/fleet/post-merge-credit-label.d.ts +10 -0
  89. package/dist/core/fleet/post-merge-credit-label.js +13 -0
  90. package/dist/core/fleet/post-merge-credit-label.js.map +1 -0
  91. package/dist/core/fleet/post-merge-credit.d.ts +119 -12
  92. package/dist/core/fleet/post-merge-credit.js +353 -14
  93. package/dist/core/fleet/post-merge-credit.js.map +1 -1
  94. package/dist/core/fleet/self-improve.d.ts +38 -5
  95. package/dist/core/fleet/self-improve.js +201 -12
  96. package/dist/core/fleet/self-improve.js.map +1 -1
  97. package/dist/core/fleet/skill-library.d.ts +21 -2
  98. package/dist/core/fleet/skill-library.js +54 -12
  99. package/dist/core/fleet/skill-library.js.map +1 -1
  100. package/dist/core/fleet/worked-ledger.d.ts +22 -0
  101. package/dist/core/fleet/worked-ledger.js +32 -0
  102. package/dist/core/fleet/worked-ledger.js.map +1 -1
  103. package/dist/core/goals/store.js +60 -6
  104. package/dist/core/goals/store.js.map +1 -1
  105. package/dist/core/inbox/apply.js +2 -1
  106. package/dist/core/inbox/apply.js.map +1 -1
  107. package/dist/core/inbox/merge.d.ts +10 -1
  108. package/dist/core/inbox/merge.js +97 -18
  109. package/dist/core/inbox/merge.js.map +1 -1
  110. package/dist/core/integrations/github.d.ts +2 -0
  111. package/dist/core/integrations/github.js +2 -1
  112. package/dist/core/integrations/github.js.map +1 -1
  113. package/dist/core/learn/reflect.d.ts +42 -4
  114. package/dist/core/learn/reflect.js +66 -5
  115. package/dist/core/learn/reflect.js.map +1 -1
  116. package/dist/core/onboard.d.ts +2 -0
  117. package/dist/core/onboard.js +9 -3
  118. package/dist/core/onboard.js.map +1 -1
  119. package/dist/core/run/engines.d.ts +8 -0
  120. package/dist/core/run/engines.js +9 -1
  121. package/dist/core/run/engines.js.map +1 -1
  122. package/dist/core/run/learned-router.js +28 -3
  123. package/dist/core/run/learned-router.js.map +1 -1
  124. package/dist/core/run/orchestrator.d.ts +7 -2
  125. package/dist/core/run/orchestrator.js +88 -60
  126. package/dist/core/run/orchestrator.js.map +1 -1
  127. package/dist/core/swarm/rollback.d.ts +11 -0
  128. package/dist/core/swarm/rollback.js +54 -0
  129. package/dist/core/swarm/rollback.js.map +1 -1
  130. package/dist/core/swarm/runner.js +94 -8
  131. package/dist/core/swarm/runner.js.map +1 -1
  132. package/dist/core/types.d.ts +103 -3
  133. package/dist/core/types.js.map +1 -1
  134. package/dist/core/web/api.d.ts +6 -0
  135. package/dist/core/web/api.js +174 -18
  136. package/dist/core/web/api.js.map +1 -1
  137. package/dist/core/web/control.d.ts +20 -0
  138. package/dist/core/web/control.js +44 -32
  139. package/dist/core/web/control.js.map +1 -1
  140. package/dist/core/web/fleet-status-cache.d.ts +68 -0
  141. package/dist/core/web/fleet-status-cache.js +120 -0
  142. package/dist/core/web/fleet-status-cache.js.map +1 -0
  143. package/dist/core/web/public/app.js +5 -1
  144. package/dist/core/web/public/next/assets/index-CDHQraxS.css +1 -0
  145. package/dist/core/web/public/next/assets/index-zVKD9krw.js +69 -0
  146. package/dist/core/web/public/next/assets/index-zVKD9krw.js.map +1 -0
  147. package/dist/core/web/public/next/index.html +15 -0
  148. package/dist/core/web/read-session-revocations.d.ts +26 -0
  149. package/dist/core/web/read-session-revocations.js +48 -0
  150. package/dist/core/web/read-session-revocations.js.map +1 -0
  151. package/dist/core/web/server.js +73 -27
  152. package/dist/core/web/server.js.map +1 -1
  153. package/dist/core/web/static.d.ts +1 -1
  154. package/dist/core/web/static.js +54 -20
  155. package/dist/core/web/static.js.map +1 -1
  156. package/dist/release-dependency-inventory.json +1 -1
  157. package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +115 -21
  158. package/docs/contracts/CONTRACT-M521.md +102 -0
  159. package/node_modules/fast-uri/index.js +201 -48
  160. package/node_modules/fast-uri/lib/schemes.js +9 -4
  161. package/node_modules/fast-uri/lib/utils.js +388 -91
  162. package/node_modules/fast-uri/package.json +1 -1
  163. package/node_modules/fast-uri/test/component-safe-serialization.test.js +105 -0
  164. package/node_modules/fast-uri/test/equal.test.js +31 -3
  165. package/node_modules/fast-uri/test/fixtures/uri-js-parse.json +2 -0
  166. package/node_modules/fast-uri/test/ipv6-canonical.test.js +34 -0
  167. package/node_modules/fast-uri/test/ipv6-validation.test.js +90 -0
  168. package/node_modules/fast-uri/test/malformed-percent.test.js +77 -0
  169. package/node_modules/fast-uri/test/malformed-urn.test.js +61 -0
  170. package/node_modules/fast-uri/test/parse.test.js +7 -3
  171. package/node_modules/fast-uri/test/query-fragment-normalization.test.js +33 -0
  172. package/node_modules/fast-uri/test/reserved-path-normalization.test.js +109 -0
  173. package/node_modules/fast-uri/test/scheme-validation.test.js +124 -0
  174. package/node_modules/fast-uri/test/security-normalization.test.js +101 -0
  175. package/node_modules/fast-uri/test/security.test.js +75 -3
  176. package/node_modules/fast-uri/test/urn-full-input.test.js +29 -0
  177. package/node_modules/fast-uri/test/websocket-query-preservation.test.js +24 -0
  178. package/node_modules/qs/.github/THREAT_MODEL.md +3 -3
  179. package/node_modules/qs/CHANGELOG.md +26 -0
  180. package/node_modules/qs/README.md +19 -1
  181. package/node_modules/qs/dist/qs.js +25 -25
  182. package/node_modules/qs/eslint.config.mjs +1 -1
  183. package/node_modules/qs/lib/parse.js +16 -6
  184. package/node_modules/qs/lib/stringify.js +23 -8
  185. package/node_modules/qs/lib/utils.js +57 -11
  186. package/node_modules/qs/package.json +8 -7
  187. package/node_modules/qs/test/parse.js +419 -0
  188. package/node_modules/qs/test/stringify.js +317 -0
  189. package/node_modules/qs/test/utils.js +206 -0
  190. package/package.json +24 -6
  191. package/schema/config.schema.json +800 -5
  192. package/node_modules/which/CHANGELOG.md +0 -166
@@ -730,6 +730,15 @@ export interface AshlrConfig {
730
730
  kimi?: {
731
731
  maxConcurrent?: number;
732
732
  costPerMTokenOut?: number;
733
+ /**
734
+ * M270: high-level convenience override that promotes the 'kimi' engine
735
+ * spec's tier (engine-registry.ts:405 applyKimiConfig — e.g.
736
+ * `foundry.kimi = { tier: 'frontier' }` grants WORK-ASSIGNMENT frontier
737
+ * eligibility without touching the builtin registry). Previously read
738
+ * only via an untyped `(cfg?.foundry as Record<string, unknown>)?.['kimi']`
739
+ * cast; live in ~/.ashlr/config.json (tier: 'frontier') as of this audit.
740
+ */
741
+ tier?: EngineTier;
733
742
  };
734
743
  /**
735
744
  * M248: guarantee fleet sandboxed engines inherit ashlr-plugin MCP tools.
@@ -1074,12 +1083,12 @@ export interface AshlrConfig {
1074
1083
  maxAutomergeFiles?: number;
1075
1084
  /** Max changed lines permitted in an auto-merge diff (default 150, hard maximum 300). */
1076
1085
  maxAutomergeLines?: number;
1077
- /** Also merge/push on the remote (gh pr merge) when applying (default false). */
1086
+ /** Stage/push a reviewed branch and open a protected PR handoff; never merges the hosted PR (default false). */
1078
1087
  pushToRemote?: boolean;
1079
1088
  /**
1080
1089
  * Remote-main policy expectation. This configuration is never
1081
1090
  * authority by itself: live GitHub protection must match it immediately
1082
- * before evidence capture, remote handoff, PR creation, and host auto-merge.
1091
+ * before evidence capture, remote handoff, and PR creation.
1083
1092
  */
1084
1093
  protectedRemote?: {
1085
1094
  /** Operator expects live branch protection; autonomous remote-main delivery requires true. */
@@ -1310,6 +1319,47 @@ export interface AshlrConfig {
1310
1319
  /** Ollama base URL (default 'http://localhost:11434'). */
1311
1320
  baseUrl?: string;
1312
1321
  };
1322
+ /**
1323
+ * M151: opt-in EDV (independent/external) verification signal folded into
1324
+ * backlog item scoring. DEFAULT false. Previously read only via an untyped
1325
+ * `(cfg.foundry as Record<string, unknown>)?.['edvVerify']` cast in
1326
+ * src/core/portfolio/backlog.ts — this only adds type safety/
1327
+ * discoverability, no behavior change.
1328
+ */
1329
+ edvVerify?: boolean;
1330
+ /**
1331
+ * M185: gate for the `ashlrcode` (ac) autonomous executor path. DEFAULT
1332
+ * false (opt-in). Previously read only via an untyped
1333
+ * `(cfg.foundry as Record<string, unknown>)?.['ashlrcodeExecutor']` cast
1334
+ * in src/core/run/ashlrcode-engine.ts.
1335
+ */
1336
+ ashlrcodeExecutor?: boolean;
1337
+ /**
1338
+ * Spec-contract acceptance-criteria checking gate. DEFAULT false
1339
+ * (disabled — emptyDetail short-circuit). Previously read only via an
1340
+ * untyped `(cfg?.foundry as Record<string, unknown>)?.['specContract']`
1341
+ * cast in src/core/run/spec-contract.ts.
1342
+ */
1343
+ specContract?: boolean;
1344
+ /**
1345
+ * Inject a lightweight repo map into sandboxed-engine prompts. DEFAULT
1346
+ * false. Previously read only via an untyped
1347
+ * `(cfg.foundry as Record<string, unknown>)?.['repoMap']` cast in
1348
+ * src/core/run/sandboxed-engine.ts (sibling to `localization`, which
1349
+ * remains untyped/out of scope for this pass).
1350
+ */
1351
+ repoMap?: boolean;
1352
+ /**
1353
+ * Repair-treatment maintenance pass toggle. DEFAULT true (opt-out via
1354
+ * false). Discovered live in ~/.ashlr/config.json during the M340b type
1355
+ * audit — previously read only via untyped
1356
+ * `(liveCfg.foundry as Record<string, unknown>)?.['proposalRepair']`
1357
+ * casts in src/core/daemon/loop.ts (3 sites) and src/core/fleet/status.ts.
1358
+ * NOT YET present in KNOWN_FOUNDRY_KEYS (src/core/effective-config.ts),
1359
+ * so a live config setting it currently trips a false-positive "unknown
1360
+ * foundry key" warning — flagged separately for that file's owner.
1361
+ */
1362
+ proposalRepair?: boolean;
1313
1363
  };
1314
1364
  /**
1315
1365
  * M24: optional autonomous-operator (daemon) tuning. When unset, the daemon
@@ -2778,6 +2828,18 @@ export interface DashboardSnapshot {
2778
2828
  * making a second request. READ-ONLY and never carries secrets.
2779
2829
  */
2780
2830
  fleet?: import('./fleet/status.js').FleetStatus;
2831
+ /**
2832
+ * Perf: OPTIONAL freshness marker for `fleet`. `buildFleetStatus` is now
2833
+ * served from a shared cache (see web/fleet-status-cache.ts) so concurrent
2834
+ * requests never trigger duplicate full recomputes. `stale: true` means
2835
+ * `fleet` is the last good value (still honestly reported, never silently
2836
+ * presented as current) while a background refresh is in flight; `ageMs`
2837
+ * is how old it is. Absent when the fleet section itself is absent.
2838
+ */
2839
+ fleetFreshness?: {
2840
+ stale: boolean;
2841
+ ageMs: number;
2842
+ };
2781
2843
  /**
2782
2844
  * M29: OPTIONAL org-level portfolio roll-up. ABSENT on existing producers /
2783
2845
  * tests (so they stay valid); populated by buildSnapshot when the v2 sources
@@ -2839,6 +2901,28 @@ export interface ProductionSummary {
2839
2901
  harmful: number;
2840
2902
  total: number;
2841
2903
  };
2904
+ /**
2905
+ * Judge calls over the last 24h that produced NO real verdict — a parse
2906
+ * failure (unparseable LLM response, even after the one-shot retry) or a
2907
+ * network failure (the API call itself threw). judgeProposal() returns via
2908
+ * its `fallback()` helper BEFORE recordJudgeTrace() runs on either path
2909
+ * (see fleet/manager.ts), so these calls write no judge-trace row and are
2910
+ * invisible to judgeVerdicts24h — without this field a failed judge call
2911
+ * looks identical to a judge call that never happened.
2912
+ *
2913
+ * Sourced from the decisions ledger's distinct 'judge-parse-failure' /
2914
+ * 'judge-network-failure' judgeReasonCode (fleet/judge-decision-metadata.ts),
2915
+ * NOT from judge-trace — fallback verdicts are deliberately never written
2916
+ * to judge-trace because their synthetic all-3 scores would corrupt
2917
+ * judge-calibration.ts (darkCurrent/kappa/degradation-harness all read
2918
+ * every trace unconditionally). The decisions ledger already records both
2919
+ * failure kinds distinctly and separately from calibration's read path.
2920
+ */
2921
+ judgeFailures24h: {
2922
+ parse: number;
2923
+ network: number;
2924
+ total: number;
2925
+ };
2842
2926
  /** Auto-merges today: proposals that reached status 'applied' today. */
2843
2927
  autoMergesToday: {
2844
2928
  count: number;
@@ -3561,6 +3645,18 @@ export interface ProposalVerifyResult {
3561
3645
  passed: boolean;
3562
3646
  failed?: string[];
3563
3647
  detail?: string;
3648
+ /**
3649
+ * Present only when passed:false. Distinguishes WHY verification failed:
3650
+ * Only explicit 'code' means the verifier ran and found a real problem with
3651
+ * the diff. An absent category is legacy/unknown and cannot authorize
3652
+ * permanent rejection. 'tool' means the verifier itself
3653
+ * could not be executed (missing binary, exit 127, spawn ENOENT — a
3654
+ * verifier-unavailable outcome, not a judgment on the diff). 'timeout' and
3655
+ * 'infra' are likewise infrastructure signals, not code signals. Consumers
3656
+ * that gate permanent rejection/auto-archival on verification failure MUST
3657
+ * check this before treating a failure as fatal to the proposal.
3658
+ */
3659
+ failureCategory?: 'code' | 'tool' | 'timeout' | 'infra' | 'cancelled' | 'invalid-command';
3564
3660
  ran?: Array<{
3565
3661
  id?: string;
3566
3662
  kind: 'typecheck' | 'lint' | 'build' | 'test';
@@ -5097,7 +5193,11 @@ export interface RunEstimate {
5097
5193
  * M119: One entry in the append-only decisions ledger
5098
5194
  * (~/.ashlr/decisions/YYYY-MM-DD.jsonl).
5099
5195
  */
5100
- export type JudgeDecisionReasonCode = 'judge-ship-would-merge' | 'judge-ship-review-required' | 'judge-review' | 'judge-noise' | 'judge-harmful' | 'judge-verdict-unrecognized';
5196
+ export type JudgeDecisionReasonCode = 'judge-ship-would-merge' | 'judge-ship-review-required' | 'judge-review' | 'judge-noise' | 'judge-harmful' | 'judge-verdict-unrecognized'
5197
+ /** Judge responded but its output could not be parsed as a verdict (after retry). NOT a considered judgment. */
5198
+ | 'judge-parse-failure'
5199
+ /** The judge call itself failed (network/API/spawn error) — no model response was obtained. NOT a considered judgment. */
5200
+ | 'judge-network-failure';
5101
5201
  export interface DecisionEntry {
5102
5202
  /** ISO timestamp. */
5103
5203
  ts: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA2zIH,MAAM,CAAC,MAAM,sCAAsC,GAAG,CAAC,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAw5IH,MAAM,CAAC,MAAM,sCAAsC,GAAG,CAAC,CAAC"}
@@ -46,6 +46,8 @@ import type { AshlrConfig } from '../types.js';
46
46
  * Each cleanup clears the interval and ends the response.
47
47
  */
48
48
  export declare function drainSseConnections(): void;
49
+ /** Revoke only streams authenticated by one exact browser session. */
50
+ export declare function drainSseSession(sessionId: string): void;
49
51
  /**
50
52
  * Handle a single request if its URL matches an /api/* route.
51
53
  *
@@ -57,4 +59,8 @@ export declare function drainSseConnections(): void;
57
59
  export declare function handleApi(req: IncomingMessage, res: ServerResponse, cfg: AshlrConfig, ctx: {
58
60
  token: string;
59
61
  allowDispatch: boolean;
62
+ readSession?: {
63
+ id: string;
64
+ expiresAt: number;
65
+ };
60
66
  }): Promise<boolean>;
@@ -72,6 +72,7 @@ import { readPublicDaemonObservation, } from '../daemon/public-observation.js';
72
72
  import { serviceStatus } from '../daemon/service.js';
73
73
  import { daemonServiceInstallOptions } from '../daemon/service-config.js';
74
74
  import { buildFleetStatus, readFleetDaemonStatus } from '../fleet/status.js';
75
+ import { getCachedFleetStatus, primeFleetStatusCache } from './fleet-status-cache.js';
75
76
  // M61: Mission Control aggregator.
76
77
  import { buildControlSnapshot } from './control.js';
77
78
  // M90: Fleet-Activity panel.
@@ -93,9 +94,9 @@ const _sseCleanups = new Map();
93
94
  * Register a cleanup callback for an SSE connection.
94
95
  * Returns the id so it can be deregistered on close.
95
96
  */
96
- function registerSse(cleanup) {
97
+ function registerSse(cleanup, sessionId) {
97
98
  const id = randomBytes(8).toString('hex');
98
- _sseCleanups.set(id, cleanup);
99
+ _sseCleanups.set(id, { cleanup, sessionId });
99
100
  return id;
100
101
  }
101
102
  /** Remove a registered SSE cleanup. */
@@ -107,7 +108,7 @@ function deregisterSse(id) {
107
108
  * Each cleanup clears the interval and ends the response.
108
109
  */
109
110
  export function drainSseConnections() {
110
- for (const cleanup of _sseCleanups.values()) {
111
+ for (const { cleanup } of _sseCleanups.values()) {
111
112
  try {
112
113
  cleanup();
113
114
  }
@@ -118,6 +119,18 @@ export function drainSseConnections() {
118
119
  _sseCleanups.clear();
119
120
  sseHistoryProjection = null;
120
121
  }
122
+ /** Revoke only streams authenticated by one exact browser session. */
123
+ export function drainSseSession(sessionId) {
124
+ for (const [id, entry] of _sseCleanups) {
125
+ if (entry.sessionId !== sessionId)
126
+ continue;
127
+ try {
128
+ entry.cleanup();
129
+ }
130
+ catch { /* best effort */ }
131
+ _sseCleanups.delete(id);
132
+ }
133
+ }
121
134
  // ---------------------------------------------------------------------------
122
135
  // SSE poll interval — bounded, not configurable by callers.
123
136
  // ---------------------------------------------------------------------------
@@ -557,7 +570,10 @@ async function buildVisionMissionSnapshot(cfg) {
557
570
  /** Write a JSON response. Never throws. */
558
571
  function sendJson(res, status, body) {
559
572
  try {
560
- const payload = JSON.stringify(sanitizePublicJson(body) ?? null);
573
+ const boundedBody = body && typeof body === 'object' && 'error' in body
574
+ ? { ...body, error: String(body.error).slice(0, 512) }
575
+ : body;
576
+ const payload = JSON.stringify(sanitizePublicJson(boundedBody) ?? null);
561
577
  res.writeHead(status, {
562
578
  'Content-Type': 'application/json',
563
579
  'Cache-Control': 'no-store',
@@ -579,7 +595,8 @@ function sendJson(res, status, body) {
579
595
  }
580
596
  /** Write a 500 JSON error. Never throws. */
581
597
  function send500(res, msg = 'internal error') {
582
- sendJson(res, 500, { error: msg });
598
+ void msg; // retained at call sites for private diagnostics; never disclose it.
599
+ sendJson(res, 500, { code: 'INTERNAL_ERROR', error: 'internal server error' });
583
600
  }
584
601
  /**
585
602
  * Constant-time string comparison to defend against timing attacks.
@@ -743,12 +760,18 @@ function cachedSseHistoryProjection() {
743
760
  * Concurrent connections are capped (SSE_MAX_CONNECTIONS) to bound timer/
744
761
  * socket growth; excess connections get a 503.
745
762
  */
746
- function handleSseEvents(req, res, cfg, allowDispatch) {
763
+ function handleSseEvents(req, res, cfg, allowDispatch, readSession) {
747
764
  // Cap concurrent SSE connections to bound timer/socket growth.
748
765
  if (_sseCleanups.size >= SSE_MAX_CONNECTIONS) {
749
766
  sendJson(res, 503, { error: 'too many live connections' });
750
767
  return;
751
768
  }
769
+ // Install the response error listener before the first header/body write.
770
+ // The indirection observes the fully wired cleanup once registration is
771
+ // complete, while also preventing an early EventEmitter 'error' crash.
772
+ let cleanup = () => { };
773
+ if (typeof res.on === 'function')
774
+ res.on('error', () => cleanup());
752
775
  // SSE headers — no buffering, keep-alive.
753
776
  res.writeHead(200, {
754
777
  'Content-Type': 'text/event-stream',
@@ -764,10 +787,26 @@ function handleSseEvents(req, res, cfg, allowDispatch) {
764
787
  return; // Socket already gone.
765
788
  }
766
789
  // Helper: send one NAMED SSE event so the client's per-name listeners fire.
790
+ let backpressured = false;
791
+ let backpressureTimer;
792
+ let cleaned = false;
767
793
  function sendNamed(event, payload) {
794
+ if (cleaned || backpressured)
795
+ return;
768
796
  try {
769
797
  const line = `event: ${event}\ndata: ${JSON.stringify(sanitizePublicJson(payload))}\n\n`;
770
- res.write(line);
798
+ if (!res.write(line)) {
799
+ backpressured = true;
800
+ // Resolve cleanup when the timer fires; the initial snapshot can
801
+ // backpressure before the fully-wired cleanup closure is assigned.
802
+ backpressureTimer = setTimeout(() => cleanup(), 10_000);
803
+ res.once('drain', () => {
804
+ backpressured = false;
805
+ if (backpressureTimer)
806
+ clearTimeout(backpressureTimer);
807
+ backpressureTimer = undefined;
808
+ });
809
+ }
771
810
  }
772
811
  catch {
773
812
  // Socket closed; cleanup will be triggered by 'close' event.
@@ -778,7 +817,7 @@ function handleSseEvents(req, res, cfg, allowDispatch) {
778
817
  let updateInFlight = false;
779
818
  // Emit one full update (runs, swarms, inbox, daemon slices).
780
819
  async function emitUpdate() {
781
- if (updateInFlight)
820
+ if (updateInFlight || backpressured)
782
821
  return;
783
822
  updateInFlight = true;
784
823
  try {
@@ -809,7 +848,11 @@ function handleSseEvents(req, res, cfg, allowDispatch) {
809
848
  catch {
810
849
  // Keep the event stream alive with explicit unavailable provenance.
811
850
  }
851
+ if (cleaned)
852
+ return;
812
853
  const daemon = await readFreshDaemonObservation();
854
+ if (cleaned)
855
+ return;
813
856
  sendNamed('daemon', legacyDaemonProjection(daemon));
814
857
  sendNamed('daemon-observation', daemon);
815
858
  // M90: fleet-activity liveness pulse — carry daemon tick count so the
@@ -856,8 +899,15 @@ function handleSseEvents(req, res, cfg, allowDispatch) {
856
899
  }
857
900
  }, SSE_POLL_MS);
858
901
  // Cleanup: clear the interval and end the response.
859
- const cleanup = () => {
902
+ cleanup = () => {
903
+ if (cleaned)
904
+ return;
905
+ cleaned = true;
860
906
  clearInterval(intervalId);
907
+ if (backpressureTimer)
908
+ clearTimeout(backpressureTimer);
909
+ if (expiryTimer)
910
+ clearTimeout(expiryTimer);
861
911
  deregisterSse(sseId);
862
912
  try {
863
913
  res.end();
@@ -866,7 +916,12 @@ function handleSseEvents(req, res, cfg, allowDispatch) {
866
916
  // Already ended.
867
917
  }
868
918
  };
869
- const sseId = registerSse(cleanup);
919
+ const sseId = registerSse(cleanup, readSession.id);
920
+ const expiryDelay = readSession.expiresAt - Date.now();
921
+ const expiryTimer = setTimeout(() => {
922
+ sendNamed('session-expired', { expired: true });
923
+ cleanup();
924
+ }, Math.max(0, expiryDelay));
870
925
  // Clear on client disconnect.
871
926
  req.on('close', cleanup);
872
927
  req.on('error', cleanup);
@@ -1114,13 +1169,71 @@ export async function handleApi(req, res, cfg, ctx) {
1114
1169
  sendJson(res, 200, pack);
1115
1170
  return true;
1116
1171
  }
1172
+ // Valid `?status=` values for GET /api/inbox — mirrors INBOX_STATUS_FILTERS
1173
+ // in src/cli/inbox.ts exactly. `?limit=` beyond the default is capped at
1174
+ // INBOX_LIST_HARD_MAX_LIMIT regardless of what the caller asks for.
1175
+ const INBOX_STATUS_QUERY_VALUES = new Set([
1176
+ 'pending', 'approved', 'rejected', 'awaiting-host-merge', 'applied', 'failed', 'all',
1177
+ ]);
1178
+ const INBOX_LIST_DEFAULT_LIMIT = 200;
1179
+ const INBOX_LIST_HARD_MAX_LIMIT = 1000;
1117
1180
  // ── GET /api/inbox ───────────────────────────────────────────────────────
1118
- // M23: read-only pending-proposals view. listProposals never throws.
1181
+ // M23: read-only proposals view. Defaults to pending-only, byte-identical
1182
+ // to the original response, unless an explicit status/since/limit opts
1183
+ // into the "history" view — mirrors `ashlr inbox --status/--since/--limit`
1184
+ // (src/cli/inbox.ts) so the web surface can finally browse rejected/
1185
+ // approved/etc, not just pending. listProposals never throws; every param
1186
+ // is validated against a fixed set / parsed strictly, and the history
1187
+ // response is capped (INBOX_LIST_DEFAULT_LIMIT / hard max) so opting into
1188
+ // filtering can never hand the browser an unbounded JSON payload.
1119
1189
  if (path === '/api/inbox' && method === 'GET') {
1120
- const proposals = listProposals({ status: 'pending' });
1190
+ const statusParam = getQueryParam(req.url ?? '/', 'status');
1191
+ const sinceParam = getQueryParam(req.url ?? '/', 'since');
1192
+ const limitParam = getQueryParam(req.url ?? '/', 'limit');
1193
+ if (statusParam !== undefined && !INBOX_STATUS_QUERY_VALUES.has(statusParam)) {
1194
+ sendJson(res, 400, {
1195
+ error: `status must be one of: ${[...INBOX_STATUS_QUERY_VALUES].join(', ')}; got: ${statusParam}`,
1196
+ });
1197
+ return true;
1198
+ }
1199
+ let sinceMs;
1200
+ if (sinceParam !== undefined) {
1201
+ const parsed = Date.parse(sinceParam);
1202
+ if (Number.isNaN(parsed)) {
1203
+ sendJson(res, 400, { error: `since must be an ISO timestamp or YYYY-MM-DD, got: ${sinceParam}` });
1204
+ return true;
1205
+ }
1206
+ sinceMs = parsed;
1207
+ }
1208
+ let limit;
1209
+ if (limitParam !== undefined) {
1210
+ const n = /^[0-9]+$/.test(limitParam) ? Number.parseInt(limitParam, 10) : NaN;
1211
+ if (!Number.isFinite(n) || n <= 0) {
1212
+ sendJson(res, 400, { error: `limit must be a positive integer, got: ${limitParam}` });
1213
+ return true;
1214
+ }
1215
+ limit = Math.min(n, INBOX_LIST_HARD_MAX_LIMIT);
1216
+ }
1217
+ // No query params at all -> the exact original call/shape (pending
1218
+ // only, unbounded). Any explicit status/since/limit opts into history.
1219
+ const filtering = statusParam !== undefined || sinceMs !== undefined || limit !== undefined;
1220
+ const effectiveStatus = statusParam ?? (sinceMs !== undefined ? 'all' : 'pending');
1221
+ const all = listProposals(); // newest-first; same full-directory read the pending-only call already did.
1222
+ let matched = effectiveStatus === 'all' ? all : all.filter((p) => p.status === effectiveStatus);
1223
+ if (sinceMs !== undefined) {
1224
+ const floor = sinceMs;
1225
+ matched = matched.filter((p) => {
1226
+ const t = Date.parse(p.createdAt);
1227
+ return !Number.isNaN(t) && t >= floor;
1228
+ });
1229
+ }
1230
+ const shown = filtering ? matched.slice(0, limit ?? INBOX_LIST_DEFAULT_LIMIT) : matched;
1121
1231
  sendJson(res, 200, {
1122
- pending: proposals.length,
1123
- proposals,
1232
+ pending: filtering ? all.filter((p) => p.status === 'pending').length : shown.length,
1233
+ total: all.length,
1234
+ proposals: shown,
1235
+ truncated: matched.length > shown.length,
1236
+ filters: { status: effectiveStatus, since: sinceParam ?? null, limit: limit ?? null },
1124
1237
  });
1125
1238
  return true;
1126
1239
  }
@@ -1136,7 +1249,34 @@ export async function handleApi(req, res, cfg, ctx) {
1136
1249
  sendJson(res, 404, { error: `proposal not found: ${id}` });
1137
1250
  return true;
1138
1251
  }
1139
- sendJson(res, 200, proposal);
1252
+ // Join the decisions ledger (read-only, additive) so the operator can
1253
+ // see the judge's actual verdict/reasonCode for this proposal — a
1254
+ // judge-parse-failure/judge-network-failure is an infra failure, NOT a
1255
+ // considered judgment, and the ledger is the only place that
1256
+ // distinction lives (Proposal itself never carries judgeReasonCode).
1257
+ // Never invents a verdict: when the ledger read is degraded or no
1258
+ // decision row matches this id, sourceQuality says exactly that so the
1259
+ // UI's Epistemic primitive renders "unknown" instead of guessing.
1260
+ let decisionEvidence;
1261
+ try {
1262
+ const { readDecisionsDetailed } = await import('../fleet/decisions-ledger.js');
1263
+ const read = readDecisionsDetailed({ proposalId: id, limit: 25 });
1264
+ decisionEvidence = {
1265
+ sourceQuality: {
1266
+ sourceState: read.sourceState,
1267
+ complete: read.complete,
1268
+ ...(read.stopReasons.length > 0 ? { reason: read.stopReasons.join(', ') } : {}),
1269
+ },
1270
+ decisions: read.decisions,
1271
+ };
1272
+ }
1273
+ catch {
1274
+ decisionEvidence = {
1275
+ sourceQuality: { sourceState: 'degraded', complete: false, reason: 'ledger read failed' },
1276
+ decisions: [],
1277
+ };
1278
+ }
1279
+ sendJson(res, 200, { ...proposal, decisionEvidence });
1140
1280
  return true;
1141
1281
  }
1142
1282
  // ── POST /api/inbox/:id/approve|reject (M32) ─────────────────────────
@@ -1251,6 +1391,10 @@ export async function handleApi(req, res, cfg, ctx) {
1251
1391
  const retryable = !unsafeStorage && !mutation.quiesced && (mutation.ok ||
1252
1392
  /fence unavailable|has not quiesced|outward mutation.*active|\bbusy\b/i.test(mutation.reason));
1253
1393
  const fleet = await buildFleetStatus(cfg);
1394
+ // Prime the shared cache with this freshly-computed value so a
1395
+ // GET /api/fleet right after this mutation observes it immediately
1396
+ // instead of a pre-mutation cached value (see fleet-status-cache.ts).
1397
+ primeFleetStatusCache(cfg, fleet);
1254
1398
  sendJson(res, retryable ? 409 : 500, {
1255
1399
  ok: false,
1256
1400
  action: paused ? 'pause' : 'resume',
@@ -1266,6 +1410,8 @@ export async function handleApi(req, res, cfg, ctx) {
1266
1410
  return true;
1267
1411
  }
1268
1412
  const fleet = await buildFleetStatus(cfg);
1413
+ // See comment above — keep the shared cache in sync with the mutation.
1414
+ primeFleetStatusCache(cfg, fleet);
1269
1415
  const service = serviceStatus(daemonServiceInstallOptions(cfg));
1270
1416
  sendJson(res, 200, {
1271
1417
  ok: true,
@@ -1282,8 +1428,14 @@ export async function handleApi(req, res, cfg, ctx) {
1282
1428
  // proposals + merges + paused state). buildFleetStatus never throws; same
1283
1429
  // no-auth read class as /api/daemon and /api/pulse.
1284
1430
  if (path === '/api/fleet' && method === 'GET') {
1285
- const fleet = await buildFleetStatus(cfg);
1286
- sendJson(res, 200, fleet);
1431
+ // M516: serve the cached FleetStatus VERBATIM. Freshness is deliberately
1432
+ // NOT spread in here: test/build-identity.test.ts pins that this route
1433
+ // performs no API-side recomposition, because a caller verifying
1434
+ // buildIdentity must be able to trust that what it received is exactly
1435
+ // what buildFleetStatus produced. Freshness is still reported on the
1436
+ // snapshot and control payloads, where their builders attach it.
1437
+ const cached = await getCachedFleetStatus(cfg);
1438
+ sendJson(res, 200, cached.status);
1287
1439
  return true;
1288
1440
  }
1289
1441
  // ── GET /api/estimate ───────────────────────────────────────────────────
@@ -1345,7 +1497,11 @@ export async function handleApi(req, res, cfg, ctx) {
1345
1497
  }
1346
1498
  // ── GET /api/events (SSE) ────────────────────────────────────────────────
1347
1499
  if (path === '/api/events' && method === 'GET') {
1348
- handleSseEvents(req, res, cfg, ctx.allowDispatch);
1500
+ if (!ctx.readSession || ctx.readSession.expiresAt <= Date.now()) {
1501
+ sendJson(res, 401, { code: 'SESSION_REQUIRED', error: 'valid read session required' });
1502
+ return true;
1503
+ }
1504
+ handleSseEvents(req, res, cfg, ctx.allowDispatch, ctx.readSession);
1349
1505
  return true;
1350
1506
  }
1351
1507
  // ── POST /api/run ────────────────────────────────────────────────────────