frizz 0.2.0 → 0.4.0

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