frizz 0.2.0 → 0.3.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 +17 -17
- package/dist/claude-agent-broker.js +63 -23
- package/dist/dev-child.js +6877 -4459
- package/dist/frizz.js +654 -529
- package/package.json +6 -2
- package/runtime/cc-worker/.claude-plugin/plugin.json +1 -1
- package/runtime/cc-worker/DECISIONS.md +64 -4
- package/runtime/cc-worker/bin/frizz-mcp.mjs +494 -57
- 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-CTdetDJJ.js} +2 -2
- package/web-dist/assets/{abnfDiagram-VRR7QNED-DcpdhBs3.js → abnfDiagram-VRR7QNED-BDKrjCMs.js} +1 -1
- package/web-dist/assets/architecture-TIHT7OUA-ChUMo004.js +1 -0
- package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-CUAKf0mn.js → architectureDiagram-ZJ3FMSHR-BJebpUUM.js} +1 -1
- package/web-dist/assets/{blockDiagram-677ZJIJ3-BPwpJIzx.js → blockDiagram-677ZJIJ3-BNbuk25k.js} +1 -1
- package/web-dist/assets/{c4Diagram-LMCZKHZV-1lptuHzZ.js → c4Diagram-LMCZKHZV-lCfyotdU.js} +1 -1
- package/web-dist/assets/channel-pr7r6raB.js +1 -0
- package/web-dist/assets/{chunk-32BRIVSS-CFR9AKjY.js → chunk-32BRIVSS-DP72SEkr.js} +1 -1
- package/web-dist/assets/{chunk-52WLFC77-CM9uct7m.js → chunk-52WLFC77-DxO3gN-p.js} +1 -1
- package/web-dist/assets/{chunk-C7G6YPKG-DiveJARw.js → chunk-C7G6YPKG-BXFx9Vlr.js} +1 -1
- package/web-dist/assets/{chunk-EX3LRPZG-BE1CBw8F.js → chunk-EX3LRPZG-CR3sHpPf.js} +1 -1
- package/web-dist/assets/{chunk-FWX5IMBZ-DL42uXiO.js → chunk-FWX5IMBZ-CJ8L__oG.js} +2 -2
- package/web-dist/assets/{chunk-HOUHSVGY-DPhJWgDw.js → chunk-HOUHSVGY-BCl3JSWr.js} +1 -1
- package/web-dist/assets/{chunk-ICXQ74PX-CwYy-6AP.js → chunk-ICXQ74PX-CZxFMci1.js} +1 -1
- package/web-dist/assets/{chunk-MOJQB5TN-Ds5I9wxq.js → chunk-MOJQB5TN-Ck3_47dB.js} +1 -1
- package/web-dist/assets/{chunk-OGEWGWER-DHiZJwQD.js → chunk-OGEWGWER-CVXjES-F.js} +1 -1
- package/web-dist/assets/{chunk-PUDLZKDR-B-eyQTsF.js → chunk-PUDLZKDR-JjrVgaR7.js} +1 -1
- package/web-dist/assets/{chunk-Q4XR5HBZ-DK7dB3Ti.js → chunk-Q4XR5HBZ-C5YMRAQ0.js} +1 -1
- package/web-dist/assets/{chunk-V7JOEXUC-Dn59m74L.js → chunk-V7JOEXUC-DK0mfUHU.js} +1 -1
- package/web-dist/assets/{chunk-VAUOI2AC-DK7x36hd.js → chunk-VAUOI2AC-BKBntpid.js} +1 -1
- package/web-dist/assets/{chunk-VR4S4FIN-D7-CI3Yl.js → chunk-VR4S4FIN-zgE1dG6U.js} +1 -1
- package/web-dist/assets/{chunk-WYO6CB5R-B3l-mLCs.js → chunk-WYO6CB5R-CZhh1IBq.js} +1 -1
- package/web-dist/assets/{chunk-ZGVPDNZ5-Dobxlxie.js → chunk-ZGVPDNZ5-CrNjIEem.js} +1 -1
- package/web-dist/assets/classDiagram-OUVF2IWQ-CvGbPMn_.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-CvGbPMn_.js +1 -0
- package/web-dist/assets/{cynefin-VYW2F7L2-Dh7RuEUJ.js → cynefin-VYW2F7L2-Ca_BPfTG.js} +1 -1
- package/web-dist/assets/{cynefinDiagram-TSTJHNR4-ClPi2mZZ.js → cynefinDiagram-TSTJHNR4-CsINQf6g.js} +1 -1
- package/web-dist/assets/{dagre-VKFMJZFB-52_WP1QV.js → dagre-VKFMJZFB-CE0DYMFy.js} +1 -1
- package/web-dist/assets/{diagram-FQU43EPY-D_1zVsTL.js → diagram-FQU43EPY-BqTduEYE.js} +1 -1
- package/web-dist/assets/{diagram-G47NLZAW-CdZxuGUy.js → diagram-G47NLZAW-Cho718s6.js} +1 -1
- package/web-dist/assets/{diagram-NH7WQ7WH-C8pSFu0P.js → diagram-NH7WQ7WH-D0Z8t9UC.js} +1 -1
- package/web-dist/assets/{diagram-OA4YK3LP-C5bjZLre.js → diagram-OA4YK3LP-CBacyeDx.js} +1 -1
- package/web-dist/assets/{diagram-WEI45ONY-Bxzhiuzn.js → diagram-WEI45ONY-CozpXa4j.js} +1 -1
- package/web-dist/assets/{ebnfDiagram-CCIWWBDH-g-Z0J2wP.js → ebnfDiagram-CCIWWBDH-ba4NbQQo.js} +1 -1
- package/web-dist/assets/{erDiagram-Q63AITRT-DVNkgIHp.js → erDiagram-Q63AITRT-B3BciOYa.js} +1 -1
- package/web-dist/assets/eventmodeling-45OFAUF4-DKmyo-jd.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-DluCBvT4.js +1 -0
- package/web-dist/assets/{ganttDiagram-NO4QXBWP-_71pQYEK.js → ganttDiagram-NO4QXBWP-lPmrRit4.js} +1 -1
- package/web-dist/assets/{gitGraph-TEB2WS4Q-ChIZiGZS.js → gitGraph-TEB2WS4Q-BjyclOq0.js} +1 -1
- package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-DCHAFI0l.js → gitGraphDiagram-IHSO6WYX-pzeb4Yrw.js} +1 -1
- package/web-dist/assets/index-BQtjYMpV.css +1 -0
- package/web-dist/assets/index-CLW1Q49U.js +360 -0
- package/web-dist/assets/{info-DKCQHKI2-BW-n_T1j.js → info-DKCQHKI2-Bwycegvf.js} +1 -1
- package/web-dist/assets/{infoDiagram-FWYZ7A6U-CgDYsKi9.js → infoDiagram-FWYZ7A6U-BCvRGj_5.js} +1 -1
- package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-ClzGNt9N.js → ishikawaDiagram-FXEZZL3T-Ofw1RMj3.js} +1 -1
- package/web-dist/assets/{journeyDiagram-5HDEW3XC-DSCQxkHC.js → journeyDiagram-5HDEW3XC-C5ROwFio.js} +1 -1
- package/web-dist/assets/{kanban-definition-HUTT4EX6-CdrdX9N8.js → kanban-definition-HUTT4EX6-YLPLkpeT.js} +1 -1
- package/web-dist/assets/{line-ha38Dc-1.js → line-KtkNqRgI.js} +1 -1
- package/web-dist/assets/{mermaid-parser.core-Z4uMcpip.js → mermaid-parser.core-D_FfqBe7.js} +3 -3
- package/web-dist/assets/{mermaid.core-iZRq3hbu.js → mermaid.core-Ffv8anVf.js} +3 -3
- package/web-dist/assets/{mindmap-definition-LN4V7U3C-DmhInJO_.js → mindmap-definition-LN4V7U3C-B2jj4vfm.js} +1 -1
- package/web-dist/assets/{packet-7NZHBO7P-DBPB36Kl.js → packet-7NZHBO7P-BjmWHwra.js} +1 -1
- package/web-dist/assets/{pegDiagram-2B236MQR-CAH3ljfj.js → pegDiagram-2B236MQR-Dq3iJDyq.js} +1 -1
- package/web-dist/assets/{pie-RZYD4A2V-_h_eX4Ca.js → pie-RZYD4A2V-jBbH1lv9.js} +1 -1
- package/web-dist/assets/{pieDiagram-ENE6RG2P-DFBPus8j.js → pieDiagram-ENE6RG2P-C3ETW9lq.js} +1 -1
- package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-DMvOCjt8.js → quadrantDiagram-ABIIQ3AL-DItSmme7.js} +1 -1
- package/web-dist/assets/{radar-I7S5WNFK-2EzoPHEZ.js → radar-I7S5WNFK-6ey6crgP.js} +1 -1
- package/web-dist/assets/{railroad-3IZDKUUU-BPJnn-hm.js → railroad-3IZDKUUU-Cii-Mn0E.js} +1 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-U_vb4BrX.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-BIHG7gNU.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-Cpd9r-tB.js +1 -0
- package/web-dist/assets/{railroadDiagram-RFXS5EU6-DKq5FagA.js → railroadDiagram-RFXS5EU6-M363ils_.js} +1 -1
- package/web-dist/assets/{requirementDiagram-TGXJPOKE-BJ5tGazp.js → requirementDiagram-TGXJPOKE-CTs2_V6T.js} +1 -1
- package/web-dist/assets/{sankeyDiagram-HTMAVEWB-XSJjcBhX.js → sankeyDiagram-HTMAVEWB-QTLLcDPD.js} +1 -1
- package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-CBb8emSe.js → sequenceDiagram-DBY2YBRQ-Bxw9Tr6e.js} +1 -1
- package/web-dist/assets/{stateDiagram-2N3HPSRC-DDfRW94V.js → stateDiagram-2N3HPSRC-BecB6roG.js} +1 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-JXw9T96l.js +1 -0
- package/web-dist/assets/{swimlanes-5IMT3BWC-DvRYbkZi.js → swimlanes-5IMT3BWC-COiYgS0w.js} +1 -1
- package/web-dist/assets/swimlanesDiagram-G3AALYLV--1Wv7FQQ.js +8 -0
- package/web-dist/assets/{timeline-definition-FHXFAJF6-BNUa_DwI.js → timeline-definition-FHXFAJF6-Ci22coeH.js} +1 -1
- package/web-dist/assets/{treeView-QDETBFTQ-I6-IW6nJ.js → treeView-QDETBFTQ-4DW35czh.js} +1 -1
- package/web-dist/assets/{treemap-6X3UGDF4-CWWmEUYJ.js → treemap-6X3UGDF4-Dq_-Z6-Y.js} +1 -1
- package/web-dist/assets/{vennDiagram-L72KCM5P-DTDrPGLk.js → vennDiagram-L72KCM5P-CHQSIEYq.js} +1 -1
- package/web-dist/assets/{wardley-OPB4EBWU-CNsdgXXA.js → wardley-OPB4EBWU-Bwlh7HCY.js} +1 -1
- package/web-dist/assets/{wardleyDiagram-EHGQE667-YE0tq3Kh.js → wardleyDiagram-EHGQE667-DctjuPYn.js} +1 -1
- package/web-dist/assets/{xychartDiagram-FW5EYKEG-D0ofMX8C.js → xychartDiagram-FW5EYKEG-ZoIGosv8.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,23 @@ 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
|
+
},
|
|
182
|
+
pause_on_questions: {
|
|
183
|
+
type: "boolean",
|
|
184
|
+
description:
|
|
185
|
+
"Send NOTHING — on any trigger — for as long as you are waiting on the human: an unanswered " +
|
|
186
|
+
"```question fence, a native ask, or a permission prompt. DEFAULTS TO TRUE, matching the " +
|
|
187
|
+
"thread footer, because being told \"keep going\" while you are holding a question up is the " +
|
|
188
|
+
"one delivery that can only make things worse. Pass false only if you genuinely want a beat " +
|
|
189
|
+
"to reach you mid-question. (The stop hook declines a rest that ends in a question fence " +
|
|
190
|
+
"always, whatever this says; this is the wider version and it covers the other triggers.)",
|
|
191
|
+
},
|
|
150
192
|
},
|
|
151
193
|
required: ["action"],
|
|
152
194
|
},
|
|
@@ -167,7 +209,7 @@ const TIMER = {
|
|
|
167
209
|
"matters: the delivery reaches you MID-TURN — a queued message you read at your next tool boundary — " +
|
|
168
210
|
"so it arrives when you asked for it whether or not you have stopped, and it never aborts what you " +
|
|
169
211
|
"are running. Unlike a recurring prompt it fires exactly once and then is gone, so there is nothing " +
|
|
170
|
-
"to switch off afterwards and
|
|
212
|
+
"to switch off afterwards and nothing to sign off from.\n\n" +
|
|
171
213
|
"You may have MANY armed at the same time, each with its own instant and its own text — they are " +
|
|
172
214
|
"independent, unlike the single recurring prompt this thread can hold.\n\n" +
|
|
173
215
|
"USE IT for anything you want to come back to at a specific time: re-check a deploy in ten minutes, " +
|
|
@@ -217,6 +259,84 @@ const TIMER = {
|
|
|
217
259
|
},
|
|
218
260
|
}
|
|
219
261
|
|
|
262
|
+
// The blocking mode's bounds. The floor is a poll cycle — below it the call is not a wait, it is a
|
|
263
|
+
// round-trip — and the ceiling matches the worker's own foreground Bash ceiling, so one number governs
|
|
264
|
+
// "the longest a worker may block" wherever it blocks.
|
|
265
|
+
const WATCH_MIN_WAIT_SECONDS = 5
|
|
266
|
+
const WATCH_MAX_WAIT_SECONDS = 24 * 60 * 60
|
|
267
|
+
|
|
268
|
+
const WATCH = {
|
|
269
|
+
name: "watch",
|
|
270
|
+
description:
|
|
271
|
+
"REGISTER something to wait on, so frizz wakes you when it resolves — and DROP it when it stops " +
|
|
272
|
+
"mattering. Your waits have identities: you can list them, and you can withdraw them.\n\n" +
|
|
273
|
+
" kind: \"shell\" — one of YOUR OWN background shells finishing. `target` is its id or its label.\n\n" +
|
|
274
|
+
"FOR A PULL REQUEST, use an ```awaiting fence with a `pr-watch: owner/repo#123` line instead. That " +
|
|
275
|
+
"watcher is durable, it replays whatever review is already sitting on the PR the first time you park " +
|
|
276
|
+
"on it, and it is where PR watching lives — this registry is for the waits that have nowhere else to " +
|
|
277
|
+
"go.\n\n" +
|
|
278
|
+
"WHY THIS RATHER THAN WAITING YOURSELF: a registered watcher is durable. It survives your turn " +
|
|
279
|
+
"ending, a compaction, a frizz restart, and your own daemon being replaced — none of which a " +
|
|
280
|
+
"blocking call or a monitor survives. Register it, come to rest, and frizz brings you back.\n\n" +
|
|
281
|
+
"REGISTERING IS IDEMPOTENT on (kind, target): asking twice for the same thing returns the SAME id " +
|
|
282
|
+
"and tells you it was already armed, so re-registering after a compaction is safe and is the right " +
|
|
283
|
+
"instinct. Use `list` when you want to know what you are holding without changing anything.\n\n" +
|
|
284
|
+
"DROP WHAT STOPS MATTERING. A watcher you no longer care about is a wake you did not want and a " +
|
|
285
|
+
"thread that looks parked when it is not — you are the only one who can know, which is why this " +
|
|
286
|
+
"tool exists (maintainer: \"this way the agent can decide when they're not necessary\").\n\n" +
|
|
287
|
+
"You can only ever register a wait on your OWN thread — there is no parameter for anyone else's.",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
type: "object",
|
|
290
|
+
properties: {
|
|
291
|
+
action: {
|
|
292
|
+
type: "string",
|
|
293
|
+
enum: ["add", "list", "drop"],
|
|
294
|
+
description:
|
|
295
|
+
"`add` registers a watcher (idempotent on kind+target); `drop` withdraws one by id; `list` " +
|
|
296
|
+
"reads back everything currently armed on this thread without changing anything. Every action " +
|
|
297
|
+
"answers with the full armed set, so you never need a second call to see where you stand.",
|
|
298
|
+
},
|
|
299
|
+
kind: {
|
|
300
|
+
type: "string",
|
|
301
|
+
enum: ["shell"],
|
|
302
|
+
description:
|
|
303
|
+
"Required for `add`. The only kind — a PR wait belongs in an ```awaiting fence, see above.",
|
|
304
|
+
},
|
|
305
|
+
target: {
|
|
306
|
+
type: "string",
|
|
307
|
+
description:
|
|
308
|
+
"Required for `add`. A background shell's id or its label. A target that does not match one " +
|
|
309
|
+
"of your live shells still registers, but the watcher only fires once frizz has SEEN it " +
|
|
310
|
+
"alive — so a typo'd label simply never fires rather than reporting a completion that never " +
|
|
311
|
+
"happened.",
|
|
312
|
+
},
|
|
313
|
+
id: {
|
|
314
|
+
type: "string",
|
|
315
|
+
description: "Required for `drop`. The watcher id returned by `add` (or listed by `list`).",
|
|
316
|
+
},
|
|
317
|
+
wait: {
|
|
318
|
+
type: "boolean",
|
|
319
|
+
description:
|
|
320
|
+
"For `add`: BLOCK here until it resolves, instead of returning immediately. Requires " +
|
|
321
|
+
"`timeout_seconds`. Use it when the wait is short enough that keeping the work in ONE turn is " +
|
|
322
|
+
"worth more than the durability of resting — you keep your context and your place in the " +
|
|
323
|
+
"reasoning, and no wake message interrupts you. For anything long, register it WITHOUT this " +
|
|
324
|
+
"and rest: a background registration survives your turn ending, a compaction and a frizz " +
|
|
325
|
+
"restart, none of which a blocking call survives.",
|
|
326
|
+
},
|
|
327
|
+
timeout_seconds: {
|
|
328
|
+
type: "integer",
|
|
329
|
+
description:
|
|
330
|
+
`REQUIRED when \`wait\` is true (minimum ${WATCH_MIN_WAIT_SECONDS}, maximum ` +
|
|
331
|
+
`${WATCH_MAX_WAIT_SECONDS}). When it expires the call RETURNS rather than failing, and the ` +
|
|
332
|
+
"watcher is handed to frizz to keep — so the wait is never lost by choosing to block on it. " +
|
|
333
|
+
"You are then free to do something else and be woken.",
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
required: ["action"],
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
220
340
|
// The unified server's tool registry: `tools/list` returns these and `tools/call` routes by name.
|
|
221
341
|
// Adding a worker-facing frizz tool = one entry here + one handler in `HANDLERS` — never a second
|
|
222
342
|
// MCP server, so every frizz tool stays under the same `mcp__frizz__*` namespace and the same
|
|
@@ -224,13 +344,14 @@ const TIMER = {
|
|
|
224
344
|
const MIN_INTERVAL_SECONDS = 60
|
|
225
345
|
const MAX_INTERVAL_SECONDS = 24 * 60 * 60
|
|
226
346
|
|
|
227
|
-
const TOOLS = [SPAWN_THREAD, RECURRING_PROMPT, TIMER]
|
|
347
|
+
const TOOLS = [SPAWN_THREAD, RECURRING_PROMPT, TIMER, WATCH]
|
|
228
348
|
|
|
229
349
|
/** @type {Record<string, (args: Record<string, unknown>) => Promise<string>>} */
|
|
230
350
|
const HANDLERS = {
|
|
231
351
|
[SPAWN_THREAD.name]: spawnThread,
|
|
232
352
|
[RECURRING_PROMPT.name]: recurringPrompt,
|
|
233
353
|
[TIMER.name]: timer,
|
|
354
|
+
[WATCH.name]: watch,
|
|
234
355
|
}
|
|
235
356
|
|
|
236
357
|
/** @param {unknown} obj */
|
|
@@ -250,22 +371,132 @@ function replyTool(id, text, isError) {
|
|
|
250
371
|
reply(id, { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) })
|
|
251
372
|
}
|
|
252
373
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Whether a pid is running. EPERM means someone else's live process, which is still ALIVE.
|
|
376
|
+
*
|
|
377
|
+
* A lock with NO pid reads as alive: absence of evidence is not evidence of death, and discarding a
|
|
378
|
+
* record written by an older or foreign publisher would turn a working server into "none found".
|
|
379
|
+
*/
|
|
380
|
+
function pidAlive(pid) {
|
|
381
|
+
if (pid === undefined || pid === null) return true
|
|
382
|
+
if (!Number.isInteger(pid)) return true
|
|
258
383
|
try {
|
|
259
|
-
|
|
384
|
+
process.kill(pid, 0)
|
|
385
|
+
return true
|
|
260
386
|
} catch (err) {
|
|
261
|
-
|
|
387
|
+
return err?.code === "EPERM"
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A lock file's `{port, pid}`, or undefined if it is missing, malformed, or names a DEAD process. */
|
|
392
|
+
function liveLock(path) {
|
|
393
|
+
try {
|
|
394
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"))
|
|
395
|
+
if (!Number.isInteger(parsed?.port)) return undefined
|
|
396
|
+
if (!pidAlive(parsed?.pid)) return undefined
|
|
397
|
+
return { port: parsed.port, path }
|
|
398
|
+
} catch {
|
|
399
|
+
return undefined
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* FIND THE RUNNING FRIZZ — every call, never cached, never frozen at spawn.
|
|
405
|
+
*
|
|
406
|
+
* This process is spawned once, inside a DETACHED worker daemon that outlives restart after restart.
|
|
407
|
+
* An address handed to it in its env is therefore true exactly until the next "Update & Restart", and
|
|
408
|
+
* a worker whose only address was stale simply lost every frizz tool it had — with no way back short of
|
|
409
|
+
* restarting the worker itself, which is not what an update button should mean.
|
|
410
|
+
*
|
|
411
|
+
* So the env is a HINT and the file is the truth, in this order:
|
|
412
|
+
* 1. FRIZZ_SERVER_LOCK — the lock this server published when it spawned us. Right almost always.
|
|
413
|
+
* 2. `<frizz root>/server.lock` — the MACHINE address (frizz-paths.ts `serverAddressPath`), rewritten
|
|
414
|
+
* by every boot whatever project launched it. This is what makes a live worker survive an update.
|
|
415
|
+
* 3. `<state dir>/server.lock` — our own project's, for a server that only ever serves one project.
|
|
416
|
+
* 4. any live `<frizz root>/projects/*/server.lock` — last resort, since one machine runs one frizz.
|
|
417
|
+
*
|
|
418
|
+
* A candidate whose PID IS DEAD IS SKIPPED, which is the difference between a legible failure and the
|
|
419
|
+
* one that cost an afternoon: a stale lock from a long-dead per-project server sent every call at a port
|
|
420
|
+
* nothing was listening on, and the tool reported only "fetch failed".
|
|
421
|
+
*
|
|
422
|
+
* The frizz root is `../..` from the state dir rather than computed: this file is dependency-free and
|
|
423
|
+
* the real root is platform-dependent (XDG, `~/Library/Application Support`, a legacy `~/.frizz`).
|
|
424
|
+
*/
|
|
425
|
+
function serverLockPort() {
|
|
426
|
+
const stateDir = process.env.FRIZZ_STATE_DIR
|
|
427
|
+
const root = stateDir ? dirname(dirname(stateDir)) : undefined
|
|
428
|
+
const candidates = [
|
|
429
|
+
process.env.FRIZZ_SERVER_LOCK,
|
|
430
|
+
root ? join(root, "server.lock") : undefined,
|
|
431
|
+
stateDir ? join(stateDir, "server.lock") : undefined,
|
|
432
|
+
].filter(Boolean)
|
|
433
|
+
for (const path of candidates) {
|
|
434
|
+
const live = liveLock(path)
|
|
435
|
+
if (live) return live.port
|
|
436
|
+
}
|
|
437
|
+
// Nothing we were told about is alive. One machine runs one frizz, so any project's live lock names
|
|
438
|
+
// it — and addressing by project id (rpcPath) means a server that does not serve us answers 404
|
|
439
|
+
// rather than acting on the wrong board.
|
|
440
|
+
if (root) {
|
|
441
|
+
let entries = []
|
|
442
|
+
try { entries = readdirSync(join(root, "projects")) } catch {}
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
const live = liveLock(join(root, "projects", entry, "server.lock"))
|
|
445
|
+
if (live) return live.port
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (candidates.length === 0) throw new Error("FRIZZ_STATE_DIR / FRIZZ_SERVER_LOCK not set — cannot locate the frizz server")
|
|
449
|
+
throw new Error(
|
|
450
|
+
`no running frizz server found (looked at ${candidates.join(", ")} and every project lock under ` +
|
|
451
|
+
`${root ? join(root, "projects") : "the frizz root"}; each was missing, malformed, or written by a process that is gone). ` +
|
|
452
|
+
`Is frizz running?`,
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The RPC base for OUR project.
|
|
458
|
+
*
|
|
459
|
+
* One frizz serves every project on the machine, and an unprefixed `/_frizz/rpc/…` is the project it
|
|
460
|
+
* was LAUNCHED from — so without the prefix a worker in any other project acted on the launcher's
|
|
461
|
+
* board (spawn_thread put its new thread there; the thread-scoped tools looked for a slug that lives
|
|
462
|
+
* in a different registry). FRIZZ_PROJECT_ID is the immutable registry id rather than the slug,
|
|
463
|
+
* because the value is handed over once at spawn and then held for the life of a detached daemon,
|
|
464
|
+
* and a project can be renamed under it. Unset ⇒ unprefixed, which is what a server that only ever
|
|
465
|
+
* serves one project passes, and what the launching project's own workers get.
|
|
466
|
+
* @param {string} procedure
|
|
467
|
+
*/
|
|
468
|
+
function rpcPath(procedure) {
|
|
469
|
+
const project = projectSegment()
|
|
470
|
+
return `${project ? `/_frizz/${encodeURIComponent(project)}` : "/_frizz"}/rpc/${procedure}`
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* WHICH PROJECT WE ACT ON — always the one this worker is actually running in.
|
|
475
|
+
*
|
|
476
|
+
* There is deliberately no tool parameter for it and no way to name another project: the id comes from
|
|
477
|
+
* the server's stamp, or failing that from the tree we are standing in (`<root>/.frizz/.id`, the same
|
|
478
|
+
* file project-root.ts treats as identity). Spawning a thread onto somebody else's board is therefore
|
|
479
|
+
* not something a model can express, rather than something it is asked not to do.
|
|
480
|
+
*
|
|
481
|
+
* The walk-up is what makes this work for a worker spawned by a server that predates the stamp, and it
|
|
482
|
+
* is the honest source anyway: a worker's project is wherever its cwd is, and that cannot go stale.
|
|
483
|
+
*/
|
|
484
|
+
function projectSegment() {
|
|
485
|
+
const stamped = process.env.FRIZZ_PROJECT_ID
|
|
486
|
+
if (stamped) return stamped
|
|
487
|
+
let dir = process.cwd()
|
|
488
|
+
for (;;) {
|
|
489
|
+
try {
|
|
490
|
+
const id = readFileSync(join(dir, ".frizz", ".id"), "utf8").trim()
|
|
491
|
+
if (id) return id
|
|
492
|
+
} catch {}
|
|
493
|
+
const parent = dirname(dir)
|
|
494
|
+
if (parent === dir) return undefined
|
|
495
|
+
dir = parent
|
|
262
496
|
}
|
|
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
497
|
}
|
|
267
498
|
|
|
268
|
-
/** The `spawn_thread` handler: POST /rpc/dispatch, return the worker-facing result text.
|
|
499
|
+
/** The `spawn_thread` handler: POST /_frizz/rpc/dispatch, return the worker-facing result text.
|
|
269
500
|
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
270
501
|
async function spawnThread(args) {
|
|
271
502
|
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
|
|
@@ -288,7 +519,7 @@ async function spawnThread(args) {
|
|
|
288
519
|
const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
|
|
289
520
|
let res
|
|
290
521
|
try {
|
|
291
|
-
res = await fetch(`http://127.0.0.1:${port}
|
|
522
|
+
res = await fetch(`http://127.0.0.1:${port}${rpcPath("dispatch")}`, {
|
|
292
523
|
method: "POST",
|
|
293
524
|
// No Origin header (undici omits it for non-browser fetch); `sec-fetch-site: same-origin`
|
|
294
525
|
// satisfies the server's loopback-origin gate (app.ts isTrustedLocalHttpRequest).
|
|
@@ -325,7 +556,7 @@ async function callRpc(procedure, body) {
|
|
|
325
556
|
const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
|
|
326
557
|
let res
|
|
327
558
|
try {
|
|
328
|
-
res = await fetch(`http://127.0.0.1:${port}
|
|
559
|
+
res = await fetch(`http://127.0.0.1:${port}${rpcPath(procedure)}`, {
|
|
329
560
|
method: "POST",
|
|
330
561
|
headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
|
|
331
562
|
body: JSON.stringify(body),
|
|
@@ -361,16 +592,79 @@ function threadSlug() {
|
|
|
361
592
|
return slug
|
|
362
593
|
}
|
|
363
594
|
|
|
364
|
-
/**
|
|
595
|
+
/** How a heartbeat cadence reads back to the worker. ONE formatter, because `start` and `get` describe
|
|
596
|
+
* the same stored number and a worker that saw "every 15 min" armed must not read "every 900s" back.
|
|
597
|
+
* @param {number|undefined} seconds */
|
|
598
|
+
function cadenceLabel(seconds) {
|
|
599
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return undefined
|
|
600
|
+
return seconds % 60 === 0 ? `${seconds / 60} min` : `${seconds}s`
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Render an armed recurring prompt for the worker to read: which triggers are live, the cadence, when
|
|
604
|
+
* each last fired, and the text VERBATIM (never truncated — reading back a summary of your own
|
|
605
|
+
* instruction is exactly as blind as not reading it).
|
|
606
|
+
* @param {{ prompt: string, stopHook: boolean, heartbeat: boolean, postCompaction: boolean,
|
|
607
|
+
* pauseOnQuestions?: boolean, intervalSeconds?: number, armedAt: string, lastRestFiredAt?: string,
|
|
608
|
+
* lastScheduleFiredAt?: string, lastCompactFiredAt?: string }} rp */
|
|
609
|
+
function recurringPromptReport(rp) {
|
|
610
|
+
const fired = (/** @type {string|undefined} */ at) => (at ? `last fired ${at}` : "never fired yet")
|
|
611
|
+
const triggers = [
|
|
612
|
+
rp.stopHook ? ` stop_hook — every time you come to rest (${fired(rp.lastRestFiredAt)})` : null,
|
|
613
|
+
rp.heartbeat
|
|
614
|
+
// The SAME cadence form `start` reports (cadenceLabel), or the two readings of one row disagree
|
|
615
|
+
// about the number they are describing — "every 15 min" armed, "every 900s" read back.
|
|
616
|
+
? ` heartbeat — every ${cadenceLabel(rp.intervalSeconds) ?? "?"} (${fired(rp.lastScheduleFiredAt)})`
|
|
617
|
+
: null,
|
|
618
|
+
rp.postCompaction ? ` post_compaction — every compaction (${fired(rp.lastCompactFiredAt)})` : null,
|
|
619
|
+
].filter(Boolean)
|
|
620
|
+
// EVERY trigger off is a real, reachable state — the human can switch them off in the footer without
|
|
621
|
+
// clearing the words — and it is the one a worker would otherwise misread as "armed and running".
|
|
622
|
+
const head = triggers.length
|
|
623
|
+
? `Armed since ${rp.armedAt}, on:\n${triggers.join("\n")}`
|
|
624
|
+
: `Text is parked (armed ${rp.armedAt}) but EVERY TRIGGER IS OFF — nothing will fire until one is switched back on.`
|
|
625
|
+
// Reported only when ON, and after the triggers: it is a HOLD over the list above rather than an item
|
|
626
|
+
// in it, so a worker reading this must already know what would fire before it is told what suspends it.
|
|
627
|
+
const hold = rp.pauseOnQuestions
|
|
628
|
+
? "\n\nHELD while you are waiting on the human — nothing is sent for as long as a question fence, a " +
|
|
629
|
+
"native ask or a permission prompt is unanswered."
|
|
630
|
+
: ""
|
|
631
|
+
return `${head}${hold}\n\nThe text, verbatim:\n\n${rp.prompt}`
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** The `recurring_prompt` handler: arm, disarm, or READ BACK this thread's re-prompt.
|
|
365
635
|
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
366
636
|
async function recurringPrompt(args) {
|
|
367
637
|
const slug = threadSlug()
|
|
368
638
|
const action = typeof args.action === "string" ? args.action.trim() : ""
|
|
369
|
-
if (action !== "start" && action !== "stop"
|
|
639
|
+
if (action !== "start" && action !== "stop" && action !== "get") {
|
|
640
|
+
throw new Error("`action` must be one of \"start\", \"stop\" or \"get\"")
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (action === "get") {
|
|
644
|
+
// A frizz server older than this tool has no such procedure and answers 404. Say what that means,
|
|
645
|
+
// rather than leaving a worker to read a bare HTTP status as "nothing is armed" — the two answers
|
|
646
|
+
// could not be further apart.
|
|
647
|
+
let payload
|
|
648
|
+
try {
|
|
649
|
+
payload = await callRpc("getOwnThreadRecurringPrompt", { slug })
|
|
650
|
+
} catch (err) {
|
|
651
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
652
|
+
if (/HTTP 404/.test(message)) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
"this frizz server predates the read action, so it cannot tell you what is armed. Treat the " +
|
|
655
|
+
"armed state as UNKNOWN — do not assume it is empty — and check the thread footer instead.",
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
throw err
|
|
659
|
+
}
|
|
660
|
+
const rp = payload?.result?.recurringPrompt
|
|
661
|
+
if (!rp) return "No recurring prompt is armed on this thread. Nothing will re-prompt you."
|
|
662
|
+
return recurringPromptReport(rp)
|
|
663
|
+
}
|
|
370
664
|
|
|
371
665
|
if (action === "stop") {
|
|
372
|
-
await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false })
|
|
373
|
-
return "Recurring prompt disarmed and cleared.
|
|
666
|
+
await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false, postCompaction: false, pauseOnQuestions: false })
|
|
667
|
+
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
668
|
}
|
|
375
669
|
|
|
376
670
|
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
|
|
@@ -387,36 +681,60 @@ async function recurringPrompt(args) {
|
|
|
387
681
|
throw new Error(`\`heartbeat_seconds\` must be between ${MIN_INTERVAL_SECONDS} and ${MAX_INTERVAL_SECONDS}`)
|
|
388
682
|
}
|
|
389
683
|
}
|
|
684
|
+
const postCompaction = args.post_compaction === true
|
|
685
|
+
// ON unless the caller says otherwise — the same default the footer panel seeds, so a worker-armed row
|
|
686
|
+
// and a human-armed one read identically in the panel. An older frizz server ignores the field.
|
|
687
|
+
const pauseOnQuestions = typeof args.pause_on_questions === "boolean" ? args.pause_on_questions : true
|
|
390
688
|
// DEFAULTED, not required: a `start` that names no trigger at all is a model asking to be re-prompted
|
|
391
689
|
// and leaving the mechanism to us, and the rest trigger is the safe reading of that — it cannot talk
|
|
392
690
|
// 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
|
|
691
|
+
const stopHook = typeof args.stop_hook === "boolean" ? args.stop_hook : !hasHeartbeat && !postCompaction
|
|
394
692
|
const heartbeat = hasHeartbeat
|
|
395
|
-
if (!stopHook && !heartbeat) {
|
|
396
|
-
throw new Error("at least one is required: set `stop_hook: true`,
|
|
693
|
+
if (!stopHook && !heartbeat && !postCompaction) {
|
|
694
|
+
throw new Error("at least one is required: set `stop_hook: true`, give `heartbeat_seconds`, set `post_compaction: true`, or any combination")
|
|
397
695
|
}
|
|
398
696
|
|
|
399
|
-
await callRpc("setOwnThreadRecurringPrompt", {
|
|
697
|
+
const written = await callRpc("setOwnThreadRecurringPrompt", {
|
|
400
698
|
slug,
|
|
401
699
|
prompt,
|
|
402
700
|
stopHook,
|
|
403
701
|
heartbeat,
|
|
702
|
+
postCompaction,
|
|
703
|
+
pauseOnQuestions,
|
|
404
704
|
...(heartbeat ? { intervalSeconds: interval } : {}),
|
|
405
705
|
})
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
706
|
+
// `replaced` is absent against a server that predates it, which is indistinguishable from "there was
|
|
707
|
+
// nothing" — so the clause only ever appears when the row genuinely carried something.
|
|
708
|
+
const replaced = written?.result?.replaced
|
|
709
|
+
|
|
710
|
+
const every = heartbeat ? cadenceLabel(interval) : null
|
|
711
|
+
// One clause per armed trigger, joined — with three of them the old nested ternary could no longer say
|
|
712
|
+
// what was actually armed, and a worker that misreads which trigger it holds waits for a delivery that
|
|
713
|
+
// is never coming.
|
|
714
|
+
const clauses = [
|
|
715
|
+
stopHook ? "every time you come to rest" : null,
|
|
716
|
+
every ? `every ${every} (the heartbeat reaches you mid-turn)` : null,
|
|
717
|
+
postCompaction ? "every time your context is compacted, delivered into the emptied window" : null,
|
|
718
|
+
].filter(Boolean)
|
|
719
|
+
const when = clauses.length === 1
|
|
720
|
+
? clauses[0]
|
|
721
|
+
: `${clauses.slice(0, -1).join(", ")} AND ${clauses[clauses.length - 1]}`
|
|
722
|
+
// Spelled out in full, not summarized: if this overwrote the human's own edit, the words themselves
|
|
723
|
+
// are the only way the worker can put them back.
|
|
724
|
+
const superseded = replaced
|
|
725
|
+
? `\n\nIT REPLACED an existing recurring prompt — check that discarding it was intended, and restore ` +
|
|
726
|
+
`it with another \`start\` if it was not:\n\n${recurringPromptReport(replaced)}\n`
|
|
727
|
+
: ""
|
|
728
|
+
const held = pauseOnQuestions
|
|
729
|
+
? " Nothing is sent while you are waiting on the human — a question fence, a native ask or a " +
|
|
730
|
+
"permission prompt holds every trigger."
|
|
731
|
+
: ""
|
|
413
732
|
return (
|
|
414
|
-
`Recurring prompt armed — frizz will send you this ${when}
|
|
415
|
-
"thread had before.\n\n" +
|
|
733
|
+
`Recurring prompt armed — frizz will send you this ${when}.${held}${superseded}\n\n` +
|
|
416
734
|
"Call this tool again with `action: \"stop\"` once the work it drives is finished — one left armed on " +
|
|
417
735
|
"a finished thread wakes it forever. The human can also edit or switch it off in the thread footer. " +
|
|
418
|
-
"
|
|
419
|
-
"
|
|
736
|
+
"Signing off with a ```done fence stops it too, but only when there is genuinely nothing left: it " +
|
|
737
|
+
"files the thread away until the human sends more work."
|
|
420
738
|
)
|
|
421
739
|
}
|
|
422
740
|
|
|
@@ -498,6 +816,125 @@ async function timer(args) {
|
|
|
498
816
|
)
|
|
499
817
|
}
|
|
500
818
|
|
|
819
|
+
/** How the armed watcher set reads back, on every action, so a worker never needs a second call.
|
|
820
|
+
* @param {{ watches?: Array<{id: string, kind: string, target: string}> }|undefined} result */
|
|
821
|
+
function armedWatchList(result) {
|
|
822
|
+
const watches = Array.isArray(result?.watches) ? result.watches : []
|
|
823
|
+
if (!watches.length) return "Nothing is armed on this thread now — no watcher will wake you."
|
|
824
|
+
const lines = watches.map((w) => ` ${w.id} ${w.kind} ${w.target}`)
|
|
825
|
+
return `Armed on this thread now:\n${lines.join("\n")}`
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** The `watch` handler: register, withdraw, or read back this thread's waits.
|
|
829
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
830
|
+
async function watch(args) {
|
|
831
|
+
const slug = threadSlug()
|
|
832
|
+
const action = typeof args.action === "string" ? args.action.trim() : ""
|
|
833
|
+
if (action !== "add" && action !== "list" && action !== "drop") {
|
|
834
|
+
throw new Error("`action` must be one of \"add\", \"list\" or \"drop\"")
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (action === "list") {
|
|
838
|
+
const result = (await callRpc("listOwnThreadWatches", { slug }))?.result
|
|
839
|
+
return armedWatchList(result)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (action === "drop") {
|
|
843
|
+
const id = typeof args.id === "string" ? args.id.trim() : ""
|
|
844
|
+
if (!id) throw new Error("`id` is required to drop a watcher — take it from `add` or from `list`")
|
|
845
|
+
const result = (await callRpc("dropOwnThreadWatch", { slug, id }))?.result
|
|
846
|
+
// A drop that matched nothing is reported rather than swallowed: the id was wrong, already settled,
|
|
847
|
+
// or another thread's — and a worker that believes it withdrew a wait it still holds will rest.
|
|
848
|
+
const head = result?.dropped
|
|
849
|
+
? `Watcher ${id} dropped. It will not wake you.`
|
|
850
|
+
: `No ARMED watcher ${id} on this thread — it was already settled, or the id is not one of yours.`
|
|
851
|
+
return `${head}\n\n${armedWatchList(result)}`
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const kind = typeof args.kind === "string" ? args.kind.trim() : ""
|
|
855
|
+
if (kind !== "shell") {
|
|
856
|
+
// REFUSED rather than stored. `pr` and `ci` rows are valid in the registry and the scheduler will
|
|
857
|
+
// poll them once its PR watcher moves off the fence — but today nothing wakes them, and a tool that
|
|
858
|
+
// accepts a wait it cannot honour is how a worker comes to rest believing it is covered.
|
|
859
|
+
throw new Error(
|
|
860
|
+
kind === "pr" || kind === "ci"
|
|
861
|
+
? `a ${kind} wait does not belong here — use an \`\`\`awaiting fence with a \`pr-watch: owner/repo#123\` line, which is durable and replays review that is already on the PR`
|
|
862
|
+
: "`kind` is required to add a watcher, and the only kind is \"shell\"",
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
const target = typeof args.target === "string" ? args.target.trim() : ""
|
|
866
|
+
if (!target) throw new Error("`target` is required — the id or label of one of your own background shells")
|
|
867
|
+
|
|
868
|
+
// THE BLOCKING MODE. `foreground` tells frizz to SETTLE this watcher silently rather than wake us —
|
|
869
|
+
// we are the ones waiting, and a wake landing mid-turn while this call is still blocked would hand the
|
|
870
|
+
// worker its own answer twice.
|
|
871
|
+
const wait = args.wait === true
|
|
872
|
+
let timeoutSeconds = 0
|
|
873
|
+
if (wait) {
|
|
874
|
+
if (typeof args.timeout_seconds !== "number" || !Number.isFinite(args.timeout_seconds)) {
|
|
875
|
+
throw new Error("`timeout_seconds` is required when `wait` is true — a blocking wait with no deadline is a hang")
|
|
876
|
+
}
|
|
877
|
+
timeoutSeconds = Math.round(args.timeout_seconds)
|
|
878
|
+
if (timeoutSeconds < WATCH_MIN_WAIT_SECONDS || timeoutSeconds > WATCH_MAX_WAIT_SECONDS) {
|
|
879
|
+
throw new Error(`\`timeout_seconds\` must be between ${WATCH_MIN_WAIT_SECONDS} and ${WATCH_MAX_WAIT_SECONDS}`)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const result = (await callRpc("addOwnThreadWatch", { slug, kind, target, ...(wait ? { foreground: true } : {}) }))?.result
|
|
884
|
+
const id = result?.id ?? "(unknown)"
|
|
885
|
+
if (wait) return await blockUntilResolved(slug, id, kind, target, timeoutSeconds)
|
|
886
|
+
const head = result?.alreadyArmed
|
|
887
|
+
? `Already watching ${kind} ${target} as ${id} — nothing new was registered, and you will be woken once.`
|
|
888
|
+
: `Watching ${kind} ${target} as ${id}. Frizz will wake you when it resolves, and the registration ` +
|
|
889
|
+
"survives your turn ending, a compaction and a frizz restart."
|
|
890
|
+
return (
|
|
891
|
+
`${head}\n\nDROP IT when it stops mattering (\`action: "drop", id: "${id}"\`) — a watcher you no ` +
|
|
892
|
+
`longer care about is a wake you did not want.\n\n${armedWatchList(result)}`
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Block until a foreground watcher settles, or until its deadline — then hand it back to frizz.
|
|
897
|
+
*
|
|
898
|
+
* POLLED, not pushed, because the MCP transport has no way to be told. The interval BACKS OFF: a wait
|
|
899
|
+
* that resolves in ten seconds should not be found thirty seconds late, and a wait that runs for hours
|
|
900
|
+
* should not cost thousands of round-trips to discover that nothing changed.
|
|
901
|
+
*
|
|
902
|
+
* The deadline RETURNS rather than throwing, and promotes the row on the way out. That is the property
|
|
903
|
+
* that makes choosing this mode safe: the worst case of guessing the timeout too short is that the wait
|
|
904
|
+
* becomes an ordinary durable one and frizz wakes you, not that the wait is silently lost.
|
|
905
|
+
*
|
|
906
|
+
* @param {string} slug @param {string} id @param {string} kind @param {string} target @param {number} timeoutSeconds
|
|
907
|
+
* @returns {Promise<string>} */
|
|
908
|
+
async function blockUntilResolved(slug, id, kind, target, timeoutSeconds) {
|
|
909
|
+
const deadline = Date.now() + timeoutSeconds * 1000
|
|
910
|
+
const started = Date.now()
|
|
911
|
+
for (;;) {
|
|
912
|
+
const elapsed = Date.now() - started
|
|
913
|
+
// 2s for the first minute, then 5s, then 15s — see the back-off note above.
|
|
914
|
+
const interval = elapsed < 60_000 ? 2_000 : elapsed < 600_000 ? 5_000 : 15_000
|
|
915
|
+
const remaining = deadline - Date.now()
|
|
916
|
+
if (remaining <= 0) break
|
|
917
|
+
await new Promise((r) => setTimeout(r, Math.min(interval, remaining)))
|
|
918
|
+
const listed = (await callRpc("listOwnThreadWatches", { slug }))?.result
|
|
919
|
+
const still = Array.isArray(listed?.watches) && listed.watches.some((w) => w.id === id)
|
|
920
|
+
if (!still) {
|
|
921
|
+
const waited = Math.round((Date.now() - started) / 1000)
|
|
922
|
+
return (
|
|
923
|
+
`${kind === "shell" ? "Your background shell" : target} resolved after ${waited}s — that is what you were ` +
|
|
924
|
+
`waiting for (${target}). The watcher is spent; you were not interrupted, because you were the one waiting.`
|
|
925
|
+
)
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// The deadline, not a failure. Hand it to frizz so the wait survives this turn.
|
|
929
|
+
const promoted = (await callRpc("promoteOwnThreadWatch", { slug, id }))?.result
|
|
930
|
+
return (
|
|
931
|
+
`Waited ${timeoutSeconds}s and ${target} has NOT resolved yet. The watcher is still armed and is now ` +
|
|
932
|
+
`frizz's to keep${promoted?.promoted === false ? " (it had already settled)" : ""} — go do something else ` +
|
|
933
|
+
`and you will be woken when it fires, or drop it with \`action: "drop", id: "${id}"\` if it has stopped ` +
|
|
934
|
+
"mattering."
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
|
|
501
938
|
/** @param {any} msg */
|
|
502
939
|
async function handle(msg) {
|
|
503
940
|
const { id, method, params } = msg ?? {}
|