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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/claude-agent-broker.js +21641 -0
- package/dist/codex-app-server-daemon.js +354 -0
- package/dist/dev-child.js +40319 -0
- package/dist/frayui.js +8738 -0
- package/package.json +46 -0
- package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
- package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
- package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
- package/runtime/cc/scripts/fray/config.mjs +982 -0
- package/runtime/cc/scripts/fray/decisions.mjs +97 -0
- package/runtime/cc/scripts/fray/index.mjs +699 -0
- package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
- package/runtime/cc/scripts/fray/notify.mjs +81 -0
- package/runtime/cc/scripts/fray/ownership.mjs +120 -0
- package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
- package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
- package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
- package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
- package/runtime/cc-worker/DECISIONS.md +923 -0
- package/runtime/cc-worker/LICENSE +21 -0
- package/runtime/cc-worker/agents/fable-high.md +8 -0
- package/runtime/cc-worker/agents/fable-low.md +8 -0
- package/runtime/cc-worker/agents/fable-max.md +8 -0
- package/runtime/cc-worker/agents/fable-medium.md +8 -0
- package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
- package/runtime/cc-worker/agents/haiku.md +7 -0
- package/runtime/cc-worker/agents/opus-high.md +8 -0
- package/runtime/cc-worker/agents/opus-low.md +8 -0
- package/runtime/cc-worker/agents/opus-max.md +8 -0
- package/runtime/cc-worker/agents/opus-medium.md +8 -0
- package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
- package/runtime/cc-worker/agents/sonnet-high.md +8 -0
- package/runtime/cc-worker/agents/sonnet-low.md +8 -0
- package/runtime/cc-worker/agents/sonnet-max.md +8 -0
- package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
- package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
- package/runtime/cc-worker/bin/fray +17 -0
- package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
- package/runtime/cc-worker/bin/fray-update +18 -0
- package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
- package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
- package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
- package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
- package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
- package/runtime/cc-worker/hooks/hooks.json +111 -0
- package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
- package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
- package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
- package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
- package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
- package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
- package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
- package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
- package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
- package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
- package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
- package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
- package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
- package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
- package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
- package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
- package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
- package/web-dist/assets/array-BifhSqXX.js +1 -0
- package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
- package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
- package/web-dist/assets/channel-C2eUWc74.js +1 -0
- package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
- package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
- package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
- package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
- package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
- package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
- package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
- package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
- package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
- package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
- package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
- package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
- package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
- package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
- package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
- package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
- package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
- package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
- package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
- package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
- package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
- package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
- package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
- package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
- package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
- package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
- package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
- package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
- package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
- package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
- package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
- package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
- package/web-dist/assets/dist-DoH_9pyS.js +1 -0
- package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
- package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
- package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
- package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
- package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
- package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
- package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
- package/web-dist/assets/index-BHzIN-tQ.js +357 -0
- package/web-dist/assets/index-Duiy4w7C.css +1 -0
- package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
- package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
- package/web-dist/assets/init-D6jRqBbL.js +1 -0
- package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
- package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
- package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
- package/web-dist/assets/katex-CddkPoXu.js +257 -0
- package/web-dist/assets/line-Ds4xvN3d.js +1 -0
- package/web-dist/assets/linear-z2V0wJk9.js +1 -0
- package/web-dist/assets/map-DsCK-0Cs.js +1 -0
- package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
- package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
- package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
- package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
- package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
- package/web-dist/assets/path-BWPyau1x.js +1 -0
- package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
- package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
- package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
- package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
- package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
- package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
- package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
- package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
- package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
- package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
- package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
- package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
- package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
- package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/web-dist/assets/src-C4XfhTaE.js +1 -0
- package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
- package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
- package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
- package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
- package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
- package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
- package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
- package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
- package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +78 -0
- package/web-dist/icon-192.png +0 -0
- package/web-dist/icon-512.png +0 -0
- package/web-dist/icon-maskable-512.png +0 -0
- package/web-dist/index.html +33 -0
- 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);
|