frayui 0.1.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/dist/claude-agent-broker.js +21641 -0
  4. package/dist/codex-app-server-daemon.js +354 -0
  5. package/dist/dev-child.js +40319 -0
  6. package/dist/frayui.js +8738 -0
  7. package/package.json +46 -0
  8. package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
  9. package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
  10. package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
  11. package/runtime/cc/scripts/fray/config.mjs +982 -0
  12. package/runtime/cc/scripts/fray/decisions.mjs +97 -0
  13. package/runtime/cc/scripts/fray/index.mjs +699 -0
  14. package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
  15. package/runtime/cc/scripts/fray/notify.mjs +81 -0
  16. package/runtime/cc/scripts/fray/ownership.mjs +120 -0
  17. package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
  18. package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
  19. package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
  20. package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
  21. package/runtime/cc-worker/DECISIONS.md +923 -0
  22. package/runtime/cc-worker/LICENSE +21 -0
  23. package/runtime/cc-worker/agents/fable-high.md +8 -0
  24. package/runtime/cc-worker/agents/fable-low.md +8 -0
  25. package/runtime/cc-worker/agents/fable-max.md +8 -0
  26. package/runtime/cc-worker/agents/fable-medium.md +8 -0
  27. package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
  28. package/runtime/cc-worker/agents/haiku.md +7 -0
  29. package/runtime/cc-worker/agents/opus-high.md +8 -0
  30. package/runtime/cc-worker/agents/opus-low.md +8 -0
  31. package/runtime/cc-worker/agents/opus-max.md +8 -0
  32. package/runtime/cc-worker/agents/opus-medium.md +8 -0
  33. package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
  34. package/runtime/cc-worker/agents/sonnet-high.md +8 -0
  35. package/runtime/cc-worker/agents/sonnet-low.md +8 -0
  36. package/runtime/cc-worker/agents/sonnet-max.md +8 -0
  37. package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
  38. package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
  39. package/runtime/cc-worker/bin/fray +17 -0
  40. package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
  41. package/runtime/cc-worker/bin/fray-update +18 -0
  42. package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
  43. package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
  44. package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
  45. package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
  46. package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
  47. package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
  48. package/runtime/cc-worker/hooks/hooks.json +111 -0
  49. package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
  50. package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
  51. package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
  52. package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
  53. package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
  54. package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
  55. package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
  56. package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
  57. package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
  58. package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
  59. package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
  60. package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
  61. package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
  62. package/web-dist/apple-touch-icon.png +0 -0
  63. package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
  64. package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
  65. package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
  66. package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
  67. package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
  68. package/web-dist/assets/array-BifhSqXX.js +1 -0
  69. package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
  70. package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
  71. package/web-dist/assets/channel-C2eUWc74.js +1 -0
  72. package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  73. package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
  74. package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
  75. package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  76. package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  77. package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
  78. package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
  79. package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
  80. package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
  81. package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
  82. package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  83. package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  84. package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
  85. package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
  86. package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
  87. package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
  88. package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
  89. package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
  90. package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
  91. package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
  92. package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
  93. package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
  94. package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  95. package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
  96. package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  97. package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
  98. package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
  99. package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
  100. package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
  101. package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
  102. package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  103. package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
  104. package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
  105. package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
  106. package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
  107. package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
  108. package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
  109. package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
  110. package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
  111. package/web-dist/assets/dist-DoH_9pyS.js +1 -0
  112. package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
  113. package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
  114. package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
  115. package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
  116. package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
  117. package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
  118. package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
  119. package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
  120. package/web-dist/assets/index-BHzIN-tQ.js +357 -0
  121. package/web-dist/assets/index-Duiy4w7C.css +1 -0
  122. package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
  123. package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
  124. package/web-dist/assets/init-D6jRqBbL.js +1 -0
  125. package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
  126. package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
  127. package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
  128. package/web-dist/assets/katex-CddkPoXu.js +257 -0
  129. package/web-dist/assets/line-Ds4xvN3d.js +1 -0
  130. package/web-dist/assets/linear-z2V0wJk9.js +1 -0
  131. package/web-dist/assets/map-DsCK-0Cs.js +1 -0
  132. package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
  133. package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
  134. package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
  135. package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
  136. package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
  137. package/web-dist/assets/path-BWPyau1x.js +1 -0
  138. package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
  139. package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
  140. package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
  141. package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
  142. package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
  143. package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
  144. package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
  145. package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
  146. package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
  147. package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
  148. package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
  149. package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
  150. package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
  151. package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
  152. package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
  153. package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  154. package/web-dist/assets/src-C4XfhTaE.js +1 -0
  155. package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
  156. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
  157. package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
  158. package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
  159. package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
  160. package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
  161. package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
  162. package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
  163. package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
  164. package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
  165. package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
  166. package/web-dist/favicon-16.png +0 -0
  167. package/web-dist/favicon-32.png +0 -0
  168. package/web-dist/favicon.svg +78 -0
  169. package/web-dist/icon-192.png +0 -0
  170. package/web-dist/icon-512.png +0 -0
  171. package/web-dist/icon-maskable-512.png +0 -0
  172. package/web-dist/index.html +33 -0
  173. package/web-dist/manifest.webmanifest +16 -0
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "frayui",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Local orchestration surface for Fray projects — a per-repo web UI over your .fray/ agent threads",
6
+ "keywords": [
7
+ "fray",
8
+ "claude",
9
+ "claude-code",
10
+ "agents",
11
+ "orchestration",
12
+ "tui"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Colin McDonnell",
16
+ "homepage": "https://github.com/colinhacks/fray",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/colinhacks/fray.git",
20
+ "directory": "packages/cli"
21
+ },
22
+ "engines": {
23
+ "node": ">=26"
24
+ },
25
+ "type": "module",
26
+ "bin": {
27
+ "frayui": "./dist/frayui.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "web-dist",
32
+ "runtime"
33
+ ],
34
+ "scripts": {
35
+ "prepack": "node ./scripts/prepare-package.mjs && node ./scripts/build-package.mjs && node ./scripts/publish-manifest.mjs --strip",
36
+ "postpack": "node ./scripts/publish-manifest.mjs --restore"
37
+ },
38
+ "dependencies": {
39
+ "@parcel/watcher": "^2.5",
40
+ "better-sqlite3": "^12",
41
+ "node-pty": "^1.1"
42
+ },
43
+ "devDependencies": {
44
+ "esbuild": "^0.25.0"
45
+ }
46
+ }
@@ -0,0 +1,287 @@
1
+ // @ts-check
2
+ /**
3
+ * fray — 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 (`.fray/.agent-bindings.jsonl`)
14
+ * is transient routing info under the already-gitignored `.fray/` — 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, '.fray', '.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 `.fray/<slug>.md`-backed tag at the top of the prompt). Returns the
36
+ * normalized slug (no `.fray/` 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(/^\.fray\//, '').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 (`.fray/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, '.fray', '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 `.fray/.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, '.fray', '.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 `.fray/<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, '.fray', '.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
+ }