frizz 0.2.0 → 0.4.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/README.md +25 -20
- package/dist/claude-agent-broker.js +72 -27
- package/dist/dev-child.js +8302 -4628
- package/dist/frizz.js +1302 -701
- package/package.json +10 -4
- package/runtime/cc-worker/.claude-plugin/plugin.json +1 -1
- package/runtime/cc-worker/DECISIONS.md +81 -4
- package/runtime/cc-worker/bin/frizz-mcp.mjs +472 -58
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +1 -1
- package/runtime/cc-worker/hooks/deny-plan.mjs +1 -1
- package/runtime/cc-worker/hooks/hooks.json +0 -7
- package/runtime/cc-worker/hooks/precompact-instructions.mjs +39 -28
- package/runtime/cc-worker/hooks/scratchpad.mjs +142 -169
- package/runtime/cc-worker/hooks/session-seed.mjs +16 -15
- package/runtime/cc-worker/skills/waits/SKILL.md +2 -2
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/{TerminalPane-ROKHp1ib.js → TerminalPane-sd3XXjx5.js} +2 -2
- package/web-dist/assets/{abnfDiagram-VRR7QNED-DcpdhBs3.js → abnfDiagram-VRR7QNED-Bry_FIqg.js} +1 -1
- package/web-dist/assets/architecture-TIHT7OUA-CoMJQ5Ya.js +1 -0
- package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-CUAKf0mn.js → architectureDiagram-ZJ3FMSHR-Baok4OeI.js} +1 -1
- package/web-dist/assets/{blockDiagram-677ZJIJ3-BPwpJIzx.js → blockDiagram-677ZJIJ3-BgHIO9lI.js} +1 -1
- package/web-dist/assets/{c4Diagram-LMCZKHZV-1lptuHzZ.js → c4Diagram-LMCZKHZV-rfj9Z17Z.js} +1 -1
- package/web-dist/assets/channel-D6uS1y7I.js +1 -0
- package/web-dist/assets/{chunk-32BRIVSS-CFR9AKjY.js → chunk-32BRIVSS-BwaHfK1Y.js} +1 -1
- package/web-dist/assets/{chunk-52WLFC77-CM9uct7m.js → chunk-52WLFC77-BmdQDZOA.js} +1 -1
- package/web-dist/assets/{chunk-C7G6YPKG-DiveJARw.js → chunk-C7G6YPKG-ByQFjoZe.js} +1 -1
- package/web-dist/assets/{chunk-EX3LRPZG-BE1CBw8F.js → chunk-EX3LRPZG-DQFudBKr.js} +1 -1
- package/web-dist/assets/{chunk-FWX5IMBZ-DL42uXiO.js → chunk-FWX5IMBZ-OW9qEPiq.js} +2 -2
- package/web-dist/assets/{chunk-HOUHSVGY-DPhJWgDw.js → chunk-HOUHSVGY-BdKLF8nO.js} +1 -1
- package/web-dist/assets/{chunk-ICXQ74PX-CwYy-6AP.js → chunk-ICXQ74PX-C1roqKR1.js} +1 -1
- package/web-dist/assets/{chunk-MOJQB5TN-Ds5I9wxq.js → chunk-MOJQB5TN-Cx0u1ZDK.js} +1 -1
- package/web-dist/assets/{chunk-OGEWGWER-DHiZJwQD.js → chunk-OGEWGWER-rxv9xtNR.js} +1 -1
- package/web-dist/assets/{chunk-PUDLZKDR-B-eyQTsF.js → chunk-PUDLZKDR-BXWPPPPi.js} +1 -1
- package/web-dist/assets/{chunk-Q4XR5HBZ-DK7dB3Ti.js → chunk-Q4XR5HBZ-CpqbD101.js} +1 -1
- package/web-dist/assets/{chunk-V7JOEXUC-Dn59m74L.js → chunk-V7JOEXUC-CP8_3qVt.js} +1 -1
- package/web-dist/assets/{chunk-VAUOI2AC-DK7x36hd.js → chunk-VAUOI2AC-Dl7xahpk.js} +1 -1
- package/web-dist/assets/{chunk-VR4S4FIN-D7-CI3Yl.js → chunk-VR4S4FIN-BKtMX64l.js} +1 -1
- package/web-dist/assets/{chunk-WYO6CB5R-B3l-mLCs.js → chunk-WYO6CB5R-DmLOdJcu.js} +1 -1
- package/web-dist/assets/{chunk-ZGVPDNZ5-Dobxlxie.js → chunk-ZGVPDNZ5-5wN7eXvS.js} +1 -1
- package/web-dist/assets/classDiagram-OUVF2IWQ-BW5YiBf4.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-BW5YiBf4.js +1 -0
- package/web-dist/assets/{cynefin-VYW2F7L2-Dh7RuEUJ.js → cynefin-VYW2F7L2-DUYahBXb.js} +1 -1
- package/web-dist/assets/{cynefinDiagram-TSTJHNR4-ClPi2mZZ.js → cynefinDiagram-TSTJHNR4-Rm0i30nE.js} +1 -1
- package/web-dist/assets/{dagre-VKFMJZFB-52_WP1QV.js → dagre-VKFMJZFB-3uhsB-W3.js} +1 -1
- package/web-dist/assets/{diagram-FQU43EPY-D_1zVsTL.js → diagram-FQU43EPY-BUvflNRQ.js} +1 -1
- package/web-dist/assets/{diagram-G47NLZAW-CdZxuGUy.js → diagram-G47NLZAW-DUluIZel.js} +1 -1
- package/web-dist/assets/{diagram-NH7WQ7WH-C8pSFu0P.js → diagram-NH7WQ7WH-BBrwsbed.js} +1 -1
- package/web-dist/assets/{diagram-OA4YK3LP-C5bjZLre.js → diagram-OA4YK3LP-DWnTQBw4.js} +1 -1
- package/web-dist/assets/{diagram-WEI45ONY-Bxzhiuzn.js → diagram-WEI45ONY-BjEavoC0.js} +1 -1
- package/web-dist/assets/{ebnfDiagram-CCIWWBDH-g-Z0J2wP.js → ebnfDiagram-CCIWWBDH-CW4xZAZH.js} +1 -1
- package/web-dist/assets/{erDiagram-Q63AITRT-DVNkgIHp.js → erDiagram-Q63AITRT-BemQ1R4B.js} +1 -1
- package/web-dist/assets/eventmodeling-45OFAUF4-BF5KB7ja.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-BYo4Eytx.js +1 -0
- package/web-dist/assets/{ganttDiagram-NO4QXBWP-_71pQYEK.js → ganttDiagram-NO4QXBWP-lDdG5I_z.js} +1 -1
- package/web-dist/assets/{gitGraph-TEB2WS4Q-ChIZiGZS.js → gitGraph-TEB2WS4Q-Bw87a_Se.js} +1 -1
- package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-DCHAFI0l.js → gitGraphDiagram-IHSO6WYX-B762r0yi.js} +1 -1
- package/web-dist/assets/index-B3QArXu2.css +1 -0
- package/web-dist/assets/index-CAapfhMv.js +365 -0
- package/web-dist/assets/{info-DKCQHKI2-BW-n_T1j.js → info-DKCQHKI2-DGOv6Gx0.js} +1 -1
- package/web-dist/assets/{infoDiagram-FWYZ7A6U-CgDYsKi9.js → infoDiagram-FWYZ7A6U-DL92kAzf.js} +1 -1
- package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-ClzGNt9N.js → ishikawaDiagram-FXEZZL3T-CFYd4OtU.js} +1 -1
- package/web-dist/assets/{journeyDiagram-5HDEW3XC-DSCQxkHC.js → journeyDiagram-5HDEW3XC-QHuYL5zq.js} +1 -1
- package/web-dist/assets/{kanban-definition-HUTT4EX6-CdrdX9N8.js → kanban-definition-HUTT4EX6-DwmMluQ8.js} +1 -1
- package/web-dist/assets/{line-ha38Dc-1.js → line-Je_PFFHm.js} +1 -1
- package/web-dist/assets/{mermaid-parser.core-Z4uMcpip.js → mermaid-parser.core-BeiGWsn3.js} +3 -3
- package/web-dist/assets/{mermaid.core-iZRq3hbu.js → mermaid.core-BP8JrW48.js} +3 -3
- package/web-dist/assets/{mindmap-definition-LN4V7U3C-DmhInJO_.js → mindmap-definition-LN4V7U3C-BsmjCIrM.js} +1 -1
- package/web-dist/assets/{packet-7NZHBO7P-DBPB36Kl.js → packet-7NZHBO7P-D6ekYYGJ.js} +1 -1
- package/web-dist/assets/{pegDiagram-2B236MQR-CAH3ljfj.js → pegDiagram-2B236MQR-CQc2Kt0u.js} +1 -1
- package/web-dist/assets/{pie-RZYD4A2V-_h_eX4Ca.js → pie-RZYD4A2V-C_45fO8J.js} +1 -1
- package/web-dist/assets/{pieDiagram-ENE6RG2P-DFBPus8j.js → pieDiagram-ENE6RG2P-Dg5z6iWt.js} +1 -1
- package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-DMvOCjt8.js → quadrantDiagram-ABIIQ3AL-c7gZxuCX.js} +1 -1
- package/web-dist/assets/{radar-I7S5WNFK-2EzoPHEZ.js → radar-I7S5WNFK-D80XNJQq.js} +1 -1
- package/web-dist/assets/{railroad-3IZDKUUU-BPJnn-hm.js → railroad-3IZDKUUU-Brz7eZQ0.js} +1 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-D44pcGhJ.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-CiWVH5SP.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-Cy1B6-pN.js +1 -0
- package/web-dist/assets/{railroadDiagram-RFXS5EU6-DKq5FagA.js → railroadDiagram-RFXS5EU6-Db9K7bty.js} +1 -1
- package/web-dist/assets/{requirementDiagram-TGXJPOKE-BJ5tGazp.js → requirementDiagram-TGXJPOKE-m1ipMrDe.js} +1 -1
- package/web-dist/assets/{sankeyDiagram-HTMAVEWB-XSJjcBhX.js → sankeyDiagram-HTMAVEWB-4Zz9ry0k.js} +1 -1
- package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-CBb8emSe.js → sequenceDiagram-DBY2YBRQ-CZeGHw4X.js} +1 -1
- package/web-dist/assets/{stateDiagram-2N3HPSRC-DDfRW94V.js → stateDiagram-2N3HPSRC-rLUVs1Pg.js} +1 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-w7QDFEvx.js +1 -0
- package/web-dist/assets/{swimlanes-5IMT3BWC-DvRYbkZi.js → swimlanes-5IMT3BWC-BAeBdscz.js} +1 -1
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-UhdgWpNZ.js +8 -0
- package/web-dist/assets/{timeline-definition-FHXFAJF6-BNUa_DwI.js → timeline-definition-FHXFAJF6-DL0eWMF-.js} +1 -1
- package/web-dist/assets/{treeView-QDETBFTQ-I6-IW6nJ.js → treeView-QDETBFTQ-gEtzLMA8.js} +1 -1
- package/web-dist/assets/{treemap-6X3UGDF4-CWWmEUYJ.js → treemap-6X3UGDF4-CCym1Gz6.js} +1 -1
- package/web-dist/assets/{vennDiagram-L72KCM5P-DTDrPGLk.js → vennDiagram-L72KCM5P-cF97-nb5.js} +1 -1
- package/web-dist/assets/{wardley-OPB4EBWU-CNsdgXXA.js → wardley-OPB4EBWU-DQJHCGbM.js} +1 -1
- package/web-dist/assets/{wardleyDiagram-EHGQE667-YE0tq3Kh.js → wardleyDiagram-EHGQE667-0CnHzXLX.js} +1 -1
- package/web-dist/assets/{xychartDiagram-FW5EYKEG-D0ofMX8C.js → xychartDiagram-FW5EYKEG-9YDOp4m5.js} +1 -1
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +17 -61
- 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 +19 -8
- package/web-dist/manifest.webmanifest +3 -3
- package/runtime/cc-worker/hooks/scratchpad-stop.mjs +0 -125
- package/runtime/cc-worker/skills/handoff/SKILL.md +0 -209
- package/web-dist/assets/architecture-TIHT7OUA-CAviNivx.js +0 -1
- package/web-dist/assets/channel-CqKDIFQF.js +0 -1
- package/web-dist/assets/classDiagram-OUVF2IWQ-B_-6iXYY.js +0 -1
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-B_-6iXYY.js +0 -1
- package/web-dist/assets/eventmodeling-45OFAUF4-MpmeH5YZ.js +0 -1
- package/web-dist/assets/flowDiagram-23GEKE2U-D-QgjjhF.js +0 -1
- package/web-dist/assets/index-w4v-GZEc.js +0 -358
- package/web-dist/assets/index-zyi22LPz.css +0 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-YeUoiySk.js +0 -1
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-Ddw1SuGG.js +0 -1
- package/web-dist/assets/railroad-peg-LSFZ7HO6-Dd8BOGeW.js +0 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-hc41W5Lx.js +0 -1
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-BZyGdgSG.js +0 -8
|
@@ -6,19 +6,29 @@
|
|
|
6
6
|
*
|
|
7
7
|
* spawn_thread — dispatch a brand-new TOP-LEVEL frizz board thread (its own session + scratchpad +
|
|
8
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
|
|
9
|
+
* recurring_prompt — arm ONE piece of text frizz re-sends the caller, at every rest and/or on a clock
|
|
10
|
+
* and/or after every compaction; and READ BACK what is currently armed.
|
|
10
11
|
* timer — arm a ONE-OFF prompt for a single instant; a thread may hold many at once.
|
|
11
12
|
*
|
|
12
13
|
* Future worker-facing frizz tools join the TOOLS registry below rather than mounting a second server:
|
|
13
14
|
* one server keeps the worker's tool namespace coherent and the server-level pre-approval single.
|
|
14
15
|
*
|
|
15
|
-
* spawn_thread wraps frizz's own dispatch RPC: it reads the running server's port from
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* spawn_thread wraps frizz's own dispatch RPC: it reads the running server's port from a server.lock
|
|
17
|
+
* and POSTs `/_frizz/<project>/rpc/dispatch`. That surface has no token auth — only a loopback-origin
|
|
18
|
+
* CSRF gate — so a headerless local POST with `sec-fetch-site: same-origin` (undici sends no Origin)
|
|
19
|
+
* satisfies it.
|
|
19
20
|
*
|
|
20
|
-
* Mounted by the server (dispatch.ts) into the Claude backend via `--mcp-config
|
|
21
|
-
*
|
|
21
|
+
* Mounted by the server (dispatch.ts) into the Claude backend via `--mcp-config`, and into codex via
|
|
22
|
+
* `-c mcp_servers.frizz` (codex-mcp.ts). Both hand this process the same env, built once in
|
|
23
|
+
* frizzMcpEnv: FRIZZ_SERVER_LOCK, FRIZZ_PROJECT_ID and FRIZZ_STATE_DIR.
|
|
24
|
+
*
|
|
25
|
+
* BUT NOTHING HERE DEPENDS ON THAT ENV STAYING TRUE. This process lives inside a DETACHED worker
|
|
26
|
+
* daemon that outlives frizz restart after restart, so anything frozen into it at spawn is a bug
|
|
27
|
+
* waiting for the next "Update & Restart" to move the port. Both facts we need are therefore resolved
|
|
28
|
+
* PER CALL, from files: the server's address (serverLockPort — the env hint, then the machine-wide
|
|
29
|
+
* `<frizz root>/server.lock`, then any live project lock, skipping any whose pid is gone) and our own
|
|
30
|
+
* project (projectSegment — the stamp, else `.frizz/.id` walked up from our cwd). The env is a hint
|
|
31
|
+
* that saves a lookup; the filesystem is the truth.
|
|
22
32
|
*
|
|
23
33
|
* Protocol: MCP over stdio = newline-delimited JSON-RPC 2.0. We implement exactly the four methods a
|
|
24
34
|
* client drives (initialize, tools/list, tools/call, ping) plus the initialized notification. Hand-
|
|
@@ -27,8 +37,8 @@
|
|
|
27
37
|
* hand-rolled-RPC aesthetic. The server NEVER crashes on a bad tool call: failures come back as an
|
|
28
38
|
* isError tool result so the worker sees a message instead of a dead tool.
|
|
29
39
|
*/
|
|
30
|
-
import { readFileSync } from "node:fs"
|
|
31
|
-
import { join } from "node:path"
|
|
40
|
+
import { readFileSync, readdirSync } from "node:fs"
|
|
41
|
+
import { dirname, join } from "node:path"
|
|
32
42
|
|
|
33
43
|
const PROTOCOL_FALLBACK = "2025-06-18"
|
|
34
44
|
// Comfortably above a codex dispatch's bounded rollout-discovery wait (~15s) so a legitimate slow
|
|
@@ -95,37 +105,52 @@ const SPAWN_THREAD = {
|
|
|
95
105
|
const RECURRING_PROMPT = {
|
|
96
106
|
name: "recurring_prompt",
|
|
97
107
|
description:
|
|
98
|
-
"Arm a RECURRING PROMPT on YOUR OWN thread: one piece of text that frizz re-sends you, on
|
|
99
|
-
"
|
|
108
|
+
"Arm a RECURRING PROMPT on YOUR OWN thread: one piece of text that frizz re-sends you, on any or all " +
|
|
109
|
+
"of three triggers, for as long as it is armed.\n\n" +
|
|
100
110
|
" stop_hook — every time you come to REST. Use it to keep a long autonomous effort moving " +
|
|
101
111
|
"without the human driving every step, and to rescue yourself from a wait that may never resolve.\n" +
|
|
102
112
|
" heartbeat_seconds — on a CLOCK, whatever you are doing. This one reaches you MID-TURN: it arrives as " +
|
|
103
113
|
"a queued message you read at your next tool boundary rather than waiting for you to stop, and it " +
|
|
104
114
|
"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
|
|
106
|
-
"
|
|
107
|
-
"
|
|
115
|
+
"matter what you happen to believe at the time.\n" +
|
|
116
|
+
" post_compaction — every time your CONTEXT IS COMPACTED, delivered into the emptied window. This " +
|
|
117
|
+
"is how you survive compaction: write a doc in your scratch directory as you work, LINK IT in this " +
|
|
118
|
+
"prompt, and the link comes back at the exact moment you have lost everything else. Also mid-turn — " +
|
|
119
|
+
"a compaction happens while you are working.\n\n" +
|
|
120
|
+
"Set at least one. The ordinary shape for a long effort is post_compaction plus stop_hook: you are " +
|
|
121
|
+
"re-grounded whenever your context is summarized away, and prompted again whenever you stop.\n\n" +
|
|
108
122
|
"USE THIS RATHER THAN `CronCreate` or `ScheduleWakeup`. Those are Claude Code's own in-session " +
|
|
109
123
|
"schedulers and they CANNOT fire in the runtime frizz runs you in: their gate stays shut for as long " +
|
|
110
124
|
"as ANY background task of yours is outstanding, so the moment you are parked behind a background " +
|
|
111
125
|
"shell or a sub-agent — exactly when you most need waking — they go silent. This one is delivered by " +
|
|
112
126
|
"frizz itself and is unaffected.\n\n" +
|
|
127
|
+
"READ IT BACK WITH `action: \"get\"` — and do that BEFORE any `start` that is not a fresh arming. A " +
|
|
128
|
+
"thread has AT MOST ONE recurring prompt, so a `start` REPLACES whatever is there, triggers and all, " +
|
|
129
|
+
"and the text you are about to destroy may not be yours: the HUMAN can edit it in the thread footer, " +
|
|
130
|
+
"and a compaction can take your own memory of arming it. `get` answers with the exact text currently " +
|
|
131
|
+
"armed, which triggers are on, the cadence, and when each trigger last fired. Reach for it whenever " +
|
|
132
|
+
"you are about to change one trigger and keep the rest, whenever you are unsure whether you are armed " +
|
|
133
|
+
"at all, and after a compaction. (A `start` also reports what it replaced, so a blind overwrite is at " +
|
|
134
|
+
"least a visible one.)\n\n" +
|
|
113
135
|
"The text arrives VERBATIM as an ordinary user turn, so write it as an instruction to your future " +
|
|
114
|
-
"self.
|
|
115
|
-
"
|
|
116
|
-
"so you can never be handed a backlog at once.\n\n" +
|
|
136
|
+
"self. At most one scheduled delivery is ever outstanding and its clock runs from the last one " +
|
|
137
|
+
"DELIVERED, so you can never be handed a backlog at once.\n\n" +
|
|
117
138
|
"STOP IT when the work it drives is done (`action: \"stop\"`) — one left armed on a finished thread " +
|
|
118
139
|
"wakes it forever. The human sees it in the thread footer and can edit or switch it off there. " +
|
|
119
|
-
"
|
|
120
|
-
"
|
|
140
|
+
"Signing off with a ```done fence stops it too, every trigger at once — but only when the work is " +
|
|
141
|
+
"genuinely finished, because that files the thread away and a thread nobody is watching does not " +
|
|
142
|
+
"restart itself.\n\n" +
|
|
121
143
|
"You can only ever arm your OWN thread — there is no parameter for anyone else's.",
|
|
122
144
|
inputSchema: {
|
|
123
145
|
type: "object",
|
|
124
146
|
properties: {
|
|
125
147
|
action: {
|
|
126
148
|
type: "string",
|
|
127
|
-
enum: ["start", "stop"],
|
|
128
|
-
description:
|
|
149
|
+
enum: ["start", "stop", "get"],
|
|
150
|
+
description:
|
|
151
|
+
"`start` arms (or replaces) this thread's recurring prompt; `stop` disarms it; `get` reads back " +
|
|
152
|
+
"what is armed right now — the text, the triggers, the cadence and each trigger's last delivery " +
|
|
153
|
+
"— without changing anything. `get` takes no other argument.",
|
|
129
154
|
},
|
|
130
155
|
prompt: {
|
|
131
156
|
type: "string",
|
|
@@ -137,8 +162,8 @@ const RECURRING_PROMPT = {
|
|
|
137
162
|
stop_hook: {
|
|
138
163
|
type: "boolean",
|
|
139
164
|
description:
|
|
140
|
-
"Send it every time you come to rest. Defaults to true when `heartbeat_seconds`
|
|
141
|
-
"`start` that names
|
|
165
|
+
"Send it every time you come to rest. Defaults to true when neither `heartbeat_seconds` nor " +
|
|
166
|
+
"`post_compaction` is given, so a `start` that names no mechanism still does the obvious thing.",
|
|
142
167
|
},
|
|
143
168
|
heartbeat_seconds: {
|
|
144
169
|
type: "integer",
|
|
@@ -147,6 +172,13 @@ const RECURRING_PROMPT = {
|
|
|
147
172
|
"delivery is read at your next tool boundary, so a sub-minute cadence buys no promptness and " +
|
|
148
173
|
"only talks over your own work.",
|
|
149
174
|
},
|
|
175
|
+
post_compaction: {
|
|
176
|
+
type: "boolean",
|
|
177
|
+
description:
|
|
178
|
+
"Also send it every time your context is compacted. Set this on any effort long enough to be " +
|
|
179
|
+
"summarized, and make the prompt LINK the doc you are keeping in your scratch directory — that " +
|
|
180
|
+
"link arriving in the emptied window is what lets you pick the work back up.",
|
|
181
|
+
},
|
|
150
182
|
},
|
|
151
183
|
required: ["action"],
|
|
152
184
|
},
|
|
@@ -167,7 +199,7 @@ const TIMER = {
|
|
|
167
199
|
"matters: the delivery reaches you MID-TURN — a queued message you read at your next tool boundary — " +
|
|
168
200
|
"so it arrives when you asked for it whether or not you have stopped, and it never aborts what you " +
|
|
169
201
|
"are running. Unlike a recurring prompt it fires exactly once and then is gone, so there is nothing " +
|
|
170
|
-
"to switch off afterwards and
|
|
202
|
+
"to switch off afterwards and nothing to sign off from.\n\n" +
|
|
171
203
|
"You may have MANY armed at the same time, each with its own instant and its own text — they are " +
|
|
172
204
|
"independent, unlike the single recurring prompt this thread can hold.\n\n" +
|
|
173
205
|
"USE IT for anything you want to come back to at a specific time: re-check a deploy in ten minutes, " +
|
|
@@ -217,6 +249,63 @@ const TIMER = {
|
|
|
217
249
|
},
|
|
218
250
|
}
|
|
219
251
|
|
|
252
|
+
|
|
253
|
+
const WATCH_PR = {
|
|
254
|
+
name: "watch_pr",
|
|
255
|
+
description:
|
|
256
|
+
"REGISTER A PULL REQUEST and frizz brings you back whenever something happens on it — CI turning " +
|
|
257
|
+
"green or red, and every later review, approval or comment, from a human or a bot alike. Register " +
|
|
258
|
+
"it, come to rest, and you are woken. Drop it when it stops mattering.\n\n" +
|
|
259
|
+
"IT REPORTS REPEATEDLY, unlike a timer. One registration covers the whole life of the PR: CI goes " +
|
|
260
|
+
"red, you push a fix, CI goes green, a reviewer comments — that is four wakes from one call, and you " +
|
|
261
|
+
"never have to re-register between them. It settles itself when the PR merges or closes, because " +
|
|
262
|
+
"there is then nothing left to report.\n\n" +
|
|
263
|
+
"REGISTER IT THE MOMENT YOU OPEN OR PUSH A PR. Nothing else watches for you: your runtime knows " +
|
|
264
|
+
"nothing about GitHub, and an ```awaiting fence STATES what you are waiting on without creating any " +
|
|
265
|
+
"wait at all. This tool is the wait.\n\n" +
|
|
266
|
+
"THE ```awaiting FENCE IS STILL WORTH WRITING, and it is a different job: it is how you come to REST " +
|
|
267
|
+
"without frizz asking you for a handoff, and how the human sees what you are waiting for. Register " +
|
|
268
|
+
"the watcher with this tool, then name the same PR on a `pr-watch:` line in your fence.\n\n" +
|
|
269
|
+
"REGISTERING IS IDEMPOTENT per pull request: asking twice returns the SAME id and tells you it was " +
|
|
270
|
+
"already armed, so re-registering after a compaction is safe and is the right instinct. Use `list` " +
|
|
271
|
+
"when you want to know what you are holding without changing anything — it answers with each PR's " +
|
|
272
|
+
"current check state too.\n\n" +
|
|
273
|
+
"You can only ever watch a PR on your OWN thread — there is no parameter for anyone else's.",
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: "object",
|
|
276
|
+
properties: {
|
|
277
|
+
action: {
|
|
278
|
+
type: "string",
|
|
279
|
+
enum: ["add", "list", "drop"],
|
|
280
|
+
description:
|
|
281
|
+
"`add` registers a watcher (idempotent per PR); `drop` withdraws one by id; `list` reads back " +
|
|
282
|
+
"everything armed on this thread, with each PR's latest check state, without changing " +
|
|
283
|
+
"anything. Every action answers with the full armed set.",
|
|
284
|
+
},
|
|
285
|
+
target: {
|
|
286
|
+
type: "string",
|
|
287
|
+
description:
|
|
288
|
+
"Required for `add`. The pull request, as `owner/repo#123` or a GitHub PR URL. A ref that " +
|
|
289
|
+
"cannot be parsed is REFUSED rather than stored — a watcher that can never fire is worse than " +
|
|
290
|
+
"no watcher, because you would come to rest believing you were covered.",
|
|
291
|
+
},
|
|
292
|
+
for: {
|
|
293
|
+
type: "string",
|
|
294
|
+
description:
|
|
295
|
+
"REQUIRED for `add`. How long to watch, as a DURATION — `30m`, `2h`, `3d` (max 24h). Never an " +
|
|
296
|
+
"instant, and there is no default. A PR nobody ever reviews would otherwise be polled forever " +
|
|
297
|
+
"and hold your thread with it; the watcher settles itself when this runs out and tells you, " +
|
|
298
|
+
"and you re-register if you still care.",
|
|
299
|
+
},
|
|
300
|
+
id: {
|
|
301
|
+
type: "string",
|
|
302
|
+
description: "Required for `drop`. The watcher id returned by `add` (or listed by `list`).",
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
required: ["action"],
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
|
|
220
309
|
// The unified server's tool registry: `tools/list` returns these and `tools/call` routes by name.
|
|
221
310
|
// Adding a worker-facing frizz tool = one entry here + one handler in `HANDLERS` — never a second
|
|
222
311
|
// MCP server, so every frizz tool stays under the same `mcp__frizz__*` namespace and the same
|
|
@@ -224,13 +313,53 @@ const TIMER = {
|
|
|
224
313
|
const MIN_INTERVAL_SECONDS = 60
|
|
225
314
|
const MAX_INTERVAL_SECONDS = 24 * 60 * 60
|
|
226
315
|
|
|
227
|
-
const
|
|
316
|
+
const ACTIVITY = {
|
|
317
|
+
name: "activity",
|
|
318
|
+
description:
|
|
319
|
+
"EVERYTHING YOU CURRENTLY HAVE RUNNING, with the id each one is named by — your background shells, " +
|
|
320
|
+
"your sub-agents, your armed timers, and the pull requests you registered.\n\n" +
|
|
321
|
+
"WHY YOU NEED IT: an ```awaiting fence names what you are waiting on BY ID, and frizz checks every " +
|
|
322
|
+
"one against what is actually live. A name that matches nothing is not a park — you are bumped and " +
|
|
323
|
+
"your thread queues. So if you have lost an id (a compaction, a long turn, a wake you did not " +
|
|
324
|
+
"expect), call this rather than guessing. Guessing is the failure this tool exists to remove.\n\n" +
|
|
325
|
+
"It takes nothing and changes nothing. You can only ever read your OWN thread.",
|
|
326
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const TOOLS = [SPAWN_THREAD, RECURRING_PROMPT, TIMER, WATCH_PR, ACTIVITY]
|
|
228
330
|
|
|
229
331
|
/** @type {Record<string, (args: Record<string, unknown>) => Promise<string>>} */
|
|
230
332
|
const HANDLERS = {
|
|
231
333
|
[SPAWN_THREAD.name]: spawnThread,
|
|
232
334
|
[RECURRING_PROMPT.name]: recurringPrompt,
|
|
233
335
|
[TIMER.name]: timer,
|
|
336
|
+
[WATCH_PR.name]: watchPr,
|
|
337
|
+
[ACTIVITY.name]: activity,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Read out every background thing this thread has running, in the shape an awaiting fence names them.
|
|
341
|
+
* @returns {Promise<string>} */
|
|
342
|
+
async function activity() {
|
|
343
|
+
const result = (await callRpc("listOwnThreadActivity", { slug: threadSlug() }))?.result
|
|
344
|
+
const items = Array.isArray(result?.activity) ? result.activity : []
|
|
345
|
+
if (!items.length) {
|
|
346
|
+
return (
|
|
347
|
+
"Nothing is running on this thread — no background shells, no sub-agents, no armed timers, no " +
|
|
348
|
+
"registered PRs.\n\nSo there is nothing to wait on: an ```awaiting fence would have nothing to " +
|
|
349
|
+
"name, and a fence naming nothing is not a park. End with ```done, or with a ```question if you " +
|
|
350
|
+
"need the human."
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
const lines = items.map((i) => {
|
|
354
|
+
const when = i.until ? ` (fires ${i.until})` : i.since ? ` (since ${i.since})` : ""
|
|
355
|
+
return ` ${i.kind}: ${i.id}${when}\n ${i.label}`
|
|
356
|
+
})
|
|
357
|
+
return (
|
|
358
|
+
`${items.length} thing${items.length === 1 ? "" : "s"} running on this thread:\n\n${lines.join("\n")}\n\n` +
|
|
359
|
+
"Name the ones you are ACTUALLY waiting on in your ```awaiting fence, one `<kind>: <id>` line each, " +
|
|
360
|
+
"plus a required `for:` duration and a one-line `reason:`. Do not name something you are not waiting " +
|
|
361
|
+
"on — a dev server you left running is not a wait."
|
|
362
|
+
)
|
|
234
363
|
}
|
|
235
364
|
|
|
236
365
|
/** @param {unknown} obj */
|
|
@@ -250,22 +379,139 @@ function replyTool(id, text, isError) {
|
|
|
250
379
|
reply(id, { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) })
|
|
251
380
|
}
|
|
252
381
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
382
|
+
/**
|
|
383
|
+
* Whether a pid is running. EPERM means someone else's live process, which is still ALIVE.
|
|
384
|
+
*
|
|
385
|
+
* A lock with NO pid reads as alive: absence of evidence is not evidence of death, and discarding a
|
|
386
|
+
* record written by an older or foreign publisher would turn a working server into "none found".
|
|
387
|
+
*/
|
|
388
|
+
function pidAlive(pid) {
|
|
389
|
+
if (pid === undefined || pid === null) return true
|
|
390
|
+
if (!Number.isInteger(pid)) return true
|
|
258
391
|
try {
|
|
259
|
-
|
|
392
|
+
process.kill(pid, 0)
|
|
393
|
+
return true
|
|
260
394
|
} catch (err) {
|
|
261
|
-
|
|
395
|
+
return err?.code === "EPERM"
|
|
262
396
|
}
|
|
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
397
|
}
|
|
267
398
|
|
|
268
|
-
/**
|
|
399
|
+
/** A lock file's `{port, pid}`, or undefined if it is missing, malformed, or names a DEAD process. */
|
|
400
|
+
function liveLock(path) {
|
|
401
|
+
try {
|
|
402
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"))
|
|
403
|
+
if (!Number.isInteger(parsed?.port)) return undefined
|
|
404
|
+
if (!pidAlive(parsed?.pid)) return undefined
|
|
405
|
+
return { port: parsed.port, path }
|
|
406
|
+
} catch {
|
|
407
|
+
return undefined
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* FIND THE RUNNING FRIZZ — every call, never cached, never frozen at spawn.
|
|
413
|
+
*
|
|
414
|
+
* This process is spawned once, inside a DETACHED worker daemon that outlives restart after restart.
|
|
415
|
+
* An address handed to it in its env is therefore true exactly until the next "Update & Restart", and
|
|
416
|
+
* a worker whose only address was stale simply lost every frizz tool it had — with no way back short of
|
|
417
|
+
* restarting the worker itself, which is not what an update button should mean.
|
|
418
|
+
*
|
|
419
|
+
* So the env is a HINT and the file is the truth, in this order:
|
|
420
|
+
* 1. FRIZZ_SERVER_LOCK — the lock this server published when it spawned us. Right almost always.
|
|
421
|
+
* 2. `<frizz root>/server.lock` — the MACHINE address (frizz-paths.ts `serverAddressPath`), rewritten
|
|
422
|
+
* by every boot whatever project launched it. This is what makes a live worker survive an update.
|
|
423
|
+
* 3. `<state dir>/server.lock` — our own project's, for a server that only ever serves one project.
|
|
424
|
+
* 4. any live `<frizz root>/projects/*/server.lock` — last resort, since one machine runs one frizz.
|
|
425
|
+
*
|
|
426
|
+
* A candidate whose PID IS DEAD IS SKIPPED, which is the difference between a legible failure and the
|
|
427
|
+
* one that cost an afternoon: a stale lock from a long-dead per-project server sent every call at a port
|
|
428
|
+
* nothing was listening on, and the tool reported only "fetch failed".
|
|
429
|
+
*
|
|
430
|
+
* The frizz root is `../..` from the state dir rather than computed: this file is dependency-free and
|
|
431
|
+
* the real root is platform-dependent (XDG, `~/Library/Application Support`, a legacy `~/.frizz`).
|
|
432
|
+
*/
|
|
433
|
+
function serverLockPort() {
|
|
434
|
+
const stateDir = process.env.FRIZZ_STATE_DIR
|
|
435
|
+
const root = stateDir ? dirname(dirname(stateDir)) : undefined
|
|
436
|
+
const candidates = [
|
|
437
|
+
process.env.FRIZZ_SERVER_LOCK,
|
|
438
|
+
root ? join(root, "server.lock") : undefined,
|
|
439
|
+
stateDir ? join(stateDir, "server.lock") : undefined,
|
|
440
|
+
].filter(Boolean)
|
|
441
|
+
for (const path of candidates) {
|
|
442
|
+
const live = liveLock(path)
|
|
443
|
+
if (live) return live.port
|
|
444
|
+
}
|
|
445
|
+
// Nothing we were told about is alive. One machine runs one frizz, so any project's live lock names
|
|
446
|
+
// it — and addressing by project id (rpcPath) means a server that does not serve us answers 404
|
|
447
|
+
// rather than acting on the wrong board.
|
|
448
|
+
if (root) {
|
|
449
|
+
let entries = []
|
|
450
|
+
try { entries = readdirSync(join(root, "projects")) } catch {}
|
|
451
|
+
for (const entry of entries) {
|
|
452
|
+
const live = liveLock(join(root, "projects", entry, "server.lock"))
|
|
453
|
+
if (live) return live.port
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (candidates.length === 0) throw new Error("FRIZZ_STATE_DIR / FRIZZ_SERVER_LOCK not set — cannot locate the frizz server")
|
|
457
|
+
// SAY THAT NOTHING WAS SAVED, and say to retry. A worker reads "is frizz running?" as a fact about the
|
|
458
|
+
// world rather than as a fact about ITS OWN call, and moves on — so whatever it was arming is silently
|
|
459
|
+
// gone. Measured 2026-08-17: a worker's `recurring_prompt start` hit a restart window, got this error,
|
|
460
|
+
// carried on, and its Goal — the thing keeping a long autonomous effort alive — never existed. The
|
|
461
|
+
// window is ordinary (frizz restarts, and this process outlives every one of them), so the recovery has
|
|
462
|
+
// to be ordinary too: try again.
|
|
463
|
+
throw new Error(
|
|
464
|
+
`no running frizz server found (looked at ${candidates.join(", ")} and every project lock under ` +
|
|
465
|
+
`${root ? join(root, "projects") : "the frizz root"}; each was missing, malformed, or written by a process that is gone). ` +
|
|
466
|
+
`NOTHING WAS SAVED — this call had no effect. frizz is probably mid-restart, which is ordinary and ` +
|
|
467
|
+
`brief; RETRY this exact call before you do anything else, and do not come to rest assuming it took.`,
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The RPC base for OUR project.
|
|
473
|
+
*
|
|
474
|
+
* One frizz serves every project on the machine, and an unprefixed `/_frizz/rpc/…` is the project it
|
|
475
|
+
* was LAUNCHED from — so without the prefix a worker in any other project acted on the launcher's
|
|
476
|
+
* board (spawn_thread put its new thread there; the thread-scoped tools looked for a slug that lives
|
|
477
|
+
* in a different registry). FRIZZ_PROJECT_ID is the immutable registry id rather than the slug,
|
|
478
|
+
* because the value is handed over once at spawn and then held for the life of a detached daemon,
|
|
479
|
+
* and a project can be renamed under it. Unset ⇒ unprefixed, which is what a server that only ever
|
|
480
|
+
* serves one project passes, and what the launching project's own workers get.
|
|
481
|
+
* @param {string} procedure
|
|
482
|
+
*/
|
|
483
|
+
function rpcPath(procedure) {
|
|
484
|
+
const project = projectSegment()
|
|
485
|
+
return `${project ? `/_frizz/${encodeURIComponent(project)}` : "/_frizz"}/rpc/${procedure}`
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* WHICH PROJECT WE ACT ON — always the one this worker is actually running in.
|
|
490
|
+
*
|
|
491
|
+
* There is deliberately no tool parameter for it and no way to name another project: the id comes from
|
|
492
|
+
* the server's stamp, or failing that from the tree we are standing in (`<root>/.frizz/.id`, the same
|
|
493
|
+
* file project-root.ts treats as identity). Spawning a thread onto somebody else's board is therefore
|
|
494
|
+
* not something a model can express, rather than something it is asked not to do.
|
|
495
|
+
*
|
|
496
|
+
* The walk-up is what makes this work for a worker spawned by a server that predates the stamp, and it
|
|
497
|
+
* is the honest source anyway: a worker's project is wherever its cwd is, and that cannot go stale.
|
|
498
|
+
*/
|
|
499
|
+
function projectSegment() {
|
|
500
|
+
const stamped = process.env.FRIZZ_PROJECT_ID
|
|
501
|
+
if (stamped) return stamped
|
|
502
|
+
let dir = process.cwd()
|
|
503
|
+
for (;;) {
|
|
504
|
+
try {
|
|
505
|
+
const id = readFileSync(join(dir, ".frizz", ".id"), "utf8").trim()
|
|
506
|
+
if (id) return id
|
|
507
|
+
} catch {}
|
|
508
|
+
const parent = dirname(dir)
|
|
509
|
+
if (parent === dir) return undefined
|
|
510
|
+
dir = parent
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The `spawn_thread` handler: POST /_frizz/rpc/dispatch, return the worker-facing result text.
|
|
269
515
|
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
270
516
|
async function spawnThread(args) {
|
|
271
517
|
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
|
|
@@ -288,7 +534,7 @@ async function spawnThread(args) {
|
|
|
288
534
|
const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
|
|
289
535
|
let res
|
|
290
536
|
try {
|
|
291
|
-
res = await fetch(`http://127.0.0.1:${port}
|
|
537
|
+
res = await fetch(`http://127.0.0.1:${port}${rpcPath("dispatch")}`, {
|
|
292
538
|
method: "POST",
|
|
293
539
|
// No Origin header (undici omits it for non-browser fetch); `sec-fetch-site: same-origin`
|
|
294
540
|
// satisfies the server's loopback-origin gate (app.ts isTrustedLocalHttpRequest).
|
|
@@ -319,13 +565,40 @@ async function spawnThread(args) {
|
|
|
319
565
|
/** POST a frizz RPC procedure and return its parsed payload. Shares spawn_thread's transport rules:
|
|
320
566
|
* the port comes from server.lock and `sec-fetch-site: same-origin` satisfies the loopback gate.
|
|
321
567
|
* @param {string} procedure @param {Record<string, unknown>} body @returns {Promise<any>} */
|
|
568
|
+
// HOW LONG A RESTART WINDOW IS ALLOWED TO BE INVISIBLE. frizz replaces its own server routinely
|
|
569
|
+
// ("Update & Restart", a dev rebuild), and this process is deliberately still here across every one of
|
|
570
|
+
// them — so a call landing in that gap is ORDINARY, and failing it is the shim reporting frizz's
|
|
571
|
+
// housekeeping as the worker's problem. Measured 2026-08-17: a `recurring_prompt start` landed in one,
|
|
572
|
+
// failed, and the Goal that was keeping a long autonomous effort alive silently never existed.
|
|
573
|
+
//
|
|
574
|
+
// Telling the model to retry (which the error also does) is strictly weaker than retrying, because it
|
|
575
|
+
// only works if the model complies. Bounded and short: a genuinely-down frizz still fails, promptly,
|
|
576
|
+
// with the same message — this only covers the seconds where a new server is coming up.
|
|
577
|
+
const LOCK_RETRY_MS = 6_000
|
|
578
|
+
const LOCK_RETRY_INTERVAL_MS = 400
|
|
579
|
+
|
|
580
|
+
/** The port, waiting out a brief restart window rather than failing into one. Rethrows the real
|
|
581
|
+
* "no running frizz server" error once the budget is spent, so a frizz that is actually down still
|
|
582
|
+
* says so — and says it with the retry guidance attached. */
|
|
583
|
+
async function serverLockPortWaiting() {
|
|
584
|
+
const deadline = Date.now() + LOCK_RETRY_MS
|
|
585
|
+
for (;;) {
|
|
586
|
+
try {
|
|
587
|
+
return serverLockPort()
|
|
588
|
+
} catch (err) {
|
|
589
|
+
if (Date.now() >= deadline) throw err
|
|
590
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_INTERVAL_MS))
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
322
595
|
async function callRpc(procedure, body) {
|
|
323
|
-
const port =
|
|
596
|
+
const port = await serverLockPortWaiting()
|
|
324
597
|
const controller = new AbortController()
|
|
325
598
|
const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
|
|
326
599
|
let res
|
|
327
600
|
try {
|
|
328
|
-
res = await fetch(`http://127.0.0.1:${port}
|
|
601
|
+
res = await fetch(`http://127.0.0.1:${port}${rpcPath(procedure)}`, {
|
|
329
602
|
method: "POST",
|
|
330
603
|
headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
|
|
331
604
|
body: JSON.stringify(body),
|
|
@@ -361,16 +634,73 @@ function threadSlug() {
|
|
|
361
634
|
return slug
|
|
362
635
|
}
|
|
363
636
|
|
|
364
|
-
/**
|
|
637
|
+
/** How a heartbeat cadence reads back to the worker. ONE formatter, because `start` and `get` describe
|
|
638
|
+
* the same stored number and a worker that saw "every 15 min" armed must not read "every 900s" back.
|
|
639
|
+
* @param {number|undefined} seconds */
|
|
640
|
+
function cadenceLabel(seconds) {
|
|
641
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return undefined
|
|
642
|
+
return seconds % 60 === 0 ? `${seconds / 60} min` : `${seconds}s`
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Render an armed recurring prompt for the worker to read: which triggers are live, the cadence, when
|
|
646
|
+
* each last fired, and the text VERBATIM (never truncated — reading back a summary of your own
|
|
647
|
+
* instruction is exactly as blind as not reading it).
|
|
648
|
+
* @param {{ prompt: string, stopHook: boolean, heartbeat: boolean, postCompaction: boolean,
|
|
649
|
+
* intervalSeconds?: number, armedAt: string, lastRestFiredAt?: string,
|
|
650
|
+
* lastScheduleFiredAt?: string, lastCompactFiredAt?: string }} rp */
|
|
651
|
+
function recurringPromptReport(rp) {
|
|
652
|
+
const fired = (/** @type {string|undefined} */ at) => (at ? `last fired ${at}` : "never fired yet")
|
|
653
|
+
const triggers = [
|
|
654
|
+
rp.stopHook ? ` stop_hook — every time you come to rest (${fired(rp.lastRestFiredAt)})` : null,
|
|
655
|
+
rp.heartbeat
|
|
656
|
+
// The SAME cadence form `start` reports (cadenceLabel), or the two readings of one row disagree
|
|
657
|
+
// about the number they are describing — "every 15 min" armed, "every 900s" read back.
|
|
658
|
+
? ` heartbeat — every ${cadenceLabel(rp.intervalSeconds) ?? "?"} (${fired(rp.lastScheduleFiredAt)})`
|
|
659
|
+
: null,
|
|
660
|
+
rp.postCompaction ? ` post_compaction — every compaction (${fired(rp.lastCompactFiredAt)})` : null,
|
|
661
|
+
].filter(Boolean)
|
|
662
|
+
// EVERY trigger off is a real, reachable state — the human can switch them off in the footer without
|
|
663
|
+
// clearing the words — and it is the one a worker would otherwise misread as "armed and running".
|
|
664
|
+
const head = triggers.length
|
|
665
|
+
? `Armed since ${rp.armedAt}, on:\n${triggers.join("\n")}`
|
|
666
|
+
: `Text is parked (armed ${rp.armedAt}) but EVERY TRIGGER IS OFF — nothing will fire until one is switched back on.`
|
|
667
|
+
return `${head}\n\nThe text, verbatim:\n\n${rp.prompt}`
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** The `recurring_prompt` handler: arm, disarm, or READ BACK this thread's re-prompt.
|
|
365
671
|
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
366
672
|
async function recurringPrompt(args) {
|
|
367
673
|
const slug = threadSlug()
|
|
368
674
|
const action = typeof args.action === "string" ? args.action.trim() : ""
|
|
369
|
-
if (action !== "start" && action !== "stop"
|
|
675
|
+
if (action !== "start" && action !== "stop" && action !== "get") {
|
|
676
|
+
throw new Error("`action` must be one of \"start\", \"stop\" or \"get\"")
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (action === "get") {
|
|
680
|
+
// A frizz server older than this tool has no such procedure and answers 404. Say what that means,
|
|
681
|
+
// rather than leaving a worker to read a bare HTTP status as "nothing is armed" — the two answers
|
|
682
|
+
// could not be further apart.
|
|
683
|
+
let payload
|
|
684
|
+
try {
|
|
685
|
+
payload = await callRpc("getOwnThreadRecurringPrompt", { slug })
|
|
686
|
+
} catch (err) {
|
|
687
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
688
|
+
if (/HTTP 404/.test(message)) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
"this frizz server predates the read action, so it cannot tell you what is armed. Treat the " +
|
|
691
|
+
"armed state as UNKNOWN — do not assume it is empty — and check the thread footer instead.",
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
throw err
|
|
695
|
+
}
|
|
696
|
+
const rp = payload?.result?.recurringPrompt
|
|
697
|
+
if (!rp) return "No recurring prompt is armed on this thread. Nothing will re-prompt you."
|
|
698
|
+
return recurringPromptReport(rp)
|
|
699
|
+
}
|
|
370
700
|
|
|
371
701
|
if (action === "stop") {
|
|
372
|
-
await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false })
|
|
373
|
-
return "Recurring prompt disarmed and cleared.
|
|
702
|
+
await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false, postCompaction: false })
|
|
703
|
+
return "Recurring prompt disarmed and cleared. No trigger will fire — not the stop hook, not the heartbeat, not the post-compaction one — and the text is gone from the thread footer."
|
|
374
704
|
}
|
|
375
705
|
|
|
376
706
|
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
|
|
@@ -387,36 +717,56 @@ async function recurringPrompt(args) {
|
|
|
387
717
|
throw new Error(`\`heartbeat_seconds\` must be between ${MIN_INTERVAL_SECONDS} and ${MAX_INTERVAL_SECONDS}`)
|
|
388
718
|
}
|
|
389
719
|
}
|
|
720
|
+
const postCompaction = args.post_compaction === true
|
|
390
721
|
// DEFAULTED, not required: a `start` that names no trigger at all is a model asking to be re-prompted
|
|
391
722
|
// and leaving the mechanism to us, and the rest trigger is the safe reading of that — it cannot talk
|
|
392
723
|
// 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
|
|
724
|
+
const stopHook = typeof args.stop_hook === "boolean" ? args.stop_hook : !hasHeartbeat && !postCompaction
|
|
394
725
|
const heartbeat = hasHeartbeat
|
|
395
|
-
if (!stopHook && !heartbeat) {
|
|
396
|
-
throw new Error("at least one is required: set `stop_hook: true`,
|
|
726
|
+
if (!stopHook && !heartbeat && !postCompaction) {
|
|
727
|
+
throw new Error("at least one is required: set `stop_hook: true`, give `heartbeat_seconds`, set `post_compaction: true`, or any combination")
|
|
397
728
|
}
|
|
398
729
|
|
|
399
|
-
await callRpc("setOwnThreadRecurringPrompt", {
|
|
730
|
+
const written = await callRpc("setOwnThreadRecurringPrompt", {
|
|
400
731
|
slug,
|
|
401
732
|
prompt,
|
|
402
733
|
stopHook,
|
|
403
734
|
heartbeat,
|
|
735
|
+
postCompaction,
|
|
404
736
|
...(heartbeat ? { intervalSeconds: interval } : {}),
|
|
405
737
|
})
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
738
|
+
// `replaced` is absent against a server that predates it, which is indistinguishable from "there was
|
|
739
|
+
// nothing" — so the clause only ever appears when the row genuinely carried something.
|
|
740
|
+
const replaced = written?.result?.replaced
|
|
741
|
+
|
|
742
|
+
const every = heartbeat ? cadenceLabel(interval) : null
|
|
743
|
+
// One clause per armed trigger, joined — with three of them the old nested ternary could no longer say
|
|
744
|
+
// what was actually armed, and a worker that misreads which trigger it holds waits for a delivery that
|
|
745
|
+
// is never coming.
|
|
746
|
+
const clauses = [
|
|
747
|
+
stopHook ? "every time you come to rest" : null,
|
|
748
|
+
every ? `every ${every} (the heartbeat reaches you mid-turn)` : null,
|
|
749
|
+
postCompaction ? "every time your context is compacted, delivered into the emptied window" : null,
|
|
750
|
+
].filter(Boolean)
|
|
751
|
+
const when = clauses.length === 1
|
|
752
|
+
? clauses[0]
|
|
753
|
+
: `${clauses.slice(0, -1).join(", ")} AND ${clauses[clauses.length - 1]}`
|
|
754
|
+
// Spelled out in full, not summarized: if this overwrote the human's own edit, the words themselves
|
|
755
|
+
// are the only way the worker can put them back.
|
|
756
|
+
const superseded = replaced
|
|
757
|
+
? `\n\nIT REPLACED an existing recurring prompt — check that discarding it was intended, and restore ` +
|
|
758
|
+
`it with another \`start\` if it was not:\n\n${recurringPromptReport(replaced)}\n`
|
|
759
|
+
: ""
|
|
760
|
+
// NO QUESTION HOLD ANY MORE (2026-08-16). Every trigger fires while you are waiting on the human, and
|
|
761
|
+
// the at-rest one fires over your own unanswered ```question fence — the delivery says so, and expects
|
|
762
|
+
// you to decide the question yourself rather than re-ask it. A ```done fence, and an ```awaiting on a
|
|
763
|
+
// wait frizz itself will deliver, still stop the at-rest trigger.
|
|
413
764
|
return (
|
|
414
|
-
`Recurring prompt armed — frizz will send you this ${when}
|
|
415
|
-
"thread had before.\n\n" +
|
|
765
|
+
`Recurring prompt armed — frizz will send you this ${when}.${superseded}\n\n` +
|
|
416
766
|
"Call this tool again with `action: \"stop\"` once the work it drives is finished — one left armed on " +
|
|
417
767
|
"a finished thread wakes it forever. The human can also edit or switch it off in the thread footer. " +
|
|
418
|
-
"
|
|
419
|
-
"
|
|
768
|
+
"Signing off with a ```done fence stops it too, but only when there is genuinely nothing left: it " +
|
|
769
|
+
"files the thread away until the human sends more work."
|
|
420
770
|
)
|
|
421
771
|
}
|
|
422
772
|
|
|
@@ -498,6 +848,70 @@ async function timer(args) {
|
|
|
498
848
|
)
|
|
499
849
|
}
|
|
500
850
|
|
|
851
|
+
|
|
852
|
+
/** How the armed PR-watcher set reads back, on every action, so a worker never needs a second call.
|
|
853
|
+
* @param {{ watches?: Array<{id: string, target: string, github?: {checks: string, running: number, passed: number, failed: number, failing: string[], merge: string, state: string}}> }|undefined} result */
|
|
854
|
+
function armedPrWatchList(result) {
|
|
855
|
+
const watches = Array.isArray(result?.watches) ? result.watches : []
|
|
856
|
+
if (!watches.length) return "No pull requests are watched on this thread — nothing will wake you."
|
|
857
|
+
const lines = watches.map((w) => {
|
|
858
|
+
const g = w.github
|
|
859
|
+
// The CHECK STATE rides the read-back because it is the reason a worker is listing at all: "where do
|
|
860
|
+
// my PRs stand" is one call, not one per PR through `gh`.
|
|
861
|
+
const state = !g
|
|
862
|
+
? "not polled yet"
|
|
863
|
+
: g.state !== "open"
|
|
864
|
+
? g.state
|
|
865
|
+
: g.checks === "passing" ? `checks green (${g.passed})`
|
|
866
|
+
: g.checks === "failing" ? `checks FAILING${g.failing.length ? `: ${g.failing.join(", ")}` : ""}`
|
|
867
|
+
: g.checks === "running" ? `checks running (${g.running} left)`
|
|
868
|
+
: "no checks"
|
|
869
|
+
return ` ${w.id} ${w.target} — ${state}${g && g.state === "open" && g.merge === "mergeable" ? ", mergeable" : ""}`
|
|
870
|
+
})
|
|
871
|
+
return `Watched on this thread now:\n${lines.join("\n")}`
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** The `watch_pr` handler: register, withdraw, or read back this thread's PR watchers.
|
|
875
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
876
|
+
async function watchPr(args) {
|
|
877
|
+
const slug = threadSlug()
|
|
878
|
+
const action = typeof args.action === "string" ? args.action.trim() : ""
|
|
879
|
+
if (action !== "add" && action !== "list" && action !== "drop") {
|
|
880
|
+
throw new Error("`action` must be one of \"add\", \"list\" or \"drop\"")
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (action === "list") {
|
|
884
|
+
return armedPrWatchList((await callRpc("listOwnPrWatches", { slug }))?.result)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (action === "drop") {
|
|
888
|
+
const id = typeof args.id === "string" ? args.id.trim() : ""
|
|
889
|
+
if (!id) throw new Error("`id` is required to drop a watcher — take it from `add` or from `list`")
|
|
890
|
+
const result = (await callRpc("dropOwnPrWatch", { slug, id }))?.result
|
|
891
|
+
// A drop that matched nothing is reported rather than swallowed: the id was wrong, already settled,
|
|
892
|
+
// or another thread's — and a worker that believes it withdrew a wait it still holds will rest.
|
|
893
|
+
const head = result?.dropped
|
|
894
|
+
? `Watcher ${id} dropped. It will not wake you.`
|
|
895
|
+
: `No ARMED watcher ${id} on this thread — it was already settled, or the id is not one of yours.`
|
|
896
|
+
return `${head}\n\n${armedPrWatchList(result)}`
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const target = typeof args.target === "string" ? args.target.trim() : ""
|
|
900
|
+
if (!target) throw new Error("`target` is required — the pull request, as `owner/repo#123` or a PR URL")
|
|
901
|
+
const result = (await callRpc("addOwnPrWatch", { slug, target, for: typeof args.for === "string" ? args.for.trim() : "" }))?.result
|
|
902
|
+
const id = result?.id ?? "(unknown)"
|
|
903
|
+
const ref = result?.target ?? target
|
|
904
|
+
const head = result?.alreadyArmed
|
|
905
|
+
? `Already watching ${ref} as ${id} — nothing new was registered, and you will be woken once per event.`
|
|
906
|
+
: `Watching ${ref} as ${id}. Frizz wakes you when CI passes or fails and on every later review or ` +
|
|
907
|
+
"comment, and the registration survives your turn ending, a compaction and a frizz restart."
|
|
908
|
+
return (
|
|
909
|
+
`${head}\n\nNAME IT IN YOUR \`\`\`awaiting FENCE TOO (\`pr-watch: ${ref}\`) — the watcher does the ` +
|
|
910
|
+
`waking, the fence is what lets you come to rest and shows the human what you are waiting for.\n\n` +
|
|
911
|
+
`DROP IT when it stops mattering (\`action: "drop", id: "${id}"\`).\n\n${armedPrWatchList(result)}`
|
|
912
|
+
)
|
|
913
|
+
}
|
|
914
|
+
|
|
501
915
|
/** @param {any} msg */
|
|
502
916
|
async function handle(msg) {
|
|
503
917
|
const { id, method, params } = msg ?? {}
|