frizz 0.12.13 → 0.13.1

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 (156) hide show
  1. package/dist/frizz.js +872 -369
  2. package/package.json +13 -13
  3. package/dist/claude-agent-broker.js +0 -35911
  4. package/dist/codex-app-server-daemon.js +0 -423
  5. package/dist/dev-child.js +0 -56194
  6. package/runtime/board/agent-bindings.mjs +0 -287
  7. package/runtime/board/agent-liveness.mjs +0 -367
  8. package/runtime/board/agent-status.mjs +0 -178
  9. package/runtime/board/config.mjs +0 -993
  10. package/runtime/board/decisions.mjs +0 -97
  11. package/runtime/board/index.mjs +0 -704
  12. package/runtime/board/notify-shared.mjs +0 -90
  13. package/runtime/board/notify.mjs +0 -81
  14. package/runtime/board/ownership.mjs +0 -120
  15. package/runtime/board/rest-detect.mjs +0 -213
  16. package/runtime/board/thread-excerpt.mjs +0 -162
  17. package/runtime/board/thread-update.mjs +0 -289
  18. package/runtime/cc-worker/.claude-plugin/plugin.json +0 -10
  19. package/runtime/cc-worker/DECISIONS.md +0 -1172
  20. package/runtime/cc-worker/LICENSE +0 -21
  21. package/runtime/cc-worker/agents/high.md +0 -7
  22. package/runtime/cc-worker/agents/low.md +0 -7
  23. package/runtime/cc-worker/agents/max.md +0 -7
  24. package/runtime/cc-worker/agents/medium.md +0 -7
  25. package/runtime/cc-worker/agents/xhigh.md +0 -7
  26. package/runtime/cc-worker/bin/frizz +0 -17
  27. package/runtime/cc-worker/bin/frizz-mcp.mjs +0 -1612
  28. package/runtime/cc-worker/bin/frizz-update +0 -18
  29. package/runtime/cc-worker/hooks/agent-bind.mjs +0 -40
  30. package/runtime/cc-worker/hooks/agent-dispatch.mjs +0 -121
  31. package/runtime/cc-worker/hooks/bash-background.d.mts +0 -6
  32. package/runtime/cc-worker/hooks/bash-background.mjs +0 -247
  33. package/runtime/cc-worker/hooks/deny-ask.mjs +0 -39
  34. package/runtime/cc-worker/hooks/deny-plan.mjs +0 -62
  35. package/runtime/cc-worker/hooks/hooks.json +0 -102
  36. package/runtime/cc-worker/hooks/perm-policy.mjs +0 -211
  37. package/runtime/cc-worker/hooks/scratchpad.mjs +0 -417
  38. package/runtime/cc-worker/hooks/session-seed.mjs +0 -107
  39. package/runtime/cc-worker/scripts/frizz/agent-bindings.mjs +0 -9
  40. package/runtime/cc-worker/scripts/frizz/config.mjs +0 -12
  41. package/runtime/cc-worker/skills/gh/SKILL.md +0 -141
  42. package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +0 -60
  43. package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +0 -130
  44. package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +0 -54
  45. package/web-dist/apple-touch-icon.png +0 -0
  46. package/web-dist/assets/TerminalPane-Cv0c_SNS.js +0 -7
  47. package/web-dist/assets/abnfDiagram-VRR7QNED-B-wED8ht.js +0 -1
  48. package/web-dist/assets/arc-BSyeo0Gb.js +0 -1
  49. package/web-dist/assets/architecture-TIHT7OUA-nsH-QfGr.js +0 -1
  50. package/web-dist/assets/architectureDiagram-ZJ3FMSHR-kHm_O2sR.js +0 -36
  51. package/web-dist/assets/array-BifhSqXX.js +0 -1
  52. package/web-dist/assets/blockDiagram-677ZJIJ3-CdazUaKU.js +0 -132
  53. package/web-dist/assets/c4Diagram-LMCZKHZV-rauLKZbJ.js +0 -10
  54. package/web-dist/assets/channel-fvwjeUt3.js +0 -1
  55. package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +0 -1
  56. package/web-dist/assets/chunk-32BRIVSS-DjOqyEDX.js +0 -1
  57. package/web-dist/assets/chunk-52WLFC77-f1SThegZ.js +0 -10
  58. package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +0 -15
  59. package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +0 -1
  60. package/web-dist/assets/chunk-C7G6YPKG-BfQsesjz.js +0 -1
  61. package/web-dist/assets/chunk-EX3LRPZG-BIzZW7OQ.js +0 -231
  62. package/web-dist/assets/chunk-FWX5IMBZ-CATINZFC.js +0 -2
  63. package/web-dist/assets/chunk-HOUHSVGY-DbMBRCCE.js +0 -1
  64. package/web-dist/assets/chunk-ICXQ74PX-COcoSWx4.js +0 -2
  65. package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +0 -1
  66. package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +0 -161
  67. package/web-dist/assets/chunk-MOJQB5TN-CVnI-o_4.js +0 -88
  68. package/web-dist/assets/chunk-OGEWGWER-U0vdZqB6.js +0 -1
  69. package/web-dist/assets/chunk-PUDLZKDR-Bukb7rem.js +0 -156
  70. package/web-dist/assets/chunk-Q4XR5HBZ-Bl019PHd.js +0 -70
  71. package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +0 -1
  72. package/web-dist/assets/chunk-V7JOEXUC-YJR0vzI6.js +0 -206
  73. package/web-dist/assets/chunk-VAUOI2AC-CK6NU9kj.js +0 -1
  74. package/web-dist/assets/chunk-VR4S4FIN-B3LBv3C3.js +0 -1
  75. package/web-dist/assets/chunk-WYO6CB5R-DjRPyRuS.js +0 -127
  76. package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +0 -1
  77. package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +0 -1
  78. package/web-dist/assets/chunk-ZGVPDNZ5-BUJCayKP.js +0 -62
  79. package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +0 -32
  80. package/web-dist/assets/classDiagram-OUVF2IWQ-BRZQ_xnN.js +0 -1
  81. package/web-dist/assets/classDiagram-v2-EOCWNBFH-BRZQ_xnN.js +0 -1
  82. package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +0 -1
  83. package/web-dist/assets/cynefin-VYW2F7L2-CE3ce51v.js +0 -1
  84. package/web-dist/assets/cynefinDiagram-TSTJHNR4-DSXSPZQV.js +0 -62
  85. package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +0 -321
  86. package/web-dist/assets/dagre-CXRCoUWR.js +0 -1
  87. package/web-dist/assets/dagre-VKFMJZFB-VwBQdjum.js +0 -4
  88. package/web-dist/assets/defaultLocale-C8Fc0cco.js +0 -1
  89. package/web-dist/assets/diagram-FQU43EPY-7Tf5_WDu.js +0 -3
  90. package/web-dist/assets/diagram-G47NLZAW-mu4aclWA.js +0 -24
  91. package/web-dist/assets/diagram-NH7WQ7WH-B3kAVNwv.js +0 -24
  92. package/web-dist/assets/diagram-OA4YK3LP-DAaV-2ou.js +0 -30
  93. package/web-dist/assets/diagram-WEI45ONY-DijPfgly.js +0 -41
  94. package/web-dist/assets/dist-DoH_9pyS.js +0 -1
  95. package/web-dist/assets/ebnfDiagram-CCIWWBDH-B1o42Wp-.js +0 -1
  96. package/web-dist/assets/erDiagram-Q63AITRT-Dqej9Wi3.js +0 -85
  97. package/web-dist/assets/eventmodeling-45OFAUF4-B8xeGcXU.js +0 -1
  98. package/web-dist/assets/flowDiagram-23GEKE2U-Cnyfcz6Z.js +0 -1
  99. package/web-dist/assets/ganttDiagram-NO4QXBWP-UoyrLPUT.js +0 -292
  100. package/web-dist/assets/gitGraph-TEB2WS4Q-CwjIwQ3T.js +0 -1
  101. package/web-dist/assets/gitGraphDiagram-IHSO6WYX-BxU758_g.js +0 -106
  102. package/web-dist/assets/graphlib-B8gBHxth.js +0 -1
  103. package/web-dist/assets/index-CT6k_A5y.css +0 -1
  104. package/web-dist/assets/index-k8HCkyvh.js +0 -319
  105. package/web-dist/assets/info-DKCQHKI2-CWtSI6aK.js +0 -1
  106. package/web-dist/assets/infoDiagram-FWYZ7A6U-n5xRfy_d.js +0 -2
  107. package/web-dist/assets/init-D6jRqBbL.js +0 -1
  108. package/web-dist/assets/ishikawaDiagram-FXEZZL3T-DU1LTQY-.js +0 -70
  109. package/web-dist/assets/journeyDiagram-5HDEW3XC-BzZWHoPv.js +0 -139
  110. package/web-dist/assets/kanban-definition-HUTT4EX6-CeneyR7c.js +0 -89
  111. package/web-dist/assets/katex-CddkPoXu.js +0 -257
  112. package/web-dist/assets/line-DTfkLWnw.js +0 -1
  113. package/web-dist/assets/linear-z2V0wJk9.js +0 -1
  114. package/web-dist/assets/map-DsCK-0Cs.js +0 -1
  115. package/web-dist/assets/mermaid-parser.core-CHzoJ5LT.js +0 -7
  116. package/web-dist/assets/mermaid.core-Cl84DLN2.js +0 -11
  117. package/web-dist/assets/mindmap-definition-LN4V7U3C-C39sBNqB.js +0 -96
  118. package/web-dist/assets/ordinal-hYBb2elL.js +0 -1
  119. package/web-dist/assets/packet-7NZHBO7P-Bj73rlQn.js +0 -1
  120. package/web-dist/assets/path-BWPyau1x.js +0 -1
  121. package/web-dist/assets/pegDiagram-2B236MQR-BEEM1vSv.js +0 -1
  122. package/web-dist/assets/pie-RZYD4A2V-CJ-fEfdy.js +0 -1
  123. package/web-dist/assets/pieDiagram-ENE6RG2P-Dcqj3oyh.js +0 -39
  124. package/web-dist/assets/quadrantDiagram-ABIIQ3AL-D5cljQ3D.js +0 -7
  125. package/web-dist/assets/radar-I7S5WNFK-G8JqqGPf.js +0 -1
  126. package/web-dist/assets/railroad-3IZDKUUU-CaGNL0iN.js +0 -1
  127. package/web-dist/assets/railroad-abnf-AHOZXSZD-DWZmdy-r.js +0 -1
  128. package/web-dist/assets/railroad-ebnf-EBAXGLYW-C-XoPuo_.js +0 -1
  129. package/web-dist/assets/railroad-peg-LSFZ7HO6-BGpxqg46.js +0 -1
  130. package/web-dist/assets/railroadDiagram-RFXS5EU6-BZGZXMY4.js +0 -1
  131. package/web-dist/assets/requirementDiagram-TGXJPOKE-Dj5yn_9e.js +0 -84
  132. package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +0 -1
  133. package/web-dist/assets/rough.esm-CSKSodPl.js +0 -1
  134. package/web-dist/assets/sankeyDiagram-HTMAVEWB-7iJ_NDNf.js +0 -40
  135. package/web-dist/assets/sequenceDiagram-DBY2YBRQ-Bl54CknA.js +0 -162
  136. package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +0 -1
  137. package/web-dist/assets/src-C4XfhTaE.js +0 -1
  138. package/web-dist/assets/stateDiagram-2N3HPSRC-DuvjeNgJ.js +0 -1
  139. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-BR-vrMgH.js +0 -1
  140. package/web-dist/assets/swimlanes-5IMT3BWC-B7-Jj4Sk.js +0 -2
  141. package/web-dist/assets/swimlanesDiagram-G3AALYLV-C1HvMf2k.js +0 -8
  142. package/web-dist/assets/timeline-definition-FHXFAJF6-jsey2pEG.js +0 -120
  143. package/web-dist/assets/treeView-QDETBFTQ-DhPmOzHr.js +0 -1
  144. package/web-dist/assets/treemap-6X3UGDF4-Dw2IaXqK.js +0 -1
  145. package/web-dist/assets/vennDiagram-L72KCM5P-CXKZkBab.js +0 -34
  146. package/web-dist/assets/wardley-OPB4EBWU-CTCdDwyE.js +0 -1
  147. package/web-dist/assets/wardleyDiagram-EHGQE667-B1w0xIqB.js +0 -78
  148. package/web-dist/assets/xychartDiagram-FW5EYKEG-gULk-uIl.js +0 -7
  149. package/web-dist/favicon-16.png +0 -0
  150. package/web-dist/favicon-32.png +0 -0
  151. package/web-dist/favicon.svg +0 -34
  152. package/web-dist/icon-192.png +0 -0
  153. package/web-dist/icon-512.png +0 -0
  154. package/web-dist/icon-maskable-512.png +0 -0
  155. package/web-dist/index.html +0 -44
  156. package/web-dist/manifest.webmanifest +0 -16
@@ -1,287 +0,0 @@
1
- // @ts-check
2
- /**
3
- * frizz — the AUTOMATIC, ephemeral thread↔agent binding.
4
- *
5
- * REPLACES the old hand-maintained `agents: [{id, label}]` thread frontmatter (a
6
- * drift-prone ledger the orchestrator had to write by hand on every dispatch). The
7
- * binding is now captured AUTOMATICALLY by the `agent-bind` hook (PostToolUse on the
8
- * `Agent` tool): the Agent tool's RESULT carries the new `agentId`, the resolved
9
- * `prompt` (with the `THREAD: <slug>` tag), and the `description` (a human label) — so
10
- * the moment a background sub-agent is launched, the hook records `agentId → thread`
11
- * here. The orchestrator records NOTHING by hand; there is no frontmatter to drift.
12
- *
13
- * EPHEMERAL ROUTING STATE, NOT DUPLICATED TRUTH. This file (`.frizz/.agent-bindings.jsonl`)
14
- * is transient routing info under the already-gitignored `.frizz/` — the same class as
15
- * `.dispatch-ledger.jsonl` / `.rested-agents.jsonl`. It maps an instance id to the thread
16
- * it serves so consumers can RECONNECT a return/rest to its thread; it is never the source
17
- * of a thread's truth (that's the thread's own `status:` + body). Append-only; a binding is
18
- * immutable once written. Liveness/doneness is still DERIVED from ground truth (output-file
19
- * mtime + thread status) — the binding only supplies the id↔thread mapping, never a state.
20
- *
21
- * FAIL-OPEN ABSOLUTELY: every reader/writer swallows errors. A missing/corrupt bindings
22
- * file simply yields no bindings — the board and hooks degrade to "no agents to surface,"
23
- * never an error.
24
- */
25
- import { appendFileSync, readFileSync } from 'node:fs';
26
- import { join } from 'node:path';
27
-
28
- /** @param {string} projectDir */
29
- export function bindingsPath(projectDir) {
30
- return join(projectDir, '.frizz', '.agent-bindings.jsonl');
31
- }
32
-
33
- /**
34
- * Extract the `THREAD: <slug>` tag from a dispatch prompt — the SAME shape the dispatch
35
- * hook enforces (a `.frizz/<slug>.md`-backed tag at the top of the prompt). Returns the
36
- * normalized slug (no `.frizz/` prefix, no `.md` suffix) or null for an untagged one-shot.
37
- * @param {string} prompt
38
- * @returns {string|null}
39
- */
40
- export function threadFromPrompt(prompt) {
41
- if (typeof prompt !== 'string') return null;
42
- const m = prompt.match(/^THREAD:\s*([\w./-]+)/m);
43
- return m ? m[1].replace(/^\.frizz\//, '').replace(/\.md$/, '') : null;
44
- }
45
-
46
- /**
47
- * Record one binding. Called by the `agent-bind` PostToolUse hook. No-ops (returns false)
48
- * unless BOTH an agent id and a thread slug are present — an untagged one-shot has no thread
49
- * to bind, so nothing is written. Fail-open: a write error never throws.
50
- * @param {string} projectDir
51
- * @param {{ agentId?: string|null, thread?: string|null, label?: string|null, session?: string|null }} b
52
- * @returns {boolean} whether a line was written
53
- */
54
- export function recordBinding(projectDir, { agentId, thread, label, session }) {
55
- try {
56
- if (!agentId || !thread) return false;
57
- appendFileSync(
58
- bindingsPath(projectDir),
59
- JSON.stringify({
60
- ts: new Date().toISOString(),
61
- agent_id: String(agentId),
62
- thread: String(thread),
63
- label: label ? String(label) : null,
64
- session: session ? String(session) : null,
65
- }) + '\n',
66
- );
67
- return true;
68
- } catch {
69
- return false; // fail-open — a binding write must never block a dispatch
70
- }
71
- }
72
-
73
- /**
74
- * Read every recorded binding (newest last). Fail-open: any error → [].
75
- * @param {string} projectDir
76
- * @returns {{ts?:string, agent_id:string, thread:string, label:string|null, session:string|null}[]}
77
- */
78
- export function readBindings(projectDir) {
79
- /** @type {{ts?:string, agent_id:string, thread:string, label:string|null, session:string|null}[]} */
80
- const out = [];
81
- try {
82
- const raw = readFileSync(bindingsPath(projectDir), 'utf8');
83
- for (const line of raw.split('\n')) {
84
- if (!line.trim()) continue;
85
- try {
86
- const r = JSON.parse(line);
87
- if (r && r.agent_id && r.thread) out.push(r);
88
- } catch {
89
- /* skip a malformed line */
90
- }
91
- }
92
- } catch {
93
- /* no file → no bindings */
94
- }
95
- return out;
96
- }
97
-
98
- /**
99
- * Group bindings by thread slug into the `{id, label}[]` shape the board + liveness hook
100
- * consume — the AUTOMATIC replacement for parsing `agents:` frontmatter. Dedupes by
101
- * agent_id (one entry per instance), keeping the LATEST label seen for that id.
102
- * @param {string} projectDir
103
- * @returns {Map<string, {id:string, label:string|null}[]>}
104
- */
105
- export function bindingsByThread(projectDir) {
106
- /** @type {Map<string, Map<string, string|null>>} thread → (agentId → label) */
107
- const byThread = new Map();
108
- for (const b of readBindings(projectDir)) {
109
- let m = byThread.get(b.thread);
110
- if (!m) byThread.set(b.thread, (m = new Map()));
111
- m.set(b.agent_id, b.label ?? m.get(b.agent_id) ?? null); // latest non-null label wins
112
- }
113
- /** @type {Map<string, {id:string, label:string|null}[]>} */
114
- const out = new Map();
115
- for (const [thread, m] of byThread) {
116
- out.set(thread, [...m].map(([id, label]) => ({ id, label })));
117
- }
118
- return out;
119
- }
120
-
121
- /**
122
- * The thread a given agent id serves (latest binding wins), or null. Used to name the
123
- * thread when a rest/return is surfaced, so reconciliation points at the right file.
124
- * @param {string} projectDir
125
- * @param {string|null|undefined} agentId
126
- * @returns {string|null}
127
- */
128
- export function threadForAgent(projectDir, agentId) {
129
- if (!agentId) return null;
130
- let found = null;
131
- for (const b of readBindings(projectDir)) if (b.agent_id === agentId) found = b.thread;
132
- return found;
133
- }
134
-
135
- /**
136
- * Per thread, the SINGLE NEWEST binding — the agent CURRENTLY serving the thread.
137
- *
138
- * Liveness must key on this, NOT on every agent ever bound: a thread is frequently
139
- * re-dispatched (an agent dies/rests and a FRESH agent picks the same thread up), and a
140
- * SUPERSEDED older agent staying quiet is EXPECTED, never a drop. Flagging the old agent
141
- * after a new one took over was the #1 false positive (e.g. dead `ac067e883` kept firing on
142
- * `gvs-warning-silent` long after live `a56d5f1` replaced it). Keying on the newest binding
143
- * makes that class structurally impossible.
144
- *
145
- * Newest = max binding `ts` (ISO), with append-order (file index) as the tiebreaker — so an
146
- * unparseable/absent ts still resolves deterministically to the later-written line.
147
- * @param {string} projectDir
148
- * @returns {Map<string, {id:string, label:string|null, ts:string|null}>}
149
- */
150
- export function newestBindingByThread(projectDir) {
151
- /** @type {Map<string, {id:string, label:string|null, ts:string|null, _ms:number, _idx:number}>} */
152
- const out = new Map();
153
- const bindings = readBindings(projectDir);
154
- for (let idx = 0; idx < bindings.length; idx++) {
155
- const b = bindings[idx];
156
- const ms = Date.parse(b.ts ?? '');
157
- const key = Number.isFinite(ms) ? ms : -Infinity;
158
- const cur = out.get(b.thread);
159
- if (!cur || key > cur._ms || (key === cur._ms && idx > cur._idx)) {
160
- out.set(b.thread, { id: b.agent_id, label: b.label ?? null, ts: b.ts ?? null, _ms: key, _idx: idx });
161
- }
162
- }
163
- /** @type {Map<string, {id:string, label:string|null, ts:string|null}>} */
164
- const clean = new Map();
165
- for (const [thread, v] of out) clean.set(thread, { id: v.id, label: v.label, ts: v.ts });
166
- return clean;
167
- }
168
-
169
- /**
170
- * Threads with a PR landing in flight via the merge cascade (`.frizz/merge-queue.jsonl`).
171
- *
172
- * Such a thread is LEGITIMATELY `active` while its PR merges — the agent that produced the
173
- * work has finished and its output is reconciled; the thread stays active purely for the
174
- * DOWNSTREAM merge, not because an agent is stuck. Suppressing liveness for these kills the
175
- * second false-positive class (a completed agent flagged because its thread is mid-merge).
176
- * Fail-open: no file / unparseable → empty set (suppress nothing).
177
- * @param {string} projectDir
178
- * @returns {Set<string>}
179
- */
180
- export function downstreamThreads(projectDir) {
181
- /** @type {Set<string>} */
182
- const out = new Set();
183
- try {
184
- const raw = readFileSync(join(projectDir, '.frizz', 'merge-queue.jsonl'), 'utf8');
185
- for (const line of raw.split('\n')) {
186
- if (!line.trim()) continue;
187
- try {
188
- const r = JSON.parse(line);
189
- if (r && r.thread) out.add(String(r.thread));
190
- } catch {
191
- /* skip a malformed line */
192
- }
193
- }
194
- } catch {
195
- /* no merge-queue → nothing downstream */
196
- }
197
- return out;
198
- }
199
-
200
- /**
201
- * agent_ids that have recorded at least one SubagentStop (rest) in `.frizz/.rested-agents.jsonl`
202
- * — i.e. reached a clean stopping point at least once (ended a turn).
203
- *
204
- * This is the signal that separates a TERMINATED/parked agent from one still grinding inside a
205
- * single long tool call. An agent mid-build (one 40-min `cargo build`, no rest yet) has stale
206
- * output but is ALIVE — and must never be called "dropped". Combined with stale output, a rest
207
- * record means the agent ended its turn and has been quiet since: the conservative "no live
208
- * agent" condition. Fail-open: no file / unparseable → empty set.
209
- * @param {string} projectDir
210
- * @returns {Set<string>}
211
- */
212
- export function restedAgentIds(projectDir) {
213
- /** @type {Set<string>} */
214
- const out = new Set();
215
- try {
216
- const raw = readFileSync(join(projectDir, '.frizz', '.rested-agents.jsonl'), 'utf8');
217
- for (const line of raw.split('\n')) {
218
- if (!line.trim()) continue;
219
- try {
220
- const r = JSON.parse(line);
221
- if (r && r.agent_id) out.add(String(r.agent_id));
222
- } catch {
223
- /* skip a malformed line */
224
- }
225
- }
226
- } catch {
227
- /* no rest log → no rested agents */
228
- }
229
- return out;
230
- }
231
-
232
- /**
233
- * Count sub-agent rests recorded strictly after `sinceMs` that the ORCHESTRATOR actually
234
- * dispatched — i.e. THREAD-BOUND agents — and have NOT yet been surfaced. This is what the
235
- * Stop-hook rest guard nags on.
236
- *
237
- * THE FILTER (the fix for the over-aggressive nag): a rest counts ONLY if its `agent_id`
238
- * appears in the bindings ledger (`.agent-bindings.jsonl`), which the agent-bind hook writes
239
- * ONLY for a `THREAD:`-tagged orchestrator dispatch. The real predicate is "has a THREAD
240
- * binding"; everything without one is excluded — none of it is orchestrator-facing reconcile work:
241
- * - NESTED / worker-spawned sub-agents (e.g. a landing agent's own fresh-context self-review
242
- * sub-agents): no THREAD tag → no binding → unbound. Their results fold into the parent's
243
- * report; they are the worker's internal business. This is the class that drove the nag.
244
- * - UNTAGGED orchestrator one-shots: also unbound (no thread), and they have no `.frizz/<slug>.md`
245
- * to fold into — the result returns as the tool result, with nothing to reconcile.
246
- * - ANON rests (no `agent_id`): inherently unbindable, so never the orchestrator's to reconcile.
247
- * The bound, THREAD-owned agent that genuinely rested and hasn't been folded STILL counts — the
248
- * legitimate signal is preserved, just made precise.
249
- *
250
- * DEDUPE: an `agent_id` already in `surfacedAgents` is skipped, so a resuming bound agent's
251
- * repeat rests don't re-nag; only genuinely-new bound ids count. Returns the count plus the
252
- * DISTINCT new bound agent-ids and their thread slugs, so the caller can nag with a real number,
253
- * grow the seen-set, and name the threads. Fail-open: any error → zero count.
254
- * @param {string} projectDir
255
- * @param {number} sinceMs
256
- * @param {string[]} [surfacedAgents]
257
- * @returns {{ count: number, agents: string[], threads: string[] }}
258
- */
259
- export function newBoundRestsSince(projectDir, sinceMs, surfacedAgents = []) {
260
- const seen = new Set(surfacedAgents);
261
- const bound = new Set(readBindings(projectDir).map((b) => b.agent_id));
262
- /** @type {Set<string>} */
263
- const newAgents = new Set();
264
- /** @type {Set<string>} */
265
- const threads = new Set();
266
- try {
267
- const raw = readFileSync(join(projectDir, '.frizz', '.rested-agents.jsonl'), 'utf8');
268
- for (const line of raw.split('\n')) {
269
- if (!line.trim()) continue;
270
- try {
271
- const rec = JSON.parse(line);
272
- const ts = Date.parse(rec.ts);
273
- if (!Number.isFinite(ts) || ts <= sinceMs) continue;
274
- const id = rec.agent_id;
275
- // bound-only: skip anon (no id), already-surfaced, and unbound (nested/worker-spawned).
276
- if (!id || seen.has(id) || !bound.has(id)) continue;
277
- newAgents.add(id);
278
- if (rec.thread) threads.add(String(rec.thread));
279
- } catch {
280
- /* skip a malformed line */
281
- }
282
- }
283
- } catch {
284
- /* no rest log → no rests */
285
- }
286
- return { count: newAgents.size, agents: [...newAgents], threads: [...threads] };
287
- }
@@ -1,367 +0,0 @@
1
- // @ts-check
2
- /**
3
- * frizz — agent-liveness helper for the Stop hook.
4
- *
5
- * Background sub-agents are bound to their thread AUTOMATICALLY: the `agent-bind`
6
- * PostToolUse hook records `agentId → thread` into `.frizz/.agent-bindings.jsonl` at
7
- * dispatch (see `./agent-bindings.mjs`). This module reads that ephemeral binding to learn
8
- * which agents serve which thread, then DERIVES each one's real liveness from ground truth
9
- * and returns reminder LINES for the Stop hook to surface. Hooks cannot call
10
- * SendMessage/Agent, so this is detect-and-remind only — exactly the ask. (The old
11
- * hand-maintained `agents: [{id, label}]` thread frontmatter is GONE — never read; a
12
- * lingering one in an old thread file is an ignored no-op.)
13
- *
14
- * COMPUTE, DON'T STORE. The binding supplies only the id↔thread mapping; it carries no
15
- * per-agent state. State comes from `deriveAgentState`
16
- * (`./agent-status.mjs`) over two ground-truth signals:
17
- * 1. The session tasks dir, derived from the Stop payload's `transcript_path`
18
- * (`~/.claude/projects/<slug>/<session>.jsonl`). The per-agent activity files
19
- * live at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<agentId>.output`, where
20
- * `<tmp>` is `/tmp` or `/private/tmp` and `<uid>` varies — so we GLOB the
21
- * `claude-*` dirs under both bases rather than hard-code the uid. The `.output`
22
- * entries are SYMLINKS to the subagent transcript jsonl; we `statSync` (follows
23
- * the link) so the age reflects the TARGET's real last-write, not the stale
24
- * symlink mtime. The mtime → idle/frozen age.
25
- * 2. The owning THREAD's own `status:` frontmatter (done/dismissed = terminal) — the
26
- * orchestrator's deliberate "I reconciled this" signal, and the ONLY mutable bit.
27
- *
28
- * THREAD-CENTRIC, LOW-NOISE. For each `active` thread we consider ONLY its NEWEST bound agent
29
- * (a superseded older agent is never flagged) and SUPPRESS the thread entirely when a PR is
30
- * landing for it via the merge cascade (it's active for the merge, not a stuck agent). The bar
31
- * is "better to miss a soft case than nag a benign one":
32
- * - ⚠ ACTIVE THREAD, NO LIVE AGENT (state 'dropped'): an active, non-downstream thread whose
33
- * NEWEST agent is quiet beyond DROPPED_MIN AND has rested (ended a turn) — the
34
- * high-confidence "this thread is stuck active with nobody on it" signal. Calm wording.
35
- * - a soft idle note (state 'idle'): quiet but not confidently dropped — fine if it's
36
- * watching CI or mid-build. Informational only.
37
- * - (thread terminal / parked / downstream / fresh agent → say nothing.)
38
- *
39
- * Thresholds (minutes), tunable via env for experimentation:
40
- * FRIZZ_IDLE_MIN (default 10) — quiet this long → soft idle note.
41
- * FRIZZ_DROPPED_MIN (default 45, FRIZZ_FROZEN_MIN honored as the old alias) — quiet this long
42
- * AND rested → call it dropped. Deliberately generous: a real agent legitimately arms a
43
- * CI watcher and sits silent for 30–40 min, so 45 min lets the watcher fire and resume it
44
- * before we ever flag. Tune via the env var for faster- or slower-paced repos.
45
- *
46
- * FAIL-OPEN ABSOLUTELY: any error (no tasks dir, no bindings file, unparseable frontmatter,
47
- * unreadable file) → return [] (no lines). This must NEVER throw or block end-of-turn.
48
- */
49
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
50
- import { join } from 'node:path';
51
- import { deriveAgentState, findAgentOutputAge, IDLE_MIN, DROPPED_MIN, LONG_RUNTIME_MIN } from './agent-status.mjs';
52
- import { newestBindingByThread, downstreamThreads, restedAgentIds } from './agent-bindings.mjs';
53
-
54
- // Thread-level terminal statuses (frontmatter `status:`), matching scripts/frizz TERMINAL.
55
- const TERMINAL_THREAD = new Set(['done', 'dismissed']);
56
-
57
- /**
58
- * Derive the session tasks dir from a Stop payload's transcript_path.
59
- * @param {string|undefined|null} transcriptPath
60
- * @returns {string|null}
61
- */
62
- export function deriveTasksDir(transcriptPath) {
63
- try {
64
- if (!transcriptPath || typeof transcriptPath !== 'string') return null;
65
- const parts = transcriptPath.split('/');
66
- const sessionFile = parts.pop(); // <session>.jsonl
67
- const slug = parts.pop(); // <project-slug>
68
- if (!sessionFile || !slug) return null;
69
- const session = sessionFile.replace(/\.jsonl$/, '');
70
- if (!session) return null;
71
- for (const base of ['/tmp', '/private/tmp']) {
72
- let dirs;
73
- try {
74
- dirs = readdirSync(base).filter((d) => d.startsWith('claude-'));
75
- } catch {
76
- continue;
77
- }
78
- for (const d of dirs) {
79
- const cand = join(base, d, slug, session, 'tasks');
80
- if (existsSync(cand)) return cand;
81
- }
82
- }
83
- } catch {
84
- /* fail-open */
85
- }
86
- return null;
87
- }
88
-
89
- /**
90
- * GROUND-TRUTH age (minutes) of an agent's last activity. Prefers the exact session's tasks
91
- * dir (fast, one statSync) when we have it; falls back to the session-independent glob whenever
92
- * the agent's `.output` isn't in THIS session's tasks dir — either there's no transcript_path,
93
- * OR the binding is from a PRIOR session (e.g. after a restart), where the cross-session glob is
94
- * what keeps anti-drop working across restarts. Returns null when no output file exists.
95
- * @param {string} agentId
96
- * @param {string|null} tasksDir
97
- * @param {number} now
98
- * @returns {number|null}
99
- */
100
- function agentAge(agentId, tasksDir, now) {
101
- if (tasksDir) {
102
- try {
103
- const st = statSync(join(tasksDir, `${agentId}.output`)); // follows the symlink → target mtime
104
- return (now - st.mtimeMs) / 60000;
105
- } catch {
106
- /* not in this session's tasks dir → fall through to the glob */
107
- }
108
- }
109
- return findAgentOutputAge(agentId, now);
110
- }
111
-
112
- /**
113
- * Compute thread-centric liveness lines, DERIVED purely from ground truth (newest-binding age +
114
- * rest log + thread status + merge-queue) — never a stored per-agent flag. At most ONE line per
115
- * thread, keyed on the thread's NEWEST agent only, with downstream (mid-merge) threads
116
- * suppressed. The loud "ACTIVE THREAD, NO LIVE AGENT" lines come first, then soft idle notes.
117
- * @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
118
- * @returns {string[]} reminder lines (possibly empty)
119
- */
120
- export function agentLivenessLines({ transcriptPath, projectDir, now = Date.now() }) {
121
- /** @type {string[]} */
122
- const dropped = [];
123
- /** @type {string[]} */
124
- const idle = [];
125
- try {
126
- const tasksDir = deriveTasksDir(transcriptPath); // may be null → agentAge falls back to glob
127
- const frizzDir = join(projectDir, '.frizz');
128
-
129
- // AUTOMATIC binding: the NEWEST agent serving each thread (a superseded older agent is
130
- // never considered). Plus the merge-cascade set (suppress mid-merge threads) and the rest
131
- // log (separate a parked agent from one still inside a long tool call).
132
- const newest = newestBindingByThread(projectDir);
133
- const downstream = downstreamThreads(projectDir);
134
- const rested = restedAgentIds(projectDir);
135
-
136
- let files;
137
- try {
138
- files = readdirSync(frizzDir).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.'));
139
- } catch {
140
- return dropped;
141
- }
142
-
143
- for (const f of files) {
144
- const slug = f.replace(/\.md$/, '');
145
- const binding = newest.get(slug);
146
- if (!binding) continue; // no agent ever bound to this thread → nothing to judge
147
-
148
- let src;
149
- try {
150
- src = readFileSync(join(frizzDir, f), 'utf8');
151
- } catch {
152
- continue;
153
- }
154
- const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
155
- const threadTerminal = TERMINAL_THREAD.has(threadStatus);
156
- const threadActive = threadStatus === 'active'; // ONLY active threads can strand an agent; parked phases holding a done agent are EXPECTED
157
- if (threadTerminal || !threadActive) continue;
158
- // PR landing via the cascade → legitimately active, suppress. Skip BEFORE agentAge so a
159
- // mid-merge thread never pays the (potentially full-/tmp-glob) age lookup just to be
160
- // dropped by deriveAgentState's downstream short-circuit anyway.
161
- if (downstream.has(slug)) continue;
162
-
163
- const ageMin = agentAge(binding.id, tasksDir, now);
164
- const hasRested = rested.has(binding.id);
165
- const state = deriveAgentState({ ageMin, threadTerminal, threadActive, hasRested, idleMin: IDLE_MIN, droppedMin: DROPPED_MIN }); // downstream already short-circuited above
166
- const who = `${binding.label ? `${binding.label} ` : ''}[${binding.id.slice(0, 9)}]`;
167
-
168
- if (state === 'dropped') {
169
- dropped.push(`⚠ ACTIVE THREAD, NO LIVE AGENT: ${slug} — its newest agent ${who} has been quiet ${Math.round(ageMin ?? 0)}m with no PR/merge in flight, so it likely finished or dropped. Fold its report and flip the thread to done, or resume it (SendMessage ${binding.id.slice(0, 9)}) if it's still mid-task.`);
170
- } else if (state === 'idle') {
171
- idle.push(`frizz: thread ${slug} — agent ${who} quiet ${Math.round(ageMin ?? 0)}m. Fine if it's watching CI or mid-build; check in (SendMessage) if that's unexpected.`);
172
- }
173
- // 'terminal' (reconciled / parked / downstream), 'fresh' (working), 'unknown' (no file) → say nothing.
174
- }
175
- } catch {
176
- /* fail-open: return whatever we have */
177
- }
178
- return [...dropped, ...idle];
179
- }
180
-
181
- /**
182
- * The SHARED in-flight predicate: is a bound agent still live (not paused/done, not stale)?
183
- * "Not rested" is the primary signal — a rest is the ONLY completion mark, so its absence
184
- * means still-in-flight; age is the secondary guard — fresh output (age < DROPPED_MIN) OR no
185
- * output file yet (age == null → just launched). A quiet-past-DROPPED_MIN + never-rested agent
186
- * is the 'dropped' case agentLivenessLines flags, so it is NOT counted live here. Terminal-thread
187
- * exclusion is the caller's job (this predicate only judges the agent). One code path shared by
188
- * runningAgentCount and liveBoundAgentForThread so the two never drift.
189
- * @param {string} agentId
190
- * @param {string|null} tasksDir
191
- * @param {Set<string>} rested
192
- * @param {number} now
193
- * @returns {boolean}
194
- */
195
- function isAgentInFlight(agentId, tasksDir, rested, now) {
196
- if (rested.has(agentId)) return false;
197
- const ageMin = agentAge(agentId, tasksDir, now);
198
- return ageMin == null || ageMin < DROPPED_MIN;
199
- }
200
-
201
- /**
202
- * The single LIVE agent bound to `slug`, or null. "Live" = the thread's NEWEST binding, on a
203
- * non-terminal thread, still in flight per {@link isAgentInFlight} (not rested, not stale). Same
204
- * logic/thresholds as {@link runningAgentCount}, scoped to one thread — the thread-edit-steer
205
- * hook uses it to decide whether an orchestrator edit needs a SendMessage steer.
206
- * @param {{slug: string, transcriptPath?: string|null, projectDir: string, now?: number}} args
207
- * @returns {{id: string, label: string|null} | null}
208
- */
209
- export function liveBoundAgentForThread({ slug, transcriptPath, projectDir, now = Date.now() }) {
210
- try {
211
- const binding = newestBindingByThread(projectDir).get(slug);
212
- if (!binding) return null;
213
- let src;
214
- try {
215
- src = readFileSync(join(projectDir, '.frizz', `${slug}.md`), 'utf8');
216
- } catch {
217
- return null; // no thread file → nothing to steer
218
- }
219
- const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
220
- if (TERMINAL_THREAD.has(threadStatus)) return null; // done/dismissed → no live agent
221
- const tasksDir = deriveTasksDir(transcriptPath);
222
- if (!isAgentInFlight(binding.id, tasksDir, restedAgentIds(projectDir), now)) return null;
223
- return { id: binding.id, label: binding.label };
224
- } catch {
225
- return null; // fail-open
226
- }
227
- }
228
-
229
- /**
230
- * Count of dispatched agents currently RUNNING — the "Waiting for N background agents" case.
231
- * An agent counts as running when: it's the NEWEST binding for its thread, it has NOT rested
232
- * (no completion recorded → still in flight, not paused/done), its thread isn't terminal, and
233
- * it isn't a long-dead stale binding. "Not rested" is the primary signal — a rest is the ONLY
234
- * completion mark, so its absence means the agent is still in flight; age is a secondary guard:
235
- * count when the output is fresh (age < DROPPED_MIN) OR there's no output file yet (age == null →
236
- * JUST launched, definitely running), and drop only the clearly-stale (quiet past DROPPED_MIN and
237
- * never rested → agentLivenessLines already flags that as 'dropped', so we don't double-count it
238
- * as "working"). This is DISTINCT from agentLivenessLines (which flags STRANDED active threads);
239
- * here we count live in-flight work so the Stop hook can surface "N still working" before the
240
- * unhookable idle-wait. Fail-open → 0. Never throws.
241
- * @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
242
- * @returns {number}
243
- */
244
- export function runningAgentCount({ transcriptPath, projectDir, now = Date.now() }) {
245
- try {
246
- const tasksDir = deriveTasksDir(transcriptPath);
247
- const frizzDir = join(projectDir, '.frizz');
248
- const newest = newestBindingByThread(projectDir);
249
- const rested = restedAgentIds(projectDir);
250
- let files;
251
- try {
252
- files = readdirSync(frizzDir).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.'));
253
- } catch {
254
- return 0;
255
- }
256
- let count = 0;
257
- const seen = new Set();
258
- for (const f of files) {
259
- const slug = f.replace(/\.md$/, '');
260
- const binding = newest.get(slug);
261
- if (!binding || seen.has(binding.id)) continue; // one count per agent even if it serves >1 thread
262
- seen.add(binding.id);
263
- let src;
264
- try {
265
- src = readFileSync(join(frizzDir, f), 'utf8');
266
- } catch {
267
- continue;
268
- }
269
- const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
270
- if (TERMINAL_THREAD.has(threadStatus)) continue; // done/dismissed → not running
271
- if (isAgentInFlight(binding.id, tasksDir, rested, now)) count++; // shared predicate (not rested + fresh/just-launched)
272
- }
273
- return count;
274
- } catch {
275
- return 0;
276
- }
277
- }
278
-
279
- /**
280
- * The high-confidence subset of {@link agentLivenessLines}: ONLY the "ACTIVE THREAD, NO LIVE
281
- * AGENT" lines. Used by the per-turn reminder, where surfacing the soft idle notes every prompt
282
- * would be noise — but a genuinely stranded active thread SHOULD nag every turn until reconciled
283
- * (that is the anti-drop signal), and it's rare by construction, so it won't cry wolf.
284
- * @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
285
- * @returns {string[]}
286
- */
287
- export function strandedThreadLines(args) {
288
- return agentLivenessLines(args).filter((l) => l.startsWith('⚠ ACTIVE THREAD'));
289
- }
290
-
291
- /**
292
- * THE WATCHER/AGENT DROP-GUARD (2026-07-06, the #327 forcing function). For each `active`,
293
- * non-downstream thread whose NEWEST agent was dispatched > LONG_RUNTIME_MIN ago AND is STILL
294
- * emitting output (age < DROPPED_MIN → looks alive) yet has produced NO terminal thread result,
295
- * emit a LOUD "VERIFY DIRECTLY" line. This is the case the existing 'dropped' signal CANNOT catch:
296
- * a `ci-watch` hung on a nameless ghost check keeps polling, so its output stays fresh and it never
297
- * looks stranded — it just never terminates, and "watcher running = fine" gets trusted for hours
298
- * (PR #327 sat effectively-green + stranded). The forcing function turns "a watcher can hang" from
299
- * a RULE into a hook-surfaced nudge: go check the target rollup YOURSELF.
300
- *
301
- * Runtime is measured from the binding `ts` (dispatch time), NOT output age — a watcher looks
302
- * "fresh" precisely because it is polling; the giveaway is the long total RUNTIME with no result.
303
- * Distinct from 'dropped' (stale + rested): the two bands don't overlap (this requires fresh
304
- * output, that requires stale). Structured return (slug/agentId/runtimeMin/tier/line) so a consumer
305
- * can dedupe by agentId AND re-arm per `tier` — `tier = floor(runtimeMin / LONG_RUNTIME_MIN)`, the
306
- * coarse 1×/2×/3× runtime multiple. Re-arming per tier is load-bearing for a PURE idle-wait (no
307
- * user prompts → the per-turn reminder never fires): a permanent agent-id dedup would block the
308
- * idle exactly ONCE at ~1× and let a genuinely-hung watcher be rationalized as "still fine" forever
309
- * (the #327 trap); re-blocking at each new tier forces a multi-hour hung watcher back into view
310
- * while a normal long-but-progressing agent still only crosses a tier occasionally. Fail-open → [].
311
- * @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
312
- * @returns {{slug:string, agentId:string, runtimeMin:number, tier:number, line:string}[]}
313
- */
314
- export function longRunningAgentLines({ transcriptPath, projectDir, now = Date.now() }) {
315
- /** @type {{slug:string, agentId:string, runtimeMin:number, line:string}[]} */
316
- const out = [];
317
- try {
318
- const tasksDir = deriveTasksDir(transcriptPath);
319
- const frizzDir = join(projectDir, '.frizz');
320
- const newest = newestBindingByThread(projectDir);
321
- const downstream = downstreamThreads(projectDir);
322
- let files;
323
- try {
324
- files = readdirSync(frizzDir).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.'));
325
- } catch {
326
- return out;
327
- }
328
- for (const f of files) {
329
- const slug = f.replace(/\.md$/, '');
330
- const binding = newest.get(slug);
331
- if (!binding) continue;
332
- let src;
333
- try {
334
- src = readFileSync(join(frizzDir, f), 'utf8');
335
- } catch {
336
- continue;
337
- }
338
- const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
339
- if (threadStatus !== 'active') continue; // only an active thread can strand a running agent
340
- if (downstream.has(slug)) continue; // PR landing via the cascade → legitimately long, suppress
341
- const dispatchedMs = Date.parse(binding.ts ?? '');
342
- if (!Number.isFinite(dispatchedMs)) continue; // no dispatch time → can't judge runtime
343
- const runtimeMin = (now - dispatchedMs) / 60_000;
344
- if (runtimeMin <= LONG_RUNTIME_MIN) continue; // not long-running yet
345
- const ageMin = agentAge(binding.id, tasksDir, now);
346
- // Must still LOOK alive (fresh output): a STALE agent is the 'dropped' case, not this one; a
347
- // null age (no output file → likely a dead prior-session binding) can't confirm alive → skip.
348
- if (ageMin == null || ageMin >= DROPPED_MIN) continue;
349
- const who = `${binding.label ? `${binding.label} ` : ''}[${binding.id.slice(0, 9)}]`;
350
- out.push({
351
- slug,
352
- agentId: binding.id,
353
- runtimeMin: Math.round(runtimeMin),
354
- tier: Math.floor(runtimeMin / LONG_RUNTIME_MIN), // coarse 1×/2×/3× runtime multiple → per-tier re-arm
355
- line:
356
- `⚠ VERIFY DIRECTLY — ${slug}: agent ${who} has run ${Math.round(runtimeMin)}m with NO terminal result ` +
357
- `(last output ${Math.round(ageMin)}m ago, so it LOOKS alive). A watcher can HANG on a stuck/ghost CI check ` +
358
- `(a nameless check-run that never reports) and look alive while stranded — check its PR/target rollup YOURSELF ` +
359
- `(e.g. \`gh pr view <n> --json statusCheckRollup -q '.statusCheckRollup[]|select(.status!="COMPLETED")'\`) and act; ` +
360
- `do NOT trust that it's still progressing.`,
361
- });
362
- }
363
- } catch {
364
- /* fail-open */
365
- }
366
- return out;
367
- }