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,188 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on `Bash` (fray-worker). Claude's native `run_in_background` flag registers a
4
+ // task, output file, terminal notification, and wake. Shell job control (`cmd &`) does none of those:
5
+ // the child can survive after the Bash tool returns, but Claude and fray have no lifecycle identity
6
+ // for it. A worker can then rest forever waiting for a notification that cannot exist.
7
+ //
8
+ // Block only an ESCAPING local background job. Self-contained shell concurrency remains valid when
9
+ // the command explicitly waits for its children or owns them with an EXIT trap before Bash returns.
10
+ //
11
+ // GATE: inert unless FRAY_UI_THREAD is set (ordinary Claude sessions keep their native behavior).
12
+ // FAIL OPEN: malformed hook input allows the command rather than wedging a worker.
13
+ import { readFileSync } from 'node:fs';
14
+ import { basename } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+
17
+ /** @param {unknown} obj @returns {never} */
18
+ function emit(obj) {
19
+ process.stdout.write(JSON.stringify(obj));
20
+ process.exit(0);
21
+ }
22
+
23
+ /**
24
+ * Blank heredoc bodies while preserving line/character positions. A script being WRITTEN may contain
25
+ * `&`; only the shell currently executing this tool call is relevant to the lifecycle escape.
26
+ * @param {string} command
27
+ */
28
+ function withoutHeredocBodies(command) {
29
+ const lines = command.split('\n');
30
+ /** @type {string[]} */
31
+ const pending = [];
32
+ let active;
33
+ return lines.map((line) => {
34
+ if (active !== undefined) {
35
+ if (line.trim() === active) {
36
+ active = pending.shift();
37
+ return line;
38
+ }
39
+ return ' '.repeat(line.length);
40
+ }
41
+
42
+ // Shell accepts quoted and bare heredoc delimiters. Multiple heredocs on one command line are
43
+ // consumed in declaration order.
44
+ for (const match of line.matchAll(/<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_-]*))/g)) {
45
+ const delimiter = match[1] ?? match[2] ?? match[3];
46
+ if (delimiter) pending.push(delimiter);
47
+ }
48
+ active = pending.shift();
49
+ return line;
50
+ }).join('\n');
51
+ }
52
+
53
+ /**
54
+ * Replace quoted regions with spaces. Operators sent inside `ssh host '…'` are remote, and quoted
55
+ * prose such as `printf '&'` is not local shell job control. Backticks are also synchronous command
56
+ * substitutions from the outer shell's perspective.
57
+ * @param {string} command
58
+ */
59
+ function withoutQuotedRegions(command) {
60
+ let quote = '';
61
+ let escaped = false;
62
+ let out = '';
63
+ for (let i = 0; i < command.length; i++) {
64
+ const c = command[i];
65
+ if (escaped) {
66
+ out += quote ? ' ' : c;
67
+ escaped = false;
68
+ continue;
69
+ }
70
+ if (c === '\\' && quote !== "'") {
71
+ out += quote ? ' ' : c;
72
+ escaped = true;
73
+ continue;
74
+ }
75
+ if (quote) {
76
+ // Double quotes may contain a complete command substitution with its OWN quote grammar:
77
+ // `"$(python -c 'print(f"{x&255}")')"`. Treating the f-string's double quote as the end of the
78
+ // outer region exposed arithmetic `&` as fake job control in a real corpus call. The outer shell
79
+ // waits for command substitution, so blank the balanced substitution as part of the quote.
80
+ if (quote === '"' && c === '$' && command[i + 1] === '(') {
81
+ out += ' ';
82
+ i += 2;
83
+ let depth = 1;
84
+ let innerQuote = '';
85
+ let innerEscaped = false;
86
+ for (; i < command.length; i++) {
87
+ const inner = command[i];
88
+ out += inner === '\n' ? '\n' : ' ';
89
+ if (innerEscaped) {
90
+ innerEscaped = false;
91
+ continue;
92
+ }
93
+ if (inner === '\\' && innerQuote !== "'") {
94
+ innerEscaped = true;
95
+ continue;
96
+ }
97
+ if (innerQuote) {
98
+ if (inner === innerQuote) innerQuote = '';
99
+ continue;
100
+ }
101
+ if (inner === "'" || inner === '"' || inner === '`') {
102
+ innerQuote = inner;
103
+ continue;
104
+ }
105
+ if (inner === '(') depth++;
106
+ else if (inner === ')' && --depth === 0) break;
107
+ }
108
+ continue;
109
+ }
110
+ if (c === quote) quote = '';
111
+ out += c === '\n' ? '\n' : ' ';
112
+ continue;
113
+ }
114
+ if (c === "'" || c === '"' || c === '`') {
115
+ quote = c;
116
+ out += ' ';
117
+ continue;
118
+ }
119
+ out += c;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * Return true when this Bash call starts a local background job and can return without joining or
126
+ * terminating it. This is deliberately a small shell-lifecycle recognizer, not a general parser.
127
+ * @param {unknown} raw
128
+ */
129
+ export function hasEscapingBackgroundJob(raw) {
130
+ if (typeof raw !== 'string' || !raw.trim()) return false;
131
+ const command = withoutQuotedRegions(withoutHeredocBodies(raw));
132
+ /** @type {number[]} */
133
+ const operators = [];
134
+ for (let i = 0; i < command.length; i++) {
135
+ if (command[i] !== '&') continue;
136
+ const before = command[i - 1] ?? '';
137
+ const after = command[i + 1] ?? '';
138
+ // `&&`, redirects (`2>&1`, `&>`), and an escaped literal are not background operators.
139
+ if (before === '&' || after === '&' || before === '>' || before === '<' || after === '>' || before === '\\') continue;
140
+ operators.push(i);
141
+ }
142
+ if (operators.length === 0) return false;
143
+
144
+ // A lifecycle action AFTER the last launch makes the command self-contained. `kill` alone does not:
145
+ // the signal is asynchronous, so the shell still needs a wait before returning. An EXIT trap owns
146
+ // cleanup at the shell boundary.
147
+ const tail = command.slice(operators[operators.length - 1] + 1);
148
+ return !(/\bwait\b/.test(tail) || /\btrap\b[^\n;]*\b(?:EXIT|0)\b/.test(tail));
149
+ }
150
+
151
+ export function evaluateBashBackgroundHook(input, env = process.env) {
152
+ if (!String(env.FRAY_UI_THREAD ?? '').trim()) return {};
153
+ const command = input && typeof input === 'object'
154
+ ? String(input.tool_input?.command ?? '')
155
+ : '';
156
+ if (!hasEscapingBackgroundJob(command)) return {};
157
+ const codex = typeof input?.model === 'string';
158
+ return {
159
+ hookSpecificOutput: {
160
+ hookEventName: 'PreToolUse',
161
+ permissionDecision: 'deny',
162
+ permissionDecisionReason:
163
+ codex
164
+ ? 'Fray blocked an untracked shell background job (`&`). Shell job control can return without a Codex lifecycle handle, so Fray cannot report completion or wake this agent. For work that must continue while you do something else, remove `&` and use the managed unified exec pattern: start `tools.exec_command(...)`, call `yield_control()`, then await and fully drain that same process. A returned `session_id` alone is only foreground continuation. For bounded parallel work inside one shell call, finish with `wait` (after `kill`, if used) or own cleanup with an EXIT trap.'
165
+ : 'Fray blocked an untracked shell background job (`&`). Shell job control can return from Bash without a Claude task ID, so Fray cannot report completion or wake this agent. For a long-running local command, remove `&` and call Bash with `run_in_background:true`. For bounded parallel work inside one Bash call, finish with `wait` (after `kill`, if used) or own cleanup with an EXIT trap.',
166
+ },
167
+ };
168
+ }
169
+
170
+ export function isDirectHookExecution(argv1, moduleUrl) {
171
+ return typeof argv1 === 'string'
172
+ && basename(argv1) === 'bash-background.mjs'
173
+ && pathToFileURL(argv1).href === moduleUrl;
174
+ }
175
+
176
+ // The server imports `hasEscapingBackgroundJob` and its production build bundles this module into
177
+ // `src/index.js`. esbuild rewrites `import.meta.url` to that bundle URL, so URL equality alone would
178
+ // mistake the whole server for this executable and block startup reading hook JSON from stdin.
179
+ if (isDirectHookExecution(process.argv[1], import.meta.url)) {
180
+ try {
181
+ const env = process.argv.includes('--fray-ui-thread')
182
+ ? { ...process.env, FRAY_UI_THREAD: process.env.FRAY_UI_THREAD || 'codex-worker' }
183
+ : process.env;
184
+ emit(evaluateBashBackgroundHook(JSON.parse(readFileSync(0, 'utf8')), env));
185
+ } catch {
186
+ emit({});
187
+ }
188
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on AskUserQuestion (fray-worker). A fray-ui worker runs under a dashboard, not a
4
+ // live chat: an interactive question prompt would hang the session invisibly (nobody is at the
5
+ // keyboard to click it). Deny with a redirect to the async pattern: ask in the FINAL MESSAGE via one
6
+ // or more ```question fenced blocks, then come to rest; answers arrive as the next user message.
7
+ // GATE: inert unless FRAY_UI_THREAD is set. FAIL OPEN on parse errors.
8
+ //
9
+ // SECOND GATE: also inert when FRAY_NATIVE_ASK=1. The premise above — "nobody is at the keyboard" —
10
+ // is only true where the question has nowhere to go. On the Claude session-broker path fray now
11
+ // intercepts the call at canUseTool and renders it as a real question card on the dashboard, which the
12
+ // operator answers and which returns the chosen labels to the tool. The broker bridge sets that var
13
+ // only when an InteractionStore is actually wired to render and resolve the card, so a denial here
14
+ // would block a question the operator CAN see. The tmux path never sets it (and additionally drops the
15
+ // tool with --disallowedTools), so it keeps the deny plus the ```question redirect.
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ const slug = process.env.FRAY_UI_THREAD;
19
+ if (!slug) process.exit(0);
20
+ if (process.env.FRAY_NATIVE_ASK === '1') process.exit(0);
21
+
22
+ try {
23
+ JSON.parse(readFileSync(0, 'utf8'));
24
+ } catch {
25
+ process.exit(0); // fail open — a broken hook must never halt work
26
+ }
27
+
28
+ process.stdout.write(
29
+ JSON.stringify({
30
+ hookSpecificOutput: {
31
+ hookEventName: 'PreToolUse',
32
+ permissionDecision: 'deny',
33
+ permissionDecisionReason:
34
+ 'Interactive prompts freeze headless workers (no one is at the keyboard to answer). Ask in your FINAL MESSAGE instead, using one or more ```question fenced blocks — each self-contained (context + the specific question + lettered `- A. …` options + a Recommendation); the fray-ui Queue renders each as a card and the human replies "A"/"2"/prose in the composer. A ```question block IS the handback: write it and END YOUR TURN (do NOT also add a done/awaiting fence, and do NOT invoke this tool again) — the human answers from the queue.',
35
+ },
36
+ }),
37
+ );
38
+ process.exit(0);
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PermissionRequest hook on ExitPlanMode (fray) — a fray-ui worker runs under a dashboard, not a
4
+ // live chat, so the plan-APPROVAL prompt (shown when the model calls ExitPlanMode to present a plan
5
+ // and ask to proceed) would hang the session invisibly. Deny it and redirect the worker to encode
6
+ // its plan/ask in the thread file instead.
7
+ //
8
+ // WHY PermissionRequest, not PreToolUse: ExitPlanMode is a PERMISSION surface, not a plain tool —
9
+ // it is denied via PermissionRequest (AskUserQuestion, a real tool, is denied via PreToolUse in the
10
+ // sibling deny-ask.mjs). CAVEAT: PermissionRequest hooks do NOT fire under `claude -p` (headless
11
+ // print mode); fray-ui workers run as INTERACTIVE tmux `claude` sessions, where they DO fire. So
12
+ // this protects the real worker path; the `-p` smoke test cannot exercise it (documented).
13
+ //
14
+ // PLAN-MODE SOFTLOCK — why this denies UNCONDITIONALLY when gated: a session genuinely IN plan mode
15
+ // is read-only until ExitPlanMode is approved, so denying ExitPlanMode there would SOFTLOCK it
16
+ // (can't exit → can't edit → can't even follow the "write to the thread" redirect). This hook does not
17
+ // branch on the session's mode. (An earlier note here claimed the PermissionRequest input carries no
18
+ // permission-mode signal — that is FALSE and was corrected 2026-07-25: the payload carries
19
+ // `permission_mode`, `tool_name`, and `tool_input`, which is exactly what the sibling perm-policy.mjs
20
+ // keys its rules on. The unconditional deny is kept because the SOURCE fix below makes plan mode
21
+ // unreachable for a fray worker anyway, not because the signal is unavailable.) The fix is at the SOURCE
22
+ // instead — fray-ui NEVER spawns a worker in plan mode (dispatch.ts coerces `--permission-mode plan`
23
+ // → `auto` in both command builders), so a real fray-ui worker is never in plan mode and this deny
24
+ // only ever meets a SPURIOUS ExitPlanMode call (nothing to exit → deny + redirect is correct). A
25
+ // worker "plans" by writing a plan file (`.fray/plans/<topic>.md`) and asking via a ```question
26
+ // approval block, never via interactive plan mode.
27
+ // RESIDUAL GAP (accepted, documented): the deny could softlock only a FOREIGN session that is
28
+ // simultaneously in plan mode AND running with FRAY_UI_THREAD set AND this plugin loaded — a combo
29
+ // fray-ui never produces (FRAY_UI_THREAD is set only by fray-ui dispatch, which coerces plan away).
30
+ // A normal plan-mode session outside fray-ui is untouched (this hook is inert without FRAY_UI_THREAD).
31
+ //
32
+ // GATE: inert unless FRAY_UI_THREAD is set. FAIL OPEN on any parse error — a broken hook must never
33
+ // halt work.
34
+ import { readFileSync } from 'node:fs';
35
+
36
+ const slug = process.env.FRAY_UI_THREAD;
37
+ if (!slug) process.exit(0);
38
+
39
+ try {
40
+ JSON.parse(readFileSync(0, 'utf8'));
41
+ } catch {
42
+ process.exit(0); // fail open — a broken hook must never halt work
43
+ }
44
+
45
+ // The instructive redirect rides TOP-LEVEL `additionalContext` (per the Claude Code hooks docs) —
46
+ // on a PermissionRequest DENY the `decision` object carries ONLY `{behavior:"deny"}`; the reason
47
+ // the model reads is `additionalContext`, injected as a plain-text system-reminder. Exit 0 with
48
+ // this JSON on stdout (exit 2 would make Claude Code ignore the JSON — never mix).
49
+ const reason =
50
+ 'Interactive plan-approval prompts freeze headless workers (no one is at the keyboard to approve). Do NOT present a plan for approval. Instead: if the plan is settled, just proceed with the work. If the plan is the deliverable, write it into a plan file `.fray/plans/<topic>.md` (free-form markdown) and/or your scratchpad. If it needs a human call before you build, ask in your FINAL MESSAGE with a two-option ```question block stating what you need approved, then come to rest — the human reviews it from the fray-ui queue.';
51
+
52
+ process.stdout.write(
53
+ JSON.stringify({
54
+ hookSpecificOutput: {
55
+ hookEventName: 'PermissionRequest',
56
+ decision: { behavior: 'deny' },
57
+ },
58
+ additionalContext: reason,
59
+ }),
60
+ );
61
+ process.exit(0);
@@ -0,0 +1,111 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/bash-background.mjs\"" }
8
+ ]
9
+ },
10
+ {
11
+ "matcher": "Agent",
12
+ "hooks": [
13
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-dispatch.mjs\"" }
14
+ ]
15
+ },
16
+ {
17
+ "matcher": "AskUserQuestion",
18
+ "hooks": [
19
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-ask.mjs\"" }
20
+ ]
21
+ }
22
+ ],
23
+ "PostToolUse": [
24
+ {
25
+ "matcher": "Agent",
26
+ "hooks": [
27
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-bind.mjs\"" }
28
+ ]
29
+ },
30
+ {
31
+ "hooks": [
32
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=nudge --event=PostToolUse" }
33
+ ]
34
+ }
35
+ ],
36
+ "UserPromptSubmit": [
37
+ {
38
+ "hooks": [
39
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=nudge" }
40
+ ]
41
+ }
42
+ ],
43
+ "Stop": [
44
+ {
45
+ "hooks": [
46
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad-stop.mjs\"" }
47
+ ]
48
+ }
49
+ ],
50
+ "PermissionRequest": [
51
+ {
52
+ "matcher": "ExitPlanMode",
53
+ "hooks": [
54
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-plan.mjs\"" }
55
+ ]
56
+ },
57
+ {
58
+ "matcher": "*",
59
+ "hooks": [
60
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/perm-policy.mjs\"" }
61
+ ]
62
+ }
63
+ ],
64
+ "PreCompact": [
65
+ {
66
+ "matcher": "auto",
67
+ "hooks": [
68
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/precompact-instructions.mjs\"" },
69
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=precompact" }
70
+ ]
71
+ },
72
+ {
73
+ "matcher": "manual",
74
+ "hooks": [
75
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/precompact-instructions.mjs\"" },
76
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=precompact" }
77
+ ]
78
+ }
79
+ ],
80
+ "SessionStart": [
81
+ {
82
+ "matcher": "startup",
83
+ "hooks": [
84
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
85
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
86
+ ]
87
+ },
88
+ {
89
+ "matcher": "resume",
90
+ "hooks": [
91
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
92
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
93
+ ]
94
+ },
95
+ {
96
+ "matcher": "clear",
97
+ "hooks": [
98
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
99
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
100
+ ]
101
+ },
102
+ {
103
+ "matcher": "compact",
104
+ "hooks": [
105
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
106
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
107
+ ]
108
+ }
109
+ ]
110
+ }
111
+ }
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PermissionRequest hook (fray worker), matcher "*" — the worker's permission POLICY, and the durable
4
+ // structured signal the tailer reads instead of scraping the tmux pane.
5
+ //
6
+ // WHY THIS DECIDES (it used to only observe): a fray worker runs under a dashboard with nobody at the
7
+ // keyboard, so a tool-approval prompt parks the thread invisibly until a human happens to look. fray
8
+ // dispatches Claude workers at `--permission-mode auto` (dispatch.ts WORKER_DISPATCH_PERMISSION), and
9
+ // `auto` is NOT non-interactive — its classifier still raises a prompt for anything it deems risky
10
+ // (a `git push`, a publish), which is exactly how a worker silently wedges for hours.
11
+ //
12
+ // The blunt fix would be to dispatch at `bypassPermissions`. This is deliberately NOT that: bypass
13
+ // removes the decision POINT, so nothing can ever inspect a request again. Keeping `auto` + deciding
14
+ // here preserves the seam — the same request that is auto-approved today can be routed to a policy,
15
+ // or to a human, without changing how workers launch. Claude Code labels the outcome in the
16
+ // transcript ("Allowed by PermissionRequest hook"), so an auto-approval stays visible rather than
17
+ // being indistinguishable from bypass.
18
+ //
19
+ // THREE OUTCOMES:
20
+ // allow — auto-approve; the worker proceeds with no prompt.
21
+ // deny — auto-refuse with a reason the model reads (rides top-level `additionalContext`).
22
+ // defer — emit NOTHING; the normal prompt is raised and a human answers it. This is the ONLY
23
+ // outcome the tailer treats as a human block (see permMarkerBlocks in tailer.ts).
24
+ //
25
+ // SCOPE: this plugin loads for EVERY project fray drives, so the built-in table carries only
26
+ // UNIVERSAL rules. Nothing repo-specific belongs here — a rule that is right for one repo (e.g. "never
27
+ // open a PR") is wrong for the next.
28
+ //
29
+ // KNOWN LIMIT — an explicit `ask` RULE outranks this hook (verified 2026-07-25). A project or user
30
+ // settings entry like `"permissions": {"ask": ["Bash(git push:*)"]}` raises a prompt that an `allow`
31
+ // from here does NOT override: Claude Code says so on the prompt itself ("Ask rule … overrides auto
32
+ // mode for this command"). This was isolated against a hook that allows unconditionally — it prompted
33
+ // too — so it is Claude Code precedence, not a defect here, and it is arguably the right precedence
34
+ // (an explicit human rule should beat a blanket policy). The practical consequence: a repo whose
35
+ // settings carry `ask` rules can still park a worker, and the fix for that repo is to relax its own
36
+ // rule, not to change this file. Mode-driven asks (the `default`-mode prompt) ARE overridden.
37
+ //
38
+ // GATE: inert unless FRAY_UI_THREAD is set, so a foreign/non-fray session is never affected.
39
+ // FAIL-SAFE: any error at all → emit nothing → the prompt is raised and the human decides. Note this
40
+ // inverts the old observer's "fail open": for a hook that can APPROVE, the safe failure is to fall
41
+ // back to asking, never to allow.
42
+ import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+
45
+ const slug = process.env.FRAY_UI_THREAD;
46
+ if (!slug) process.exit(0);
47
+
48
+ // Top-level targets whose recursive deletion is unrecoverable. `/tmp/x` and `./build` are NOT here —
49
+ // only paths that take the machine or the home directory with them.
50
+ const ROOTISH =
51
+ /^(\/|\/\*|~|~\/|~\/\*|\$\{?HOME\}?|\$\{?HOME\}?\/\*?|\/(usr|etc|bin|sbin|var|lib|opt|System|Library|Applications|Users|home|boot|dev|proc)\/?\*?)$/;
52
+
53
+ // True when `cmd` contains an `rm` that is BOTH recursive and forced AND aimed at a root-ish target.
54
+ // Scans each pipeline/list segment so `cd /tmp && rm -rf /` is caught as readily as a bare `rm -rf /`.
55
+ function isCatastrophicRm(cmd) {
56
+ for (const seg of cmd.split(/[|;&\n]+/)) {
57
+ const m = /(?:^|\s)rm(\s.*)$/.exec(seg);
58
+ if (!m) continue;
59
+ const rest = m[1];
60
+ const flags = (rest.match(/(?:^|\s)-[a-zA-Z]+/g) || []).join('');
61
+ if (!/[rR]/.test(flags) || !/f/.test(flags)) continue;
62
+ const targets = rest.split(/\s+/).filter((t) => t && !t.startsWith('-'));
63
+ if (targets.some((t) => ROOTISH.test(t.replace(/["']/g, '')))) return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ // Writes straight to a raw device / formats a filesystem — unrecoverable, and never something a
69
+ // worker needs to do unattended.
70
+ function isDiskWrite(cmd) {
71
+ return /\bmkfs(\.\w+)?\b/.test(cmd) || /\bdd\b[^|;&]*\bof=\/dev\/(disk|r?disk|sd|nvme|hd)/.test(cmd);
72
+ }
73
+
74
+ // The ordered policy table: FIRST MATCH WINS. Each rule returns a decision plus the `rule` id and
75
+ // `reason` that get recorded on the marker, so fray can always say WHICH rule decided and WHY.
76
+ // `deny` reasons are written to be read by the MODEL (they become additionalContext).
77
+ const RULES = [
78
+ {
79
+ id: 'catastrophic-delete',
80
+ test: (i) => i.tool_name === 'Bash' && isCatastrophicRm(String(i.tool_input?.command ?? '')),
81
+ decision: 'deny',
82
+ reason:
83
+ 'Refused: this recursively force-deletes a root-level or home directory, which is unrecoverable. If you genuinely need to remove a large tree, target an explicit project-relative path instead, and never `/`, `~`, or a top-level system directory.',
84
+ },
85
+ {
86
+ id: 'raw-disk-write',
87
+ test: (i) => i.tool_name === 'Bash' && isDiskWrite(String(i.tool_input?.command ?? '')),
88
+ decision: 'deny',
89
+ reason:
90
+ 'Refused: this formats a filesystem or writes directly to a raw block device, which destroys data irrecoverably and is never required of an unattended worker.',
91
+ },
92
+ {
93
+ // Respect a DELIBERATELY restrictive mode. fray dispatches workers at `auto`; a thread sitting at
94
+ // `default`/`plan` got there because a human moved it there (the live per-thread permission
95
+ // control), and auto-approving would silently overrule that intent. This is what makes a genuine
96
+ // lower-permission mode usable today: switch a thread to `default` and its prompts come back.
97
+ id: 'restrictive-mode',
98
+ test: (i) => typeof i.permission_mode === 'string' && i.permission_mode !== 'auto',
99
+ decision: 'defer',
100
+ reason: 'The thread is in a restrictive permission mode, so this request is left for a human to answer.',
101
+ },
102
+ {
103
+ // Escape hatch for review-style operation without changing how workers launch.
104
+ id: 'review-policy',
105
+ test: () => (process.env.FRAY_PERM_POLICY ?? 'auto').toLowerCase() === 'review',
106
+ decision: 'defer',
107
+ reason: 'FRAY_PERM_POLICY=review — every request is left for a human to answer.',
108
+ },
109
+ {
110
+ id: 'worker-autonomy',
111
+ test: () => true,
112
+ decision: 'allow',
113
+ reason: 'Unattended fray worker: approved automatically because no human is watching the terminal to answer a prompt.',
114
+ },
115
+ ];
116
+
117
+ function evaluate(input) {
118
+ for (const rule of RULES) {
119
+ let hit = false;
120
+ try {
121
+ hit = rule.test(input);
122
+ } catch {
123
+ continue; // a throwing rule is skipped, never fatal
124
+ }
125
+ if (hit) return { decision: rule.decision, rule: rule.id, reason: rule.reason };
126
+ }
127
+ return { decision: 'defer', rule: 'no-rule-matched', reason: 'No policy rule matched.' };
128
+ }
129
+
130
+ let input;
131
+ try {
132
+ input = JSON.parse(readFileSync(0, 'utf8'));
133
+ } catch {
134
+ process.exit(0); // unparseable payload → defer to the human
135
+ }
136
+
137
+ // ExitPlanMode is always auto-denied by the sibling deny-plan.mjs (a fray worker is never in plan
138
+ // mode), so it never becomes a real human block — leave it entirely alone, marker included.
139
+ if (input.tool_name === 'ExitPlanMode') process.exit(0);
140
+
141
+ // AskUserQuestion is not an authorization request — it is the agent ASKING, and the permission
142
+ // decision is where the ANSWER travels. `worker-autonomy` would allow it with `updatedInput` set to
143
+ // the untouched tool input, i.e. the questions and NO answers, and claude's own result mapper then
144
+ // tells the model "The user did not answer the questions." So the auto-approval that keeps a worker
145
+ // moving for every other tool is, for this one, a guaranteed wasted turn.
146
+ //
147
+ // Fray's broker intercepts this call at canUseTool and renders it as a real question card the
148
+ // operator answers (claude-permission-interactions.ts), so the right move here is to say NOTHING —
149
+ // no decision AND no marker. A `defer` verdict would write a marker the tailer reads as a human
150
+ // permission block, stacking a second "needs you" surface on top of the card already asking.
151
+ // (Verified 2026-07-27 on a promoted artifact: workers dispatch at --permission-mode auto, so
152
+ // `restrictive-mode` does not catch this and `worker-autonomy` did allow it. A dev-stack harness at
153
+ // the default permission mode deferred and looked fine, which is exactly why this needed an
154
+ // artifact run to find.)
155
+ if (input.tool_name === 'AskUserQuestion') process.exit(0);
156
+
157
+ const verdict = evaluate(input);
158
+
159
+ // Record the decision BEFORE acting on it, best-effort. The marker is fray's only structured view of
160
+ // what happened here: `decision` tells the tailer whether a human is actually blocked, and
161
+ // rule/reason/command are what the dashboard shows the human afterwards. A failed write must not
162
+ // hold up the worker, so this swallows its own errors — telemetry loss, not a stall.
163
+ const dir = process.env.FRAY_PERM_DIR;
164
+ if (dir) {
165
+ try {
166
+ const command = input.tool_name === 'Bash' ? String(input.tool_input?.command ?? '') : '';
167
+ const marker = {
168
+ slug,
169
+ tool: typeof input.tool_name === 'string' ? input.tool_name : null,
170
+ promptId: typeof input.prompt_id === 'string' ? input.prompt_id : null,
171
+ permissionMode: typeof input.permission_mode === 'string' ? input.permission_mode : null,
172
+ at: new Date().toISOString(),
173
+ decision: verdict.decision,
174
+ rule: verdict.rule,
175
+ reason: verdict.reason,
176
+ // Truncated: this is display text for the dashboard, not a re-executable command.
177
+ ...(command ? { command: command.length > 300 ? `${command.slice(0, 300)}…` : command } : {}),
178
+ };
179
+ mkdirSync(dir, { recursive: true });
180
+ // Write to a temp sibling then rename, so the tailer never reads a half-written marker.
181
+ const dest = join(dir, `${slug}.json`);
182
+ const tmp = `${dest}.${process.pid}.tmp`;
183
+ writeFileSync(tmp, JSON.stringify(marker));
184
+ renameSync(tmp, dest);
185
+ } catch {
186
+ // telemetry only — never block the worker on a marker write
187
+ }
188
+ }
189
+
190
+ if (verdict.decision === 'allow') {
191
+ process.stdout.write(
192
+ JSON.stringify({
193
+ hookSpecificOutput: {
194
+ hookEventName: 'PermissionRequest',
195
+ decision: { behavior: 'allow', updatedInput: input.tool_input ?? {} },
196
+ },
197
+ }),
198
+ );
199
+ } else if (verdict.decision === 'deny') {
200
+ // On a PermissionRequest DENY the `decision` object carries ONLY `{behavior:"deny"}`; the reason the
201
+ // model reads rides top-level `additionalContext` (same contract as deny-plan.mjs). Exit 0 with this
202
+ // JSON on stdout — exit 2 would make Claude Code ignore the JSON, so never mix the two.
203
+ process.stdout.write(
204
+ JSON.stringify({
205
+ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
206
+ additionalContext: verdict.reason,
207
+ }),
208
+ );
209
+ }
210
+ // defer → emit nothing: the normal prompt is raised and the human answers it.
211
+ process.exit(0);