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
@@ -0,0 +1,367 @@
1
+ // @ts-check
2
+ /**
3
+ * fray — 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 `.fray/.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
+ * FRAY_IDLE_MIN (default 10) — quiet this long → soft idle note.
41
+ * FRAY_DROPPED_MIN (default 45, FRAY_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/fray 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 frayDir = join(projectDir, '.fray');
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(frayDir).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(frayDir, 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(`fray: 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, '.fray', `${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 frayDir = join(projectDir, '.fray');
248
+ const newest = newestBindingByThread(projectDir);
249
+ const rested = restedAgentIds(projectDir);
250
+ let files;
251
+ try {
252
+ files = readdirSync(frayDir).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(frayDir, 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 frayDir = join(projectDir, '.fray');
320
+ const newest = newestBindingByThread(projectDir);
321
+ const downstream = downstreamThreads(projectDir);
322
+ let files;
323
+ try {
324
+ files = readdirSync(frayDir).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(frayDir, 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
+ }
@@ -0,0 +1,178 @@
1
+ // @ts-check
2
+ /**
3
+ * fray — DERIVED agent state. The single shared derivation used by BOTH the Stop-hook
4
+ * liveness helper (`./agent-liveness.mjs`) and the board (`./index.mjs`), so an agent's
5
+ * reported state can never drift between the two.
6
+ *
7
+ * THE PRINCIPLE — compute, don't store (the same rule the board already follows for
8
+ * thread status). The thread↔agent binding (`.fray/.agent-bindings.jsonl`, written
9
+ * AUTOMATICALLY by the agent-bind hook) records ONLY immutable-at-dispatch facts
10
+ * (`agentId → thread`, plus a label); it carries NO per-agent `status`. (The old
11
+ * hand-maintained `agents:` frontmatter is gone; a lingering one is an ignored no-op.)
12
+ * Every liveness/doneness judgement is DERIVED here from ground truth:
13
+ *
14
+ * - output-file (`tasks/<id>.output`) mtime → how long since the agent last wrote,
15
+ * - the THREAD's own `status:` (done/dismissed = terminal) → whether the orchestrator
16
+ * has deliberately reconciled the thread.
17
+ *
18
+ * There is NO durable per-agent COMPLETION signal: a rest (`.rested-agents.jsonl`) records
19
+ * an `agent_id` but a rest is NOT "done" (an agent rests repeatedly). So "done" is INFERRED
20
+ * (terminal-or-stale output + thread status), never read from a stored per-agent flag. That
21
+ * is exactly why the old hand-maintained `status` field drifted and false-flagged a
22
+ * completed agent as idle; deriving it makes that drift class structurally impossible.
23
+ *
24
+ * Derived states (one per dispatched agent — keyed on the thread's NEWEST agent only):
25
+ * - 'terminal' — nothing to flag, for ANY of: the THREAD is terminal (done/dismissed);
26
+ * the thread is PARKED (non-terminal but not `active`); or the thread is
27
+ * DOWNSTREAM (a PR is landing via the merge cascade, so it is
28
+ * legitimately active while it merges, not because an agent is stuck).
29
+ * - 'dropped' — the conservative, high-confidence "no live agent" signal: an `active`
30
+ * thread whose newest agent's output is stale beyond `droppedMin` AND
31
+ * which has rested at least once (ended a turn) — so it is quiet at a
32
+ * stopping point, not mid-tool-call. Likely finished-but-unreconciled or
33
+ * genuinely dropped. THE one signal that matters.
34
+ * - 'idle' — quiet, but NOT confidently dropped: output between `idleMin` and
35
+ * `droppedMin`, OR stale-but-never-rested (still inside one long tool
36
+ * call — a build/test — so alive). Informational only; say it softly.
37
+ * - 'fresh' — output recent (<=idleMin): actively working, say nothing.
38
+ * - 'unknown' — no readable output file (placeholder id, never-started): can't
39
+ * judge; fail-open (say nothing).
40
+ *
41
+ * WHY a generous `droppedMin` (45m, vs the old 25m). Sub-agents now legitimately arm CI
42
+ * watchers and go quiet for long stretches while CI runs (30–40m), so "no output for 25m" is
43
+ * NORMAL, not stuck — the old threshold cried wolf. 45m gives a watcher room to fire and
44
+ * resume the agent (which refreshes its output) before we ever call it dropped; the bar is
45
+ * deliberately "better to miss a soft case than nag a benign one."
46
+ */
47
+
48
+ import { readdirSync, statSync } from 'node:fs';
49
+ import { join } from 'node:path';
50
+
51
+ export const DEFAULT_IDLE_MIN = 10;
52
+ export const DEFAULT_DROPPED_MIN = 45;
53
+ // Back-compat alias for the old name; repointed to the new, generous default.
54
+ export const DEFAULT_FROZEN_MIN = DEFAULT_DROPPED_MIN;
55
+ // The WATCHER/AGENT DROP-GUARD threshold (min): a dispatched agent that has RUN this long while
56
+ // still emitting output (looks alive) but has produced NO terminal thread result gets a LOUD
57
+ // "verify directly" flag — the #327 forcing function (a `ci-watch` hung on a nameless ghost check
58
+ // polls forever and looks alive while stranded; "watcher running = fine" then gets trusted). This
59
+ // is DISTINCT from DROPPED_MIN (stale + rested = went quiet): the drop-guard targets the
60
+ // looks-alive-but-maybe-hung band. Measured from the binding `ts` (dispatch time), not output age.
61
+ export const DEFAULT_LONG_RUNTIME_MIN = 35;
62
+
63
+ // Env-resolved thresholds, defined ONCE here so EVERY consumer (the Stop-hook liveness helper
64
+ // AND the board) reads the same knobs and can never disagree. FRAY_FROZEN_MIN is honored as the
65
+ // old alias for FRAY_DROPPED_MIN.
66
+ export const IDLE_MIN = parseInt(process.env.FRAY_IDLE_MIN || '', 10) || DEFAULT_IDLE_MIN;
67
+ export const DROPPED_MIN = parseInt(process.env.FRAY_DROPPED_MIN || process.env.FRAY_FROZEN_MIN || '', 10) || DEFAULT_DROPPED_MIN;
68
+ export const LONG_RUNTIME_MIN = parseInt(process.env.FRAY_LONG_RUNTIME_MIN || '', 10) || DEFAULT_LONG_RUNTIME_MIN;
69
+
70
+ /**
71
+ * GROUND-TRUTH age of an agent's last activity, in minutes — globbed across ALL local
72
+ * Claude task dirs (`<tmp>/claude-<uid>/<project>/<session>/tasks/<id>.output`). Unlike
73
+ * the Stop hook's `deriveTasksDir` (which has the transcript_path and so knows the exact
74
+ * session), the BOARD runs standalone with no session id, so it must search every session
75
+ * for the agent's output symlink. `statSync` follows the symlink → the TARGET transcript's
76
+ * real last-write (the symlink's own mtime is stale). Returns the freshest match's age, or
77
+ * null when no output file exists (placeholder id / never-started / different machine).
78
+ * Fail-open: any error → null.
79
+ * @param {string} agentId
80
+ * @param {number} [now]
81
+ * @returns {number|null} minutes since last activity, or null
82
+ */
83
+ export function findAgentOutputAge(agentId, now = Date.now()) {
84
+ if (!agentId) return null;
85
+ let best = null; // most-recent mtimeMs found
86
+ try {
87
+ for (const base of ['/tmp', '/private/tmp']) {
88
+ let claudeDirs;
89
+ try {
90
+ claudeDirs = readdirSync(base).filter((d) => d.startsWith('claude-'));
91
+ } catch {
92
+ continue;
93
+ }
94
+ for (const cd of claudeDirs) {
95
+ const root = join(base, cd);
96
+ let projects;
97
+ try {
98
+ projects = readdirSync(root);
99
+ } catch {
100
+ continue;
101
+ }
102
+ for (const proj of projects) {
103
+ let sessions;
104
+ try {
105
+ sessions = readdirSync(join(root, proj));
106
+ } catch {
107
+ continue;
108
+ }
109
+ for (const sess of sessions) {
110
+ try {
111
+ const st = statSync(join(root, proj, sess, 'tasks', `${agentId}.output`));
112
+ if (best == null || st.mtimeMs > best) best = st.mtimeMs;
113
+ } catch {
114
+ /* not in this session */
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ } catch {
121
+ /* fail-open */
122
+ }
123
+ return best == null ? null : (now - best) / 60000;
124
+ }
125
+
126
+ /**
127
+ * Derive one agent's state PURELY from ground truth. No per-agent stored status is
128
+ * consulted — `ageMin` comes from the output-file mtime and `threadTerminal` from the
129
+ * thread's own `status:` frontmatter.
130
+ *
131
+ * @param {object} a
132
+ * @param {number|null} a.ageMin minutes since the agent's output last changed, or
133
+ * null when there is no readable output file.
134
+ * @param {boolean} a.threadTerminal is the owning thread's status done/dismissed?
135
+ * @param {boolean} [a.threadActive] is the owning thread's status exactly `active`? Only
136
+ * `active` threads are "being worked" by an agent, so
137
+ * only they can have an UNRECONCILED/idle agent. Other
138
+ * non-terminal phases (planning/planned/blocked)
139
+ * with a done/absent agent are EXPECTED, not a drift
140
+ * signal — never flagged. NOTE `planning` is human-or-
141
+ * architect-driven design and may legitimately have NO
142
+ * live agent, so it is deliberately NOT `active` here and
143
+ * never triggers the no-live-agent warning. (Defaults
144
+ * true for backward-compat when a caller doesn't pass it.)
145
+ * @param {boolean} [a.threadDownstream] does the thread have a PR landing via the merge
146
+ * cascade (`.fray/merge-queue.jsonl`)? Such a thread is
147
+ * legitimately `active` while it merges — the producing
148
+ * agent has finished and reconciled — so it is never a
149
+ * drop. (Defaults false.)
150
+ * @param {boolean} [a.hasRested] has the agent recorded at least one rest (ended a
151
+ * turn)? Stale output + NO rest = still inside one long
152
+ * tool call (a build/test) → ALIVE, never 'dropped'.
153
+ * (Defaults true for back-compat with callers that don't
154
+ * supply it.)
155
+ * @param {number} [a.idleMin] idle threshold (min). Default {@link DEFAULT_IDLE_MIN}.
156
+ * @param {number} [a.droppedMin] dropped/stale threshold (min). Default {@link DEFAULT_DROPPED_MIN}.
157
+ * @param {number} [a.frozenMin] DEPRECATED alias for `droppedMin` (old callers).
158
+ * @returns {'terminal'|'dropped'|'idle'|'fresh'|'unknown'}
159
+ */
160
+ export function deriveAgentState({ ageMin, threadTerminal, threadActive = true, threadDownstream = false, hasRested = true, idleMin = DEFAULT_IDLE_MIN, droppedMin = DEFAULT_DROPPED_MIN, frozenMin }) {
161
+ if (frozenMin != null) droppedMin = frozenMin; // back-compat: old callers passed frozenMin
162
+ // A reconciled thread is the orchestrator's deliberate "I folded this" signal — the
163
+ // only mutable bit in the whole loop, and it lives on the THREAD, not the agent.
164
+ if (threadTerminal) return 'terminal';
165
+ // A non-`active` non-terminal phase (planning/planned/blocked) is also a
166
+ // deliberate orchestrator state — a stale/done/absent agent on such a thread is EXPECTED
167
+ // (the work finished, the thread awaits a human/dep, or `planning` is human-driven with no
168
+ // agent at all), NOT a drop. Only an `active` thread is "being worked right now" by an
169
+ // agent, so only it can have a drift-signal agent.
170
+ if (!threadActive) return 'terminal';
171
+ // DOWNSTREAM: a PR is landing for this thread via the cascade — it is active for the
172
+ // merge, not a stuck agent. The producing agent has finished + been reconciled; suppress.
173
+ if (threadDownstream) return 'terminal';
174
+ if (ageMin == null) return 'unknown'; // no activity file → can't judge (fail-open)
175
+ if (ageMin > droppedMin) return hasRested ? 'dropped' : 'idle'; // stale + rested = quiet at a stopping point (the signal); stale + never-rested = still in one long tool call → alive
176
+ if (ageMin > idleMin) return 'idle';
177
+ return 'fresh';
178
+ }