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.
Files changed (115) hide show
  1. package/README.md +17 -17
  2. package/dist/claude-agent-broker.js +63 -23
  3. package/dist/dev-child.js +6877 -4459
  4. package/dist/frizz.js +654 -529
  5. package/package.json +6 -2
  6. package/runtime/cc-worker/.claude-plugin/plugin.json +1 -1
  7. package/runtime/cc-worker/DECISIONS.md +64 -4
  8. package/runtime/cc-worker/bin/frizz-mcp.mjs +494 -57
  9. package/runtime/cc-worker/hooks/agent-dispatch.mjs +1 -1
  10. package/runtime/cc-worker/hooks/deny-plan.mjs +1 -1
  11. package/runtime/cc-worker/hooks/hooks.json +0 -7
  12. package/runtime/cc-worker/hooks/precompact-instructions.mjs +39 -28
  13. package/runtime/cc-worker/hooks/scratchpad.mjs +142 -169
  14. package/runtime/cc-worker/hooks/session-seed.mjs +16 -15
  15. package/runtime/cc-worker/skills/waits/SKILL.md +2 -2
  16. package/web-dist/apple-touch-icon.png +0 -0
  17. package/web-dist/assets/{TerminalPane-ROKHp1ib.js → TerminalPane-CTdetDJJ.js} +2 -2
  18. package/web-dist/assets/{abnfDiagram-VRR7QNED-DcpdhBs3.js → abnfDiagram-VRR7QNED-BDKrjCMs.js} +1 -1
  19. package/web-dist/assets/architecture-TIHT7OUA-ChUMo004.js +1 -0
  20. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-CUAKf0mn.js → architectureDiagram-ZJ3FMSHR-BJebpUUM.js} +1 -1
  21. package/web-dist/assets/{blockDiagram-677ZJIJ3-BPwpJIzx.js → blockDiagram-677ZJIJ3-BNbuk25k.js} +1 -1
  22. package/web-dist/assets/{c4Diagram-LMCZKHZV-1lptuHzZ.js → c4Diagram-LMCZKHZV-lCfyotdU.js} +1 -1
  23. package/web-dist/assets/channel-pr7r6raB.js +1 -0
  24. package/web-dist/assets/{chunk-32BRIVSS-CFR9AKjY.js → chunk-32BRIVSS-DP72SEkr.js} +1 -1
  25. package/web-dist/assets/{chunk-52WLFC77-CM9uct7m.js → chunk-52WLFC77-DxO3gN-p.js} +1 -1
  26. package/web-dist/assets/{chunk-C7G6YPKG-DiveJARw.js → chunk-C7G6YPKG-BXFx9Vlr.js} +1 -1
  27. package/web-dist/assets/{chunk-EX3LRPZG-BE1CBw8F.js → chunk-EX3LRPZG-CR3sHpPf.js} +1 -1
  28. package/web-dist/assets/{chunk-FWX5IMBZ-DL42uXiO.js → chunk-FWX5IMBZ-CJ8L__oG.js} +2 -2
  29. package/web-dist/assets/{chunk-HOUHSVGY-DPhJWgDw.js → chunk-HOUHSVGY-BCl3JSWr.js} +1 -1
  30. package/web-dist/assets/{chunk-ICXQ74PX-CwYy-6AP.js → chunk-ICXQ74PX-CZxFMci1.js} +1 -1
  31. package/web-dist/assets/{chunk-MOJQB5TN-Ds5I9wxq.js → chunk-MOJQB5TN-Ck3_47dB.js} +1 -1
  32. package/web-dist/assets/{chunk-OGEWGWER-DHiZJwQD.js → chunk-OGEWGWER-CVXjES-F.js} +1 -1
  33. package/web-dist/assets/{chunk-PUDLZKDR-B-eyQTsF.js → chunk-PUDLZKDR-JjrVgaR7.js} +1 -1
  34. package/web-dist/assets/{chunk-Q4XR5HBZ-DK7dB3Ti.js → chunk-Q4XR5HBZ-C5YMRAQ0.js} +1 -1
  35. package/web-dist/assets/{chunk-V7JOEXUC-Dn59m74L.js → chunk-V7JOEXUC-DK0mfUHU.js} +1 -1
  36. package/web-dist/assets/{chunk-VAUOI2AC-DK7x36hd.js → chunk-VAUOI2AC-BKBntpid.js} +1 -1
  37. package/web-dist/assets/{chunk-VR4S4FIN-D7-CI3Yl.js → chunk-VR4S4FIN-zgE1dG6U.js} +1 -1
  38. package/web-dist/assets/{chunk-WYO6CB5R-B3l-mLCs.js → chunk-WYO6CB5R-CZhh1IBq.js} +1 -1
  39. package/web-dist/assets/{chunk-ZGVPDNZ5-Dobxlxie.js → chunk-ZGVPDNZ5-CrNjIEem.js} +1 -1
  40. package/web-dist/assets/classDiagram-OUVF2IWQ-CvGbPMn_.js +1 -0
  41. package/web-dist/assets/classDiagram-v2-EOCWNBFH-CvGbPMn_.js +1 -0
  42. package/web-dist/assets/{cynefin-VYW2F7L2-Dh7RuEUJ.js → cynefin-VYW2F7L2-Ca_BPfTG.js} +1 -1
  43. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-ClPi2mZZ.js → cynefinDiagram-TSTJHNR4-CsINQf6g.js} +1 -1
  44. package/web-dist/assets/{dagre-VKFMJZFB-52_WP1QV.js → dagre-VKFMJZFB-CE0DYMFy.js} +1 -1
  45. package/web-dist/assets/{diagram-FQU43EPY-D_1zVsTL.js → diagram-FQU43EPY-BqTduEYE.js} +1 -1
  46. package/web-dist/assets/{diagram-G47NLZAW-CdZxuGUy.js → diagram-G47NLZAW-Cho718s6.js} +1 -1
  47. package/web-dist/assets/{diagram-NH7WQ7WH-C8pSFu0P.js → diagram-NH7WQ7WH-D0Z8t9UC.js} +1 -1
  48. package/web-dist/assets/{diagram-OA4YK3LP-C5bjZLre.js → diagram-OA4YK3LP-CBacyeDx.js} +1 -1
  49. package/web-dist/assets/{diagram-WEI45ONY-Bxzhiuzn.js → diagram-WEI45ONY-CozpXa4j.js} +1 -1
  50. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-g-Z0J2wP.js → ebnfDiagram-CCIWWBDH-ba4NbQQo.js} +1 -1
  51. package/web-dist/assets/{erDiagram-Q63AITRT-DVNkgIHp.js → erDiagram-Q63AITRT-B3BciOYa.js} +1 -1
  52. package/web-dist/assets/eventmodeling-45OFAUF4-DKmyo-jd.js +1 -0
  53. package/web-dist/assets/flowDiagram-23GEKE2U-DluCBvT4.js +1 -0
  54. package/web-dist/assets/{ganttDiagram-NO4QXBWP-_71pQYEK.js → ganttDiagram-NO4QXBWP-lPmrRit4.js} +1 -1
  55. package/web-dist/assets/{gitGraph-TEB2WS4Q-ChIZiGZS.js → gitGraph-TEB2WS4Q-BjyclOq0.js} +1 -1
  56. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-DCHAFI0l.js → gitGraphDiagram-IHSO6WYX-pzeb4Yrw.js} +1 -1
  57. package/web-dist/assets/index-BQtjYMpV.css +1 -0
  58. package/web-dist/assets/index-CLW1Q49U.js +360 -0
  59. package/web-dist/assets/{info-DKCQHKI2-BW-n_T1j.js → info-DKCQHKI2-Bwycegvf.js} +1 -1
  60. package/web-dist/assets/{infoDiagram-FWYZ7A6U-CgDYsKi9.js → infoDiagram-FWYZ7A6U-BCvRGj_5.js} +1 -1
  61. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-ClzGNt9N.js → ishikawaDiagram-FXEZZL3T-Ofw1RMj3.js} +1 -1
  62. package/web-dist/assets/{journeyDiagram-5HDEW3XC-DSCQxkHC.js → journeyDiagram-5HDEW3XC-C5ROwFio.js} +1 -1
  63. package/web-dist/assets/{kanban-definition-HUTT4EX6-CdrdX9N8.js → kanban-definition-HUTT4EX6-YLPLkpeT.js} +1 -1
  64. package/web-dist/assets/{line-ha38Dc-1.js → line-KtkNqRgI.js} +1 -1
  65. package/web-dist/assets/{mermaid-parser.core-Z4uMcpip.js → mermaid-parser.core-D_FfqBe7.js} +3 -3
  66. package/web-dist/assets/{mermaid.core-iZRq3hbu.js → mermaid.core-Ffv8anVf.js} +3 -3
  67. package/web-dist/assets/{mindmap-definition-LN4V7U3C-DmhInJO_.js → mindmap-definition-LN4V7U3C-B2jj4vfm.js} +1 -1
  68. package/web-dist/assets/{packet-7NZHBO7P-DBPB36Kl.js → packet-7NZHBO7P-BjmWHwra.js} +1 -1
  69. package/web-dist/assets/{pegDiagram-2B236MQR-CAH3ljfj.js → pegDiagram-2B236MQR-Dq3iJDyq.js} +1 -1
  70. package/web-dist/assets/{pie-RZYD4A2V-_h_eX4Ca.js → pie-RZYD4A2V-jBbH1lv9.js} +1 -1
  71. package/web-dist/assets/{pieDiagram-ENE6RG2P-DFBPus8j.js → pieDiagram-ENE6RG2P-C3ETW9lq.js} +1 -1
  72. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-DMvOCjt8.js → quadrantDiagram-ABIIQ3AL-DItSmme7.js} +1 -1
  73. package/web-dist/assets/{radar-I7S5WNFK-2EzoPHEZ.js → radar-I7S5WNFK-6ey6crgP.js} +1 -1
  74. package/web-dist/assets/{railroad-3IZDKUUU-BPJnn-hm.js → railroad-3IZDKUUU-Cii-Mn0E.js} +1 -1
  75. package/web-dist/assets/railroad-abnf-AHOZXSZD-U_vb4BrX.js +1 -0
  76. package/web-dist/assets/railroad-ebnf-EBAXGLYW-BIHG7gNU.js +1 -0
  77. package/web-dist/assets/railroad-peg-LSFZ7HO6-Cpd9r-tB.js +1 -0
  78. package/web-dist/assets/{railroadDiagram-RFXS5EU6-DKq5FagA.js → railroadDiagram-RFXS5EU6-M363ils_.js} +1 -1
  79. package/web-dist/assets/{requirementDiagram-TGXJPOKE-BJ5tGazp.js → requirementDiagram-TGXJPOKE-CTs2_V6T.js} +1 -1
  80. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-XSJjcBhX.js → sankeyDiagram-HTMAVEWB-QTLLcDPD.js} +1 -1
  81. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-CBb8emSe.js → sequenceDiagram-DBY2YBRQ-Bxw9Tr6e.js} +1 -1
  82. package/web-dist/assets/{stateDiagram-2N3HPSRC-DDfRW94V.js → stateDiagram-2N3HPSRC-BecB6roG.js} +1 -1
  83. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-JXw9T96l.js +1 -0
  84. package/web-dist/assets/{swimlanes-5IMT3BWC-DvRYbkZi.js → swimlanes-5IMT3BWC-COiYgS0w.js} +1 -1
  85. package/web-dist/assets/swimlanesDiagram-G3AALYLV--1Wv7FQQ.js +8 -0
  86. package/web-dist/assets/{timeline-definition-FHXFAJF6-BNUa_DwI.js → timeline-definition-FHXFAJF6-Ci22coeH.js} +1 -1
  87. package/web-dist/assets/{treeView-QDETBFTQ-I6-IW6nJ.js → treeView-QDETBFTQ-4DW35czh.js} +1 -1
  88. package/web-dist/assets/{treemap-6X3UGDF4-CWWmEUYJ.js → treemap-6X3UGDF4-Dq_-Z6-Y.js} +1 -1
  89. package/web-dist/assets/{vennDiagram-L72KCM5P-DTDrPGLk.js → vennDiagram-L72KCM5P-CHQSIEYq.js} +1 -1
  90. package/web-dist/assets/{wardley-OPB4EBWU-CNsdgXXA.js → wardley-OPB4EBWU-Bwlh7HCY.js} +1 -1
  91. package/web-dist/assets/{wardleyDiagram-EHGQE667-YE0tq3Kh.js → wardleyDiagram-EHGQE667-DctjuPYn.js} +1 -1
  92. package/web-dist/assets/{xychartDiagram-FW5EYKEG-D0ofMX8C.js → xychartDiagram-FW5EYKEG-ZoIGosv8.js} +1 -1
  93. package/web-dist/favicon-16.png +0 -0
  94. package/web-dist/favicon-32.png +0 -0
  95. package/web-dist/favicon.svg +17 -61
  96. package/web-dist/icon-192.png +0 -0
  97. package/web-dist/icon-512.png +0 -0
  98. package/web-dist/icon-maskable-512.png +0 -0
  99. package/web-dist/index.html +19 -8
  100. package/web-dist/manifest.webmanifest +3 -3
  101. package/runtime/cc-worker/hooks/scratchpad-stop.mjs +0 -125
  102. package/runtime/cc-worker/skills/handoff/SKILL.md +0 -209
  103. package/web-dist/assets/architecture-TIHT7OUA-CAviNivx.js +0 -1
  104. package/web-dist/assets/channel-CqKDIFQF.js +0 -1
  105. package/web-dist/assets/classDiagram-OUVF2IWQ-B_-6iXYY.js +0 -1
  106. package/web-dist/assets/classDiagram-v2-EOCWNBFH-B_-6iXYY.js +0 -1
  107. package/web-dist/assets/eventmodeling-45OFAUF4-MpmeH5YZ.js +0 -1
  108. package/web-dist/assets/flowDiagram-23GEKE2U-D-QgjjhF.js +0 -1
  109. package/web-dist/assets/index-w4v-GZEc.js +0 -358
  110. package/web-dist/assets/index-zyi22LPz.css +0 -1
  111. package/web-dist/assets/railroad-abnf-AHOZXSZD-YeUoiySk.js +0 -1
  112. package/web-dist/assets/railroad-ebnf-EBAXGLYW-Ddw1SuGG.js +0 -1
  113. package/web-dist/assets/railroad-peg-LSFZ7HO6-Dd8BOGeW.js +0 -1
  114. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-hc41W5Lx.js +0 -1
  115. 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
- * `<state-dir>/server.lock` and POSTs `/rpc/dispatch`. The `/rpc` surface has no token auth — only a
17
- * loopback-origin CSRF gate — so a headerless local POST with `sec-fetch-site: same-origin` (undici
18
- * sends no Origin) satisfies it.
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`. The server passes
21
- * FRIZZ_STATE_DIR in this process's env so we can locate server.lock without recomputing the project id.
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 either or " +
99
- "both of two triggers, for as long as it is armed.\n\n" +
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\n" +
106
- "Set at least one. Setting BOTH is the ordinary case for \"keep this moving\": you are prompted " +
107
- "whenever you stop, and at least every N seconds even if you never do.\n\n" +
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. A thread has AT MOST ONE recurring prompt: calling this again REPLACES it, triggers and all. " +
115
- "At most one scheduled delivery is ever outstanding and its clock runs from the last one DELIVERED, " +
116
- "so you can never be handed a backlog at once.\n\n" +
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
- "Replying ALLDONE on its own line also stops it, both triggers at once, but be sure before you do: " +
120
- "it permanently stalls the run, and a run nobody is watching does not restart itself.\n\n" +
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: "`start` arms (or replaces) this thread's recurring prompt; `stop` disarms it.",
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` is omitted, so a " +
141
- "`start` that names neither mechanism still does the obvious thing.",
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 no ALLDONE involved.\n\n" +
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
- function serverLockPort() {
254
- const lock = process.env.FRIZZ_SERVER_LOCK
255
- || (process.env.FRIZZ_STATE_DIR ? join(process.env.FRIZZ_STATE_DIR, "server.lock") : undefined)
256
- if (!lock) throw new Error("FRIZZ_STATE_DIR / FRIZZ_SERVER_LOCK not set cannot locate the frizz server")
257
- let parsed
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
- parsed = JSON.parse(readFileSync(lock, "utf8"))
384
+ process.kill(pid, 0)
385
+ return true
260
386
  } catch (err) {
261
- throw new Error(`could not read the frizz server lock at ${lock} (is the server running?): ${err instanceof Error ? err.message : err}`)
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}/rpc/dispatch`, {
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}/rpc/${procedure}`, {
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
- /** The `recurring_prompt` handler: arm or disarm this thread's re-prompt, on either or both triggers.
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") throw new Error("`action` must be either \"start\" or \"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. Neither the stop hook nor the heartbeat will fire, and the text is gone from the thread footer."
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`, or give `heartbeat_seconds`, or both")
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
- const every = heartbeat ? (interval % 60 === 0 ? `${interval / 60} min` : `${interval}s`) : null
408
- const when = stopHook && every
409
- ? `every time you come to rest AND every ${every} (the heartbeat reaches you mid-turn)`
410
- : stopHook
411
- ? "every time you come to rest"
412
- : `every ${every}, reaching you mid-turn rather than waiting for you to stop`
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}. It replaces any recurring prompt this ` +
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
- "Replying ALLDONE stops it too, but only use that when there is genuinely nothing left: it " +
419
- "permanently stalls the run."
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 ?? {}