frizz 0.0.1 → 0.2.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 (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/dist/claude-agent-broker.js +21714 -0
  4. package/dist/codex-app-server-daemon.js +354 -0
  5. package/dist/dev-child.js +39342 -0
  6. package/dist/frizz.js +10517 -0
  7. package/package.json +54 -20
  8. package/runtime/board/agent-bindings.mjs +287 -0
  9. package/runtime/board/agent-liveness.mjs +367 -0
  10. package/runtime/board/agent-status.mjs +178 -0
  11. package/runtime/board/config.mjs +982 -0
  12. package/runtime/board/decisions.mjs +97 -0
  13. package/runtime/board/index.mjs +699 -0
  14. package/runtime/board/notify-shared.mjs +90 -0
  15. package/runtime/board/notify.mjs +81 -0
  16. package/runtime/board/ownership.mjs +120 -0
  17. package/runtime/board/rest-detect.mjs +213 -0
  18. package/runtime/board/thread-excerpt.mjs +162 -0
  19. package/runtime/board/thread-update.mjs +285 -0
  20. package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
  21. package/runtime/cc-worker/DECISIONS.md +1025 -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/frizz +17 -0
  40. package/runtime/cc-worker/bin/frizz-mcp.mjs +564 -0
  41. package/runtime/cc-worker/bin/frizz-update +18 -0
  42. package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
  43. package/runtime/cc-worker/hooks/agent-dispatch.mjs +98 -0
  44. package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
  45. package/runtime/cc-worker/hooks/bash-background.mjs +236 -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 +446 -0
  53. package/runtime/cc-worker/hooks/session-seed.mjs +106 -0
  54. package/runtime/cc-worker/scripts/frizz/agent-bindings.mjs +9 -0
  55. package/runtime/cc-worker/scripts/frizz/config.mjs +12 -0
  56. package/runtime/cc-worker/skills/gh/SKILL.md +154 -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 +209 -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-ROKHp1ib.js +7 -0
  64. package/web-dist/assets/abnfDiagram-VRR7QNED-DcpdhBs3.js +1 -0
  65. package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
  66. package/web-dist/assets/architecture-TIHT7OUA-CAviNivx.js +1 -0
  67. package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CUAKf0mn.js +36 -0
  68. package/web-dist/assets/array-BifhSqXX.js +1 -0
  69. package/web-dist/assets/blockDiagram-677ZJIJ3-BPwpJIzx.js +132 -0
  70. package/web-dist/assets/c4Diagram-LMCZKHZV-1lptuHzZ.js +10 -0
  71. package/web-dist/assets/channel-CqKDIFQF.js +1 -0
  72. package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  73. package/web-dist/assets/chunk-32BRIVSS-CFR9AKjY.js +1 -0
  74. package/web-dist/assets/chunk-52WLFC77-CM9uct7m.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-DiveJARw.js +1 -0
  78. package/web-dist/assets/chunk-EX3LRPZG-BE1CBw8F.js +231 -0
  79. package/web-dist/assets/chunk-FWX5IMBZ-DL42uXiO.js +2 -0
  80. package/web-dist/assets/chunk-HOUHSVGY-DPhJWgDw.js +1 -0
  81. package/web-dist/assets/chunk-ICXQ74PX-CwYy-6AP.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-Ds5I9wxq.js +88 -0
  85. package/web-dist/assets/chunk-OGEWGWER-DHiZJwQD.js +1 -0
  86. package/web-dist/assets/chunk-PUDLZKDR-B-eyQTsF.js +156 -0
  87. package/web-dist/assets/chunk-Q4XR5HBZ-DK7dB3Ti.js +70 -0
  88. package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
  89. package/web-dist/assets/chunk-V7JOEXUC-Dn59m74L.js +206 -0
  90. package/web-dist/assets/chunk-VAUOI2AC-DK7x36hd.js +1 -0
  91. package/web-dist/assets/chunk-VR4S4FIN-D7-CI3Yl.js +1 -0
  92. package/web-dist/assets/chunk-WYO6CB5R-B3l-mLCs.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-Dobxlxie.js +62 -0
  96. package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  97. package/web-dist/assets/classDiagram-OUVF2IWQ-B_-6iXYY.js +1 -0
  98. package/web-dist/assets/classDiagram-v2-EOCWNBFH-B_-6iXYY.js +1 -0
  99. package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
  100. package/web-dist/assets/cynefin-VYW2F7L2-Dh7RuEUJ.js +1 -0
  101. package/web-dist/assets/cynefinDiagram-TSTJHNR4-ClPi2mZZ.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-52_WP1QV.js +4 -0
  105. package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
  106. package/web-dist/assets/diagram-FQU43EPY-D_1zVsTL.js +3 -0
  107. package/web-dist/assets/diagram-G47NLZAW-CdZxuGUy.js +24 -0
  108. package/web-dist/assets/diagram-NH7WQ7WH-C8pSFu0P.js +24 -0
  109. package/web-dist/assets/diagram-OA4YK3LP-C5bjZLre.js +30 -0
  110. package/web-dist/assets/diagram-WEI45ONY-Bxzhiuzn.js +41 -0
  111. package/web-dist/assets/dist-DoH_9pyS.js +1 -0
  112. package/web-dist/assets/ebnfDiagram-CCIWWBDH-g-Z0J2wP.js +1 -0
  113. package/web-dist/assets/erDiagram-Q63AITRT-DVNkgIHp.js +85 -0
  114. package/web-dist/assets/eventmodeling-45OFAUF4-MpmeH5YZ.js +1 -0
  115. package/web-dist/assets/flowDiagram-23GEKE2U-D-QgjjhF.js +1 -0
  116. package/web-dist/assets/ganttDiagram-NO4QXBWP-_71pQYEK.js +292 -0
  117. package/web-dist/assets/gitGraph-TEB2WS4Q-ChIZiGZS.js +1 -0
  118. package/web-dist/assets/gitGraphDiagram-IHSO6WYX-DCHAFI0l.js +106 -0
  119. package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
  120. package/web-dist/assets/index-w4v-GZEc.js +358 -0
  121. package/web-dist/assets/index-zyi22LPz.css +1 -0
  122. package/web-dist/assets/info-DKCQHKI2-BW-n_T1j.js +1 -0
  123. package/web-dist/assets/infoDiagram-FWYZ7A6U-CgDYsKi9.js +2 -0
  124. package/web-dist/assets/init-D6jRqBbL.js +1 -0
  125. package/web-dist/assets/ishikawaDiagram-FXEZZL3T-ClzGNt9N.js +70 -0
  126. package/web-dist/assets/journeyDiagram-5HDEW3XC-DSCQxkHC.js +139 -0
  127. package/web-dist/assets/kanban-definition-HUTT4EX6-CdrdX9N8.js +89 -0
  128. package/web-dist/assets/katex-CddkPoXu.js +257 -0
  129. package/web-dist/assets/line-ha38Dc-1.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-Z4uMcpip.js +7 -0
  133. package/web-dist/assets/mermaid.core-iZRq3hbu.js +11 -0
  134. package/web-dist/assets/mindmap-definition-LN4V7U3C-DmhInJO_.js +96 -0
  135. package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
  136. package/web-dist/assets/packet-7NZHBO7P-DBPB36Kl.js +1 -0
  137. package/web-dist/assets/path-BWPyau1x.js +1 -0
  138. package/web-dist/assets/pegDiagram-2B236MQR-CAH3ljfj.js +1 -0
  139. package/web-dist/assets/pie-RZYD4A2V-_h_eX4Ca.js +1 -0
  140. package/web-dist/assets/pieDiagram-ENE6RG2P-DFBPus8j.js +39 -0
  141. package/web-dist/assets/quadrantDiagram-ABIIQ3AL-DMvOCjt8.js +7 -0
  142. package/web-dist/assets/radar-I7S5WNFK-2EzoPHEZ.js +1 -0
  143. package/web-dist/assets/railroad-3IZDKUUU-BPJnn-hm.js +1 -0
  144. package/web-dist/assets/railroad-abnf-AHOZXSZD-YeUoiySk.js +1 -0
  145. package/web-dist/assets/railroad-ebnf-EBAXGLYW-Ddw1SuGG.js +1 -0
  146. package/web-dist/assets/railroad-peg-LSFZ7HO6-Dd8BOGeW.js +1 -0
  147. package/web-dist/assets/railroadDiagram-RFXS5EU6-DKq5FagA.js +1 -0
  148. package/web-dist/assets/requirementDiagram-TGXJPOKE-BJ5tGazp.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-XSJjcBhX.js +40 -0
  152. package/web-dist/assets/sequenceDiagram-DBY2YBRQ-CBb8emSe.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-DDfRW94V.js +1 -0
  156. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-hc41W5Lx.js +1 -0
  157. package/web-dist/assets/swimlanes-5IMT3BWC-DvRYbkZi.js +2 -0
  158. package/web-dist/assets/swimlanesDiagram-G3AALYLV-BZyGdgSG.js +8 -0
  159. package/web-dist/assets/timeline-definition-FHXFAJF6-BNUa_DwI.js +120 -0
  160. package/web-dist/assets/treeView-QDETBFTQ-I6-IW6nJ.js +1 -0
  161. package/web-dist/assets/treemap-6X3UGDF4-CWWmEUYJ.js +1 -0
  162. package/web-dist/assets/vennDiagram-L72KCM5P-DTDrPGLk.js +34 -0
  163. package/web-dist/assets/wardley-OPB4EBWU-CNsdgXXA.js +1 -0
  164. package/web-dist/assets/wardleyDiagram-EHGQE667-YE0tq3Kh.js +78 -0
  165. package/web-dist/assets/xychartDiagram-FW5EYKEG-D0ofMX8C.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
  174. package/index.d.ts +0 -1
  175. package/index.js +0 -2
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on the `Agent` tool (frizz-worker). A worker MAY spin up its own helper
4
+ // sub-agents; this hook holds them to the same rules cc enforces for the orchestrator:
5
+ // 1) ENFORCE background dispatch — deny any Agent call lacking run_in_background:true (a
6
+ // foreground agent blocks the worker's turn; a human interjection orphans it).
7
+ // 2) STRIP `name`/`team_name` — setting either strands a nested dispatch (its result routes
8
+ // wrong and never returns cleanly), so scrub both silently.
9
+ // 3) AUTO-APPEND a repo-neutral ORCHESTRATION EPILOGUE so helpers return a useful handoff,
10
+ // know how to reach their dispatcher mid-flight, do NOT fan out a layer of their own unless
11
+ // their prompt asked for it, and collect a helper correctly when it did — without imposing
12
+ // build, test, git, compilation, or process-lifecycle policy on arbitrary repos.
13
+ //
14
+ // WHY NESTING IS DEFAULT-OFF (2026-08-04, maintainer's call): the epilogue used to speak about a
15
+ // helper's own helper only in the conditional ("if you dispatch a helper of your own…"), which reads
16
+ // as neutral permission, so a depth-1 child could decompose again purely because it could. A child is
17
+ // already one prong of a fan-out; another layer splits the context its dispatcher assembled and moves
18
+ // the real work further from the board. So the paragraph now LEADS with "do the work yourself unless
19
+ // your prompt says otherwise" and keeps the collection rules for the case where it does. This is a
20
+ // PROMPT-level default, not the hook-level depth-2 DENY rejected on 2026-07-31 as too blunt — an
21
+ // explicit instruction to fan out still dispatches, unmodified.
22
+ //
23
+ // WHY THE NESTED-DISPATCH PARAGRAPH EXISTS (2026-07-31): this hook fires at EVERY depth, but the
24
+ // frizz worker contract reaches only the ROOT worker — so its "keep fan-out shallow / a rested agent
25
+ // is not reliably re-woken by grandchildren" rule was delivered exclusively to the one agent that
26
+ // does not spawn grandchildren, and withheld from the depth-1 child that does. Into that silence a
27
+ // user-level CLAUDE.md ("the parent stays awake and polls the child's transcript") supplied a
28
+ // hand-rolled polling recipe, and it failed: the `.output` path is a SYMLINK, so `stat` without -L
29
+ // returns the LINK's size (= the length of its target path) and its frozen creation mtime, while
30
+ // the `"type":"result"` record is never reliably written. Both halves of the predicate
31
+ // false-negatived at once, a live 413KB helper read as "size=153 age=325s results=0", and the
32
+ // dispatcher discarded it and redid the work itself. This paragraph is the only place that reaches
33
+ // a nested dispatcher without depending on its parent remembering to restate the norm.
34
+ //
35
+ // GATE: inert unless FRIZZ_THREAD is set (not a frizz worker → allow every dispatch unmodified).
36
+ //
37
+ // DROPPED vs cc's agent-dispatch.mjs (see DECISIONS.md): the `.dispatch-count` bump (it only gates
38
+ // cc's SubagentStop rest-recorder, which cc-worker does not ship) and the THREAD:-ledger write +
39
+ // thread-existence DENY gate (that guards the orchestrator's "file the thread before dispatching"
40
+ // discipline; a worker owns exactly one already-existing thread and its helpers own no thread).
41
+ //
42
+ // FAIL OPEN: any parse error → allow unmodified. A broken dispatch hook must never halt work.
43
+ import { readFileSync } from 'node:fs';
44
+
45
+ const EPILOGUE = `
46
+
47
+ ---
48
+ [ORCHESTRATION EPILOGUE — auto-appended by the frizz worker dispatch hook] You are a helper sub-agent for a frizz worker. Your final message is the handoff: report your outcome/status; what you did; changed files, artifacts, and commit SHA when applicable; verification or evidence and its result; caveats or unresolved work; and the next action when one remains. A bare "done" or progress-only message is not a complete handoff.
49
+ The worker's scratchpad is shared: merge your own scoped progress into it as you work rather than leaving the root as its sole writer. Re-read before every edit and preserve every other agent's content. This exact scratchpad is Frizz coordination state, not a project deliverable or source edit: a scoped merge is an explicit exception to delegated phrases such as "write only <path>" or "do not modify the repo"; never classify it as unauthorized or roll it back. Never delete, truncate, reinitialize, move, or replace the whole file. Other project files, including repository-root files, remain governed by your delegated authority; their location alone neither permits nor forbids editing. Do not edit other \`.frizz/\` state unless your prompt explicitly asks you to.
50
+ You have an upward channel while you work: \`SendMessage({to: "main", summary: "<5-10 words>", message: "…"})\` delivers to your dispatcher. Use it when the dispatcher acting before you finish could change the outcome—for example, when you hit an unresolved blocker, complete a milestone another task needs, or discover that your instructions should change. Do not use it for routine progress updates.
51
+ Do the work yourself: do NOT dispatch sub-agents of your own unless your dispatch prompt explicitly tells you to. You are already one prong of someone else's fan-out, and another layer below you buys little — it splits the context you were handed, buries the real work one level further from whoever reads the tree, and leaves you collecting a handoff instead of doing the task. A slice that feels large is still yours to work through in your own turn.
52
+ If your prompt DOES ask you to dispatch a helper, its completion is delivered to you automatically. Never hand-roll a wait loop over a helper's transcript or \`.output\` path to decide whether it finished: that path is a SYMLINK, so \`stat\` without \`-L\` reports the link's own size (the length of its target path, ~150 bytes) and its frozen creation mtime, and the \`"type":"result"\` record is not reliably written — so a helper that is working hard reads as tiny, stale, and dead, and you will discard live work and redo it. Judge a helper only by its completion notification or the text it returns. Give it a \`description\` naming its narrower slice rather than restating your own, so the dispatch tree stays readable.`;
53
+
54
+ /** @param {unknown} obj @returns {never} */
55
+ function emit(obj) {
56
+ process.stdout.write(JSON.stringify(obj));
57
+ process.exit(0);
58
+ }
59
+
60
+ try {
61
+ // WORKER GATE — inert outside a frizz worker session.
62
+ if (!(process.env.FRIZZ_THREAD ?? '').trim()) emit({});
63
+
64
+ const input = JSON.parse(readFileSync(0, 'utf8'));
65
+ const ti = input.tool_input ?? {};
66
+
67
+ if (ti.run_in_background !== true) {
68
+ emit({
69
+ hookSpecificOutput: {
70
+ hookEventName: 'PreToolUse',
71
+ permissionDecision: 'deny',
72
+ permissionDecisionReason:
73
+ 'frizz worker (hook-enforced): Agent sub-agents MUST be dispatched with run_in_background:true — never foreground/blocking. A foreground agent blocks the worker turn and a human interjection orphans its work. Re-send this Agent call with run_in_background:true.',
74
+ },
75
+ });
76
+ }
77
+
78
+ // Strip name/team_name (they strand nested dispatches), then append the epilogue once.
79
+ const { name: _droppedName, team_name: _droppedTeam, ...tiStripped } = ti;
80
+ // Idempotence is "this prompt ALREADY ENDS WITH the epilogue", not "this prompt mentions the
81
+ // marker anywhere". A substring test silently ate the epilogue for any prompt that merely QUOTED
82
+ // the marker — e.g. a worker asking a helper to report whether the epilogue reached it, which is
83
+ // exactly how this was caught. endsWith still catches a genuine double-fire (the only real case).
84
+ const prompt = typeof ti.prompt === 'string' ? ti.prompt : '';
85
+ const updatedInput = prompt.endsWith(EPILOGUE)
86
+ ? tiStripped
87
+ : { ...tiStripped, prompt: prompt + EPILOGUE };
88
+
89
+ emit({
90
+ hookSpecificOutput: {
91
+ hookEventName: 'PreToolUse',
92
+ permissionDecision: 'allow',
93
+ updatedInput,
94
+ },
95
+ });
96
+ } catch {
97
+ emit({}); // fail open — allow unmodified
98
+ }
@@ -0,0 +1,6 @@
1
+ export function hasEscapingBackgroundJob(raw: unknown): boolean
2
+ export function evaluateBashBackgroundHook(
3
+ input: unknown,
4
+ env?: Record<string, string | undefined>,
5
+ ): Record<string, unknown>
6
+ export function isDirectHookExecution(argv1: unknown, moduleUrl: string): boolean
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on `Bash` (frizz-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 frizz 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 FRIZZ_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
+ // A LOCAL shell wrapper runs its script in THIS machine's process tree, so `bash -c 'job &'` escapes
125
+ // exactly as a bare `job &` does — the wrapper forks the job and exits. Blanking quoted regions is
126
+ // what exempts `ssh host '… &'` (that job is remote and the ssh client still waits), but it hid the
127
+ // local wrapper along with it, leaving a one-token bypass of the whole guard. So scan the sanitized
128
+ // text for a wrapper, then re-read its script from the ORIGINAL command and recurse.
129
+ //
130
+ // Only in COMMAND POSITION. A wrapper passed as an ARGUMENT belongs to whatever program precedes it,
131
+ // and that program decides where the script runs: `docker run img bash -c …` and
132
+ // `limactl shell vm bash -lc …` both appear in the real corpus, and neither backgrounds anything
133
+ // local. That is the same call the `ssh` exemption already makes. It costs the genuinely-local
134
+ // `xargs sh -c 'job &'`, which the corpus never shows, and under-blocking is the safer miss here.
135
+ const LOCAL_SHELL_SCRIPT = /(?:^|[\n;|&({])\s*(?:\/\S*\/)?(?:bash|sh|zsh|dash|ksh)(?:\s+-[A-Za-z]+)*\s+-[A-Za-z]*c(?=\s)/g;
136
+
137
+ /**
138
+ * Read the quoted argument that begins at `from` in the original command, undoing only the escaping
139
+ * the surrounding quote itself performs.
140
+ * @param {string} raw @param {number} from
141
+ */
142
+ function quotedArgumentAt(raw, from) {
143
+ let i = from;
144
+ while (raw[i] === ' ' || raw[i] === '\t') i++;
145
+ const quote = raw[i];
146
+ if (quote !== "'" && quote !== '"') return '';
147
+ let out = '';
148
+ for (i++; i < raw.length; i++) {
149
+ if (raw[i] === '\\' && quote === '"' && i + 1 < raw.length) {
150
+ out += raw[++i];
151
+ continue;
152
+ }
153
+ if (raw[i] === quote) return out;
154
+ out += raw[i];
155
+ }
156
+ return '';
157
+ }
158
+
159
+ /**
160
+ * Return true when this Bash call starts a local background job and can return without joining or
161
+ * terminating it. This is deliberately a small shell-lifecycle recognizer, not a general parser.
162
+ * @param {unknown} raw
163
+ * @param {number} depth
164
+ */
165
+ export function hasEscapingBackgroundJob(raw, depth = 0) {
166
+ if (typeof raw !== 'string' || !raw.trim()) return false;
167
+ const command = withoutQuotedRegions(withoutHeredocBodies(raw));
168
+ /** @type {number[]} */
169
+ const operators = [];
170
+ for (let i = 0; i < command.length; i++) {
171
+ if (command[i] !== '&') continue;
172
+ const before = command[i - 1] ?? '';
173
+ const after = command[i + 1] ?? '';
174
+ // `&&`, redirects (`2>&1`, `&>`), and an escaped literal are not background operators.
175
+ if (before === '&' || after === '&' || before === '>' || before === '<' || after === '>' || before === '\\') continue;
176
+ operators.push(i);
177
+ }
178
+ if (operators.length > 0) {
179
+ // A lifecycle action AFTER the last launch makes the command self-contained. `kill` alone does
180
+ // not: the signal is asynchronous, so the shell still needs a wait before returning. An EXIT trap
181
+ // owns cleanup at the shell boundary.
182
+ const tail = command.slice(operators[operators.length - 1] + 1);
183
+ if (!(/\bwait\b/.test(tail) || /\btrap\b[^\n;]*\b(?:EXIT|0)\b/.test(tail))) return true;
184
+ }
185
+
186
+ // Both sanitizers substitute character-for-character, so a match position in the sanitized text
187
+ // still indexes the original. A length mismatch would mean that invariant broke: fail open rather
188
+ // than slice the wrong bytes. The depth cap bounds `sh -c 'sh -c …'` nesting.
189
+ if (depth >= 3 || command.length !== raw.length) return false;
190
+ // `matchAll` iterates a CLONE, so the module-level regex keeps no cursor for a nested call to reset
191
+ // out from under this loop — `exec` on the shared object spins forever on `bash -c "sh -c '…'"`.
192
+ for (const match of command.matchAll(LOCAL_SHELL_SCRIPT)) {
193
+ const script = quotedArgumentAt(raw, match.index + match[0].length);
194
+ if (hasEscapingBackgroundJob(script, depth + 1)) return true;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ export function evaluateBashBackgroundHook(input, env = process.env) {
200
+ if (!String(env.FRIZZ_THREAD ?? '').trim()) return {};
201
+ const command = input && typeof input === 'object'
202
+ ? String(input.tool_input?.command ?? '')
203
+ : '';
204
+ if (!hasEscapingBackgroundJob(command)) return {};
205
+ const codex = typeof input?.model === 'string';
206
+ return {
207
+ hookSpecificOutput: {
208
+ hookEventName: 'PreToolUse',
209
+ permissionDecision: 'deny',
210
+ permissionDecisionReason:
211
+ codex
212
+ ? 'Frizz blocked an untracked shell background job (`&`). Shell job control can return without a Codex lifecycle handle, so Frizz 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.'
213
+ : 'Frizz blocked an untracked shell background job (`&`). Shell job control can return from Bash without a Claude task ID, so Frizz 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.',
214
+ },
215
+ };
216
+ }
217
+
218
+ export function isDirectHookExecution(argv1, moduleUrl) {
219
+ return typeof argv1 === 'string'
220
+ && basename(argv1) === 'bash-background.mjs'
221
+ && pathToFileURL(argv1).href === moduleUrl;
222
+ }
223
+
224
+ // The server imports `hasEscapingBackgroundJob` and its production build bundles this module into
225
+ // `src/index.js`. esbuild rewrites `import.meta.url` to that bundle URL, so URL equality alone would
226
+ // mistake the whole server for this executable and block startup reading hook JSON from stdin.
227
+ if (isDirectHookExecution(process.argv[1], import.meta.url)) {
228
+ try {
229
+ const env = process.argv.includes('--frizz-thread')
230
+ ? { ...process.env, FRIZZ_THREAD: process.env.FRIZZ_THREAD || 'codex-worker' }
231
+ : process.env;
232
+ emit(evaluateBashBackgroundHook(JSON.parse(readFileSync(0, 'utf8')), env));
233
+ } catch {
234
+ emit({});
235
+ }
236
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on AskUserQuestion (frizz-worker). A frizz 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 FRIZZ_THREAD is set. FAIL OPEN on parse errors.
8
+ //
9
+ // SECOND GATE: also inert when FRIZZ_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 frizz 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.FRIZZ_THREAD;
19
+ if (!slug) process.exit(0);
20
+ if (process.env.FRIZZ_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 frizz 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 (frizz) — a frizz 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); frizz 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 frizz worker anyway, not because the signal is unavailable.) The fix is at the SOURCE
22
+ // instead — frizz NEVER spawns a worker in plan mode (dispatch.ts coerces `--permission-mode plan`
23
+ // → `auto` in both command builders), so a real frizz 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 (`.frizz/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 FRIZZ_THREAD set AND this plugin loaded — a combo
29
+ // frizz never produces (FRIZZ_THREAD is set only by frizz dispatch, which coerces plan away).
30
+ // A normal plan-mode session outside frizz is untouched (this hook is inert without FRIZZ_THREAD).
31
+ //
32
+ // GATE: inert unless FRIZZ_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.FRIZZ_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 `.frizz/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 frizz 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
+ }