@animalabs/agent-framework 0.29.0 → 0.31.0

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 (236) hide show
  1. package/README.md +71 -8
  2. package/dist/src/admission/admit.d.ts +90 -0
  3. package/dist/src/admission/admit.d.ts.map +1 -0
  4. package/dist/src/admission/admit.js +43 -0
  5. package/dist/src/admission/admit.js.map +1 -0
  6. package/dist/src/agent.d.ts +50 -9
  7. package/dist/src/agent.d.ts.map +1 -1
  8. package/dist/src/agent.js +125 -23
  9. package/dist/src/agent.js.map +1 -1
  10. package/dist/src/api/mcp-server-core.d.ts.map +1 -1
  11. package/dist/src/api/mcp-server-core.js +10 -3
  12. package/dist/src/api/mcp-server-core.js.map +1 -1
  13. package/dist/src/author-presentation.d.ts +50 -0
  14. package/dist/src/author-presentation.d.ts.map +1 -0
  15. package/dist/src/author-presentation.js +58 -0
  16. package/dist/src/author-presentation.js.map +1 -0
  17. package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
  18. package/dist/src/code-execution/isolate/render.js +7 -6
  19. package/dist/src/code-execution/isolate/render.js.map +1 -1
  20. package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
  21. package/dist/src/code-execution/isolate/scripts.js +5 -4
  22. package/dist/src/code-execution/isolate/scripts.js.map +1 -1
  23. package/dist/src/dendrite/index.d.ts +6 -0
  24. package/dist/src/dendrite/index.d.ts.map +1 -0
  25. package/dist/src/dendrite/index.js +3 -0
  26. package/dist/src/dendrite/index.js.map +1 -0
  27. package/dist/src/dendrite/presets.d.ts +60 -0
  28. package/dist/src/dendrite/presets.d.ts.map +1 -0
  29. package/dist/src/dendrite/presets.js +117 -0
  30. package/dist/src/dendrite/presets.js.map +1 -0
  31. package/dist/src/dendrite/registry.d.ts +260 -0
  32. package/dist/src/dendrite/registry.d.ts.map +1 -0
  33. package/dist/src/dendrite/registry.js +742 -0
  34. package/dist/src/dendrite/registry.js.map +1 -0
  35. package/dist/src/dendrite/types.d.ts +392 -0
  36. package/dist/src/dendrite/types.d.ts.map +1 -0
  37. package/dist/src/dendrite/types.js +24 -0
  38. package/dist/src/dendrite/types.js.map +1 -0
  39. package/dist/src/framework.d.ts +806 -118
  40. package/dist/src/framework.d.ts.map +1 -1
  41. package/dist/src/framework.js +4180 -1121
  42. package/dist/src/framework.js.map +1 -1
  43. package/dist/src/gate/event-gate.d.ts +36 -21
  44. package/dist/src/gate/event-gate.d.ts.map +1 -1
  45. package/dist/src/gate/event-gate.js +223 -149
  46. package/dist/src/gate/event-gate.js.map +1 -1
  47. package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
  48. package/dist/src/gate/recipe-reconcile.js +6 -5
  49. package/dist/src/gate/recipe-reconcile.js.map +1 -1
  50. package/dist/src/host-presentation.d.ts +4 -0
  51. package/dist/src/host-presentation.d.ts.map +1 -1
  52. package/dist/src/host-presentation.js +12 -10
  53. package/dist/src/host-presentation.js.map +1 -1
  54. package/dist/src/index.d.ts +6 -2
  55. package/dist/src/index.d.ts.map +1 -1
  56. package/dist/src/index.js +7 -1
  57. package/dist/src/index.js.map +1 -1
  58. package/dist/src/inference-bounds.d.ts +37 -0
  59. package/dist/src/inference-bounds.d.ts.map +1 -0
  60. package/dist/src/inference-bounds.js +84 -0
  61. package/dist/src/inference-bounds.js.map +1 -0
  62. package/dist/src/line-reader.d.ts +7 -1
  63. package/dist/src/line-reader.d.ts.map +1 -1
  64. package/dist/src/line-reader.js +17 -6
  65. package/dist/src/line-reader.js.map +1 -1
  66. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  67. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  68. package/dist/src/mcpl/channel-registry.js +136 -58
  69. package/dist/src/mcpl/channel-registry.js.map +1 -1
  70. package/dist/src/mcpl/errors.d.ts +1 -0
  71. package/dist/src/mcpl/errors.d.ts.map +1 -1
  72. package/dist/src/mcpl/errors.js +3 -0
  73. package/dist/src/mcpl/errors.js.map +1 -1
  74. package/dist/src/mcpl/inbound-source.d.ts +2 -2
  75. package/dist/src/mcpl/inbound-source.js +2 -2
  76. package/dist/src/mcpl/inference-router.d.ts +11 -1
  77. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  78. package/dist/src/mcpl/inference-router.js +65 -9
  79. package/dist/src/mcpl/inference-router.js.map +1 -1
  80. package/dist/src/mcpl/modern-connection.d.ts +10 -7
  81. package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
  82. package/dist/src/mcpl/modern-connection.js +85 -74
  83. package/dist/src/mcpl/modern-connection.js.map +1 -1
  84. package/dist/src/mcpl/protocol-family.d.ts +1 -1
  85. package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
  86. package/dist/src/mcpl/protocol-family.js +45 -3
  87. package/dist/src/mcpl/protocol-family.js.map +1 -1
  88. package/dist/src/mcpl/push-coalescer.d.ts +11 -7
  89. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  90. package/dist/src/mcpl/push-coalescer.js +21 -0
  91. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  92. package/dist/src/mcpl/references.d.ts +1 -2
  93. package/dist/src/mcpl/references.d.ts.map +1 -1
  94. package/dist/src/mcpl/references.js +3 -3
  95. package/dist/src/mcpl/references.js.map +1 -1
  96. package/dist/src/mcpl/server-connection.d.ts +14 -0
  97. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  98. package/dist/src/mcpl/server-connection.js +26 -22
  99. package/dist/src/mcpl/server-connection.js.map +1 -1
  100. package/dist/src/mcpl/tool-signature.d.ts +1 -0
  101. package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
  102. package/dist/src/mcpl/tool-signature.js +16 -11
  103. package/dist/src/mcpl/tool-signature.js.map +1 -1
  104. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  105. package/dist/src/mcpl/toolsets-module.js +7 -0
  106. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  107. package/dist/src/mcpl/transport.d.ts +44 -21
  108. package/dist/src/mcpl/transport.d.ts.map +1 -1
  109. package/dist/src/mcpl/transport.js +283 -64
  110. package/dist/src/mcpl/transport.js.map +1 -1
  111. package/dist/src/mcpl/types.d.ts +17 -1
  112. package/dist/src/mcpl/types.d.ts.map +1 -1
  113. package/dist/src/mcpl/types.js.map +1 -1
  114. package/dist/src/module-registry.d.ts +32 -4
  115. package/dist/src/module-registry.d.ts.map +1 -1
  116. package/dist/src/module-registry.js +189 -25
  117. package/dist/src/module-registry.js.map +1 -1
  118. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.js +24 -16
  120. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  121. package/dist/src/modules/sender-name.d.ts +5 -4
  122. package/dist/src/modules/sender-name.d.ts.map +1 -1
  123. package/dist/src/modules/sender-name.js +7 -5
  124. package/dist/src/modules/sender-name.js.map +1 -1
  125. package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
  126. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  127. package/dist/src/modules/workspace/disk-agreement.js +33 -12
  128. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  129. package/dist/src/modules/workspace/index.d.ts +6 -3
  130. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  131. package/dist/src/modules/workspace/index.js +61 -15
  132. package/dist/src/modules/workspace/index.js.map +1 -1
  133. package/dist/src/modules/workspace/observe.d.ts +9 -1
  134. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  135. package/dist/src/modules/workspace/observe.js +50 -10
  136. package/dist/src/modules/workspace/observe.js.map +1 -1
  137. package/dist/src/modules/workspace/reconcile.d.ts +11 -1
  138. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  139. package/dist/src/modules/workspace/reconcile.js +19 -8
  140. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  141. package/dist/src/modules/workspace/types.d.ts +8 -0
  142. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  143. package/dist/src/modules/workspace/types.js.map +1 -1
  144. package/dist/src/modules/workspace/watcher.d.ts +7 -0
  145. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/watcher.js +7 -5
  147. package/dist/src/modules/workspace/watcher.js.map +1 -1
  148. package/dist/src/notice-tags.d.ts +2 -0
  149. package/dist/src/notice-tags.d.ts.map +1 -1
  150. package/dist/src/notice-tags.js +2 -0
  151. package/dist/src/notice-tags.js.map +1 -1
  152. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  153. package/dist/src/recovery/discord-awareness-outbox.js +1 -9
  154. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  155. package/dist/src/silent-heartbeat.d.ts +4 -2
  156. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  157. package/dist/src/silent-heartbeat.js +7 -4
  158. package/dist/src/silent-heartbeat.js.map +1 -1
  159. package/dist/src/stored-injections.d.ts +4 -0
  160. package/dist/src/stored-injections.d.ts.map +1 -1
  161. package/dist/src/stored-injections.js +6 -0
  162. package/dist/src/stored-injections.js.map +1 -1
  163. package/dist/src/supplied-text.d.ts +2 -0
  164. package/dist/src/supplied-text.d.ts.map +1 -1
  165. package/dist/src/supplied-text.js +16 -7
  166. package/dist/src/supplied-text.js.map +1 -1
  167. package/dist/src/tool-result-guard.js +1 -1
  168. package/dist/src/tool-result-guard.js.map +1 -1
  169. package/dist/src/tune-out/coordinator.d.ts +368 -16
  170. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  171. package/dist/src/tune-out/coordinator.js +1224 -136
  172. package/dist/src/tune-out/coordinator.js.map +1 -1
  173. package/dist/src/tune-out/tools.d.ts +115 -7
  174. package/dist/src/tune-out/tools.d.ts.map +1 -1
  175. package/dist/src/tune-out/tools.js +55 -18
  176. package/dist/src/tune-out/tools.js.map +1 -1
  177. package/dist/src/types/agent.d.ts +27 -1
  178. package/dist/src/types/agent.d.ts.map +1 -1
  179. package/dist/src/types/framework.d.ts +153 -15
  180. package/dist/src/types/framework.d.ts.map +1 -1
  181. package/dist/src/types/module.d.ts +42 -3
  182. package/dist/src/types/module.d.ts.map +1 -1
  183. package/dist/src/types/trace.d.ts +140 -3
  184. package/dist/src/types/trace.d.ts.map +1 -1
  185. package/package.json +4 -4
  186. package/src/admission/admit.ts +113 -0
  187. package/src/agent.ts +138 -31
  188. package/src/api/mcp-server-core.ts +10 -3
  189. package/src/author-presentation.ts +81 -0
  190. package/src/code-execution/isolate/render.ts +7 -6
  191. package/src/code-execution/isolate/scripts.ts +5 -4
  192. package/src/dendrite/index.ts +32 -0
  193. package/src/dendrite/presets.ts +149 -0
  194. package/src/dendrite/registry.ts +808 -0
  195. package/src/dendrite/types.ts +397 -0
  196. package/src/framework.ts +4344 -1052
  197. package/src/gate/event-gate.ts +251 -150
  198. package/src/gate/recipe-reconcile.ts +7 -5
  199. package/src/host-presentation.ts +14 -10
  200. package/src/index.ts +16 -2
  201. package/src/inference-bounds.ts +111 -0
  202. package/src/line-reader.ts +22 -5
  203. package/src/mcpl/channel-registry.ts +170 -60
  204. package/src/mcpl/errors.ts +4 -0
  205. package/src/mcpl/inbound-source.ts +2 -2
  206. package/src/mcpl/inference-router.ts +67 -10
  207. package/src/mcpl/modern-connection.ts +83 -68
  208. package/src/mcpl/protocol-family.ts +37 -4
  209. package/src/mcpl/push-coalescer.ts +21 -7
  210. package/src/mcpl/references.ts +3 -4
  211. package/src/mcpl/server-connection.ts +30 -21
  212. package/src/mcpl/tool-signature.ts +18 -11
  213. package/src/mcpl/toolsets-module.ts +7 -0
  214. package/src/mcpl/transport.ts +261 -60
  215. package/src/mcpl/types.ts +18 -1
  216. package/src/module-registry.ts +178 -25
  217. package/src/modules/discord/discord-js-client.ts +33 -29
  218. package/src/modules/sender-name.ts +8 -5
  219. package/src/modules/workspace/disk-agreement.ts +39 -15
  220. package/src/modules/workspace/index.ts +65 -14
  221. package/src/modules/workspace/observe.ts +51 -10
  222. package/src/modules/workspace/reconcile.ts +22 -8
  223. package/src/modules/workspace/types.ts +5 -0
  224. package/src/modules/workspace/watcher.ts +8 -6
  225. package/src/notice-tags.ts +2 -0
  226. package/src/recovery/discord-awareness-outbox.ts +1 -11
  227. package/src/silent-heartbeat.ts +7 -4
  228. package/src/stored-injections.ts +7 -0
  229. package/src/supplied-text.ts +17 -6
  230. package/src/tool-result-guard.ts +1 -1
  231. package/src/tune-out/coordinator.ts +1374 -141
  232. package/src/tune-out/tools.ts +166 -13
  233. package/src/types/agent.ts +30 -1
  234. package/src/types/framework.ts +157 -15
  235. package/src/types/module.ts +42 -3
  236. package/src/types/trace.ts +154 -3
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { NOTICE_TAG } from '../notice-tags.js';
15
- import { cutToFit, suppliedValue } from '../supplied-text.js';
15
+ import { cutToFit, quotedJson, quotedValue, suppliedValue } from '../supplied-text.js';
16
16
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
17
  import { basename, dirname, join } from 'node:path';
18
18
  import { GateScript } from './gate-script.js';
@@ -483,10 +483,10 @@ function beforeWriting(err: unknown): boolean {
483
483
  * "policies" that isn't a list and holds something. `runs` is what the gate
484
484
  * runs meanwhile (rulesRun).
485
485
  */
486
- function noListRefusal(noList: string, runs: string): string {
486
+ function noListRefusal(noList: string, runs: string, path: string): string {
487
487
  return `${noList}, so it has no list of rules a change could edit without writing over what it holds, and nothing `
488
- + `was saved. Meanwhile the gate runs ${runs}. Until gate.json is an object whose "policies" is a list holding its `
489
- + 'rules, a change is refused the same way.';
488
+ + `was saved. Meanwhile the gate runs ${runs}. Until gate.json, at ${path}, is an object whose "policies" is a list `
489
+ + 'holding its rules, a change is refused the same way.';
490
490
  }
491
491
 
492
492
  /**
@@ -495,15 +495,15 @@ function noListRefusal(noList: string, runs: string): string {
495
495
  * written over it, so what it holds, often an operator's unfinished edit, stays
496
496
  * as it is. `runs` is what the gate runs meanwhile (rulesRun).
497
497
  */
498
- function unusableRefusal(unusable: { error: string; notJson?: true }, runs: string): string {
498
+ function unusableRefusal(unusable: { error: string; notJson?: true }, runs: string, path: string): string {
499
499
  if (!unusable.notJson) {
500
- return `gate.json couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written over it. `
501
- + `Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json readable; until then, a change is `
502
- + 'refused the same way.';
500
+ return `gate.json, at ${path}, couldn't be read (${unusable.error}), so a change can't edit it, and nothing was written `
501
+ + `over it. Meanwhile the gate runs ${runs}. Whoever runs this host can make gate.json a file the gate can read; `
502
+ + 'until then, a change is refused the same way.';
503
503
  }
504
- return `gate.json isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without writing `
505
- + `over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change is `
506
- + 'refused the same way.';
504
+ return `gate.json, at ${path}, isn't JSON the gate can read (${unusable.error}), so a change can't edit its rules without `
505
+ + `writing over what it holds, and nothing was saved. Meanwhile the gate runs ${runs}. Until gate.json is JSON, a change `
506
+ + 'is refused the same way.';
507
507
  }
508
508
 
509
509
  /**
@@ -514,13 +514,19 @@ function unusableRefusal(unusable: { error: string; notJson?: true }, runs: stri
514
514
  * (notUsedClause, describeStart) say which rules keep gate.json out of use
515
515
  * and each fix.
516
516
  */
517
- function notUsedRefusal(runs: string): string {
518
- return "gate.json isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in "
517
+ function notUsedRefusal(runs: string, path: string): string {
518
+ return `gate.json, at ${path}, isn't used at this start, which runs as one without it does, and a change would put gate.json's rules in `
519
519
  + `force in place of what that runs, so nothing was saved. Meanwhile the gate runs ${runs}. gate_status says what keeps `
520
520
  + 'gate.json out of use and what would put it in use, such as giving it rules under "policies", or fixing the starting '
521
521
  + 'rules, which whoever runs this host can do, for the next start; until then, a change is refused the same way.';
522
522
  }
523
523
 
524
+ function savedOnlyInForce(path: string, remedy: string | undefined): string {
525
+ return remedy === undefined
526
+ ? ''
527
+ : ` gate.json, at ${path}, isn't in force, and a change is saved only if it brings it into force. ${remedy}`;
528
+ }
529
+
524
530
  /**
525
531
  * A value as a message quotes it: its JSON, cut short when long, never inside a
526
532
  * surrogate pair, since the message reaches the agent's context.
@@ -531,7 +537,8 @@ export function shownValue(value: unknown): string {
531
537
  if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
532
538
  let json: string;
533
539
  try {
534
- json = JSON.stringify(value) ?? String(value);
540
+ const stringified = JSON.stringify(value);
541
+ json = stringified === undefined ? String(value) : quotedJson(stringified);
535
542
  } catch {
536
543
  json = String(value);
537
544
  }
@@ -548,9 +555,22 @@ function joinedList(items: readonly string[], joiner: 'and' | 'or' = 'and'): str
548
555
  return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
549
556
  }
550
557
 
558
+ const asGateNames = (path: string): string => path;
559
+
560
+ function namedBy(names: Readonly<Record<string, string>> | undefined): (path: string) => string {
561
+ if (!names) return asGateNames;
562
+ return (path) => {
563
+ for (let end = path.length; end > 0; end = path.lastIndexOf('.', end - 1)) {
564
+ const head = path.slice(0, end);
565
+ if (Object.hasOwn(names, head)) return `${names[head]}${path.slice(end)}`;
566
+ }
567
+ return path;
568
+ };
569
+ }
570
+
551
571
  /** `"a"`, `"a" and "b"`, `"a", "b" and "c"` (or "or"). */
552
572
  function quotedList(names: readonly string[], joiner: 'and' | 'or' = 'and'): string {
553
- return joinedList(names.map((n) => `"${n}"`), joiner);
573
+ return joinedList(names.map((n) => quotedValue(n)), joiner);
554
574
  }
555
575
 
556
576
  /** 1st, 2nd, 3rd, 4th, 11th, 21st. */
@@ -611,7 +631,7 @@ function fieldNames(
611
631
 
612
632
  /** `"tags" (did you mean "tagsAny", "tagsAll" or "tagsNone"?)` */
613
633
  function slipShown(slip: Slip): string {
614
- return `"${slip.key}" (did you mean ${quotedList(slip.meant, 'or')}?)`;
634
+ return `${quotedValue(slip.key)} (did you mean ${quotedList(slip.meant, 'or')}?)`;
615
635
  }
616
636
 
617
637
  /** Names an object has that are fields read at another level, with where that is (`homes`). */
@@ -651,22 +671,22 @@ function misplacedShown(m: Misplaced): string {
651
671
  * The levels of a gate.json the gate reads fields at, each as a message names
652
672
  * it, `file` naming the file itself.
653
673
  */
654
- function fieldLevels(file: string): ReadonlyArray<{ name: string; fields: readonly string[] }> {
674
+ function fieldLevels(file: string, named: (path: string) => string = asGateNames): ReadonlyArray<{ name: string; fields: readonly string[] }> {
655
675
  return [
656
676
  { name: file, fields: CONFIG_FIELDS },
657
677
  { name: 'a rule', fields: POLICY_FIELDS },
658
678
  { name: '"match"', fields: MATCH_FIELDS },
659
679
  { name: '"match.filter"', fields: FILTER_FIELDS },
660
680
  { name: '"behavior"', fields: BEHAVIOR_KINDS },
661
- { name: '"behavior.rate_limit"', fields: RATE_LIMIT_FIELDS },
662
- { name: '"behavior.passive_sample"', fields: PASSIVE_SAMPLE_FIELDS },
663
- { name: '"behavior.judge"', fields: JUDGE_FIELDS },
681
+ { name: `"${named('behavior.rate_limit')}"`, fields: RATE_LIMIT_FIELDS },
682
+ { name: `"${named('behavior.passive_sample')}"`, fields: PASSIVE_SAMPLE_FIELDS },
683
+ { name: `"${named('behavior.judge')}"`, fields: JUDGE_FIELDS },
664
684
  ];
665
685
  }
666
686
 
667
687
  /** Where a name is a field the gate reads, at a level other than the one whose fields are `here`. */
668
- function homesOf(file: string, here: readonly string[]): (key: string) => string[] {
669
- return (key) => fieldLevels(file)
688
+ function homesOf(file: string, here: readonly string[], named?: (path: string) => string): (key: string) => string[] {
689
+ return (key) => fieldLevels(file, named)
670
690
  .filter((level) => level.fields !== here && level.fields.includes(key))
671
691
  .map((level) => level.name);
672
692
  }
@@ -798,7 +818,7 @@ interface RuleProblem {
798
818
 
799
819
  /** A rule as the gate reads it. */
800
820
  interface RuleReading {
801
- /** How messages name it: `Policy "x"`, or its place when it has no name. */
821
+ /** How messages name it: `Rule "x"`, or its place when it has no name. */
802
822
  where: string;
803
823
  /** The rule as read, unless a problem has no reading. */
804
824
  policy?: GatePolicy;
@@ -833,6 +853,7 @@ interface Reader {
833
853
  where: string;
834
854
  /** How messages name the file the rule is in, for a field of the file's own put in the rule. */
835
855
  file: string;
856
+ named: (path: string) => string;
836
857
  problems: RuleProblem[];
837
858
  notes: string[];
838
859
  /** Set where its rate limit's tokens are a number not over 0, so it wakes on none of what it matches. */
@@ -859,14 +880,15 @@ function readFieldNames(
859
880
  misplacedReadOf: (m: Misplaced) => string | undefined = () => undefined,
860
881
  ): void {
861
882
  const { slips, unread } = fieldNames(obj, fields);
862
- const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields));
863
- noteUnread(level ? `${r.where}: ${level}` : r.where, rest, fields, r.notes);
883
+ const { misplaced, rest } = placedElsewhere(unread, homesOf(r.file, fields, r.named));
884
+ const shown = level === null ? null : r.named(level);
885
+ noteUnread(shown ? `${r.where}: ${shown}` : r.where, rest, fields, r.notes);
864
886
  for (const slip of slips) {
865
- r.problems.push({ text: `${level ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
887
+ r.problems.push({ text: `${shown ?? 'it'} has ${slipShown(slip)}, which the gate doesn't read`, read: readOf(slip) });
866
888
  }
867
889
  for (const m of misplaced) {
868
890
  r.problems.push({
869
- text: `${level ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
891
+ text: `${shown ?? 'it'} has ${misplacedShown(m)}, which the gate doesn't read here`,
870
892
  read: misplacedReadOf(m) ?? (m.keys.length === 1 ? SLIP_READ : 'so they do nothing'),
871
893
  });
872
894
  }
@@ -881,7 +903,7 @@ function droppedEntries(count: number): string {
881
903
  * A rule, read as written, or past each problem it has. `unnamed` names a rule
882
904
  * without a name in messages, and `file` the file it's in.
883
905
  */
884
- function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReading {
906
+ function readRule(raw: unknown, unnamed: string, file = 'gate.json', named: (path: string) => string = asGateNames): RuleReading {
885
907
  if (!isPlainObject(raw)) {
886
908
  return {
887
909
  where: unnamed,
@@ -891,7 +913,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
891
913
  };
892
914
  }
893
915
  const name = nameOf(raw);
894
- const r: Reader = { where: name ? `Policy "${name}"` : unnamed, file, problems: [], notes: [] };
916
+ const r: Reader = { where: name ? `Rule ${quotedValue(name)}` : unnamed, file, named, problems: [], notes: [] };
895
917
  const obj = withoutEmpty(r.where, raw, r.notes, RULE_LEFT_OUT);
896
918
  // Field names first, so a slip of one is named before what its absence does.
897
919
  readFieldNames(r, null, obj, POLICY_FIELDS, (slip) => {
@@ -914,7 +936,7 @@ function readRule(raw: unknown, unnamed: string, file = 'gate.json'): RuleReadin
914
936
  const { behavior, kind } = readBehavior(r, obj.behavior);
915
937
  const match = readMatch(r, obj.match);
916
938
  const resets = readResets(r, obj.resets);
917
- const passthrough = readPassthrough(r, obj.passthrough, behavior, kind);
939
+ const passthrough = readPassthrough(r, obj.passthrough, kind);
918
940
  const reading: RuleReading = {
919
941
  where: r.where, problems: r.problems, notes: r.notes, quiet: kind === 'skip' || kind === 'defer' || r.noTokens === true,
920
942
  };
@@ -969,7 +991,7 @@ function readBehavior(r: Reader, value: unknown): { behavior?: GateBehavior | un
969
991
  }
970
992
  const behavior = READ_KIND[kind](r, b[kind]);
971
993
  for (const other of kinds.slice(1)) {
972
- const unread: Reader = { where: r.where, file: r.file, problems: [], notes: [] };
994
+ const unread: Reader = { where: r.where, file: r.file, named: r.named, problems: [], notes: [] };
973
995
  READ_KIND[other](unread, b[other]);
974
996
  for (const p of unread.problems) r.problems.push({ text: p.text, read: `which goes unread with the rest of "${other}"` });
975
997
  }
@@ -985,18 +1007,18 @@ function readBehavior(r: Reader, value: unknown): { behavior?: GateBehavior | un
985
1007
  */
986
1008
  function readDebounce(r: Reader, ms: unknown): GateBehavior | undefined {
987
1009
  if (typeof ms !== 'number' || !Number.isFinite(ms)) {
988
- r.problems.push({ text: `behavior.debounce is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
1010
+ r.problems.push({ text: `${r.named('behavior.debounce')} is ${shownValue(ms)}, where a number of milliseconds is needed, such as 60000` });
989
1011
  return undefined;
990
1012
  }
991
1013
  if (ms < MIN_DEBOUNCE_MS) {
992
1014
  r.problems.push({
993
- text: `behavior.debounce is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
1015
+ text: `${r.named('behavior.debounce')} is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
994
1016
  });
995
1017
  return { debounce: MIN_DEBOUNCE_MS };
996
1018
  }
997
1019
  if (ms > MAX_DEBOUNCE_MS) {
998
1020
  r.problems.push({
999
- text: `behavior.debounce is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
1021
+ text: `${r.named('behavior.debounce')} is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
1000
1022
  });
1001
1023
  return { debounce: MAX_DEBOUNCE_MS };
1002
1024
  }
@@ -1019,27 +1041,27 @@ function readDebounce(r: Reader, ms: unknown): GateBehavior | undefined {
1019
1041
  function readRateLimit(r: Reader, value: unknown): GateBehavior | undefined {
1020
1042
  if (!isPlainObject(value)) {
1021
1043
  r.problems.push({
1022
- text: `behavior.rate_limit is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
1044
+ text: `${r.named('behavior.rate_limit')} is ${shownValue(value)}, where an object is needed, such as {"tokens": 3, "refillIntervalMs": 60000}`,
1023
1045
  });
1024
1046
  return undefined;
1025
1047
  }
1026
- const conf = withoutEmpty(`${r.where}: behavior.rate_limit`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
1048
+ const conf = withoutEmpty(`${r.where}: ${r.named('behavior.rate_limit')}`, value, r.notes, { keyBy: 'every event the rule matches shares one bucket' });
1027
1049
  readFieldNames(r, 'behavior.rate_limit', conf, RATE_LIMIT_FIELDS);
1028
1050
  const tokens = conf.tokens;
1029
1051
  const refill = conf.refillIntervalMs;
1030
1052
  const tokensUsable = typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0;
1031
1053
  if (typeof tokens === 'number' && !(tokens > 0)) r.noTokens = true;
1032
1054
  if (!tokensUsable) {
1033
- r.problems.push({ text: `behavior.rate_limit.tokens is ${shownValue(tokens)}, where a positive number is needed` });
1055
+ r.problems.push({ text: `${r.named('behavior.rate_limit.tokens')} is ${shownValue(tokens)}, where a positive number is needed` });
1034
1056
  }
1035
1057
  const refillUsable = typeof refill === 'number' && Number.isFinite(refill);
1036
1058
  if (!refillUsable) {
1037
1059
  r.problems.push({
1038
- text: `behavior.rate_limit.refillIntervalMs is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
1060
+ text: `${r.named('behavior.rate_limit.refillIntervalMs')} is ${shownValue(refill)}, where a positive number of milliseconds is needed`,
1039
1061
  });
1040
1062
  } else if (refill <= 0) {
1041
1063
  r.problems.push({
1042
- text: `behavior.rate_limit.refillIntervalMs is ${refill}, where a positive number of milliseconds is needed`,
1064
+ text: `${r.named('behavior.rate_limit.refillIntervalMs')} is ${refill}, where a positive number of milliseconds is needed`,
1043
1065
  read: refill === 0
1044
1066
  ? 'so its tokens refill at once, and it limits nothing it matches'
1045
1067
  : "so its tokens never refill, and once they're spent it wakes the agent on nothing it matches",
@@ -1059,21 +1081,21 @@ function readRateLimit(r: Reader, value: unknown): GateBehavior | undefined {
1059
1081
  */
1060
1082
  function readPassiveSample(r: Reader, value: unknown): GateBehavior | undefined {
1061
1083
  if (!isPlainObject(value)) {
1062
- r.problems.push({ text: `behavior.passive_sample is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
1084
+ r.problems.push({ text: `${r.named('behavior.passive_sample')} is ${shownValue(value)}, where an object is needed, such as {"every": 10}` });
1063
1085
  return undefined;
1064
1086
  }
1065
- const conf = withoutEmpty(`${r.where}: behavior.passive_sample`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
1087
+ const conf = withoutEmpty(`${r.where}: ${r.named('behavior.passive_sample')}`, value, r.notes, { keyBy: 'every event the rule matches shares one count' });
1066
1088
  readFieldNames(r, 'behavior.passive_sample', conf, PASSIVE_SAMPLE_FIELDS);
1067
1089
  const given = conf.every;
1068
1090
  let every: number | undefined;
1069
1091
  if (typeof given !== 'number' || !Number.isFinite(given)) {
1070
- r.problems.push({ text: `behavior.passive_sample.every is ${shownValue(given)}, where a whole number of 1 or more is needed` });
1092
+ r.problems.push({ text: `${r.named('behavior.passive_sample.every')} is ${shownValue(given)}, where a whole number of 1 or more is needed` });
1071
1093
  } else if (Number.isInteger(given) && given > 0) {
1072
1094
  every = given;
1073
1095
  } else {
1074
1096
  every = given <= 0 ? 1 : Math.ceil(given);
1075
1097
  r.problems.push({
1076
- text: `behavior.passive_sample.every is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
1098
+ text: `${r.named('behavior.passive_sample.every')} is ${given}, ${given <= 0 ? 'under 1' : 'not a whole number'}`,
1077
1099
  read: `so it's read as ${every}`,
1078
1100
  });
1079
1101
  }
@@ -1096,11 +1118,11 @@ function readPassiveSample(r: Reader, value: unknown): GateBehavior | undefined
1096
1118
  function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
1097
1119
  if (!isPlainObject(value)) {
1098
1120
  r.problems.push({
1099
- text: `behavior.judge is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
1121
+ text: `${r.named('behavior.judge')} is ${shownValue(value)}, where an object is needed, such as {"debounceMs": 30000, "instructions": "..."}`,
1100
1122
  });
1101
1123
  return undefined;
1102
1124
  }
1103
- const conf = withoutEmpty(`${r.where}: behavior.judge`, value, r.notes, {
1125
+ const conf = withoutEmpty(`${r.where}: ${r.named('behavior.judge')}`, value, r.notes, {
1104
1126
  maxWaitMs: 'a batch is judged only after its quiet period',
1105
1127
  instructions: 'the judge decides without the agent\'s word',
1106
1128
  });
@@ -1109,16 +1131,16 @@ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
1109
1131
  const ms = conf.debounceMs;
1110
1132
  let debounceMs: number | undefined;
1111
1133
  if (typeof ms !== 'number' || !Number.isFinite(ms)) {
1112
- r.problems.push({ text: `behavior.judge.debounceMs is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
1134
+ r.problems.push({ text: `${r.named('behavior.judge.debounceMs')} is ${shownValue(ms)}, where a number of milliseconds is needed, such as 30000` });
1113
1135
  ok = false;
1114
1136
  } else if (ms < MIN_DEBOUNCE_MS) {
1115
1137
  r.problems.push({
1116
- text: `behavior.judge.debounceMs is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
1138
+ text: `${r.named('behavior.judge.debounceMs')} is ${ms} ms, under the ${MIN_DEBOUNCE_MS} ms minimum`, read: `so it's read as ${MIN_DEBOUNCE_MS} ms`,
1117
1139
  });
1118
1140
  debounceMs = MIN_DEBOUNCE_MS;
1119
1141
  } else if (ms > MAX_DEBOUNCE_MS) {
1120
1142
  r.problems.push({
1121
- text: `behavior.judge.debounceMs is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
1143
+ text: `${r.named('behavior.judge.debounceMs')} is ${ms} ms, over the ${MAX_DEBOUNCE_MS} ms maximum`, read: `so it's read as ${MAX_DEBOUNCE_MS} ms`,
1122
1144
  });
1123
1145
  debounceMs = MAX_DEBOUNCE_MS;
1124
1146
  } else {
@@ -1128,19 +1150,19 @@ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
1128
1150
  const wait = conf.maxWaitMs;
1129
1151
  if (wait !== undefined) {
1130
1152
  if (typeof wait !== 'number' || !Number.isFinite(wait)) {
1131
- r.problems.push({ text: `behavior.judge.maxWaitMs is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
1153
+ r.problems.push({ text: `${r.named('behavior.judge.maxWaitMs')} is ${shownValue(wait)}, where a number of milliseconds is needed, such as 120000` });
1132
1154
  ok = false;
1133
1155
  } else {
1134
1156
  const floor = debounceMs ?? MIN_DEBOUNCE_MS;
1135
1157
  if (wait < floor) {
1136
1158
  r.problems.push({
1137
- text: `behavior.judge.maxWaitMs is ${wait} ms, under the rule's ${floor} ms quiet period`,
1159
+ text: `${r.named('behavior.judge.maxWaitMs')} is ${wait} ms, under the rule's ${floor} ms quiet period`,
1138
1160
  read: `so it's read as ${floor} ms`,
1139
1161
  });
1140
1162
  maxWaitMs = floor;
1141
1163
  } else if (wait > MAX_JUDGE_WAIT_MS) {
1142
1164
  r.problems.push({
1143
- text: `behavior.judge.maxWaitMs is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
1165
+ text: `${r.named('behavior.judge.maxWaitMs')} is ${wait} ms, over the ${MAX_JUDGE_WAIT_MS} ms maximum`,
1144
1166
  read: `so it's read as ${MAX_JUDGE_WAIT_MS} ms`,
1145
1167
  });
1146
1168
  maxWaitMs = MAX_JUDGE_WAIT_MS;
@@ -1153,11 +1175,11 @@ function readJudge(r: Reader, value: unknown): GateBehavior | undefined {
1153
1175
  let instructions: string | undefined;
1154
1176
  if (said !== undefined) {
1155
1177
  if (typeof said !== 'string') {
1156
- r.problems.push({ text: `behavior.judge.instructions is ${shownValue(said)}, where text is needed` });
1178
+ r.problems.push({ text: `${r.named('behavior.judge.instructions')} is ${shownValue(said)}, where text is needed` });
1157
1179
  ok = false;
1158
1180
  } else if (said.length > MAX_JUDGE_INSTRUCTIONS) {
1159
1181
  r.problems.push({
1160
- text: `behavior.judge.instructions is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
1182
+ text: `${r.named('behavior.judge.instructions')} is ${said.length} characters long, over the ${MAX_JUDGE_INSTRUCTIONS}-character maximum`,
1161
1183
  });
1162
1184
  ok = false;
1163
1185
  } else {
@@ -1184,7 +1206,7 @@ function readKeyBy(r: Reader, kind: 'rate_limit' | 'passive_sample', value: unkn
1184
1206
  if (value === undefined) return undefined;
1185
1207
  if (typeof value === 'string') return value;
1186
1208
  r.problems.push({
1187
- text: `behavior.${kind}.keyBy is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
1209
+ text: `${r.named(`behavior.${kind}.keyBy`)} is ${shownValue(value)}, where a field name is needed, such as "channelId"`,
1188
1210
  read: `so it does nothing, and every event the rule matches shares one ${kind === 'rate_limit' ? 'bucket' : 'count'}`,
1189
1211
  });
1190
1212
  return undefined;
@@ -1299,7 +1321,10 @@ function readMatch(r: Reader, value: unknown): GatePolicyMatch {
1299
1321
  new RegExp(f.pattern);
1300
1322
  match.filter = { type, pattern: f.pattern };
1301
1323
  } catch (e) {
1302
- r.problems.push({ text: `match.filter.pattern isn't a regular expression the gate can read (${errorText(e)})` });
1324
+ r.problems.push({
1325
+ text: `match.filter.pattern isn't a regular expression the gate can read (${quotedValue(errorText(e))}), so fix it, `
1326
+ + 'or give match.filter.type "text" to look for the pattern as plain text',
1327
+ });
1303
1328
  }
1304
1329
  }
1305
1330
  }
@@ -1341,18 +1366,17 @@ function readResets(r: Reader, value: unknown): string[] | undefined {
1341
1366
  * always, defer, skip or a debounce it would be a no-op at best and a
1342
1367
  * misleading promise at worst, so it's refused rather than ignored.
1343
1368
  */
1344
- function readPassthrough(
1345
- r: Reader, value: unknown, behavior: GateBehavior | undefined, kind: BehaviorKind | undefined,
1346
- ): boolean {
1369
+ function readPassthrough(r: Reader, value: unknown, kind: BehaviorKind | undefined): boolean {
1347
1370
  if (value === undefined || value === false) return false;
1348
1371
  if (value !== true) {
1349
1372
  r.problems.push({ text: `passthrough is ${shownValue(value)}, where true or false is needed` });
1350
1373
  return false;
1351
1374
  }
1352
1375
  if (kind === undefined || kind === 'rate_limit' || kind === 'passive_sample') return true;
1376
+ const given = kind === 'always' || kind === 'defer' || kind === 'skip' ? `behavior "${kind}"` : r.named(`behavior.${kind}`);
1353
1377
  r.problems.push({
1354
- text: `passthrough is only valid with rate_limit or passive_sample behaviors — for "${behavior ? formatBehavior(behavior) : kind}" `
1355
- + 'there is no non-firing match to fall through on',
1378
+ text: `passthrough is true, and only a ${r.named('behavior.rate_limit')} or ${r.named('behavior.passive_sample')} rule passes on `
1379
+ + `an event it matches without waking on it; with ${given}, leave passthrough out`,
1356
1380
  });
1357
1381
  return false;
1358
1382
  }
@@ -1518,7 +1542,7 @@ function readConfig(
1518
1542
  }
1519
1543
 
1520
1544
  const rules = policies.map((rule, i) => {
1521
- const reading = readRule(rule, `The ${ordinal(i + 1)} policy`, file);
1545
+ const reading = readRule(rule, `The ${ordinal(i + 1)} rule`, file);
1522
1546
  return { raw: rule, reading, outcome: ruleOutcome(reading) };
1523
1547
  });
1524
1548
  if (defaultBehavior !== 'always') {
@@ -1604,7 +1628,7 @@ function readLastRecipe(path: string): { recipe: WrittenConfig | null; problem?:
1604
1628
  if (read.refusedWhole !== undefined) return { recipe: null, problem: "it isn't read as the starting rules are" };
1605
1629
  const rules = read.rules.map((rule) => rule.raw);
1606
1630
  const twice = duplicateName(rules);
1607
- if (twice !== undefined) return { recipe: null, problem: `it names the rule "${twice}" twice` };
1631
+ if (twice !== undefined) return { recipe: null, problem: `it names the rule ${quotedValue(twice)} twice` };
1608
1632
  return { recipe: { rules, default: isPlainObject(raw) ? raw.default : undefined } };
1609
1633
  }
1610
1634
 
@@ -1614,7 +1638,7 @@ function readLastRecipe(path: string): { recipe: WrittenConfig | null; problem?:
1614
1638
  * wrote as read agrees with the same rule written more briefly.
1615
1639
  */
1616
1640
  function ruleMeaning(rule: unknown): string {
1617
- const reading = readRule(rule, 'A policy');
1641
+ const reading = readRule(rule, 'A rule');
1618
1642
  return reading.policy ? `read ${canonical(reading.policy)}` : `written ${canonical(rule)}`;
1619
1643
  }
1620
1644
 
@@ -1725,7 +1749,7 @@ function notUsedClause(write: Extract<StartWrite, { kind: 'start-without' }>, st
1725
1749
  */
1726
1750
  function outOfForce(write: Extract<StartWrite, { kind: 'start-without' }>): { said: string; fix: string; nextStart: boolean } | null {
1727
1751
  const nameless = write.nameless ?? 0;
1728
- const left = [...(write.leftOut ?? []).map((name) => `"${name}"`), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
1752
+ const left = [...(write.leftOut ?? []).map((name) => quotedValue(name)), ...(nameless === 0 ? [] : [nameless === 1 ? 'one with no name' : `${nameless} with no name`])];
1729
1753
  const held = write.heldOut ?? [];
1730
1754
  const said: string[] = [];
1731
1755
  const fix: string[] = [];
@@ -1954,14 +1978,14 @@ export function findShadowedPolicies(policies: GatePolicy[], eventTypesFrom?: Ev
1954
1978
  * three it is (the rule never applies; it misses some of its events; it
1955
1979
  * may not be meant for those events at all) and what to do. */
1956
1980
  export function formatShadowWarning(w: ShadowWarning): string {
1957
- const later = `"${w.later}"`;
1958
- const earlier = `"${w.earlier}"`;
1959
- const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore: "${w.earlier}")`;
1981
+ const later = quotedValue(w.later);
1982
+ const earlier = quotedValue(w.earlier);
1983
+ const move = `move ${later} above ${earlier} (wake_add_rule with the whole rule and insertBefore: ${quotedValue(w.earlier)})`;
1960
1984
  if (w.whole) {
1961
1985
  return `${later} never applies: ${earlier} comes first and matches every event ${later} would. `
1962
1986
  + `If ${later} should apply, ${move}, or narrow ${earlier}.`;
1963
1987
  }
1964
- const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.join(', ');
1988
+ const types = w.eventTypes === 'all' ? 'all' : w.eventTypes.map((type) => quotedValue(type).slice(1, -1)).join(', ');
1965
1989
  if (w.known) {
1966
1990
  return `${earlier} comes first and takes ${later}'s ${types} events, so ${later} applies only to its others. `
1967
1991
  + `If it should apply to those too, ${move}.`;
@@ -1994,20 +2018,23 @@ function afterChange(name: string, callback: () => void): void {
1994
2018
  }
1995
2019
 
1996
2020
  /** The privileged-users file's error when some entries of its list aren't user ids. */
1997
- function skippedPrivilegedIds(skipped: unknown[], loaded: number): string {
2021
+ function skippedPrivilegedIds(skipped: unknown[], loaded: number, path: string): string {
1998
2022
  const one = skipped.length === 1;
1999
2023
  const shown = skipped.slice(0, 5).map((x) => {
2000
2024
  if (typeof x === 'number' && Number.isInteger(x)) {
2001
2025
  return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`;
2002
2026
  }
2003
- const json = JSON.stringify(x) ?? String(x);
2027
+ const json = quotedValue(x);
2004
2028
  return json.length > 40 ? `${safeSlice(json, 0, 39)}…` : json;
2005
2029
  });
2006
2030
  const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : '';
2007
- const rest = loaded === 0 ? 'no id loaded, so no one is privileged' : `${loaded} ${loaded === 1 ? 'id' : 'ids'} did load`;
2008
- return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file ${one ? "isn't a user id" : "aren't user ids"}, `
2031
+ const rest = loaded === 0
2032
+ ? 'no id loaded, so no one is privileged'
2033
+ : `the ${loaded === 1 ? 'one id that did load is' : `${loaded} ids that did load are`} privileged`;
2034
+ return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file, at ${path}, ${one ? "isn't a user id" : "aren't user ids"}, `
2009
2035
  + `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer `
2010
- + `(a whole number within 2^53 - 1 of zero); ${rest}.`;
2036
+ + `(a whole number within 2^53 - 1 of zero); ${rest}. Once ${one ? 'that entry is a user id' : 'those entries are user ids'}, the gate `
2037
+ + 'reads the file again at its next check for changes.';
2011
2038
  }
2012
2039
 
2013
2040
  // ---------------------------------------------------------------------------
@@ -2039,6 +2066,7 @@ export class EventGate {
2039
2066
  /** Whether gate.json's error is one that keeps what it holds out of force
2040
2067
  * (configUnreadable), so gate_status says what's in force instead. */
2041
2068
  private configUnused = false;
2069
+ private configFix: string | null = null;
2042
2070
  private privilegedError: string | null = null;
2043
2071
  /** What the start did with the host's starting rules that it couldn't use
2044
2072
  * or add (describeStart), kept apart from gate.json's own error, until the
@@ -2119,8 +2147,8 @@ export class EventGate {
2119
2147
  private sleepSuppressed = 0;
2120
2148
  private sleepTimer: ReturnType<typeof setTimeout> | null = null;
2121
2149
  private sleepAgent: string | undefined;
2122
- // From dispose() to the next resumeSleep(): no sleep or self-wake timer is
2123
- // armed, though a turn a failed stop() left running can still ask for one.
2150
+ // From dispose() on, no sleep or self-wake timer is armed, though a turn a
2151
+ // failed stop() left running can still ask for one.
2124
2152
  private disposed = false;
2125
2153
 
2126
2154
  // Self-wake timers per agent (skip_reply's wake_in_seconds). Unlike sleep,
@@ -2156,6 +2184,8 @@ export class EventGate {
2156
2184
  ) => unknown;
2157
2185
  private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
2158
2186
  private getAgentNamesFn: () => string[];
2187
+ /** Delivery-time re-check: is this channel held from the recipients now? */
2188
+ private isHeldFn: (serverId: string | undefined, channelId: string | undefined) => boolean;
2159
2189
  private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined;
2160
2190
  private channelShownFn?: (channel: { serverId: string; channelId: string }) => string;
2161
2191
  private isNewestEntryFn?: (entry: GateEntryRef) => boolean;
@@ -2180,6 +2210,13 @@ export class EventGate {
2180
2210
  * message is answered. */
2181
2211
  requestInference: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void;
2182
2212
  getAgentNames: () => string[];
2213
+ /**
2214
+ * Whether a channel is held from the recipients (a tune-out epoch in
2215
+ * force). A held event never enters a batch — admission runs before the
2216
+ * gate — but a hold can begin after an event was batched; at delivery
2217
+ * the batch is re-checked and held events are dropped (#232 H3).
2218
+ */
2219
+ isHeld?: (serverId: string | undefined, channelId: string | undefined) => boolean;
2183
2220
  /** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
2184
2221
  now?: () => number;
2185
2222
  /** Per-event timeout (ms) for the optional gate.js script. Default 50. */
@@ -2228,6 +2265,7 @@ export class EventGate {
2228
2265
  this.addMessageFn = opts.addMessage;
2229
2266
  this.requestInferenceFn = opts.requestInference;
2230
2267
  this.getAgentNamesFn = opts.getAgentNames;
2268
+ this.isHeldFn = opts.isHeld ?? (() => false);
2231
2269
  this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel;
2232
2270
  this.channelShownFn = opts.channelShown;
2233
2271
  this.isNewestEntryFn = opts.isNewestEntry;
@@ -2356,7 +2394,7 @@ export class EventGate {
2356
2394
  }
2357
2395
  const twice = duplicateName(starting.rules.map((rule) => rule.raw));
2358
2396
  if (twice !== undefined) {
2359
- this.startMergeNote = `At start, the recipe names the rule "${twice}" twice, so gate.json was seeded with it as given and it `
2397
+ this.startMergeNote = `At start, the recipe names the rule ${quotedValue(twice)} twice, so gate.json was seeded with it as given and it `
2360
2398
  + "wasn't recorded as the recipe last applied: a later start merges the recipe by name, once gate.json and the recipe "
2361
2399
  + 'name each rule once.';
2362
2400
  } else {
@@ -2550,7 +2588,13 @@ export class EventGate {
2550
2588
  try {
2551
2589
  raw = readFileSync(this.configPath, 'utf-8');
2552
2590
  } catch (err) {
2553
- if (!isMissing(err)) return this.configUnreadable(err);
2591
+ if (!isMissing(err)) {
2592
+ const why = quotedValue(errorText(err));
2593
+ return this.configUnreadable(
2594
+ why, `gate.json, at ${this.configPath}, can't be read: ${why}`,
2595
+ 'Whoever runs this host can make it a file the gate can read, and the gate reads it again at its next check for changes.',
2596
+ );
2597
+ }
2554
2598
  // No file is no problem with it: the rules in force stay.
2555
2599
  this.configError = null;
2556
2600
  this.configUnused = false;
@@ -2569,12 +2613,25 @@ export class EventGate {
2569
2613
  try {
2570
2614
  parsed = parseGateJson(raw);
2571
2615
  } catch (err) {
2572
- return { ...this.configUnreadable(err), notJson: true };
2616
+ const why = quotedValue(errorText(err));
2617
+ return {
2618
+ ...this.configUnreadable(
2619
+ why, `gate.json, at ${this.configPath}, isn't JSON: ${why}`,
2620
+ "Once it's JSON, the gate reads it again at its next check for changes.",
2621
+ ),
2622
+ notJson: true,
2623
+ };
2573
2624
  }
2574
2625
  const read = readConfig(parsed, 'gate.json');
2575
2626
  // JSON the gate doesn't use is still the operator's file, edited as written.
2576
2627
  if ('refused' in read) {
2577
- return { ...this.configUnreadable(new Error(read.refused)), state: 'refused', listed: read.listed, silenced: read.silenced };
2628
+ const problem = read.refused.startsWith('gate.json ')
2629
+ ? `gate.json, at ${this.configPath},${read.refused.slice('gate.json'.length)}`
2630
+ : read.refused;
2631
+ return {
2632
+ ...this.configUnreadable(read.refused, problem, 'Once gate.json is mended, the gate reads it again at its next check for changes.'),
2633
+ state: 'refused', listed: read.listed, silenced: read.silenced,
2634
+ };
2578
2635
  }
2579
2636
  // A field the gate doesn't read does nothing, and a rule is read past a
2580
2637
  // problem or left out, so the file loads, and each is named.
@@ -2588,20 +2645,20 @@ export class EventGate {
2588
2645
  * gate.json's current error, replacing the last one, and traced and logged
2589
2646
  * when it's new, so an edit that doesn't load is in the log once.
2590
2647
  */
2591
- private configUnreadable(err: unknown): { state: 'unreadable'; error: string } {
2592
- const msg = err instanceof Error ? err.message : String(err);
2593
- if (msg !== this.configError) {
2594
- console.error(`[gate] ${this.configPath} couldn't be used (${msg}), so what it holds isn't in force`);
2648
+ private configUnreadable(why: string, problem: string, fix: string | null = null): { state: 'unreadable'; error: string } {
2649
+ if (problem !== this.configError) {
2650
+ console.error(`[gate] ${this.configPath} couldn't be used (${why}), so what it holds isn't in force${fix ? `. ${fix}` : ''}`);
2595
2651
  this.emitTrace({
2596
2652
  type: 'gate:config-error',
2597
- error: msg,
2653
+ error: why,
2598
2654
  configPath: this.configPath,
2599
2655
  timestamp: Date.now(),
2600
2656
  });
2601
2657
  }
2602
- this.configError = msg;
2658
+ this.configError = problem;
2659
+ this.configFix = fix;
2603
2660
  this.configUnused = true;
2604
- return { state: 'unreadable', error: msg };
2661
+ return { state: 'unreadable', error: why };
2605
2662
  }
2606
2663
 
2607
2664
  /**
@@ -2610,7 +2667,8 @@ export class EventGate {
2610
2667
  */
2611
2668
  private configErrorShown(): string | null {
2612
2669
  if (this.configError === null || !this.configUnused) return this.configError;
2613
- return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`;
2670
+ return `${sentence(this.configError)} So what gate.json holds isn't in force, and the gate runs ${this.rulesRun()}.`
2671
+ + (this.configFix ? ` ${this.configFix}` : '');
2614
2672
  }
2615
2673
 
2616
2674
  /** What the gate runs, by where its rules came from, for a text that says so: "the host's starting rules". */
@@ -2655,13 +2713,13 @@ export class EventGate {
2655
2713
  | { top: Record<string, unknown>; rules: unknown[]; remedy?: string }
2656
2714
  | { refused: string } {
2657
2715
  const read = this.readConfigFile();
2658
- if (read.state === 'unreadable') return { refused: unusableRefusal(read, this.rulesRun()) };
2716
+ if (read.state === 'unreadable') return { refused: unusableRefusal(read, this.rulesRun(), this.configPath) };
2659
2717
  if (read.state === 'loaded' || read.state === 'refused') {
2660
- if ('noList' in read.listed) return { refused: noListRefusal(read.listed.noList, this.rulesRun()) };
2718
+ if ('noList' in read.listed) return { refused: noListRefusal(read.listed.noList, this.rulesRun(), this.configPath) };
2661
2719
  // Rules not gate.json's in force while it holds the text in force: what a
2662
2720
  // start ran in place of one with no rules of its own, until it changes.
2663
2721
  if (read.state === 'loaded' && read.text === this.configText && this.configSource !== 'file') {
2664
- return { refused: notUsedRefusal(this.rulesRun()) };
2722
+ return { refused: notUsedRefusal(this.rulesRun(), this.configPath) };
2665
2723
  }
2666
2724
  const edit = { top: read.listed.top, rules: [...read.listed.rules] };
2667
2725
  if (read.state === 'loaded') return edit;
@@ -2678,9 +2736,9 @@ export class EventGate {
2678
2736
  return {
2679
2737
  ...edit,
2680
2738
  remedy: sharing === 1
2681
- ? `${removes ? 'Replacing or removing' : 'Replacing'} "${only}" by name would bring gate.json into force.`
2739
+ ? `${removes ? 'Replacing or removing' : 'Replacing'} ${quotedValue(only)} by name would bring gate.json into force.`
2682
2740
  : sharing > 1
2683
- ? `gate.json has ${sharing} rules named "${only}", and a change by that name replaces the first of them`
2741
+ ? `gate.json has ${sharing} rules named ${quotedValue(only)}, and a change by that name replaces the first of them`
2684
2742
  + `${removes ? ' or removes them all' : ''}.`
2685
2743
  : 'No change through the gate can bring it into force, so gate.json has to be fixed by an edit of its own; '
2686
2744
  + 'until then, a change is refused the same way.',
@@ -2854,9 +2912,18 @@ export class EventGate {
2854
2912
  options?: {
2855
2913
  position?: 'append' | 'prepend' | { before: string } | { after: string };
2856
2914
  onIgnoredFields?: (notes: string[]) => void;
2915
+ /** The caller's own name for a field a refusal names, keyed by gate.json's name for it: one of
2916
+ * `behavior.debounce`, `behavior.rate_limit`, `behavior.passive_sample` and `behavior.judge`, or
2917
+ * `position.before` and `position.after` for a placement. A key names the fields under it too:
2918
+ * `behavior.rate_limit` gives `behavior.rate_limit.tokens` its name, and a longer key wins.
2919
+ * Other fields keep gate.json's names.
2920
+ *
2921
+ * @internal For `wake_add_rule`'s refusals; not a host API. */
2922
+ fieldNames?: Readonly<Record<string, string>>;
2857
2923
  },
2858
2924
  ): GatePolicy {
2859
- const reading = readRule(rawPolicy, 'A policy');
2925
+ const named = namedBy(options?.fieldNames);
2926
+ const reading = readRule(rawPolicy, 'A rule', 'gate.json', named);
2860
2927
  if (reading.problems.length > 0 || !reading.policy) throw new Error(refusalOf(reading));
2861
2928
  const policy = reading.policy;
2862
2929
  const ignored = notesOf(reading);
@@ -2876,9 +2943,26 @@ export class EventGate {
2876
2943
  const anchorName = 'before' in position ? position.before : position.after;
2877
2944
  const anchorIdx = rules.findIndex((rule) => nameOf(rule) === anchorName);
2878
2945
  if (anchorIdx < 0) {
2946
+ const field = named('before' in position ? 'position.before' : 'position.after');
2947
+ const others = [...new Set(rules.map(nameOf).filter((name): name is string => name !== undefined))];
2948
+ if (idx >= 0 && anchorName === policy.name) {
2949
+ throw new Error(
2950
+ `${reading.where} wasn't saved: ${field} is ${quotedValue(anchorName)}, the rule being saved, and a rule can't be `
2951
+ + 'placed beside itself. '
2952
+ + (others.length > 0
2953
+ ? `The other rules are ${quotedList(others)}. Give ${field} one of those to place the rule beside it, or leave it out`
2954
+ : `There are no other rules, so leave ${field} out`)
2955
+ + ' to replace the rule where it is.'
2956
+ + savedOnlyInForce(this.configPath, remedy),
2957
+ );
2958
+ }
2879
2959
  throw new Error(
2880
- `addPolicy: no policy named "${anchorName}" to anchor on — `
2881
- + `have: [${rules.map(nameOf).filter((name) => name !== undefined).join(', ')}]`,
2960
+ `${reading.where} wasn't saved: ${field} is ${quotedValue(anchorName)}, and no rule has that name. `
2961
+ + (others.length > 0
2962
+ ? `The other rules are ${quotedList(others)}. Give ${field} one of those to place the rule beside it, or leave it out`
2963
+ : `There are no other rules, so leave ${field} out`)
2964
+ + ' to add the rule at the end, or, where a rule already has its name, to replace that rule where it is.'
2965
+ + savedOnlyInForce(this.configPath, remedy),
2882
2966
  );
2883
2967
  }
2884
2968
  rules.splice('before' in position ? anchorIdx : anchorIdx + 1, 0, policy);
@@ -2953,6 +3037,16 @@ export class EventGate {
2953
3037
  return current.policies.map((p) => p.name);
2954
3038
  }
2955
3039
 
3040
+ /** @internal For `wake_add_rule`'s and `wake_remove_rule`'s refusals; not a host API. */
3041
+ ruleNamesForChange(): { names: string[]; note: string } | { refused: string } {
3042
+ const edit = this.rulesForChange();
3043
+ if ('refused' in edit) return { refused: edit.refused };
3044
+ return {
3045
+ names: [...new Set(edit.rules.map(nameOf).filter((name): name is string => name !== undefined))],
3046
+ note: savedOnlyInForce(this.configPath, edit.remedy),
3047
+ };
3048
+ }
3049
+
2956
3050
  /**
2957
3051
  * Persist gate.json and apply it in memory together: `top`, its own fields,
2958
3052
  * with `rules` as its rule list. Only what loads is written: a change to a
@@ -2979,15 +3073,16 @@ export class EventGate {
2979
3073
  const read = readConfig(JSON.parse(text), 'gate.json');
2980
3074
  if ('refused' in read) {
2981
3075
  throw new Error(
2982
- `Nothing was saved: with this change, gate.json wouldn't be used. ${sentence(read.refused)}${remedy ? ` ${remedy}` : ''}`,
3076
+ `Nothing was saved: with this change, gate.json, at ${this.configPath}, wouldn't be used. ${sentence(read.refused)}`
3077
+ + (remedy ? ` ${remedy}` : ''),
2983
3078
  );
2984
3079
  }
2985
3080
  // One the gate doesn't use, and that a change can mend, holds back what a
2986
3081
  // rule it leaves out would wake on, so its default holds events back.
2987
3082
  if (remedy !== undefined && read.config.policies.length === 0) {
2988
3083
  throw new Error(
2989
- 'Nothing was saved: with this change, gate.json would have no rules the gate reads, and its default would hold back every '
2990
- + `event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`,
3084
+ `Nothing was saved: with this change, gate.json, at ${this.configPath}, would have no rules the gate reads, and its default `
3085
+ + `would hold back every event. Meanwhile the gate runs ${this.rulesRun()}. ${remedy}`,
2991
3086
  );
2992
3087
  }
2993
3088
  try {
@@ -3669,7 +3764,7 @@ export class EventGate {
3669
3764
  } catch (err) {
3670
3765
  const one = events.length === 1;
3671
3766
  console.error(
3672
- `[gate] Policy "${policyName}": its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3767
+ `[gate] Rule ${quotedValue(policyName)}: its pending batch of ${events.length} ${one ? 'event' : 'events'} couldn't be delivered `
3673
3768
  + `(${errorText(err)}), so the batch is dropped`,
3674
3769
  );
3675
3770
  return;
@@ -3687,7 +3782,23 @@ export class EventGate {
3687
3782
  // Event delivery
3688
3783
  // =========================================================================
3689
3784
 
3690
- private deliverEvents(events: PendingEvent[]): void {
3785
+ private deliverEvents(batched: PendingEvent[]): void {
3786
+ // Delivery-time re-check: a channel tuned out after its events were
3787
+ // batched is held now, and "already shown above at arrival" would be
3788
+ // untrue of them. They are dropped; the hold's own reader has them.
3789
+ const heldAt = (e: PendingEvent): string | undefined => e.routeChannelId ?? e.channelId;
3790
+ const events = batched.filter((e) => {
3791
+ const channel = heldAt(e);
3792
+ return !(channel && this.isHeldFn(e.serverId, channel));
3793
+ });
3794
+ if (events.length < batched.length) {
3795
+ this.emitTrace({
3796
+ type: 'gate:held-dropped',
3797
+ dropped: batched.length - events.length,
3798
+ channels: [...new Set(batched.filter((e) => !events.includes(e)).map(heldAt))],
3799
+ timestamp: this.now(),
3800
+ });
3801
+ }
3691
3802
  if (events.length === 0) return;
3692
3803
 
3693
3804
  const policyNames = [...new Set(events.map(e => e.policyName))];
@@ -3851,7 +3962,7 @@ export class EventGate {
3851
3962
  ).finally(() => {
3852
3963
  if (state.timer) clearTimeout(state.timer);
3853
3964
  }).catch((err: unknown) => {
3854
- console.error(`[gate] Policy "${policyName}": the judge's verdict couldn't be applied (${errorText(err)})`);
3965
+ console.error(`[gate] Rule ${quotedValue(policyName)}: the judge's verdict couldn't be applied (${errorText(err)})`);
3855
3966
  });
3856
3967
  }
3857
3968
 
@@ -3922,8 +4033,8 @@ export class EventGate {
3922
4033
  const n = state.events.length;
3923
4034
  const batch = `${n} ${n === 1 ? 'event' : 'events'}`;
3924
4035
  wakes.push(state.judge
3925
- ? { at: state.dueAt, certain: false, what: `rule "${name}"'s batch of ${batch} goes to the judge` }
3926
- : { at: state.dueAt, certain: true, what: `rule "${name}"'s debounced batch of ${batch}` });
4036
+ ? { at: state.dueAt, certain: false, what: `rule ${quotedValue(name)}'s batch of ${batch} goes to the judge` }
4037
+ : { at: state.dueAt, certain: true, what: `rule ${quotedValue(name)}'s debounced batch of ${batch}` });
3927
4038
  }
3928
4039
  return wakes.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
3929
4040
  }
@@ -3973,7 +4084,7 @@ export class EventGate {
3973
4084
  if (failed && (err as { providerAdmission?: unknown } | undefined)?.providerAdmission === 'deferred') {
3974
4085
  const wakes = this.judgeOnError === 'wake';
3975
4086
  console.error(
3976
- `[gate] Policy "${policyName}": the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
4087
+ `[gate] Rule ${quotedValue(policyName)}: the reply judge made no call (${errorText(err)}), so its batch of ${state.events.length} `
3977
4088
  + `${state.events.length === 1 ? 'event' : 'events'} ${wakes ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
3978
4089
  );
3979
4090
  if (this.disposed) return;
@@ -4005,7 +4116,7 @@ export class EventGate {
4005
4116
  const wake = outcome === 'reply' || (outcome === 'error' && this.judgeOnError === 'wake');
4006
4117
  if (failed) {
4007
4118
  console.error(
4008
- `[gate] Policy "${policyName}": the reply judge failed (${reason}), so its batch of ${state.events.length} `
4119
+ `[gate] Rule ${quotedValue(policyName)}: the reply judge failed (${reason}), so its batch of ${state.events.length} `
4009
4120
  + `${state.events.length === 1 ? 'event' : 'events'} ${wake ? 'wakes the agent' : "doesn't wake the agent"} (judge onError "${this.judgeOnError}")`,
4010
4121
  );
4011
4122
  }
@@ -4099,11 +4210,10 @@ export class EventGate {
4099
4210
 
4100
4211
  /** A turn that continues another (a budget restart, a tool-result-guard
4101
4212
  * retry) holds the entry its predecessor kept for it, and takes it again
4102
- * where that entry ended in between: at a stop(), whose dispose() clears
4103
- * every entry, or at a provider hold that kept the turn from starting,
4104
- * whose end queues it again. Unlike onInferenceStarted it leaves a
4105
- * pending self-wake alone: the logical turn that armed it is the one
4106
- * still running. */
4213
+ * where that entry ended in between, as at a provider hold that kept the
4214
+ * turn from starting, whose end queues it again. Unlike
4215
+ * onInferenceStarted it leaves a pending self-wake alone: the logical
4216
+ * turn that armed it is the one still running. */
4107
4217
  onInferenceResumed(agentName: string): void {
4108
4218
  this.inferring.add(agentName);
4109
4219
  if (this.changesJudgedContext(agentName)) this.stopJudging();
@@ -4228,7 +4338,7 @@ export class EventGate {
4228
4338
  // Arm the self-wake: when the window elapses, clear sleep and request an
4229
4339
  // inference so the agent resumes on its own (not dependent on a heartbeat
4230
4340
  // tick or an incoming message). Cancelled by clearSleep()/`wake`, and by
4231
- // dispose(), after which start() arms it again (resumeSleep).
4341
+ // dispose(), after which nothing arms it.
4232
4342
  this.armSleepWake();
4233
4343
  this.reloadPrivilegedIfChanged();
4234
4344
  this.emitTrace({
@@ -4242,24 +4352,11 @@ export class EventGate {
4242
4352
  return { until: this.sleepUntil };
4243
4353
  }
4244
4354
 
4245
- /**
4246
- * Arm timers again for a lifecycle that starts after a stop(), and the
4247
- * sleep's wake first: dispose() clears its timer and keeps the sleep. A
4248
- * sleep still ahead wakes the agent at its end; one that ended while
4249
- * stopped wakes it now, since that wake is what the agent asked for. With
4250
- * no sleep, wakeFromSleep does nothing.
4251
- */
4252
- resumeSleep(): void {
4253
- this.disposed = false;
4254
- if (this.sleepUntil > this.now()) this.armSleepWake();
4255
- else this.wakeFromSleep('sleep-expired');
4256
- }
4257
-
4258
4355
  /**
4259
4356
  * Arm the sleep's wake for its end by the gate's own clock. One timer holds
4260
4357
  * at most MAX_TIMER_MS, about 24.8 days, so a longer sleep waits in steps,
4261
4358
  * and wakeFromSleep reads the clock as each one fires. Nothing is armed
4262
- * while the gate is disposed: resumeSleep() arms it at the next start().
4359
+ * once the gate is disposed.
4263
4360
  */
4264
4361
  private armSleepWake(): void {
4265
4362
  if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
@@ -4269,10 +4366,9 @@ export class EventGate {
4269
4366
  }
4270
4367
 
4271
4368
  /**
4272
- * `agentName` is gone, as a conversation fork's end or an ephemeral run's
4273
- * removes one: its pending self-wake is cancelled, rather than wake a name
4274
- * that no longer exists. It holds no sleep: neither can set one (the
4275
- * framework refuses their `sleep`).
4369
+ * `agentName` is gone, as an agent's end removes one: its pending
4370
+ * self-wake is cancelled, and a sleep it set ends, rather than go on
4371
+ * quieting every agent and then wake a name that no longer exists.
4276
4372
  */
4277
4373
  agentGone(agentName: string): void {
4278
4374
  const selfWake = this.selfWakeTimers.get(agentName);
@@ -4280,6 +4376,7 @@ export class EventGate {
4280
4376
  clearTimeout(selfWake);
4281
4377
  this.selfWakeTimers.delete(agentName);
4282
4378
  }
4379
+ if (this.sleepAgent === agentName) this.clearSleep();
4283
4380
  }
4284
4381
 
4285
4382
  // =========================================================================
@@ -4368,9 +4465,7 @@ export class EventGate {
4368
4465
  * fires early (Node's can, by a millisecond), or a clock set back.
4369
4466
  */
4370
4467
  private wakeFromSleep(reason: string): void {
4371
- // The timer that called it has fired; from resumeSleep(), for a sleep
4372
- // already over, one may still be armed, and it goes, so no stale step of
4373
- // it can fire later.
4468
+ // The timer that called it has fired.
4374
4469
  if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
4375
4470
  if (this.sleepUntil === 0) return;
4376
4471
  if (this.sleepUntil > this.now()) {
@@ -4427,14 +4522,20 @@ export class EventGate {
4427
4522
  * each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file
4428
4523
  * means no one is privileged. A file that can't be read, isn't JSON, or
4429
4524
  * holds no list leaves the list that last loaded in this process in effect
4430
- * (none, at startup), with its error in gate_status until the file is read
4431
- * again. In a list, an entry that isn't a user id is skipped, and named in
4432
- * the file's error, and the rest load. With `unlessUnchanged`, a file whose
4525
+ * (none, at startup), with its error in gate_status until a later read
4526
+ * loads it, finds it gone, or fails another way. In a list, an entry that isn't a user id is skipped, and named
4527
+ * in the file's error, and the rest load. With `unlessUnchanged`, a file whose
4433
4528
  * text is the text last read isn't parsed again.
4434
4529
  */
4435
4530
  private loadPrivileged(opts?: { unlessUnchanged?: boolean }): void {
4436
4531
  if (!this.privilegedUsersPath) return;
4437
- const failed = (why: string): string => `Failed to load privileged-users file: ${why}`;
4532
+ const file = `The privileged-users file, at ${this.privilegedUsersPath},`;
4533
+ const held = this.privilegedUserIds.size;
4534
+ const meanwhile = held === 0
4535
+ ? 'no one is privileged'
4536
+ : `${held === 1 ? 'the one id of the list that last loaded stays' : `the ${held} ids of the list that last loaded stay`} privileged`;
4537
+ const revoke = 'Replacing it with a file holding [], or removing it, makes no one privileged at the gate\'s next check for changes.';
4538
+ const failed = (problem: string, fix: string): string => `${file} ${problem}, so ${meanwhile}. ${fix} ${revoke}`;
4438
4539
  let raw: string;
4439
4540
  try {
4440
4541
  raw = readFileSync(this.privilegedUsersPath, 'utf8');
@@ -4446,24 +4547,28 @@ export class EventGate {
4446
4547
  this.privilegedUserIds = new Set();
4447
4548
  this.privilegedError = null;
4448
4549
  } else {
4449
- this.privilegedError = failed(err instanceof Error ? err.message : String(err));
4550
+ this.privilegedError = failed(
4551
+ `can't be read: ${quotedValue(errorText(err))}`,
4552
+ 'Whoever runs this host can make it a file the gate can read, and the gate reads it again at its next check for changes.',
4553
+ );
4450
4554
  }
4451
4555
  return;
4452
4556
  }
4453
4557
  if (opts?.unlessUnchanged && raw === this.privilegedText) return;
4454
4558
  this.privilegedText = raw;
4559
+ const fixed = 'Once it holds a list of user ids, the gate loads it at its next check for changes.';
4455
4560
  let ids: unknown;
4456
4561
  try {
4457
4562
  const parsed: unknown = parseGateJson(raw);
4458
4563
  ids = Array.isArray(parsed) ? parsed : (parsed as { userIds?: unknown } | null)?.userIds;
4459
4564
  } catch (err) {
4460
- this.privilegedError = failed(err instanceof Error ? err.message : String(err));
4565
+ this.privilegedError = failed(`isn't JSON: ${quotedValue(errorText(err))}`, fixed);
4461
4566
  return;
4462
4567
  }
4463
4568
  // A file that parses but holds no list hasn't loaded either: it used to
4464
4569
  // mean no one is privileged, without a word.
4465
4570
  if (!Array.isArray(ids)) {
4466
- this.privilegedError = failed('expected a list of user ids, or {"userIds": [...]}');
4571
+ this.privilegedError = failed('holds no list of user ids, where one is needed, such as ["123"] or {"userIds": ["123"]}', fixed);
4467
4572
  return;
4468
4573
  }
4469
4574
  const loaded = new Set<string>();
@@ -4476,7 +4581,7 @@ export class EventGate {
4476
4581
  else skipped.push(id);
4477
4582
  }
4478
4583
  this.privilegedUserIds = loaded;
4479
- this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null;
4584
+ this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size, this.privilegedUsersPath) : null;
4480
4585
  }
4481
4586
 
4482
4587
  /** At most once a second, the privileged-users file read again, and loaded when its text has changed. */
@@ -4634,9 +4739,9 @@ export class EventGate {
4634
4739
  this.selfWakeDue.clear();
4635
4740
  // The sleep stays, with its note and agent, and its timer goes: fired
4636
4741
  // after a stop(), its wake reached a framework that had stopped, and the
4637
- // timer held the process until the sleep's end. The next start() arms it
4638
- // again (resumeSleep). Until then nothing arms one, nor a self-wake: a
4639
- // turn a failed stop() left running can still call sleep or skip_reply.
4742
+ // timer held the process until the sleep's end. Nothing arms one after
4743
+ // this, nor a self-wake: a turn a failed stop() left running can still
4744
+ // call sleep or skip_reply.
4640
4745
  if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
4641
4746
  this.disposed = true;
4642
4747
  this.rateLimitBuckets.clear();
@@ -4646,13 +4751,9 @@ export class EventGate {
4646
4751
  this.inferenceBuffer = [];
4647
4752
  // stop() disposes the gate once its streams and event loop have ended (a
4648
4753
  // turn still compiling then is dropped at its next check, before it sends
4649
- // anything), and a start() goes on with this gate. An agent left here by
4650
- // a turn whose exit didn't end it (a retry stop() cancelled, a successor
4651
- // stop() refused) would make every debounced batch buffer until that
4652
- // agent's next turn ended. A turn that continues after the start() takes
4653
- // its entry again (onInferenceResumed). A start() made during the stop()
4654
- // is refused, so no turn of a new lifecycle can have begun before this
4655
- // runs.
4754
+ // anything). An agent left here by a turn whose exit didn't end it (a
4755
+ // retry stop() cancelled, a successor stop() refused) would make every
4756
+ // debounced batch buffer until that agent's next turn ended.
4656
4757
  this.inferring.clear();
4657
4758
  }
4658
4759
  }