frizz 0.8.0 → 0.9.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 (110) hide show
  1. package/README.md +3 -2
  2. package/dist/claude-agent-broker.js +73 -2
  3. package/dist/dev-child.js +4052 -1861
  4. package/dist/frizz.js +621 -260
  5. package/package.json +1 -1
  6. package/runtime/board/config.mjs +12 -1
  7. package/runtime/board/index.mjs +5 -0
  8. package/runtime/board/thread-update.mjs +4 -0
  9. package/runtime/cc-worker/.claude-plugin/plugin.json +1 -1
  10. package/runtime/cc-worker/DECISIONS.md +7 -0
  11. package/runtime/cc-worker/agents/high.md +1 -1
  12. package/runtime/cc-worker/agents/low.md +1 -1
  13. package/runtime/cc-worker/agents/max.md +1 -1
  14. package/runtime/cc-worker/agents/medium.md +1 -1
  15. package/runtime/cc-worker/agents/xhigh.md +1 -1
  16. package/runtime/cc-worker/bin/frizz-mcp.mjs +561 -46
  17. package/runtime/cc-worker/hooks/agent-dispatch.mjs +4 -4
  18. package/runtime/cc-worker/hooks/scratchpad.mjs +20 -24
  19. package/runtime/cc-worker/hooks/session-seed.mjs +2 -2
  20. package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +1 -1
  21. package/web-dist/assets/{TerminalPane-BIBHC61j.js → TerminalPane-CNFymHXK.js} +1 -1
  22. package/web-dist/assets/{abnfDiagram-VRR7QNED-CcFQGFPf.js → abnfDiagram-VRR7QNED-DNBrcJfV.js} +1 -1
  23. package/web-dist/assets/architecture-TIHT7OUA-BSUDKbro.js +1 -0
  24. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-DErIQOIs.js → architectureDiagram-ZJ3FMSHR-SXVydqly.js} +1 -1
  25. package/web-dist/assets/{blockDiagram-677ZJIJ3-CpsUqur1.js → blockDiagram-677ZJIJ3-CgI1GSdx.js} +1 -1
  26. package/web-dist/assets/{c4Diagram-LMCZKHZV-D2dHmCEJ.js → c4Diagram-LMCZKHZV-KDmJm2tG.js} +1 -1
  27. package/web-dist/assets/channel-DaCOnTxr.js +1 -0
  28. package/web-dist/assets/{chunk-32BRIVSS-aOCEfxRW.js → chunk-32BRIVSS-j3eOjq4Z.js} +1 -1
  29. package/web-dist/assets/{chunk-52WLFC77-2iw4nJPC.js → chunk-52WLFC77-DZHfa78H.js} +1 -1
  30. package/web-dist/assets/{chunk-C7G6YPKG-BPD3iHVE.js → chunk-C7G6YPKG-CiB5uo24.js} +1 -1
  31. package/web-dist/assets/{chunk-EX3LRPZG-DrefHift.js → chunk-EX3LRPZG-B35Inyjz.js} +1 -1
  32. package/web-dist/assets/{chunk-FWX5IMBZ-By940EHT.js → chunk-FWX5IMBZ-D2sRvM5U.js} +2 -2
  33. package/web-dist/assets/{chunk-HOUHSVGY-B1Cqvz7e.js → chunk-HOUHSVGY-D247PDv6.js} +1 -1
  34. package/web-dist/assets/{chunk-ICXQ74PX-1LkVpWuI.js → chunk-ICXQ74PX-Bu3Tb6Ec.js} +1 -1
  35. package/web-dist/assets/{chunk-MOJQB5TN-Ip1cy64r.js → chunk-MOJQB5TN-C_lQy4on.js} +1 -1
  36. package/web-dist/assets/{chunk-OGEWGWER-CNjVyu8F.js → chunk-OGEWGWER-DU5ZBedR.js} +1 -1
  37. package/web-dist/assets/{chunk-PUDLZKDR-9HZR6DAU.js → chunk-PUDLZKDR-DWRT7ztS.js} +1 -1
  38. package/web-dist/assets/{chunk-Q4XR5HBZ-vtWwOCuQ.js → chunk-Q4XR5HBZ-CCX4AK_n.js} +1 -1
  39. package/web-dist/assets/{chunk-V7JOEXUC-DC6YaHG0.js → chunk-V7JOEXUC-VbixbkDG.js} +1 -1
  40. package/web-dist/assets/{chunk-VAUOI2AC-CP-xe7SD.js → chunk-VAUOI2AC-PJvChxMA.js} +1 -1
  41. package/web-dist/assets/{chunk-VR4S4FIN-wbxDFptH.js → chunk-VR4S4FIN-BjsMfxj8.js} +1 -1
  42. package/web-dist/assets/{chunk-WYO6CB5R-dibXmwpK.js → chunk-WYO6CB5R-Bcb97r3w.js} +1 -1
  43. package/web-dist/assets/{chunk-ZGVPDNZ5-_y_itJHG.js → chunk-ZGVPDNZ5-CT2sZlhT.js} +1 -1
  44. package/web-dist/assets/classDiagram-OUVF2IWQ-BlylbZXV.js +1 -0
  45. package/web-dist/assets/classDiagram-v2-EOCWNBFH-BlylbZXV.js +1 -0
  46. package/web-dist/assets/{cynefin-VYW2F7L2-D1RYokZP.js → cynefin-VYW2F7L2-dUpO4u0w.js} +1 -1
  47. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-qofqoy3T.js → cynefinDiagram-TSTJHNR4-BQZ3UIsx.js} +1 -1
  48. package/web-dist/assets/{dagre-VKFMJZFB-CgHLoGFk.js → dagre-VKFMJZFB-K-oO8w51.js} +1 -1
  49. package/web-dist/assets/{diagram-FQU43EPY-JEAs4LOb.js → diagram-FQU43EPY-Cr47GJ_S.js} +1 -1
  50. package/web-dist/assets/{diagram-G47NLZAW--tLd4Lf7.js → diagram-G47NLZAW-BbWzSkoZ.js} +1 -1
  51. package/web-dist/assets/{diagram-NH7WQ7WH-DPbw9CJU.js → diagram-NH7WQ7WH-BGJJBgGM.js} +1 -1
  52. package/web-dist/assets/{diagram-OA4YK3LP-CDrb7nWs.js → diagram-OA4YK3LP-2W7aunEN.js} +1 -1
  53. package/web-dist/assets/{diagram-WEI45ONY-DJswZe3f.js → diagram-WEI45ONY-D-4ITFI6.js} +1 -1
  54. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-BP8-PZMF.js → ebnfDiagram-CCIWWBDH-CxUcx103.js} +1 -1
  55. package/web-dist/assets/{erDiagram-Q63AITRT-BQXuebf5.js → erDiagram-Q63AITRT-Cf_5SWAE.js} +1 -1
  56. package/web-dist/assets/eventmodeling-45OFAUF4-DxKQnZNV.js +1 -0
  57. package/web-dist/assets/flowDiagram-23GEKE2U-Cdz9YM2Z.js +1 -0
  58. package/web-dist/assets/{ganttDiagram-NO4QXBWP-DotMb935.js → ganttDiagram-NO4QXBWP-CVZI8Ywa.js} +1 -1
  59. package/web-dist/assets/{gitGraph-TEB2WS4Q-B1vbYyKQ.js → gitGraph-TEB2WS4Q-BsiGpR9l.js} +1 -1
  60. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-BjDXKk4m.js → gitGraphDiagram-IHSO6WYX-DVym_4l1.js} +1 -1
  61. package/web-dist/assets/index-CYzJK7u0.js +497 -0
  62. package/web-dist/assets/index-CovJDCWc.css +1 -0
  63. package/web-dist/assets/{info-DKCQHKI2-xLTLqQju.js → info-DKCQHKI2-DYcsKFv-.js} +1 -1
  64. package/web-dist/assets/{infoDiagram-FWYZ7A6U-BFfYUsrC.js → infoDiagram-FWYZ7A6U-hjVxlrTT.js} +1 -1
  65. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-45a7VMwm.js → ishikawaDiagram-FXEZZL3T-B0DKOPSS.js} +1 -1
  66. package/web-dist/assets/{journeyDiagram-5HDEW3XC-CEBUpu59.js → journeyDiagram-5HDEW3XC-Cohibc5O.js} +1 -1
  67. package/web-dist/assets/{kanban-definition-HUTT4EX6-RwKtDFzG.js → kanban-definition-HUTT4EX6-_6Zr31vM.js} +1 -1
  68. package/web-dist/assets/{line-DStcMIry.js → line-B14EZI6M.js} +1 -1
  69. package/web-dist/assets/{mermaid-parser.core-B0ekgQMA.js → mermaid-parser.core-DHkc6cqw.js} +3 -3
  70. package/web-dist/assets/{mermaid.core-w1hcQaPy.js → mermaid.core-DuTvdsKf.js} +3 -3
  71. package/web-dist/assets/{mindmap-definition-LN4V7U3C-D_n38Imb.js → mindmap-definition-LN4V7U3C-DPRF9pW2.js} +1 -1
  72. package/web-dist/assets/{packet-7NZHBO7P-CstGeBOX.js → packet-7NZHBO7P-Ci8oCcGM.js} +1 -1
  73. package/web-dist/assets/{pegDiagram-2B236MQR-DvowVyTO.js → pegDiagram-2B236MQR-Cr3ctC6e.js} +1 -1
  74. package/web-dist/assets/{pie-RZYD4A2V-BOP2j_HH.js → pie-RZYD4A2V-EHgA307y.js} +1 -1
  75. package/web-dist/assets/{pieDiagram-ENE6RG2P-CKRqu_2f.js → pieDiagram-ENE6RG2P-UW6Bmu6F.js} +1 -1
  76. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-zSArSS8R.js → quadrantDiagram-ABIIQ3AL-D6tAsRj9.js} +1 -1
  77. package/web-dist/assets/{radar-I7S5WNFK-BqRdNIBw.js → radar-I7S5WNFK-CAQqojcd.js} +1 -1
  78. package/web-dist/assets/{railroad-3IZDKUUU-BaKcl_LU.js → railroad-3IZDKUUU-BZsAOTrm.js} +1 -1
  79. package/web-dist/assets/railroad-abnf-AHOZXSZD-_2rSHfPz.js +1 -0
  80. package/web-dist/assets/railroad-ebnf-EBAXGLYW-xM3eAMFz.js +1 -0
  81. package/web-dist/assets/railroad-peg-LSFZ7HO6-cZlBUsED.js +1 -0
  82. package/web-dist/assets/{railroadDiagram-RFXS5EU6-BrXTQge9.js → railroadDiagram-RFXS5EU6-bSpff55H.js} +1 -1
  83. package/web-dist/assets/{requirementDiagram-TGXJPOKE-DBBNi_SS.js → requirementDiagram-TGXJPOKE-DIAYvXoB.js} +1 -1
  84. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-BwFi7xFy.js → sankeyDiagram-HTMAVEWB-DEnsSz3F.js} +1 -1
  85. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-CeivqMeT.js → sequenceDiagram-DBY2YBRQ-DsG7eb9R.js} +1 -1
  86. package/web-dist/assets/{stateDiagram-2N3HPSRC-C6NZsnFm.js → stateDiagram-2N3HPSRC-DT3XNwjo.js} +1 -1
  87. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-C06v0Ltu.js +1 -0
  88. package/web-dist/assets/{swimlanes-5IMT3BWC-C0MubXTG.js → swimlanes-5IMT3BWC-B5hZwgRo.js} +1 -1
  89. package/web-dist/assets/swimlanesDiagram-G3AALYLV-CXyN7ZyO.js +8 -0
  90. package/web-dist/assets/{timeline-definition-FHXFAJF6-CtxwSvFu.js → timeline-definition-FHXFAJF6-BVTsjkfu.js} +1 -1
  91. package/web-dist/assets/{treeView-QDETBFTQ-CXcwSjey.js → treeView-QDETBFTQ-M6HmP4bB.js} +1 -1
  92. package/web-dist/assets/{treemap-6X3UGDF4-Uspgr-d1.js → treemap-6X3UGDF4-C60TppfC.js} +1 -1
  93. package/web-dist/assets/{vennDiagram-L72KCM5P-tZamQQAQ.js → vennDiagram-L72KCM5P-Dv1y3g4S.js} +1 -1
  94. package/web-dist/assets/{wardley-OPB4EBWU-0_79c19P.js → wardley-OPB4EBWU-D0lMJMWt.js} +1 -1
  95. package/web-dist/assets/{wardleyDiagram-EHGQE667-uYmUsP66.js → wardleyDiagram-EHGQE667-B1E5NtZY.js} +1 -1
  96. package/web-dist/assets/{xychartDiagram-FW5EYKEG-BsOU7Wf7.js → xychartDiagram-FW5EYKEG-oA91YOvh.js} +1 -1
  97. package/web-dist/index.html +2 -2
  98. package/web-dist/assets/architecture-TIHT7OUA-BZEZt7jZ.js +0 -1
  99. package/web-dist/assets/channel-DdFRPviT.js +0 -1
  100. package/web-dist/assets/classDiagram-OUVF2IWQ-Bf3duLb8.js +0 -1
  101. package/web-dist/assets/classDiagram-v2-EOCWNBFH-Bf3duLb8.js +0 -1
  102. package/web-dist/assets/eventmodeling-45OFAUF4-DOLxRQQ5.js +0 -1
  103. package/web-dist/assets/flowDiagram-23GEKE2U-DoK1yb0H.js +0 -1
  104. package/web-dist/assets/index-CmrDpsD8.css +0 -1
  105. package/web-dist/assets/index-Cs0TDGMu.js +0 -491
  106. package/web-dist/assets/railroad-abnf-AHOZXSZD-itqDLg1p.js +0 -1
  107. package/web-dist/assets/railroad-ebnf-EBAXGLYW-ze7rLVCi.js +0 -1
  108. package/web-dist/assets/railroad-peg-LSFZ7HO6-DKsYkEuG.js +0 -1
  109. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-B3YRnI4W.js +0 -1
  110. package/web-dist/assets/swimlanesDiagram-G3AALYLV-CXva4Ic9.js +0 -8
@@ -6,7 +6,7 @@
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
+ * goal — arm ONE piece of text frizz re-sends the caller, at every rest and/or on a clock
10
10
  * and/or after every compaction; and READ BACK what is currently armed.
11
11
  * timer — arm a ONE-OFF prompt for a single instant; a thread may hold many at once.
12
12
  *
@@ -110,30 +110,29 @@ const SPAWN_THREAD = {
110
110
  },
111
111
  }
112
112
 
113
- const RECURRING_PROMPT = {
114
- name: "recurring_prompt",
113
+ const GOAL = {
114
+ name: "goal",
115
115
  description:
116
- "Arm a RECURRING PROMPT on YOUR OWN thread: one piece of text that frizz re-sends you, on any or all " +
117
- "of three triggers, for as long as it is armed.\n\n" +
116
+ "Arm a GOAL on YOUR OWN thread: one piece of text that frizz re-sends you, on any or all of three " +
117
+ "triggers, for as long as it is armed. The board shows it as the thread's Goal. (This tool was " +
118
+ "named `goal` until 2026-08-28 — a summary or note that says so means this one.)\n\n" +
118
119
  " stop_hook — every time you come to REST. Use it to keep a long autonomous effort moving " +
119
120
  "without the human driving every step, and to rescue yourself from a wait that may never resolve.\n" +
120
121
  " heartbeat_seconds — on a CLOCK, whatever you are doing. This one reaches you MID-TURN: it arrives as " +
121
122
  "a queued message you read at your next tool boundary rather than waiting for you to stop, and it " +
122
123
  "never aborts what you are running. Use it for something that must be revisited on a schedule no " +
123
124
  "matter what you happen to believe at the time.\n" +
124
- " post_compaction — every time your CONTEXT IS COMPACTED, delivered into the emptied window. This " +
125
- "is how you survive compaction: write a doc in your scratch directory as you work, LINK IT in this " +
126
- "prompt, and the link comes back at the exact moment you have lost everything else. Also mid-turn — " +
127
- "a compaction happens while you are working.\n\n" +
128
- "Set at least one. The ordinary shape for a long effort is post_compaction plus stop_hook: you are " +
129
- "re-grounded whenever your context is summarized away, and prompted again whenever you stop.\n\n" +
125
+ " post_compaction — every time your CONTEXT IS COMPACTED, delivered into the emptied window. If " +
126
+ "you keep notes in your scratch directory, a prompt that LINKS them comes back at the exact moment " +
127
+ "you have lost everything else. Also mid-turn — a compaction happens while you are working.\n\n" +
128
+ "Set at least one; any combination is fine.\n\n" +
130
129
  "USE THIS RATHER THAN `CronCreate` or `ScheduleWakeup`. Those are Claude Code's own in-session " +
131
130
  "schedulers and they CANNOT fire in the runtime frizz runs you in: their gate stays shut for as long " +
132
131
  "as ANY background task of yours is outstanding, so the moment you are parked behind a background " +
133
132
  "shell or a sub-agent — exactly when you most need waking — they go silent. This one is delivered by " +
134
133
  "frizz itself and is unaffected.\n\n" +
135
134
  "READ IT BACK WITH `action: \"get\"` — and do that BEFORE any `start` that is not a fresh arming. A " +
136
- "thread has AT MOST ONE recurring prompt, so a `start` REPLACES whatever is there, triggers and all, " +
135
+ "thread has AT MOST ONE goal, so a `start` REPLACES whatever is there, triggers and all, " +
137
136
  "and the text you are about to destroy may not be yours: the HUMAN can edit it in the thread footer, " +
138
137
  "and a compaction can take your own memory of arming it. `get` answers with the exact text currently " +
139
138
  "armed, which triggers are on, the cadence, and when each trigger last fired. Reach for it whenever " +
@@ -156,7 +155,7 @@ const RECURRING_PROMPT = {
156
155
  type: "string",
157
156
  enum: ["start", "stop", "get"],
158
157
  description:
159
- "`start` arms (or replaces) this thread's recurring prompt; `stop` disarms it; `get` reads back " +
158
+ "`start` arms (or replaces) this thread's goal; `stop` disarms it; `get` reads back " +
160
159
  "what is armed right now — the text, the triggers, the cadence and each trigger's last delivery " +
161
160
  "— without changing anything. `get` takes no other argument.",
162
161
  },
@@ -183,9 +182,8 @@ const RECURRING_PROMPT = {
183
182
  post_compaction: {
184
183
  type: "boolean",
185
184
  description:
186
- "Also send it every time your context is compacted. Set this on any effort long enough to be " +
187
- "summarized, and make the prompt LINK the doc you are keeping in your scratch directory — that " +
188
- "link arriving in the emptied window is what lets you pick the work back up.",
185
+ "Also send it every time your context is compacted, into the emptied window — useful when the " +
186
+ "prompt links notes you keep in your scratch directory.",
189
187
  },
190
188
  },
191
189
  required: ["action"],
@@ -203,16 +201,16 @@ const TIMER = {
203
201
  description:
204
202
  "Set a ONE-OFF timer on YOUR OWN thread: a piece of text frizz hands back to you at ONE instant, " +
205
203
  "ONCE. Your own alarm clock.\n\n" +
206
- "It is `recurring_prompt`'s heartbeat with the repetition taken out, and it shares the property that " +
204
+ "It is `goal`'s heartbeat with the repetition taken out, and it shares the property that " +
207
205
  "matters: the delivery reaches you MID-TURN — a queued message you read at your next tool boundary — " +
208
206
  "so it arrives when you asked for it whether or not you have stopped, and it never aborts what you " +
209
- "are running. Unlike a recurring prompt it fires exactly once and then is gone, so there is nothing " +
207
+ "are running. Unlike a goal it fires exactly once and then is gone, so there is nothing " +
210
208
  "to switch off afterwards and nothing to sign off from.\n\n" +
211
209
  "You may have MANY armed at the same time, each with its own instant and its own text — they are " +
212
- "independent, unlike the single recurring prompt this thread can hold.\n\n" +
210
+ "independent, unlike the single goal this thread can hold.\n\n" +
213
211
  "USE IT for anything you want to come back to at a specific time: re-check a deploy in ten minutes, " +
214
212
  "re-read a slow log at the top of the hour, revisit a decision after a build finishes. USE " +
215
- "`recurring_prompt` instead when the thing must repeat, and remember that Claude Code's own " +
213
+ "`goal` instead when the thing must repeat, and remember that Claude Code's own " +
216
214
  "`CronCreate`/`ScheduleWakeup` cannot fire in the runtime frizz runs you in.\n\n" +
217
215
  "IT IS NOT A WAY TO POLL SOMETHING YOU COULD WAIT ON. If a background shell, a sub-agent or a " +
218
216
  "monitor can tell you the moment a thing happens, use that — an alarm every N seconds asking \"is it " +
@@ -315,6 +313,328 @@ const WATCH_PR = {
315
313
  },
316
314
  }
317
315
 
316
+ // The registry that replaces a ```awaiting fence's `shells:` line: a wait the worker CREATES rather
317
+ // than one it restates at every rest. Two tools rather than one action-switch, because they are two
318
+ // verbs and a worker reaching for `unwatch` should find `unwatch`. See plans/rest-by-registration.md.
319
+ const WATCH = {
320
+ name: "watch",
321
+ description:
322
+ "REGISTER A WAIT on something this thread already has running — a background shell, a sub-agent — " +
323
+ "and frizz holds your thread out of the queue until it finishes, then brings you back.\n\n" +
324
+ "IT IS THE WAIT, NOT A STATEMENT ABOUT ONE. A ```awaiting fence NAMES what you are waiting on and " +
325
+ "has the lifetime of the message carrying it, so it has to be rewritten at every single rest and is " +
326
+ "wrong the moment anything changes. This creates a ROW: it survives your turn ending, a compaction " +
327
+ "and a frizz restart, and it keeps holding your thread whatever you say next.\n\n" +
328
+ "`for` IS REQUIRED and it is a DURATION, never an instant. When it runs out the row is CANCELLED " +
329
+ "and you are woken to re-decide — that is deliberate, and it is what stops a wait outliving the " +
330
+ "reason you made it. Register again if you still mean it.\n\n" +
331
+ "THE TARGET IS CHECKED AGAINST WHAT IS ACTUALLY RUNNING, not against its shape. A handle nothing " +
332
+ "live answers to is REFUSED rather than stored, and so is a `kind` that disagrees with what frizz " +
333
+ "can see — a sub-agent registered as a shell is refused and told what it actually is. If you have " +
334
+ "lost an id (a compaction, a long turn), call `activity` rather than guessing.\n\n" +
335
+ "A SUB-AGENT ALREADY HOLDS YOUR THREAD without any registration, so the case this exists for is a " +
336
+ "background SHELL: frizz cannot tell a build you are waiting on from a dev server you started and " +
337
+ "moved on from, and only you know which it is.\n\n" +
338
+ "NEVER WATCH SOMETHING YOU INTEND TO OUTLIVE. A dev server, a log tail, a file watcher — those are " +
339
+ "things you started, not things you are waiting for, and registering one parks your thread on work " +
340
+ "that will never finish.\n\n" +
341
+ "REGISTERING IS IDEMPOTENT per (kind, target): asking twice returns the SAME id, says it was " +
342
+ "already armed, and leaves the original expiry alone — so re-registering after a compaction is safe " +
343
+ "and is the right instinct. Use `unwatch` to withdraw one. A PULL REQUEST is `watch_pr`, not this: " +
344
+ "that one polls GitHub and reports repeatedly.\n\n" +
345
+ "You can only ever watch work on your OWN thread.",
346
+ inputSchema: {
347
+ type: "object",
348
+ properties: {
349
+ kind: {
350
+ type: "string",
351
+ enum: ["shell", "agent"],
352
+ description:
353
+ "What the target IS. Checked against live telemetry, not taken on trust — the two kinds of " +
354
+ "handle are both opaque runtime strings and look identical, so frizz answers this exactly " +
355
+ "rather than guessing, and refuses a mismatch by name.",
356
+ },
357
+ target: {
358
+ type: "string",
359
+ description:
360
+ "The handle you were shown. For a shell that is the runtime's own background-task id " +
361
+ "(\"Command running in background with ID: bzvtnt3ig\"); its launch tool_use id and its " +
362
+ "command label are accepted too. For a sub-agent it is the dispatch id or its description. " +
363
+ "`activity` prints all of them.",
364
+ },
365
+ for: {
366
+ type: "string",
367
+ description:
368
+ "REQUIRED. How long to hold the wait, as a DURATION — `30m`, `2h`, `3d` (max 24h). Never an " +
369
+ "instant, and there is no default: choose it for THIS wait. When it elapses the row is " +
370
+ "cancelled and you are woken to re-decide, so an over-long guess costs a wait that outlives " +
371
+ "its reason and a too-short one costs one extra turn.",
372
+ },
373
+ },
374
+ required: ["kind", "target", "for"],
375
+ },
376
+ }
377
+
378
+ const UNWATCH = {
379
+ name: "unwatch",
380
+ description:
381
+ "WITHDRAW A WATCH you registered with `watch`, by its id. It stops holding your thread out of the " +
382
+ "queue and it will not wake you.\n\n" +
383
+ "Use it the moment a wait stops mattering — you decided not to wait for that build after all, or " +
384
+ "you are about to end the thread. A watch you no longer care about still parks you, and a thread " +
385
+ "parked on a wait nobody is waiting for is invisible to the human.\n\n" +
386
+ "You do NOT need this when the work simply finishes: frizz settles the row itself and wakes you. " +
387
+ "`activity` prints the id of everything you hold.",
388
+ inputSchema: {
389
+ type: "object",
390
+ properties: {
391
+ id: {
392
+ type: "string",
393
+ description: "The watch id `watch` returned (or that `activity` lists). Only your own thread's.",
394
+ },
395
+ },
396
+ required: ["id"],
397
+ },
398
+ }
399
+
400
+ // ---- `ask` / `unask`: a question the human owes an answer to, as a ROW ------------------------------
401
+
402
+ // The question tree, generated rather than written three times over. MCP tool schemas are JSON Schema,
403
+ // and a `$ref` cycle is the natural way to express a recursive shape — but client support for one is
404
+ // uneven, and a schema a client silently drops is a tool a worker cannot call. ASK_MAX_DEPTH is 3, so
405
+ // the nesting is INLINED to exactly that depth: `followUps` simply does not exist on the deepest level,
406
+ // which makes the limit visible in the schema instead of being a refusal the worker meets at runtime.
407
+ const ASK_MAX_DEPTH = 3
408
+ /** @param {number} depth 1 = the root question. @returns {Record<string, unknown>} */
409
+ function questionSchema(depth) {
410
+ const option = {
411
+ type: "object",
412
+ properties: {
413
+ label: { type: "string", description: "The choice itself, short — this is what the answer hands back to you." },
414
+ description: {
415
+ type: "string",
416
+ description:
417
+ "ONE LINE of trade-off. What this option costs, or why it is the one to take. An option list " +
418
+ "with no trade-offs asks the human to reconstruct your reasoning before they can choose.",
419
+ },
420
+ recommended: {
421
+ type: "boolean",
422
+ description:
423
+ "Mark the ONE option you would take, and put it first. At most one per question — a " +
424
+ "recommendation on two of three choices says nothing. IF YOU CAN MARK ONE, ASK YOURSELF WHY " +
425
+ "YOU ARE ASKING: you already know the answer, so implement it and say which way you went. " +
426
+ "This is for the fork you genuinely cannot take yourself.",
427
+ },
428
+ preview: {
429
+ type: "string",
430
+ description:
431
+ "Markdown revealed under this option when the human picks it — the diff it produces, the " +
432
+ "message that would be posted, the mockup. Use it when SEEING the outcome is what decides the " +
433
+ "question; skip it when the one-line trade-off already says everything.",
434
+ },
435
+ ...(depth < ASK_MAX_DEPTH
436
+ ? {
437
+ followUps: {
438
+ type: "array",
439
+ maxItems: 4,
440
+ description:
441
+ "Questions that become live ONLY if the human picks this option — the conditional " +
442
+ "branch. A branch nobody takes is never asked and never answered, so this is how you " +
443
+ "ask \"and if so, which?\" without asking it of somebody who said no. A `multi` " +
444
+ "question cannot carry these (several picked options would open several branches at " +
445
+ "once) and neither can a free-text one (there is no answer to branch on).",
446
+ items: questionSchema(depth + 1),
447
+ },
448
+ }
449
+ : {}),
450
+ },
451
+ required: ["label"],
452
+ }
453
+ return {
454
+ type: "object",
455
+ properties: {
456
+ question: {
457
+ type: "string",
458
+ description:
459
+ "THE QUESTION, on one line, in the human's own vocabulary. They have their original prompt " +
460
+ "and nothing else — not your plan, not your notes, not the names you coined while working. " +
461
+ "Lead with the behaviour, not the identifier. NO \"I\" AND NO \"you\": clicking an option is " +
462
+ "the HUMAN speaking, so first and second person flip between writer and reader. Name the " +
463
+ "actor outright instead.",
464
+ },
465
+ header: { type: "string", description: "A very short chip label for the card, 12 characters or so — \"Auth method\", \"Storage\"." },
466
+ kind: {
467
+ type: "string",
468
+ enum: ["question", "multi"],
469
+ description:
470
+ "`question` = pick ONE. `multi` = pick SEVERAL, for choices that are not mutually exclusive. " +
471
+ "A question with NO options at all is a free-text box, which is the right shape when you need " +
472
+ "a name, a value or a sentence rather than a decision between things you have enumerated.",
473
+ },
474
+ danger: {
475
+ type: "boolean",
476
+ description:
477
+ "The DESTRUCTIVE gate, and nothing softer: a force-push, a deletion, a history rewrite, a " +
478
+ "production rollback. It changes two things — the card wears the risk tone, and the human's " +
479
+ "x cannot dismiss it, because a generic close icon is not consent for something irreversible. " +
480
+ "Declining must therefore be one of your own options.",
481
+ },
482
+ options: {
483
+ type: "array",
484
+ maxItems: 8,
485
+ description: "Two to four is almost always right. Omit entirely for a free-text question.",
486
+ items: option,
487
+ },
488
+ },
489
+ required: ["question", "kind"],
490
+ }
491
+ }
492
+
493
+ const ASK = {
494
+ name: "ask",
495
+ description:
496
+ "ASK THE HUMAN SOMETHING YOU CANNOT DECIDE, as a ROW they still owe an answer to — not a fence in a " +
497
+ "message. It renders as an answerable card on the board and in the thread, and it STAYS there: it " +
498
+ "survives your turn ending, a compaction, a restart, and the transcript scrolling past. A fence has " +
499
+ "the lifetime of the message carrying it, which is why a question written into one is unanswerable " +
500
+ "an hour later.\n\n" +
501
+ "YOUR DEFAULT IS TO DECIDE, AND THIS TOOL DOES NOT CHANGE THAT. A reversible call costs minutes to " +
502
+ "redo; a round-trip to the human costs hours with the whole effort idle. Anything derivable from " +
503
+ "the code, the conventions or ordinary engineering judgement is yours: make it, say which way you " +
504
+ "went, and keep moving. THE TEST THAT CATCHES ALMOST EVERY BAD QUESTION: if you are about to mark " +
505
+ "one option `recommended`, you already know the answer — so implement it instead of asking.\n\n" +
506
+ "ASK WHEN A WRONG GUESS WOULD BE BOTH COSTLY AND HARD TO UNDO — something destructive or " +
507
+ "irreversible, an external-facing commitment, a security posture with real exposure, product or UX " +
508
+ "direction that is genuinely the human's taste to set. And ask when you KNOW the answer but cannot " +
509
+ "ACT on it: a merge, a publish, a spend, a comment that goes out under their name. Then the " +
510
+ "recommendation is the point, and it goes first.\n\n" +
511
+ "ASKING DOES NOT END YOUR TURN. A question waits on a person, so it carries no timeout and expires " +
512
+ "never — but you keep working. Do everything that does NOT depend on the answer first, and register " +
513
+ "the question at the moment you find it rather than saving it for the end.\n\n" +
514
+ "AND WHEN YOU DO STOP, THE OPEN QUESTION IS YOUR SIGN-OFF — rest normally. Frizz draws every open " +
515
+ "question at the rest you stopped at whether you mention it or not, so nothing you write can hide " +
516
+ "one. The card draws itself at the rest the question was asked — never write the question into " +
517
+ "your handoff, because a fence that names or restates a registered question draws nothing (one " +
518
+ "question, one card).\n\n" +
519
+ "SEVERAL AT ONCE IS ONE CALL. The card sends every answer as a unit, so a second `ask` for a second " +
520
+ "question just makes the human send twice. Register them together.\n\n" +
521
+ "The answer comes back to you as its own wake, restating what was asked. Withdraw one you no longer " +
522
+ "need with `unask` — a question you have since answered yourself, still sitting on the human's " +
523
+ "board, is worse than never having asked it.\n\n" +
524
+ "ON AN AUTONOMOUS THREAD THIS REFUSES, and tells you the standing instruction you are working " +
525
+ "under. A thread carrying a rest Goal has already been told to keep going and decide for itself, " +
526
+ "so the refusal is that instruction arriving at the moment it matters. Decide, and say which way " +
527
+ "you went in your write-up. If the call is genuinely the human's — destructive, irreversible, or " +
528
+ "an act you are not permitted to take — put it in your FINAL MESSAGE instead of here; autonomous " +
529
+ "does not mean nobody is reading.",
530
+ inputSchema: {
531
+ type: "object",
532
+ properties: {
533
+ questions: {
534
+ type: "array",
535
+ minItems: 1,
536
+ maxItems: 4,
537
+ description: "The questions to register, together. Each becomes its own card and its own row.",
538
+ items: questionSchema(1),
539
+ },
540
+ },
541
+ required: ["questions"],
542
+ },
543
+ }
544
+
545
+ const UNASK = {
546
+ name: "unask",
547
+ description:
548
+ "WITHDRAW A QUESTION you registered with `ask`, by its id. Its card disappears and the human is " +
549
+ "never asked.\n\n" +
550
+ "Use it the moment the question stops mattering: you worked out the answer yourself, the code moved " +
551
+ "and the fork is gone, or you are about to finish. A stale question on someone's board is worse " +
552
+ "than no question — they answer it, and the answer is about a decision that no longer exists.\n\n" +
553
+ "You do NOT need this for a question that gets answered; that settles itself and wakes you. " +
554
+ "Withdrawing is YOUR move and is never reported back to you as news.",
555
+ inputSchema: {
556
+ type: "object",
557
+ properties: {
558
+ id: { type: "string", description: "The question id `ask` returned, or that `activity` lists. Only your own thread's." },
559
+ },
560
+ required: ["id"],
561
+ },
562
+ }
563
+
564
+ const DONE = {
565
+ name: "done",
566
+ description:
567
+ "DECLARE THIS EFFORT FINISHED, with the write-up the human reads. Your thread cards as a checked " +
568
+ "success in their queue and stays there until they archive it — marking done is not dismissal, and " +
569
+ "it does not close, archive or hide anything.\n\n" +
570
+ "FRIZZ CAN REFUSE THIS, which is the whole reason it is a tool rather than a fence. An OPEN " +
571
+ "QUESTION or an ARMED REGISTRATION blocks it, and the refusal names each one by id: a question " +
572
+ "nobody answered dies with the card, and a live wait means the thing you were waiting for has not " +
573
+ "happened yet. Resolve them for real — answer it yourself and `unask`, or `unwatch` the wait you no " +
574
+ "longer need — then call again. There is no force parameter and there will not be one.\n\n" +
575
+ "IT ONLY COUNTS WHAT IS REGISTERED. A background shell or a sub-agent you never registered does " +
576
+ "not block this, because frizz cannot tell a build you are waiting on from a dev server you walked " +
577
+ "away from. That judgement is yours, and registering it is how you make it.\n\n" +
578
+ "DONE MEANS THE WORK LANDED, NOT THAT YOU STOPPED. Code committed to the project's mainline; a " +
579
+ "plan, doc or commissioned report written INTO A FILE. An open pull request is not done — the " +
580
+ "merge is. An investigation headed for a fix is not done — the fix is. And a verdict that ends in " +
581
+ "SOMEBODY SHOULD NOW DO SOMETHING (merge it, post this, pick one of these) is not done either: " +
582
+ "that is an `ask`, carrying your recommendation as the first option.\n\n" +
583
+ "THE TEST IS NEVER \"HAVE I STOPPED WORKING\". It is: WHAT IS LOST IF NOBODY EVER OPENS THIS " +
584
+ "THREAD AGAIN? Name one thing and you are not done. Uncertain is not done.",
585
+ inputSchema: {
586
+ type: "object",
587
+ properties: {
588
+ body: {
589
+ type: "string",
590
+ description:
591
+ "THE CARD, as markdown. One to three sentences, then a bullet per deliverable, each opening " +
592
+ "with a bolded verb phrase naming what shipped and where. Backtick every path, identifier and " +
593
+ "command, and make file references real links. It is a LEDGER, not a summary: reasoning, " +
594
+ "caveats and anything the human must do belong in your final message instead, because a " +
595
+ "sentence that would read the same in both places belongs in exactly one of them. Nothing " +
596
+ "here may point vaguely forward — no \"a follow-up could…\". Do it, ask about it, or drop it.",
597
+ },
598
+ },
599
+ required: ["body"],
600
+ },
601
+ }
602
+
603
+ const TITLE = {
604
+ name: "title",
605
+ description:
606
+ "NAME THIS THREAD on the human's board, once you actually know what the work is.\n\n" +
607
+ "WHY IT EXISTS: the name your thread is wearing right now was minted the instant you were " +
608
+ "dispatched, from the raw text of the prompt, before you had read a single file. It can only ever " +
609
+ "paraphrase what the operator typed — so it inherits their shorthand, their ambiguity and their " +
610
+ "typos. One zod thread went onto the board as \"Zon4.5 features and z.properties documentation " +
611
+ "audit\" because the operator typed \"Zon4.5\" and nothing in the session yet knew the product is " +
612
+ "called Zod. You know. That is the entire point of this tool.\n\n" +
613
+ "WHEN TO CALL IT: after you have oriented — read the issue, opened the code, found the bug — and " +
614
+ "can name the actual work in your own words. Not on arrival: a name you register before you " +
615
+ "understand the task is the same guess the board already has. Once is normally enough; call it " +
616
+ "again only if the work turns out to be genuinely something else.\n\n" +
617
+ "NAME THE WORK, NOT THE PROMPT. \"Is this true? We should probably…\" is what the human said, not " +
618
+ "what you are doing. A good name is the thing a reader picking one card out of thirty needs: the " +
619
+ "subject and the verb.\n\n" +
620
+ "A HUMAN RENAME OUTRANKS YOU, always. If the human has already named this thread, frizz refuses " +
621
+ "this and tells you so — that is a correct answer, not a failure, and you should not retry it.",
622
+ inputSchema: {
623
+ type: "object",
624
+ properties: {
625
+ title: {
626
+ type: "string",
627
+ description:
628
+ "The thread's name: 3-8 words, SENTENCE case (capitalize only the first word and proper " +
629
+ "nouns — \"Fix queue focus\", never \"Fix Queue Focus\"). No trailing period, no ticks, no " +
630
+ "issue-body quoting. Spell every product, file and identifier the way the PROJECT spells it, " +
631
+ "not the way the prompt did.",
632
+ },
633
+ },
634
+ required: ["title"],
635
+ },
636
+ }
637
+
318
638
  // The unified server's tool registry: `tools/list` returns these and `tools/call` routes by name.
319
639
  // Adding a worker-facing frizz tool = one entry here + one handler in `HANDLERS` — never a second
320
640
  // MCP server, so every frizz tool stays under the same `mcp__frizz__*` namespace and the same
@@ -325,43 +645,92 @@ const MAX_INTERVAL_SECONDS = 24 * 60 * 60
325
645
  const ACTIVITY = {
326
646
  name: "activity",
327
647
  description:
328
- "EVERYTHING YOU CURRENTLY HAVE RUNNING, with the id each one is named by — your background shells, " +
329
- "your sub-agents, your armed timers, and the pull requests you registered.\n\n" +
648
+ "EVERYTHING YOU CURRENTLY HAVE OUT, with the id each one is named by — your background shells, your " +
649
+ "sub-agents, your armed timers, the pull requests you registered, the `wch_…` of every watch holding " +
650
+ "one of them, and every QUESTION still owed an answer.\n\n" +
330
651
  "WHY YOU NEED IT: an ```awaiting fence names what you are waiting on BY ID, and frizz checks every " +
331
652
  "one against what is actually live. A name that matches nothing is not a park — you are bumped and " +
332
- "your thread queues. So if you have lost an id (a compaction, a long turn, a wake you did not " +
333
- "expect), call this rather than guessing. Guessing is the failure this tool exists to remove.\n\n" +
653
+ "your thread queues. The same goes for the ids `unwatch` and `unask` take, and for the id you put in " +
654
+ "a ```question fence to PLACE a registered question in your handoff. So if you have lost one (a " +
655
+ "compaction, a long turn, a wake you did not expect), call this rather than guessing. Guessing is " +
656
+ "the failure this tool exists to remove — and it is the only way to read your open questions " +
657
+ "WITHOUT registering or withdrawing one.\n\n" +
334
658
  "It takes nothing and changes nothing. You can only ever read your OWN thread.",
335
659
  inputSchema: { type: "object", properties: {}, required: [] },
336
660
  }
337
661
 
338
- const TOOLS = [SPAWN_THREAD, RECURRING_PROMPT, TIMER, WATCH_PR, ACTIVITY]
662
+ const TOOLS = [SPAWN_THREAD, GOAL, TIMER, WATCH_PR, WATCH, UNWATCH, ASK, UNASK, DONE, TITLE, ACTIVITY]
339
663
 
340
664
  /** @type {Record<string, (args: Record<string, unknown>) => Promise<string>>} */
341
665
  const HANDLERS = {
342
666
  [SPAWN_THREAD.name]: spawnThread,
343
- [RECURRING_PROMPT.name]: recurringPrompt,
667
+ [GOAL.name]: goal,
344
668
  [TIMER.name]: timer,
345
669
  [WATCH_PR.name]: watchPr,
670
+ [WATCH.name]: watch,
671
+ [ASK.name]: ask,
672
+ [UNASK.name]: unask,
673
+ [DONE.name]: done,
674
+ [TITLE.name]: title,
675
+ [UNWATCH.name]: unwatch,
346
676
  [ACTIVITY.name]: activity,
347
677
  }
348
678
 
679
+ /** The `title` handler: register this thread's considered name.
680
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
681
+ async function title(args) {
682
+ const slug = threadSlug()
683
+ const wanted = typeof args.title === "string" ? args.title.trim() : ""
684
+ if (!wanted) throw new Error("`title` is required — 3-8 words naming the work, in sentence case")
685
+ const result = (await callRpc("setOwnThreadTitle", { slug, title: wanted }))?.result
686
+ if (result?.accepted) return `This thread is now named "${result.title}" on the board.`
687
+ // The refusal is REPORTED, never thrown: a human who renamed the thread owns its name, and a worker
688
+ // told "error" would retry a call that can only ever fail again.
689
+ if (result?.lockedByHuman) {
690
+ return (
691
+ `Not renamed — the human has named this thread "${result.title}" themselves, and their name ` +
692
+ "outranks yours. Leave it; do not call this again for this thread."
693
+ )
694
+ }
695
+ return `Not renamed — frizz did not accept the write. This thread still reads "${result?.title ?? slug}".`
696
+ }
697
+
349
698
  /** Read out every background thing this thread has running, in the shape an awaiting fence names them.
350
699
  * @returns {Promise<string>} */
351
700
  async function activity() {
352
701
  const result = (await callRpc("listOwnThreadActivity", { slug: threadSlug() }))?.result
353
702
  const items = Array.isArray(result?.activity) ? result.activity : []
703
+ const questions = Array.isArray(result?.questions) ? result.questions : []
704
+ // THE QUESTIONS ARE NOT PART OF THE FENCE, so they are printed in their own section and never fed to
705
+ // the fence builder below. A question waits on a person; there is no `questions:` key to write it into.
706
+ const askedBlock = questions.length === 0 ? "" : (
707
+ `\n\n${questions.length} question${questions.length === 1 ? "" : "s"} still owed an answer:\n\n` +
708
+ questions.map((q) => ` question: ${q.id}\n ${String(q?.spec?.question ?? "").replace(/\s+/g, " ").slice(0, 160)}`).join("\n") +
709
+ "\n\nEach one blocks `done` until it is answered or withdrawn, and draws its own card at the rest " +
710
+ "it was asked — never write it into a handoff. `unask` the ones since decided. A question is never " +
711
+ "named in an ```awaiting fence."
712
+ )
354
713
  if (!items.length) {
714
+ if (questions.length > 0) {
715
+ return (
716
+ "Nothing is RUNNING on this thread — no background shells, no sub-agents, no armed timers, no " +
717
+ "registered PRs. So an ```awaiting fence would have nothing to name, and a fence naming nothing " +
718
+ "is not a park." + askedBlock
719
+ )
720
+ }
355
721
  return (
356
722
  "Nothing is running on this thread — no background shells, no sub-agents, no armed timers, no " +
357
- "registered PRs.\n\nSo there is nothing to wait on: an ```awaiting fence would have nothing to " +
358
- "name, and a fence naming nothing is not a park. End with ```done, or with a ```question if you " +
359
- "need the human."
723
+ "registered PRs, and no open questions.\n\nSo there is nothing to wait on: an ```awaiting fence " +
724
+ "would have nothing to name, and a fence naming nothing is not a park. End with ```done, or with " +
725
+ "a ```question if you need the human."
360
726
  )
361
727
  }
362
728
  const lines = items.map((i) => {
363
729
  const when = i.until ? ` (fires ${i.until})` : i.since ? ` (since ${i.since})` : ""
364
- return ` ${i.kind}: ${i.id}${when}\n ${i.label}`
730
+ // The `wch_…` id of the watch holding this item, where one is armed — this readout exists to hand a
731
+ // worker back the ids it lost, and that includes the one `unwatch` takes.
732
+ const held = i.watchId ? ` [watched as ${i.watchId}]` : ""
733
+ return ` ${i.kind}: ${i.id}${when}${held}\n ${i.label}`
365
734
  })
366
735
  // A READY-TO-PASTE FENCE, not a description of one. The frontmatter is YAML since 2026-08-24 and its
367
736
  // keys are PLURAL sequences, so an id printed on its own line is no longer something a worker can copy
@@ -378,7 +747,10 @@ async function activity() {
378
747
  "PLURAL key per kind, taking a list — plus a required `for:` duration, and your handoff prose BELOW " +
379
748
  "the `---` (there is no `reason:` key).\n\nEverything above, as a fence:\n\n```awaiting\n" +
380
749
  `${block.join("\n")}\n for: 2h\n ---\n <what you are waiting for, and what you will do when it lands>\n` +
381
- "```\n\nDrop the lines you are not actually waiting on — a dev server you left running is not a wait."
750
+ "```\n\nDrop the lines you are not actually waiting on — a dev server you left running is not a wait." +
751
+ "\n\nBETTER THAN NAMING A SHELL IN THE FENCE: `watch` REGISTERS the wait, so it survives your turn " +
752
+ "ending and you never restate it. Anything already marked `[watched as …]` above needs no fence line." +
753
+ askedBlock
382
754
  )
383
755
  }
384
756
 
@@ -641,34 +1013,40 @@ async function callRpc(procedure, body) {
641
1013
  * FRIZZ_THREAD is the fallback: every frizz worker process is tagged with it, so it is right
642
1014
  * whenever the env is inherited — but it is not relied upon, hence the explicit var first.
643
1015
  *
644
- * This is also the reason a model can never point `recurring_prompt` at someone else's thread: the slug is
1016
+ * This is also the reason a model can never point `goal` at someone else's thread: the slug is
645
1017
  * read from HERE, never from the tool arguments. */
646
1018
  function threadSlug() {
647
1019
  const slug = process.env.FRIZZ_THREAD_SLUG || process.env.FRIZZ_THREAD
648
1020
  if (!slug) {
649
1021
  throw new Error(
650
1022
  "this frizz MCP server was not told which thread it belongs to (no FRIZZ_THREAD_SLUG), so it cannot " +
651
- "arm a recurring prompt for it. This is a frizz bug — report it rather than working around it.",
1023
+ "arm a goal for it. This is a frizz bug — report it rather than working around it.",
652
1024
  )
653
1025
  }
654
1026
  return slug
655
1027
  }
656
1028
 
657
1029
  /** How a heartbeat cadence reads back to the worker. ONE formatter, because `start` and `get` describe
658
- * the same stored number and a worker that saw "every 15 min" armed must not read "every 900s" back.
1030
+ * the same stored number and a worker that saw "every 15m" armed must not read "every 900s" back.
1031
+ *
1032
+ * The house duration grammar (`packages/web/src/lib/durationLabels.ts`), matching the trailer
1033
+ * `formatIntervalLabel` writes into the delivery itself — a worker reads both.
659
1034
  * @param {number|undefined} seconds */
660
1035
  function cadenceLabel(seconds) {
661
1036
  if (typeof seconds !== "number" || !Number.isFinite(seconds)) return undefined
662
- return seconds % 60 === 0 ? `${seconds / 60} min` : `${seconds}s`
1037
+ if (seconds % 60 !== 0) return `${seconds}s`
1038
+ const minutes = seconds / 60
1039
+ if (minutes < 60) return `${minutes}m`
1040
+ return minutes % 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${Math.floor(minutes / 60)}h`
663
1041
  }
664
1042
 
665
- /** Render an armed recurring prompt for the worker to read: which triggers are live, the cadence, when
1043
+ /** Render an armed goal for the worker to read: which triggers are live, the cadence, when
666
1044
  * each last fired, and the text VERBATIM (never truncated — reading back a summary of your own
667
1045
  * instruction is exactly as blind as not reading it).
668
1046
  * @param {{ prompt: string, stopHook: boolean, heartbeat: boolean, postCompaction: boolean,
669
1047
  * intervalSeconds?: number, armedAt: string, lastRestFiredAt?: string,
670
1048
  * lastScheduleFiredAt?: string, lastCompactFiredAt?: string }} rp */
671
- function recurringPromptReport(rp) {
1049
+ function goalReport(rp) {
672
1050
  const fired = (/** @type {string|undefined} */ at) => (at ? `last fired ${at}` : "never fired yet")
673
1051
  const triggers = [
674
1052
  rp.stopHook ? ` stop_hook — every time you come to rest (${fired(rp.lastRestFiredAt)})` : null,
@@ -687,9 +1065,9 @@ function recurringPromptReport(rp) {
687
1065
  return `${head}\n\nThe text, verbatim:\n\n${rp.prompt}`
688
1066
  }
689
1067
 
690
- /** The `recurring_prompt` handler: arm, disarm, or READ BACK this thread's re-prompt.
1068
+ /** The `goal` handler: arm, disarm, or READ BACK this thread's re-prompt.
691
1069
  * @param {Record<string, unknown>} args @returns {Promise<string>} */
692
- async function recurringPrompt(args) {
1070
+ async function goal(args) {
693
1071
  const slug = threadSlug()
694
1072
  const action = typeof args.action === "string" ? args.action.trim() : ""
695
1073
  if (action !== "start" && action !== "stop" && action !== "get") {
@@ -714,18 +1092,18 @@ async function recurringPrompt(args) {
714
1092
  throw err
715
1093
  }
716
1094
  const rp = payload?.result?.recurringPrompt
717
- if (!rp) return "No recurring prompt is armed on this thread. Nothing will re-prompt you."
718
- return recurringPromptReport(rp)
1095
+ if (!rp) return "No goal is armed on this thread. Nothing will re-prompt you."
1096
+ return goalReport(rp)
719
1097
  }
720
1098
 
721
1099
  if (action === "stop") {
722
1100
  await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false, postCompaction: false })
723
- 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."
1101
+ return "Goal 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."
724
1102
  }
725
1103
 
726
1104
  const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
727
1105
  if (!prompt) {
728
- throw new Error("`prompt` is required to start a recurring prompt — it is the text you will be sent on every trigger")
1106
+ throw new Error("`prompt` is required to start a goal — it is the text you will be sent on every trigger")
729
1107
  }
730
1108
 
731
1109
  const hasHeartbeat = args.heartbeat_seconds !== undefined && args.heartbeat_seconds !== null
@@ -774,15 +1152,15 @@ async function recurringPrompt(args) {
774
1152
  // Spelled out in full, not summarized: if this overwrote the human's own edit, the words themselves
775
1153
  // are the only way the worker can put them back.
776
1154
  const superseded = replaced
777
- ? `\n\nIT REPLACED an existing recurring prompt — check that discarding it was intended, and restore ` +
778
- `it with another \`start\` if it was not:\n\n${recurringPromptReport(replaced)}\n`
1155
+ ? `\n\nIT REPLACED an existing goal — check that discarding it was intended, and restore ` +
1156
+ `it with another \`start\` if it was not:\n\n${goalReport(replaced)}\n`
779
1157
  : ""
780
1158
  // NO QUESTION HOLD ANY MORE (2026-08-16). Every trigger fires while you are waiting on the human, and
781
1159
  // the at-rest one fires over your own unanswered ```question fence — the delivery says so, and expects
782
1160
  // you to decide the question yourself rather than re-ask it. A ```done fence, and an ```awaiting on a
783
1161
  // wait frizz itself will deliver, still stop the at-rest trigger.
784
1162
  return (
785
- `Recurring prompt armed — frizz will send you this ${when}.${superseded}\n\n` +
1163
+ `Goal armed — frizz will send you this ${when}.${superseded}\n\n` +
786
1164
  "Call this tool again with `action: \"stop\"` once the work it drives is finished — one left armed on " +
787
1165
  "a finished thread wakes it forever. The human can also edit or switch it off in the thread footer. " +
788
1166
  "Signing off with a ```done fence stops it too, but only when there is genuinely nothing left: it " +
@@ -891,6 +1269,143 @@ function armedPrWatchList(result) {
891
1269
  return `Watched on this thread now:\n${lines.join("\n")}`
892
1270
  }
893
1271
 
1272
+ /** The armed watches on this thread, as the read-back prints them. */
1273
+ function armedWatchList(result) {
1274
+ const watches = Array.isArray(result?.watches) ? result.watches : []
1275
+ if (!watches.length) return "No watches are armed on this thread — nothing here is holding it out of the queue."
1276
+ const lines = watches.map((w) => {
1277
+ const what = w.kind === "agent" ? "sub-agent" : "shell"
1278
+ // The LABEL is frizz's live reading, not a copy stored at registration — so it names the work as it
1279
+ // stands, and its ABSENCE means the target no longer resolves to anything running.
1280
+ const name = w.label ? `${w.label} (${w.target})` : w.target
1281
+ return ` ${w.id} ${what}: ${name} — expires ${w.expiresAt}`
1282
+ })
1283
+ return `Armed on this thread now:\n${lines.join("\n")}`
1284
+ }
1285
+
1286
+ /** The `watch` handler: register a wait on this thread's own running work.
1287
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1288
+ async function watch(args) {
1289
+ const slug = threadSlug()
1290
+ const kind = typeof args.kind === "string" ? args.kind.trim() : ""
1291
+ if (kind !== "shell" && kind !== "agent") throw new Error("`kind` must be \"shell\" or \"agent\"")
1292
+ const target = typeof args.target === "string" ? args.target.trim() : ""
1293
+ if (!target) throw new Error("`target` is required — the handle you were shown; `activity` prints them all")
1294
+ const forValue = typeof args.for === "string" ? args.for.trim() : ""
1295
+ if (!forValue) throw new Error("`for` is required — a DURATION like `30m`, `2h` or `3d` (max 24h), never an instant")
1296
+ const result = (await callRpc("addOwnWatch", { slug, kind, target, for: forValue }))?.result
1297
+ const id = result?.id ?? "(unknown)"
1298
+ const head = result?.alreadyArmed
1299
+ ? `Already watching \`${target}\` as ${id} — nothing new was registered, and its original expiry stands.`
1300
+ : `Watching \`${target}\` as ${id}. Your thread is held out of the queue until it finishes, and the ` +
1301
+ "registration survives your turn ending, a compaction and a frizz restart."
1302
+ return (
1303
+ `${head}\n\nWHEN \`for\` RUNS OUT the row is CANCELLED and you are woken to re-decide — register ` +
1304
+ `again if you still mean it.\n\nDROP IT the moment it stops mattering (\`unwatch\`, id \`${id}\`); ` +
1305
+ `you do NOT need to when the work simply finishes.\n\n${armedWatchList(result)}`
1306
+ )
1307
+ }
1308
+
1309
+ /** The `unwatch` handler: withdraw one registered watch by id.
1310
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1311
+ async function unwatch(args) {
1312
+ const slug = threadSlug()
1313
+ const id = typeof args.id === "string" ? args.id.trim() : ""
1314
+ if (!id) throw new Error("`id` is required — take it from `watch` or from `activity`")
1315
+ const result = (await callRpc("dropOwnWatch", { slug, id }))?.result
1316
+ // A drop that matched nothing is reported rather than swallowed: the id was wrong, already settled, or
1317
+ // another thread's — and a worker that believes it withdrew a wait it still holds will rest on it.
1318
+ const head = result?.dropped
1319
+ ? `Watch ${id} dropped. It is no longer holding your thread, and it will not wake you.`
1320
+ : `No ARMED watch ${id} on this thread — it was already settled, or the id is not one of yours.`
1321
+ return `${head}\n\n${armedWatchList(result)}`
1322
+ }
1323
+
1324
+ /** Read back what the human still owes an answer on, so a worker never needs a second call to find out.
1325
+ * @param {Record<string, unknown> | undefined} result @returns {string} */
1326
+ function openQuestionList(result) {
1327
+ const open = Array.isArray(result?.open) ? result.open : []
1328
+ if (!open.length) return "Nothing else is open on this thread — the human owes you no answer."
1329
+ const lines = open.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
1330
+ return `Open on this thread now:\n${lines.join("\n")}`
1331
+ }
1332
+
1333
+ /** The `ask` handler: register one or more questions the human owes an answer to.
1334
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1335
+ async function ask(args) {
1336
+ const slug = threadSlug()
1337
+ const questions = Array.isArray(args.questions) ? args.questions : []
1338
+ if (!questions.length) throw new Error("`questions` is required — at least one question to register")
1339
+ const result = (await callRpc("ask", { slug, questions }))?.result
1340
+ const registered = Array.isArray(result?.registered) ? result.registered : []
1341
+ const lines = registered.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
1342
+ const head = registered.length === 1
1343
+ ? `Registered 1 question. It is on the human's board now and it will stay there until they answer it.`
1344
+ : `Registered ${registered.length} questions. They are on the human's board now and they will stay ` +
1345
+ "there until answered — the card sends every answer as one batch."
1346
+ return (
1347
+ `${head}\n${lines.join("\n")}\n\n` +
1348
+ "KEEP WORKING. A question waits on a person, carries no timeout and does not end your turn — do " +
1349
+ "everything that does not depend on the answer while it sits there. The answer arrives as its own " +
1350
+ "wake, restating what was asked.\n\n" +
1351
+ "WITHDRAW ONE THE MOMENT IT STOPS MATTERING (`unask`), above all if you work the answer out " +
1352
+ `yourself.\n\n${openQuestionList(result)}`
1353
+ )
1354
+ }
1355
+
1356
+ /** The `unask` handler: withdraw one registered question by id.
1357
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1358
+ async function unask(args) {
1359
+ const slug = threadSlug()
1360
+ const id = typeof args.id === "string" ? args.id.trim() : ""
1361
+ if (!id) throw new Error("`id` is required — take it from `ask`")
1362
+ const result = (await callRpc("unask", { slug, id }))?.result
1363
+ // A withdrawal that matched nothing is reported rather than swallowed: the id was wrong, the human
1364
+ // already answered it, or it is another thread's — and a worker that believes it withdrew a question
1365
+ // the human is still looking at will get an answer it has stopped expecting.
1366
+ const head = result?.withdrawn
1367
+ ? `Question ${id} withdrawn. Its card is gone and the human will not be asked.`
1368
+ : `No OPEN question ${id} on this thread — it was already answered or dismissed, or the id is not one of yours.`
1369
+ return `${head}\n\n${openQuestionList(result)}`
1370
+ }
1371
+
1372
+ /** The `done` handler: declare the effort finished, or report exactly what refuses to let it.
1373
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1374
+ async function done(args) {
1375
+ const slug = threadSlug()
1376
+ const body = typeof args.body === "string" ? args.body.trim() : ""
1377
+ if (!body) throw new Error("`body` is required — the write-up the human reads on the card")
1378
+ const result = (await callRpc("markOwnDone", { slug, body }))?.result
1379
+ if (result?.done) {
1380
+ return (
1381
+ "Marked done. Your thread cards as a checked success in the human's queue and stays there until " +
1382
+ "they archive it.\n\nNOTHING WAS CLOSED, HIDDEN OR ARCHIVED — if there is more to say, say it in " +
1383
+ "your final message; if more work appears, keep going and call this again."
1384
+ )
1385
+ }
1386
+ // REFUSED, with everything that refuses it named by id, so the next move is a tool call and not a
1387
+ // guess. Reported as an ordinary result rather than thrown: this is a gate doing its job, not a fault.
1388
+ const questions = (result?.blockingQuestions ?? []).map((q) => ` ${q.id} ${(q.question ?? "").split("\n")[0]}`)
1389
+ const watches = (result?.blockingWatches ?? []).map((w) => ` ${w.id} ${w.what}`)
1390
+ const parts = ["NOT marked done. This thread still holds work open."]
1391
+ if (questions.length) {
1392
+ parts.push(
1393
+ `${questions.length} question${questions.length === 1 ? "" : "s"} the human has not answered:\n${questions.join("\n")}\n` +
1394
+ "Each one dies unread with a done card. Decide it yourself and withdraw it (`unask`), or leave it " +
1395
+ "open and keep working until it is answered.",
1396
+ )
1397
+ }
1398
+ if (watches.length) {
1399
+ parts.push(
1400
+ `${watches.length} registration${watches.length === 1 ? "" : "s"} still armed:\n${watches.join("\n")}\n` +
1401
+ "A live wait means the thing you were waiting for has not happened. Wait for it, or drop the ones " +
1402
+ "that stopped mattering (`unwatch`, or `watch_pr` with `action: \"drop\"`, or `timer` cancel).",
1403
+ )
1404
+ }
1405
+ parts.push("There is no force parameter. Resolve them and call `done` again.")
1406
+ return parts.join("\n\n")
1407
+ }
1408
+
894
1409
  /** The `watch_pr` handler: register, withdraw, or read back this thread's PR watchers.
895
1410
  * @param {Record<string, unknown>} args @returns {Promise<string>} */
896
1411
  async function watchPr(args) {