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,564 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * frizz-mcp — THE frizz MCP server: one unified, dependency-free MCP stdio server (mounted as `frizz`,
5
+ * so its tools are `mcp__frizz__<tool>`) carrying every capability frizz hands its own WORKERS:
6
+ *
7
+ * spawn_thread — dispatch a brand-new TOP-LEVEL frizz board thread (its own session + scratchpad +
8
+ * independent drive — NOT an in-session Agent/Task helper).
9
+ * recurring_prompt — arm ONE piece of text frizz re-sends the caller, at every rest and/or on a clock.
10
+ * timer — arm a ONE-OFF prompt for a single instant; a thread may hold many at once.
11
+ *
12
+ * Future worker-facing frizz tools join the TOOLS registry below rather than mounting a second server:
13
+ * one server keeps the worker's tool namespace coherent and the server-level pre-approval single.
14
+ *
15
+ * spawn_thread wraps frizz's own dispatch RPC: it reads the running server's port from
16
+ * `<state-dir>/server.lock` and POSTs `/rpc/dispatch`. The `/rpc` surface has no token auth — only a
17
+ * loopback-origin CSRF gate — so a headerless local POST with `sec-fetch-site: same-origin` (undici
18
+ * sends no Origin) satisfies it.
19
+ *
20
+ * Mounted by the server (dispatch.ts) into the Claude backend via `--mcp-config`. The server passes
21
+ * FRIZZ_STATE_DIR in this process's env so we can locate server.lock without recomputing the project id.
22
+ *
23
+ * Protocol: MCP over stdio = newline-delimited JSON-RPC 2.0. We implement exactly the four methods a
24
+ * client drives (initialize, tools/list, tools/call, ping) plus the initialized notification. Hand-
25
+ * rolled rather than pulling @modelcontextprotocol/sdk: the surface is tiny, it ships as one loose
26
+ * .mjs next to bin/frizz (no build/bundle/resolution concerns), and it matches this repo's own
27
+ * hand-rolled-RPC aesthetic. The server NEVER crashes on a bad tool call: failures come back as an
28
+ * isError tool result so the worker sees a message instead of a dead tool.
29
+ */
30
+ import { readFileSync } from "node:fs"
31
+ import { join } from "node:path"
32
+
33
+ const PROTOCOL_FALLBACK = "2025-06-18"
34
+ // Comfortably above a codex dispatch's bounded rollout-discovery wait (~15s) so a legitimate slow
35
+ // dispatch is never aborted client-side (which would make the worker think it failed and retry,
36
+ // double-spawning). The server completes regardless; this is only the client's patience.
37
+ const DISPATCH_TIMEOUT_MS = 30_000
38
+
39
+ const SPAWN_THREAD = {
40
+ name: "spawn_thread",
41
+ description:
42
+ "Spawn a brand-new, separate top-level frizz thread — its own board card, session, and scratchpad, " +
43
+ "driving INDEPENDENTLY. This is FIRE-AND-FORGET: the new thread reports to the HUMAN on the board via " +
44
+ "its own final message, and its results NEVER come back to you, the caller. It is NOT an in-session " +
45
+ "sub-agent. It returns only the new thread's slug and a ready-to-paste markdown link " +
46
+ "`[title](/thread/<slug>)` that opens the thread in the frizz drawer — put that link in your handoff. " +
47
+ "USE IT ONLY for a distinct, self-contained effort that belongs on the board in its own right and whose " +
48
+ "output you do NOT need to read. Do NOT use it for a helper whose result you must COLLECT and fold into " +
49
+ "your own work — a self-review, a verification pass, a research prong, a critic, any collect-back helper: " +
50
+ "those are in-session sub-agents (Claude: the Agent tool with `run_in_background`; Codex: native " +
51
+ "delegation), which return their findings to you. Spawning such a helper here STRANDS it — its work lands " +
52
+ "on another card and never reaches you, so you gain nothing. " +
53
+ "You MUST deliberately choose `model` and `effort` to match the NEW thread's task complexity — they are " +
54
+ "required, there is NO default. Do not reflexively pick the cheapest; a hard task on a weak model/effort " +
55
+ "wastes the whole thread.",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {
59
+ prompt: {
60
+ type: "string",
61
+ description: "The full task/prompt for the new thread's worker. Be self-contained — the new thread starts with empty context.",
62
+ },
63
+ model: {
64
+ type: "string",
65
+ description:
66
+ "REQUIRED — pick by the NEW task's complexity; there is no default. For the `claude` backend: " +
67
+ "`opus` (the TOP tier — hardest reasoning, architecture, subtle correctness/security, adversarial " +
68
+ "review, the fix that must land), `sonnet` (ordinary substantive implementation/research), `haiku` " +
69
+ "(simple, fully-specified mechanical work). Do NOT pick `fable`: Opus 5 is just as good and cheaper, " +
70
+ "so a high-intensity task takes `opus` at a higher `effort`, not a different model — `fable` only " +
71
+ "when the human explicitly asks for it. For " +
72
+ "the `codex` backend use a codex model id instead (e.g. `gpt-5.6-sol`/`gpt-5.6-terra`/`gpt-5.6-luna`). " +
73
+ "Match the model to the backend you choose. Bias toward Opus/a strong model when the task is " +
74
+ "non-trivial or its outcome is load-bearing.",
75
+ },
76
+ effort: {
77
+ type: "string",
78
+ enum: ["low", "medium", "high", "xhigh", "max"],
79
+ description:
80
+ "REQUIRED — reasoning effort, pick by complexity; no default. `low` only for trivial tasks; " +
81
+ "`medium` for routine work; `high` for ordinary substantive work; `xhigh` for hard coding/agentic " +
82
+ "work; `max` for the single hardest problems. (Codex also accepts `ultra`.)",
83
+ },
84
+ backend: {
85
+ type: "string",
86
+ enum: ["claude", "codex"],
87
+ description: "Optional agent backend (default `claude`). If `codex`, `model` must be a codex model id.",
88
+ },
89
+ title: { type: "string", description: "Optional short title for the new thread (else derived from the prompt)." },
90
+ },
91
+ required: ["prompt", "model", "effort"],
92
+ },
93
+ }
94
+
95
+ const RECURRING_PROMPT = {
96
+ name: "recurring_prompt",
97
+ description:
98
+ "Arm a RECURRING PROMPT on YOUR OWN thread: one piece of text that frizz re-sends you, on either or " +
99
+ "both of two triggers, for as long as it is armed.\n\n" +
100
+ " stop_hook — every time you come to REST. Use it to keep a long autonomous effort moving " +
101
+ "without the human driving every step, and to rescue yourself from a wait that may never resolve.\n" +
102
+ " heartbeat_seconds — on a CLOCK, whatever you are doing. This one reaches you MID-TURN: it arrives as " +
103
+ "a queued message you read at your next tool boundary rather than waiting for you to stop, and it " +
104
+ "never aborts what you are running. Use it for something that must be revisited on a schedule no " +
105
+ "matter what you happen to believe at the time.\n\n" +
106
+ "Set at least one. Setting BOTH is the ordinary case for \"keep this moving\": you are prompted " +
107
+ "whenever you stop, and at least every N seconds even if you never do.\n\n" +
108
+ "USE THIS RATHER THAN `CronCreate` or `ScheduleWakeup`. Those are Claude Code's own in-session " +
109
+ "schedulers and they CANNOT fire in the runtime frizz runs you in: their gate stays shut for as long " +
110
+ "as ANY background task of yours is outstanding, so the moment you are parked behind a background " +
111
+ "shell or a sub-agent — exactly when you most need waking — they go silent. This one is delivered by " +
112
+ "frizz itself and is unaffected.\n\n" +
113
+ "The text arrives VERBATIM as an ordinary user turn, so write it as an instruction to your future " +
114
+ "self. A thread has AT MOST ONE recurring prompt: calling this again REPLACES it, triggers and all. " +
115
+ "At most one scheduled delivery is ever outstanding and its clock runs from the last one DELIVERED, " +
116
+ "so you can never be handed a backlog at once.\n\n" +
117
+ "STOP IT when the work it drives is done (`action: \"stop\"`) — one left armed on a finished thread " +
118
+ "wakes it forever. The human sees it in the thread footer and can edit or switch it off there. " +
119
+ "Replying ALLDONE on its own line also stops it, both triggers at once, but be sure before you do: " +
120
+ "it permanently stalls the run, and a run nobody is watching does not restart itself.\n\n" +
121
+ "You can only ever arm your OWN thread — there is no parameter for anyone else's.",
122
+ inputSchema: {
123
+ type: "object",
124
+ properties: {
125
+ action: {
126
+ type: "string",
127
+ enum: ["start", "stop"],
128
+ description: "`start` arms (or replaces) this thread's recurring prompt; `stop` disarms it.",
129
+ },
130
+ prompt: {
131
+ type: "string",
132
+ description:
133
+ "Required for `start`. The text delivered to you on every trigger, verbatim, as a user turn. " +
134
+ "Make it self-contained and ACTIONABLE — say what to do and what would make it right to stop " +
135
+ "— because you may receive it with none of the context you have right now.",
136
+ },
137
+ stop_hook: {
138
+ type: "boolean",
139
+ description:
140
+ "Send it every time you come to rest. Defaults to true when `heartbeat_seconds` is omitted, so a " +
141
+ "`start` that names neither mechanism still does the obvious thing.",
142
+ },
143
+ heartbeat_seconds: {
144
+ type: "integer",
145
+ description:
146
+ "Also send it on this clock, in seconds (minimum 60, maximum 86400). Omit for no heartbeat. A " +
147
+ "delivery is read at your next tool boundary, so a sub-minute cadence buys no promptness and " +
148
+ "only talks over your own work.",
149
+ },
150
+ },
151
+ required: ["action"],
152
+ },
153
+ }
154
+
155
+ // The ONE-OFF TIMER's bounds, mirrored from @frizz/shared (this file is dependency-free by design and
156
+ // ships as a loose .mjs, so it cannot import them). The server validates the same numbers; these exist so
157
+ // a wrong delay is refused HERE, with an explanation, instead of coming back as an HTTP 400.
158
+ const TIMER_MIN_DELAY_SECONDS = 10
159
+ const TIMER_MAX_DELAY_SECONDS = 30 * 24 * 60 * 60
160
+
161
+ const TIMER = {
162
+ name: "timer",
163
+ description:
164
+ "Set a ONE-OFF timer on YOUR OWN thread: a piece of text frizz hands back to you at ONE instant, " +
165
+ "ONCE. Your own alarm clock.\n\n" +
166
+ "It is `recurring_prompt`'s heartbeat with the repetition taken out, and it shares the property that " +
167
+ "matters: the delivery reaches you MID-TURN — a queued message you read at your next tool boundary — " +
168
+ "so it arrives when you asked for it whether or not you have stopped, and it never aborts what you " +
169
+ "are running. Unlike a recurring prompt it fires exactly once and then is gone, so there is nothing " +
170
+ "to switch off afterwards and no ALLDONE involved.\n\n" +
171
+ "You may have MANY armed at the same time, each with its own instant and its own text — they are " +
172
+ "independent, unlike the single recurring prompt this thread can hold.\n\n" +
173
+ "USE IT for anything you want to come back to at a specific time: re-check a deploy in ten minutes, " +
174
+ "re-read a slow log at the top of the hour, revisit a decision after a build finishes. USE " +
175
+ "`recurring_prompt` instead when the thing must repeat, and remember that Claude Code's own " +
176
+ "`CronCreate`/`ScheduleWakeup` cannot fire in the runtime frizz runs you in.\n\n" +
177
+ "IT IS NOT A WAY TO POLL SOMETHING YOU COULD WAIT ON. If a background shell, a sub-agent or a " +
178
+ "monitor can tell you the moment a thing happens, use that — an alarm every N seconds asking \"is it " +
179
+ "done yet\" is strictly worse than being woken when it is.\n\n" +
180
+ "The text arrives VERBATIM as an ordinary user turn, so write it as an instruction to your future " +
181
+ "self — self-contained and actionable, because you may receive it with none of the context you have " +
182
+ "now. Give exactly one of `in_seconds` or `at`. You can only ever set a timer on your OWN thread.",
183
+ inputSchema: {
184
+ type: "object",
185
+ properties: {
186
+ action: {
187
+ type: "string",
188
+ enum: ["set", "cancel", "list"],
189
+ description:
190
+ "`set` arms a new one-off timer (it never replaces an existing one); `cancel` withdraws one by " +
191
+ "`id`; `list` returns the timers currently armed on this thread. Every action answers with the " +
192
+ "resulting armed list.",
193
+ },
194
+ prompt: {
195
+ type: "string",
196
+ description: "Required for `set`. The text delivered to you when it fires, verbatim, as a user turn.",
197
+ },
198
+ in_seconds: {
199
+ type: "integer",
200
+ description:
201
+ `For \`set\`: fire this many seconds from now (minimum ${TIMER_MIN_DELAY_SECONDS}, maximum ` +
202
+ `${TIMER_MAX_DELAY_SECONDS} — thirty days). Give this OR \`at\`, not both. Sub-minute precision ` +
203
+ "is not real: the delivery is read at your next tool boundary.",
204
+ },
205
+ at: {
206
+ type: "string",
207
+ description:
208
+ "For `set`: fire at this exact instant, as an ISO-8601 timestamp (e.g. `2026-08-04T15:00:00Z`). " +
209
+ "Give this OR `in_seconds`, not both. Must be in the future and within thirty days.",
210
+ },
211
+ id: {
212
+ type: "string",
213
+ description: "Required for `cancel`. The timer id returned by `set` (or listed by `list`).",
214
+ },
215
+ },
216
+ required: ["action"],
217
+ },
218
+ }
219
+
220
+ // The unified server's tool registry: `tools/list` returns these and `tools/call` routes by name.
221
+ // Adding a worker-facing frizz tool = one entry here + one handler in `HANDLERS` — never a second
222
+ // MCP server, so every frizz tool stays under the same `mcp__frizz__*` namespace and the same
223
+ // server-level pre-approval the dispatch layer already grants.
224
+ const MIN_INTERVAL_SECONDS = 60
225
+ const MAX_INTERVAL_SECONDS = 24 * 60 * 60
226
+
227
+ const TOOLS = [SPAWN_THREAD, RECURRING_PROMPT, TIMER]
228
+
229
+ /** @type {Record<string, (args: Record<string, unknown>) => Promise<string>>} */
230
+ const HANDLERS = {
231
+ [SPAWN_THREAD.name]: spawnThread,
232
+ [RECURRING_PROMPT.name]: recurringPrompt,
233
+ [TIMER.name]: timer,
234
+ }
235
+
236
+ /** @param {unknown} obj */
237
+ function send(obj) {
238
+ process.stdout.write(JSON.stringify(obj) + "\n")
239
+ }
240
+ /** @param {string|number} id @param {unknown} result */
241
+ function reply(id, result) {
242
+ send({ jsonrpc: "2.0", id, result })
243
+ }
244
+ /** @param {string|number} id @param {number} code @param {string} message */
245
+ function replyError(id, code, message) {
246
+ send({ jsonrpc: "2.0", id, error: { code, message } })
247
+ }
248
+ /** @param {string|number} id @param {string} text @param {boolean} [isError] */
249
+ function replyTool(id, text, isError) {
250
+ reply(id, { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) })
251
+ }
252
+
253
+ function serverLockPort() {
254
+ const lock = process.env.FRIZZ_SERVER_LOCK
255
+ || (process.env.FRIZZ_STATE_DIR ? join(process.env.FRIZZ_STATE_DIR, "server.lock") : undefined)
256
+ if (!lock) throw new Error("FRIZZ_STATE_DIR / FRIZZ_SERVER_LOCK not set — cannot locate the frizz server")
257
+ let parsed
258
+ try {
259
+ parsed = JSON.parse(readFileSync(lock, "utf8"))
260
+ } catch (err) {
261
+ throw new Error(`could not read the frizz server lock at ${lock} (is the server running?): ${err instanceof Error ? err.message : err}`)
262
+ }
263
+ const port = parsed?.port
264
+ if (!Number.isInteger(port)) throw new Error(`frizz server lock at ${lock} has no valid port`)
265
+ return port
266
+ }
267
+
268
+ /** The `spawn_thread` handler: POST /rpc/dispatch, return the worker-facing result text.
269
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
270
+ async function spawnThread(args) {
271
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
272
+ if (!prompt) throw new Error("`prompt` is required and must be a non-empty string")
273
+ // model + effort are REQUIRED (no default) so the caller must choose by task complexity — a defaulted
274
+ // model (e.g. the project's cheap default) is exactly the bug this guards. Enforced server-side too,
275
+ // not only in the tool schema, so a lenient client can't skip the decision.
276
+ const model = typeof args.model === "string" ? args.model.trim() : ""
277
+ if (!model) throw new Error("`model` is required — choose one by the new task's complexity (claude: opus/sonnet/haiku, opus being the top tier; codex: a gpt-5.6 model id). There is no default.")
278
+ const effort = typeof args.effort === "string" ? args.effort.trim() : ""
279
+ if (!effort) throw new Error("`effort` is required — choose one by complexity (low/medium/high/xhigh/max). There is no default.")
280
+
281
+ /** @type {Record<string, unknown>} */
282
+ const body = { prompt, model, effort }
283
+ if (typeof args.title === "string" && args.title.trim()) body.title = args.title.trim()
284
+ if (args.backend === "claude" || args.backend === "codex") body.backend = args.backend
285
+
286
+ const port = serverLockPort()
287
+ const controller = new AbortController()
288
+ const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
289
+ let res
290
+ try {
291
+ res = await fetch(`http://127.0.0.1:${port}/rpc/dispatch`, {
292
+ method: "POST",
293
+ // No Origin header (undici omits it for non-browser fetch); `sec-fetch-site: same-origin`
294
+ // satisfies the server's loopback-origin gate (app.ts isTrustedLocalHttpRequest).
295
+ headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
296
+ body: JSON.stringify(body),
297
+ signal: controller.signal,
298
+ })
299
+ } catch (err) {
300
+ throw new Error(`dispatch request failed: ${err instanceof Error ? err.message : err}`)
301
+ } finally {
302
+ clearTimeout(timer)
303
+ }
304
+ if (!res.ok) {
305
+ const detail = await res.text().catch(() => "")
306
+ throw new Error(`dispatch returned HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`)
307
+ }
308
+ const payload = await res.json().catch(() => null)
309
+ const slug = payload?.result?.slug
310
+ if (typeof slug !== "string" || !slug) throw new Error(`dispatch response missing a slug: ${JSON.stringify(payload)?.slice(0, 300)}`)
311
+ const label = typeof body.title === "string" ? body.title : slug
312
+ return (
313
+ `Spawned a new frizz thread \`${slug}\`. It is now on the board driving independently — it reports ` +
314
+ `to the human via its own final message, NOT back to you, so do not wait on a result from it.\n\n` +
315
+ `Paste this link to let the human open it in the drawer:\n\n[${label}](/thread/${slug})`
316
+ )
317
+ }
318
+
319
+ /** POST a frizz RPC procedure and return its parsed payload. Shares spawn_thread's transport rules:
320
+ * the port comes from server.lock and `sec-fetch-site: same-origin` satisfies the loopback gate.
321
+ * @param {string} procedure @param {Record<string, unknown>} body @returns {Promise<any>} */
322
+ async function callRpc(procedure, body) {
323
+ const port = serverLockPort()
324
+ const controller = new AbortController()
325
+ const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
326
+ let res
327
+ try {
328
+ res = await fetch(`http://127.0.0.1:${port}/rpc/${procedure}`, {
329
+ method: "POST",
330
+ headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
331
+ body: JSON.stringify(body),
332
+ signal: controller.signal,
333
+ })
334
+ } catch (err) {
335
+ throw new Error(`${procedure} request failed: ${err instanceof Error ? err.message : err}`)
336
+ } finally {
337
+ clearTimeout(timer)
338
+ }
339
+ if (!res.ok) {
340
+ const detail = await res.text().catch(() => "")
341
+ throw new Error(`${procedure} returned HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`)
342
+ }
343
+ return await res.json().catch(() => null)
344
+ }
345
+
346
+ /** Which thread this MCP server belongs to. Stamped into our env at spawn (dispatch.ts for the tmux
347
+ * path, the broker bridge for the SDK path) because the MCP protocol carries no caller identity.
348
+ * FRIZZ_THREAD is the fallback: every frizz worker process is tagged with it, so it is right
349
+ * whenever the env is inherited — but it is not relied upon, hence the explicit var first.
350
+ *
351
+ * This is also the reason a model can never point `recurring_prompt` at someone else's thread: the slug is
352
+ * read from HERE, never from the tool arguments. */
353
+ function threadSlug() {
354
+ const slug = process.env.FRIZZ_THREAD_SLUG || process.env.FRIZZ_THREAD
355
+ if (!slug) {
356
+ throw new Error(
357
+ "this frizz MCP server was not told which thread it belongs to (no FRIZZ_THREAD_SLUG), so it cannot " +
358
+ "arm a recurring prompt for it. This is a frizz bug — report it rather than working around it.",
359
+ )
360
+ }
361
+ return slug
362
+ }
363
+
364
+ /** The `recurring_prompt` handler: arm or disarm this thread's re-prompt, on either or both triggers.
365
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
366
+ async function recurringPrompt(args) {
367
+ const slug = threadSlug()
368
+ const action = typeof args.action === "string" ? args.action.trim() : ""
369
+ if (action !== "start" && action !== "stop") throw new Error("`action` must be either \"start\" or \"stop\"")
370
+
371
+ if (action === "stop") {
372
+ await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false })
373
+ return "Recurring prompt disarmed and cleared. Neither the stop hook nor the heartbeat will fire, and the text is gone from the thread footer."
374
+ }
375
+
376
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
377
+ if (!prompt) {
378
+ throw new Error("`prompt` is required to start a recurring prompt — it is the text you will be sent on every trigger")
379
+ }
380
+
381
+ const hasHeartbeat = args.heartbeat_seconds !== undefined && args.heartbeat_seconds !== null
382
+ let interval
383
+ if (hasHeartbeat) {
384
+ interval = typeof args.heartbeat_seconds === "number" ? Math.round(args.heartbeat_seconds) : NaN
385
+ if (!Number.isFinite(interval)) throw new Error("`heartbeat_seconds` must be a number of seconds")
386
+ if (interval < MIN_INTERVAL_SECONDS || interval > MAX_INTERVAL_SECONDS) {
387
+ throw new Error(`\`heartbeat_seconds\` must be between ${MIN_INTERVAL_SECONDS} and ${MAX_INTERVAL_SECONDS}`)
388
+ }
389
+ }
390
+ // DEFAULTED, not required: a `start` that names no trigger at all is a model asking to be re-prompted
391
+ // and leaving the mechanism to us, and the rest trigger is the safe reading of that — it cannot talk
392
+ // over a running turn, and it cannot fire on a thread that has stopped needing it.
393
+ const stopHook = typeof args.stop_hook === "boolean" ? args.stop_hook : !hasHeartbeat
394
+ const heartbeat = hasHeartbeat
395
+ if (!stopHook && !heartbeat) {
396
+ throw new Error("at least one is required: set `stop_hook: true`, or give `heartbeat_seconds`, or both")
397
+ }
398
+
399
+ await callRpc("setOwnThreadRecurringPrompt", {
400
+ slug,
401
+ prompt,
402
+ stopHook,
403
+ heartbeat,
404
+ ...(heartbeat ? { intervalSeconds: interval } : {}),
405
+ })
406
+
407
+ const every = heartbeat ? (interval % 60 === 0 ? `${interval / 60} min` : `${interval}s`) : null
408
+ const when = stopHook && every
409
+ ? `every time you come to rest AND every ${every} (the heartbeat reaches you mid-turn)`
410
+ : stopHook
411
+ ? "every time you come to rest"
412
+ : `every ${every}, reaching you mid-turn rather than waiting for you to stop`
413
+ return (
414
+ `Recurring prompt armed — frizz will send you this ${when}. It replaces any recurring prompt this ` +
415
+ "thread had before.\n\n" +
416
+ "Call this tool again with `action: \"stop\"` once the work it drives is finished — one left armed on " +
417
+ "a finished thread wakes it forever. The human can also edit or switch it off in the thread footer. " +
418
+ "Replying ALLDONE stops it too, but only use that when there is genuinely nothing left: it " +
419
+ "permanently stalls the run."
420
+ )
421
+ }
422
+
423
+ /** How a timer reads back to the worker: its id, when it fires, and enough of its text to tell two apart.
424
+ * @param {{ id: string, fireAt: string, prompt: string }} t */
425
+ function timerLine(t) {
426
+ const words = t.prompt.replace(/\s+/g, " ").trim()
427
+ return ` ${t.id} — ${t.fireAt} — ${words.length > 72 ? `${words.slice(0, 72)}…` : words}`
428
+ }
429
+
430
+ /** @param {{ timers?: { id: string, fireAt: string, prompt: string }[] }|null} payload */
431
+ function armedList(payload) {
432
+ const timers = payload?.timers ?? []
433
+ if (!timers.length) return "No timers are armed on this thread."
434
+ return `Armed timers (${timers.length}):\n${timers.map(timerLine).join("\n")}`
435
+ }
436
+
437
+ /** The `timer` handler: set / cancel / list this thread's ONE-OFF timers.
438
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
439
+ async function timer(args) {
440
+ const slug = threadSlug()
441
+ const action = typeof args.action === "string" ? args.action.trim() : ""
442
+ if (action !== "set" && action !== "cancel" && action !== "list") {
443
+ throw new Error("`action` must be one of \"set\", \"cancel\" or \"list\"")
444
+ }
445
+
446
+ if (action === "list") {
447
+ return armedList((await callRpc("listOwnThreadTimers", { slug }))?.result)
448
+ }
449
+
450
+ if (action === "cancel") {
451
+ const id = typeof args.id === "string" ? args.id.trim() : ""
452
+ if (!id) throw new Error("`id` is required to cancel a timer — take it from `set`'s reply or from `action: \"list\"`")
453
+ const result = (await callRpc("cancelOwnThreadTimer", { slug, id }))?.result
454
+ const head = result?.cancelled
455
+ ? `Timer ${id} cancelled — it will not fire.`
456
+ : `No ARMED timer ${id} on this thread (it may have already fired, or already been cancelled).`
457
+ return `${head}\n\n${armedList(result)}`
458
+ }
459
+
460
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
461
+ if (!prompt) throw new Error("`prompt` is required to set a timer — it is the text you will be sent when it fires")
462
+
463
+ // Exactly one of the two ways to name the instant. Accepting both would mean silently preferring one,
464
+ // and a worker that gave two different times meant something by each of them.
465
+ const hasIn = args.in_seconds !== undefined && args.in_seconds !== null
466
+ const hasAt = typeof args.at === "string" && args.at.trim() !== ""
467
+ if (hasIn && hasAt) throw new Error("give `in_seconds` OR `at`, not both")
468
+ if (!hasIn && !hasAt) throw new Error("give either `in_seconds` (fire N seconds from now) or `at` (an ISO-8601 instant)")
469
+
470
+ const nowMs = Date.now()
471
+ let fireMs
472
+ if (hasIn) {
473
+ const seconds = typeof args.in_seconds === "number" ? Math.round(args.in_seconds) : NaN
474
+ if (!Number.isFinite(seconds)) throw new Error("`in_seconds` must be a number of seconds")
475
+ if (seconds < TIMER_MIN_DELAY_SECONDS || seconds > TIMER_MAX_DELAY_SECONDS) {
476
+ throw new Error(`\`in_seconds\` must be between ${TIMER_MIN_DELAY_SECONDS} and ${TIMER_MAX_DELAY_SECONDS} (thirty days)`)
477
+ }
478
+ fireMs = nowMs + seconds * 1000
479
+ } else {
480
+ fireMs = Date.parse(String(args.at))
481
+ if (!Number.isFinite(fireMs)) throw new Error("`at` must be an ISO-8601 instant, e.g. `2026-08-04T15:00:00Z`")
482
+ const delta = Math.round((fireMs - nowMs) / 1000)
483
+ if (delta < TIMER_MIN_DELAY_SECONDS) {
484
+ throw new Error(`\`at\` must be at least ${TIMER_MIN_DELAY_SECONDS}s in the future (it reads as ${delta}s from now)`)
485
+ }
486
+ if (delta > TIMER_MAX_DELAY_SECONDS) throw new Error("`at` must be within thirty days")
487
+ }
488
+
489
+ // ONE representation crosses the wire — the exact UTC instant — so the stored row, the trailer on the
490
+ // delivered message and this reply all name the same string.
491
+ const fireAt = new Date(fireMs).toISOString()
492
+ const result = (await callRpc("setOwnThreadTimer", { slug, prompt, fireAt }))?.result
493
+ const id = result?.id ?? "(unknown)"
494
+ return (
495
+ `Timer ${id} set for ${fireAt} (${Math.round((fireMs - nowMs) / 1000)}s from now). It fires ONCE and ` +
496
+ "then is gone — it may reach you mid-turn, so receiving it does not mean you had stopped. Cancel it " +
497
+ `with \`action: "cancel", id: "${id}"\` if it stops being useful.\n\n${armedList(result)}`
498
+ )
499
+ }
500
+
501
+ /** @param {any} msg */
502
+ async function handle(msg) {
503
+ const { id, method, params } = msg ?? {}
504
+ const isNotification = id === undefined || id === null
505
+
506
+ switch (method) {
507
+ case "initialize": {
508
+ const requested = params?.protocolVersion
509
+ reply(id, {
510
+ protocolVersion: typeof requested === "string" ? requested : PROTOCOL_FALLBACK,
511
+ capabilities: { tools: {} },
512
+ serverInfo: { name: "frizz", version: "0.1.0" },
513
+ })
514
+ return
515
+ }
516
+ case "notifications/initialized":
517
+ case "initialized":
518
+ return // notification — no reply
519
+ case "ping":
520
+ if (!isNotification) reply(id, {})
521
+ return
522
+ case "tools/list":
523
+ reply(id, { tools: TOOLS })
524
+ return
525
+ case "tools/call": {
526
+ const name = typeof params?.name === "string" ? params.name : ""
527
+ const handler = HANDLERS[name]
528
+ if (!handler) {
529
+ replyError(id, -32602, `unknown tool: ${params?.name}`)
530
+ return
531
+ }
532
+ try {
533
+ replyTool(id, await handler(params?.arguments ?? {}))
534
+ } catch (err) {
535
+ replyTool(id, `\`${name}\` failed: ${err instanceof Error ? err.message : String(err)}`, true)
536
+ }
537
+ return
538
+ }
539
+ default:
540
+ if (!isNotification) replyError(id, -32601, `method not found: ${method}`)
541
+ return
542
+ }
543
+ }
544
+
545
+ // NDJSON reader: buffer stdin, dispatch each complete line. Messages never contain raw newlines.
546
+ let buf = ""
547
+ process.stdin.setEncoding("utf8")
548
+ process.stdin.on("data", (chunk) => {
549
+ buf += chunk
550
+ let nl
551
+ while ((nl = buf.indexOf("\n")) >= 0) {
552
+ const line = buf.slice(0, nl).trim()
553
+ buf = buf.slice(nl + 1)
554
+ if (!line) continue
555
+ let msg
556
+ try {
557
+ msg = JSON.parse(line)
558
+ } catch {
559
+ continue // ignore unparseable lines
560
+ }
561
+ void handle(msg)
562
+ }
563
+ })
564
+ process.stdin.on("end", () => process.exit(0))
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * frizz-update — the structured thread updater, on the worker's Bash PATH while the frizz-worker
5
+ * plugin is enabled. This is the worker's PRIMARY tool for owning its one thread file: it writes
6
+ * `.frizz/<slug>.md` through the same code path frizz uses (never hand-rolled markdown edits).
7
+ * Execs the repo board's bundled updater directly (NOT a copy) — cc-worker never forks updater logic.
8
+ *
9
+ * Resolving the script: this file is <plugin>/bin/frizz-update, board/ is a sibling of the plugin root,
10
+ * so the updater is at ../../board/thread-update.mjs relative to THIS file. Imported via
11
+ * a file:// URL so argv/process.env flow straight through.
12
+ */
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const updater = join(here, '..', '..', 'board', 'thread-update.mjs');
18
+ await import(pathToFileURL(updater).href);
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PostToolUse hook on the `Agent` tool (frizz-worker) — the AUTOMATIC thread↔agent binding, kept
4
+ // COMPATIBLE with cc's board. When a worker dispatches a helper tagged `THREAD: <slug>` at the top
5
+ // of the prompt, this records `agentId → thread` into `.frizz/.agent-bindings.jsonl` in the exact
6
+ // shape cc's board (`bindingsByThread`) reads for per-thread sub-agent liveness — so frizz renders
7
+ // a worker's helper activity the same way the cc board does. An untagged helper writes nothing.
8
+ //
9
+ // GATE: inert unless FRIZZ_THREAD is set. FAIL-OPEN ABSOLUTELY: any error → exit 0, no output.
10
+ // A missed binding just means one helper isn't surfaced; a PostToolUse hook must never disturb the turn.
11
+ import { readFileSync } from 'node:fs';
12
+ import { recordBinding, threadFromPrompt } from '../scripts/frizz/agent-bindings.mjs';
13
+
14
+ try {
15
+ // WORKER GATE.
16
+ if (!(process.env.FRIZZ_THREAD ?? '').trim()) process.exit(0);
17
+
18
+ const input = JSON.parse(readFileSync(0, 'utf8'));
19
+ const dir = process.env.CLAUDE_PROJECT_DIR ?? '.';
20
+
21
+ const ti = input.tool_input ?? {};
22
+ const tr = input.tool_response ?? {};
23
+ const trInner = tr.toolUseResult ?? tr;
24
+
25
+ const agentId = trInner.agentId ?? tr.agentId ?? null;
26
+ const prompt =
27
+ (typeof trInner.prompt === 'string' && trInner.prompt) ||
28
+ (typeof tr.prompt === 'string' && tr.prompt) ||
29
+ (typeof ti.prompt === 'string' && ti.prompt) ||
30
+ '';
31
+ const thread = threadFromPrompt(prompt);
32
+ const label = (typeof ti.description === 'string' && ti.description) || trInner.description || null;
33
+
34
+ if (agentId && thread) {
35
+ recordBinding(dir, { agentId, thread, label, session: input.session_id ?? null });
36
+ }
37
+ } catch {
38
+ /* fail-open — never disturb the turn */
39
+ }
40
+ process.exit(0);