frizz-server 0.15.22 → 0.15.23

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 (112) hide show
  1. package/dist/claude-agent-broker.js +347 -27
  2. package/dist/dev-child.js +4762 -2746
  3. package/package.json +1 -1
  4. package/runtime/cc-worker/DECISIONS.md +36 -0
  5. package/runtime/cc-worker/bin/frizz-mcp.mjs +11 -1850
  6. package/runtime/cc-worker/bin/frizzctl +14 -0
  7. package/runtime/cc-worker/bin/frizzctl.cmd +9 -0
  8. package/runtime/cc-worker/bin/frizzctl.mjs +438 -0
  9. package/runtime/cc-worker/hooks/agent-dispatch.mjs +2 -2
  10. package/runtime/cc-worker/hooks/deny-ask.mjs +3 -3
  11. package/runtime/cc-worker/hooks/deny-plan.mjs +2 -2
  12. package/runtime/cc-worker/hooks/perm-policy.mjs +42 -0
  13. package/runtime/cc-worker/hooks/scratchpad.mjs +16 -10
  14. package/runtime/cc-worker/hooks/session-seed.mjs +1 -1
  15. package/runtime/cc-worker/lib/frizz-tools.mjs +2241 -0
  16. package/runtime/cc-worker/skills/gh/SKILL.md +6 -6
  17. package/web-dist/assets/{abnfDiagram-VRR7QNED-DqTQZ2Ex.js → abnfDiagram-VRR7QNED-BW_CIF9J.js} +1 -1
  18. package/web-dist/assets/{arc-t56zg8G8.js → arc-B475yv46.js} +1 -1
  19. package/web-dist/assets/architecture-TIHT7OUA-wAP7tOoN.js +1 -0
  20. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-CSdC9-eQ.js → architectureDiagram-ZJ3FMSHR-DkPvFjFp.js} +1 -1
  21. package/web-dist/assets/{blockDiagram-677ZJIJ3-Dmw8EopV.js → blockDiagram-677ZJIJ3-Rls7Nttu.js} +1 -1
  22. package/web-dist/assets/{c4Diagram-LMCZKHZV-BCRaFnis.js → c4Diagram-LMCZKHZV-maV0q2bv.js} +1 -1
  23. package/web-dist/assets/channel-Clg0HJPA.js +1 -0
  24. package/web-dist/assets/{chunk-32BRIVSS-DqOJDRw_.js → chunk-32BRIVSS-Coi7L0de.js} +1 -1
  25. package/web-dist/assets/{chunk-52WLFC77-BsibludT.js → chunk-52WLFC77-DZaDe-48.js} +1 -1
  26. package/web-dist/assets/{chunk-C7G6YPKG-D7Me5JQU.js → chunk-C7G6YPKG-DNQ-JhD9.js} +1 -1
  27. package/web-dist/assets/{chunk-EX3LRPZG-CTa4YlOO.js → chunk-EX3LRPZG-BQCebwoA.js} +1 -1
  28. package/web-dist/assets/{chunk-FWX5IMBZ-CjqOt5Jn.js → chunk-FWX5IMBZ-CqiHtpEB.js} +2 -2
  29. package/web-dist/assets/{chunk-HOUHSVGY-BDIBDR-8.js → chunk-HOUHSVGY-Bop69PFg.js} +1 -1
  30. package/web-dist/assets/{chunk-ICXQ74PX-C4exiYRm.js → chunk-ICXQ74PX-D3PkeZ6B.js} +1 -1
  31. package/web-dist/assets/{chunk-MOJQB5TN-g8M_GVaO.js → chunk-MOJQB5TN-lidXw8-C.js} +1 -1
  32. package/web-dist/assets/{chunk-OGEWGWER-C_80lLFe.js → chunk-OGEWGWER-CzD91FAh.js} +1 -1
  33. package/web-dist/assets/{chunk-PUDLZKDR-B73Odmxv.js → chunk-PUDLZKDR-Cilkzvck.js} +1 -1
  34. package/web-dist/assets/{chunk-Q4XR5HBZ-D-VkroM1.js → chunk-Q4XR5HBZ-C37UQCfb.js} +1 -1
  35. package/web-dist/assets/{chunk-RYQCIY6F-DmPJ8s-8.js → chunk-RYQCIY6F-C0qnX-Ux.js} +1 -1
  36. package/web-dist/assets/{chunk-V7JOEXUC-BQKrRLYX.js → chunk-V7JOEXUC-Da5vOGHX.js} +1 -1
  37. package/web-dist/assets/{chunk-VAUOI2AC-DRKYvsWa.js → chunk-VAUOI2AC-FrJadrJY.js} +1 -1
  38. package/web-dist/assets/{chunk-VR4S4FIN-DaMMs6V-.js → chunk-VR4S4FIN-DYxK2Z50.js} +1 -1
  39. package/web-dist/assets/{chunk-WYO6CB5R-CQAWjeZH.js → chunk-WYO6CB5R-C164KTfT.js} +1 -1
  40. package/web-dist/assets/{chunk-XXDRQBXY-DJvt-abB.js → chunk-XXDRQBXY-D4wHtxet.js} +1 -1
  41. package/web-dist/assets/{chunk-ZGVPDNZ5-B1v24MrX.js → chunk-ZGVPDNZ5-BXKkVljT.js} +1 -1
  42. package/web-dist/assets/classDiagram-OUVF2IWQ-23bCXfvg.js +1 -0
  43. package/web-dist/assets/classDiagram-v2-EOCWNBFH-23bCXfvg.js +1 -0
  44. package/web-dist/assets/{cose-bilkent-JH36ORCC-CeNttASr.js → cose-bilkent-JH36ORCC-BFSgVzNT.js} +1 -1
  45. package/web-dist/assets/{cynefin-VYW2F7L2-ClJzZ-Yx.js → cynefin-VYW2F7L2-BSoEbT-s.js} +1 -1
  46. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-BCrL5Bu_.js → cynefinDiagram-TSTJHNR4-BBZgLbdX.js} +1 -1
  47. package/web-dist/assets/{dagre-VKFMJZFB-DO7qy19A.js → dagre-VKFMJZFB-Dx6giO7u.js} +1 -1
  48. package/web-dist/assets/{diagram-FQU43EPY-KPbJc2w8.js → diagram-FQU43EPY-BMI3qRca.js} +1 -1
  49. package/web-dist/assets/{diagram-G47NLZAW-9tqHFXzf.js → diagram-G47NLZAW-CJY-MA3L.js} +1 -1
  50. package/web-dist/assets/{diagram-NH7WQ7WH-BUbmU0cY.js → diagram-NH7WQ7WH-CbQVFJ3r.js} +1 -1
  51. package/web-dist/assets/{diagram-OA4YK3LP-QmMo96Qi.js → diagram-OA4YK3LP-CPnrakdY.js} +1 -1
  52. package/web-dist/assets/{diagram-WEI45ONY-BYAyhW16.js → diagram-WEI45ONY-D9DNwzlo.js} +1 -1
  53. package/web-dist/assets/{dist-Cewx01EF.js → dist-CO93_xVa.js} +1 -1
  54. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-BIMiEDl1.js → ebnfDiagram-CCIWWBDH-DNHudB8r.js} +1 -1
  55. package/web-dist/assets/{erDiagram-Q63AITRT-aLYQSs2d.js → erDiagram-Q63AITRT-Cwt63Xz8.js} +1 -1
  56. package/web-dist/assets/eventmodeling-45OFAUF4-Q3Nq1ecc.js +1 -0
  57. package/web-dist/assets/flowDiagram-23GEKE2U-B2snV3ot.js +1 -0
  58. package/web-dist/assets/{ganttDiagram-NO4QXBWP-Bu6_kuAX.js → ganttDiagram-NO4QXBWP-DiFpSJc2.js} +1 -1
  59. package/web-dist/assets/{gitGraph-TEB2WS4Q-yz2DuHDq.js → gitGraph-TEB2WS4Q-2-hTvDOu.js} +1 -1
  60. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-BXfbKaor.js → gitGraphDiagram-IHSO6WYX-mbc4Il71.js} +1 -1
  61. package/web-dist/assets/index-C6AXnNpg.css +2 -0
  62. package/web-dist/assets/index-DewBdeY0.js +352 -0
  63. package/web-dist/assets/{info-DKCQHKI2-BdItukhP.js → info-DKCQHKI2-BO0ZUEnn.js} +1 -1
  64. package/web-dist/assets/{infoDiagram-FWYZ7A6U-DXrq8wS3.js → infoDiagram-FWYZ7A6U-Cn3DmnjS.js} +1 -1
  65. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-iM2fPeJv.js → ishikawaDiagram-FXEZZL3T-Ckwh42aC.js} +1 -1
  66. package/web-dist/assets/{journeyDiagram-5HDEW3XC-DNa58xSH.js → journeyDiagram-5HDEW3XC-D26ySLLD.js} +1 -1
  67. package/web-dist/assets/{kanban-definition-HUTT4EX6-DhSaBCRv.js → kanban-definition-HUTT4EX6-CXKwXGxe.js} +1 -1
  68. package/web-dist/assets/{line-CQn0ApgD.js → line-BHqhqZTw.js} +1 -1
  69. package/web-dist/assets/{linear-DMSasryg.js → linear-Xq8ICqCe.js} +1 -1
  70. package/web-dist/assets/{mermaid-parser.core-BiJ3pVQT.js → mermaid-parser.core-ByagDKoG.js} +3 -3
  71. package/web-dist/assets/{mermaid.core-8ofZrJs5.js → mermaid.core-CEwbkqrb.js} +3 -3
  72. package/web-dist/assets/{mindmap-definition-LN4V7U3C-BLr4IGuI.js → mindmap-definition-LN4V7U3C-C3S3nEo5.js} +1 -1
  73. package/web-dist/assets/{packet-7NZHBO7P-D15LrSHU.js → packet-7NZHBO7P-BjcgqWf7.js} +1 -1
  74. package/web-dist/assets/{pegDiagram-2B236MQR-C9IcvZ_z.js → pegDiagram-2B236MQR-Due7wPzY.js} +1 -1
  75. package/web-dist/assets/{pie-RZYD4A2V-79-1CSta.js → pie-RZYD4A2V-C1X-Fm2K.js} +1 -1
  76. package/web-dist/assets/{pieDiagram-ENE6RG2P-dm_ORKO7.js → pieDiagram-ENE6RG2P-CbKS5jFv.js} +1 -1
  77. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-vQkbT0Ej.js → quadrantDiagram-ABIIQ3AL-CVF1CNNO.js} +1 -1
  78. package/web-dist/assets/{radar-I7S5WNFK-B8xvrM0r.js → radar-I7S5WNFK-Co7Otr5p.js} +1 -1
  79. package/web-dist/assets/{railroad-3IZDKUUU-G0rN-YuN.js → railroad-3IZDKUUU-BTwZqHtN.js} +1 -1
  80. package/web-dist/assets/railroad-abnf-AHOZXSZD-CEkmgARz.js +1 -0
  81. package/web-dist/assets/railroad-ebnf-EBAXGLYW-D2dXzrFd.js +1 -0
  82. package/web-dist/assets/railroad-peg-LSFZ7HO6-yA2XVaxN.js +1 -0
  83. package/web-dist/assets/{railroadDiagram-RFXS5EU6-BtGt9H2_.js → railroadDiagram-RFXS5EU6-DCymlvVU.js} +1 -1
  84. package/web-dist/assets/{requirementDiagram-TGXJPOKE-oHUUvJjC.js → requirementDiagram-TGXJPOKE-CEFbAu30.js} +1 -1
  85. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-BoO0DzUn.js → sankeyDiagram-HTMAVEWB-fnX7WrAj.js} +1 -1
  86. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-B344KPVn.js → sequenceDiagram-DBY2YBRQ-BUvsAYkQ.js} +1 -1
  87. package/web-dist/assets/{src-BygbIYKj.js → src-V5VjKILh.js} +1 -1
  88. package/web-dist/assets/{stateDiagram-2N3HPSRC-DMTN-MuE.js → stateDiagram-2N3HPSRC-C9LJKbHM.js} +1 -1
  89. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-Bm1cPGFT.js +1 -0
  90. package/web-dist/assets/{swimlanes-5IMT3BWC-DQEhwI0k.js → swimlanes-5IMT3BWC-aq9B5aBs.js} +1 -1
  91. package/web-dist/assets/swimlanesDiagram-G3AALYLV-DR6Yx_Ic.js +8 -0
  92. package/web-dist/assets/{timeline-definition-FHXFAJF6-CUoCgDT7.js → timeline-definition-FHXFAJF6-DhJ9Jf4f.js} +1 -1
  93. package/web-dist/assets/{treeView-QDETBFTQ-BTHFGWtv.js → treeView-QDETBFTQ-D42FrEH4.js} +1 -1
  94. package/web-dist/assets/{treemap-6X3UGDF4-BI2j1PYm.js → treemap-6X3UGDF4-BV_9JyG6.js} +1 -1
  95. package/web-dist/assets/{vennDiagram-L72KCM5P-Cc3sJIb2.js → vennDiagram-L72KCM5P-BkwHzxsN.js} +1 -1
  96. package/web-dist/assets/{wardley-OPB4EBWU-kYXYWMgL.js → wardley-OPB4EBWU-fwm52ei3.js} +1 -1
  97. package/web-dist/assets/{wardleyDiagram-EHGQE667-KHMHPO99.js → wardleyDiagram-EHGQE667-D_UqvgcE.js} +1 -1
  98. package/web-dist/assets/{xychartDiagram-FW5EYKEG-DzSZk-Dq.js → xychartDiagram-FW5EYKEG-tKdmcTfS.js} +1 -1
  99. package/web-dist/index.html +2 -2
  100. package/web-dist/assets/architecture-TIHT7OUA-Czzg2iAA.js +0 -1
  101. package/web-dist/assets/channel-CxehNUC2.js +0 -1
  102. package/web-dist/assets/classDiagram-OUVF2IWQ-BwIDe0nq.js +0 -1
  103. package/web-dist/assets/classDiagram-v2-EOCWNBFH-BwIDe0nq.js +0 -1
  104. package/web-dist/assets/eventmodeling-45OFAUF4-jYGf30I4.js +0 -1
  105. package/web-dist/assets/flowDiagram-23GEKE2U-uJJwGAew.js +0 -1
  106. package/web-dist/assets/index-DS1RBTF4.js +0 -336
  107. package/web-dist/assets/index-DX1b-Y8G.css +0 -2
  108. package/web-dist/assets/railroad-abnf-AHOZXSZD-BaCc6741.js +0 -1
  109. package/web-dist/assets/railroad-ebnf-EBAXGLYW-cUMY4WQi.js +0 -1
  110. package/web-dist/assets/railroad-peg-LSFZ7HO6-JS2RjlNP.js +0 -1
  111. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-4HUOVSEd.js +0 -1
  112. package/web-dist/assets/swimlanesDiagram-G3AALYLV-PPFuhngt.js +0 -8
@@ -0,0 +1,2241 @@
1
+ // @ts-check
2
+ /**
3
+ * frizz-tools — every capability Frizz hands its own WORKERS, as ONE registry: the definition of each
4
+ * command (name, summary, description, input schema, how its arguments are spelled on a command line)
5
+ * and the handler that performs it. Dependency-free, and transport-free: a front-end imports TOOLS and
6
+ * HANDLERS and decides how a call arrives and how its answer leaves.
7
+ *
8
+ * bin/frizzctl — the command line a worker runs from its own shell. THE worker surface.
9
+ * bin/frizz-mcp.mjs — the same registry over MCP stdio, for a worker dispatched before the CLI.
10
+ *
11
+ * A new worker-facing capability joins the TOOLS registry below rather than growing a second surface.
12
+ *
13
+ * Every handler is a thin wrapper over one of Frizz's own RPCs: it reads the running server's port from
14
+ * a server.lock and POSTs `/_frizz/<project>/rpc/<procedure>`. That surface has no token auth — only a
15
+ * loopback-origin CSRF gate — so a headerless local POST with `sec-fetch-site: same-origin` (undici
16
+ * sends no Origin) satisfies it.
17
+ *
18
+ * NOTHING HERE DEPENDS ON THE ENVIRONMENT STAYING TRUE. A worker lives inside a DETACHED daemon that
19
+ * outlives Frizz restart after restart, so anything frozen into it at spawn is a bug waiting for the
20
+ * next "Update & Restart" to move the port. Both facts we need are therefore resolved PER CALL, from
21
+ * files: the server's address (serverLockPort — the env hint, then the machine-wide
22
+ * `<frizz root>/server.lock`, then any live project lock, skipping any whose pid is gone) and our own
23
+ * project (projectSegment — the stamp, else `.frizz/.id` walked up from our cwd). The env is a hint
24
+ * that saves a lookup; the filesystem is the truth.
25
+ *
26
+ * A handler NEVER takes the caller's thread from its arguments: threadSlug() reads it from the
27
+ * environment the worker was started with, which is what stops one thread acting as another.
28
+ */
29
+ import { readFileSync, readdirSync } from "node:fs"
30
+ import { basename, dirname, join } from "node:path"
31
+
32
+ // Comfortably above a codex dispatch's bounded rollout-discovery wait (~15s) so a legitimate slow
33
+ // dispatch is never aborted client-side (which would make the worker think it failed and retry,
34
+ // double-spawning). The server completes regardless; this is only the client's patience.
35
+ const DISPATCH_TIMEOUT_MS = 30_000
36
+
37
+ const SPAWN_THREAD = {
38
+ name: "spawn_thread",
39
+ description:
40
+ "LAST RESORT — try the two cheaper exits FIRST. Follow-up work you discovered is not a reason to spawn: " +
41
+ "if you could DO it (dispatching an in-session sub-agent, whose result comes back to you, so the work " +
42
+ "lands on YOUR card under one review), do that instead; if the human should choose, ASK instead. " +
43
+ "Spawn a brand-new, separate top-level frizz thread — its own board card, session, and scratchpad, " +
44
+ "driving INDEPENDENTLY. This is FIRE-AND-FORGET: the new thread reports to the HUMAN on the board via " +
45
+ "its own final message, and its results come back to you, the caller, only if it sends them with " +
46
+ "`steer` — which its prompt is told it can, and which you must ASK for in that prompt if you need " +
47
+ "them. You cannot wait on it as you wait on a sub-agent. It is NOT an in-session " +
48
+ "sub-agent. It returns only the new thread's slug and a ready-to-paste markdown link " +
49
+ "`[title](/thread/<slug>)` that opens the thread in the frizz drawer — put that link in your handoff. " +
50
+ "USE IT ONLY for a distinct, self-contained effort that belongs on the board in its own right and whose " +
51
+ "output you do NOT need to read. Do NOT use it for a helper whose result you must COLLECT and fold into " +
52
+ "your own work — a self-review, a verification pass, a research prong, a critic, any collect-back helper: " +
53
+ "those are in-session sub-agents (Claude: the Agent tool with `run_in_background`; Codex: native " +
54
+ "delegation), which return their findings to you. Spawning such a helper here STRANDS it — its work lands " +
55
+ "on another card and never reaches you, so you gain nothing. " +
56
+ "Because nothing it learns returns to you OR to its siblings unless somebody steers it across, a chain of " +
57
+ "spawned threads re-derives the same facts in parallel and nobody notices — measured here: one thread " +
58
+ "spawned four, three of those spawned more, and three descendants independently rediscovered the same " +
59
+ "root cause over twenty hours. " +
60
+ "Spawn only when the work genuinely cannot ride on your own card: a different repo, a different long-lived " +
61
+ "runtime, an effort that must outlive yours. Never spawn merely to clear your own `done` fence. " +
62
+ "You MUST deliberately choose `model` and `effort` to match the NEW thread's task complexity — they are " +
63
+ "required, there is NO default. Do not reflexively pick the cheapest; a hard task on a weak model/effort " +
64
+ "wastes the whole thread.",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ prompt: {
69
+ type: "string",
70
+ description: "The full task/prompt for the new thread's worker. Be self-contained — the new thread starts with empty context.",
71
+ },
72
+ model: {
73
+ type: "string",
74
+ description:
75
+ "REQUIRED — pick by the NEW task's complexity; there is no default. For the `claude` backend: " +
76
+ "`opus` (the TOP tier — hardest reasoning, architecture, subtle correctness/security, adversarial " +
77
+ "review, the fix that must land), `sonnet` (ordinary substantive implementation/research), `haiku` " +
78
+ "(simple, fully-specified mechanical work). Do NOT pick `fable`: Opus 5 is just as good and cheaper, " +
79
+ "so a high-intensity task takes `opus` at a higher `effort`, not a different model — `fable` only " +
80
+ "when the human explicitly asks for it. For " +
81
+ "the `codex` backend use a codex model id instead (e.g. `gpt-5.6-sol`/`gpt-5.6-terra`/`gpt-5.6-luna`). " +
82
+ "Match the model to the backend you choose. Bias toward Opus/a strong model when the task is " +
83
+ "non-trivial or its outcome is load-bearing.",
84
+ },
85
+ effort: {
86
+ type: "string",
87
+ enum: ["low", "medium", "high", "xhigh", "max"],
88
+ description:
89
+ "REQUIRED — reasoning effort, pick by complexity; no default. `low` only for trivial tasks; " +
90
+ "`medium` for routine work; `high` for ordinary substantive work; `xhigh` for hard coding/agentic " +
91
+ "work; `max` for the single hardest problems. (Codex also accepts `ultra`.)",
92
+ },
93
+ backend: {
94
+ type: "string",
95
+ enum: ["claude", "codex"],
96
+ description: "Optional agent backend (default `claude`). If `codex`, `model` must be a codex model id.",
97
+ },
98
+ title: { type: "string", description: "Optional short title for the new thread (else derived from the prompt)." },
99
+ },
100
+ required: ["prompt", "model", "effort"],
101
+ },
102
+ }
103
+
104
+ const GOAL = {
105
+ name: "goal",
106
+ description:
107
+ "Arm a GOAL on YOUR OWN thread: one piece of text that frizz re-sends you, on any or all of three " +
108
+ "triggers, for as long as it is armed. The board shows it as the thread's Goal. (This command was " +
109
+ "the `recurring_prompt` tool until 2026-08-28 — a summary or note that says so means this one.)\n\n" +
110
+ " --stop-hook — every time you come to REST. Use it to keep a long autonomous effort moving " +
111
+ "without the human driving every step, and to rescue yourself from a wait that may never resolve.\n" +
112
+ " --heartbeat-seconds — on a CLOCK, whatever you are doing. This one reaches you MID-TURN: it arrives as " +
113
+ "a queued message you read at your next tool boundary rather than waiting for you to stop, and it " +
114
+ "never aborts what you are running. Use it for something that must be revisited on a schedule no " +
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. If " +
117
+ "you keep notes in your scratch directory, a prompt that LINKS them comes back at the exact moment " +
118
+ "you have lost everything else. Also mid-turn — a compaction happens while you are working.\n\n" +
119
+ "Set at least one; any combination is fine.\n\n" +
120
+ "USE THIS RATHER THAN `CronCreate` or `ScheduleWakeup`. Those are Claude Code's own in-session " +
121
+ "schedulers and they CANNOT fire in the runtime frizz runs you in: their gate stays shut for as long " +
122
+ "as ANY background task of yours is outstanding, so the moment you are parked behind a background " +
123
+ "shell or a sub-agent — exactly when you most need waking — they go silent. This one is delivered by " +
124
+ "frizz itself and is unaffected.\n\n" +
125
+ "READ IT BACK WITH `frizzctl goal get` — and do that BEFORE any `start` that is not a fresh arming. A " +
126
+ "thread has AT MOST ONE goal, so a `start` REPLACES whatever is there, triggers and all, " +
127
+ "and the text you are about to destroy may not be yours: the HUMAN can edit it in the thread footer, " +
128
+ "and a compaction can take your own memory of arming it. `get` answers with the exact text currently " +
129
+ "armed, which triggers are on, the cadence, and when each trigger last fired. Reach for it whenever " +
130
+ "you are about to change one trigger and keep the rest, whenever you are unsure whether you are armed " +
131
+ "at all, and after a compaction. (A `start` also reports what it replaced, so a blind overwrite is at " +
132
+ "least a visible one.)\n\n" +
133
+ "The text arrives VERBATIM as an ordinary user turn, so write it as an instruction to your future " +
134
+ "self. At most one scheduled delivery is ever outstanding and its clock runs from the last one " +
135
+ "DELIVERED, so you can never be handed a backlog at once.\n\n" +
136
+ "STOP IT when the work it drives is done (`frizzctl goal stop`) — one left armed on a finished thread " +
137
+ "wakes it forever. The human sees it in the thread footer and can edit or switch it off there. " +
138
+ "Signing off with a ```done fence stops it too, every trigger at once — but only when the work is " +
139
+ "genuinely finished, because that files the thread away and a thread nobody is watching does not " +
140
+ "restart itself.\n\n" +
141
+ "You can only ever arm your OWN thread — there is no argument for anyone else's.",
142
+ inputSchema: {
143
+ type: "object",
144
+ properties: {
145
+ action: {
146
+ type: "string",
147
+ enum: ["start", "stop", "get"],
148
+ description:
149
+ "`start` arms (or replaces) this thread's goal; `stop` disarms it; `get` reads back " +
150
+ "what is armed right now — the text, the triggers, the cadence and each trigger's last delivery " +
151
+ "— without changing anything. `get` takes no other argument.",
152
+ },
153
+ prompt: {
154
+ type: "string",
155
+ description:
156
+ "Required for `start`. The text delivered to you on every trigger, verbatim, as a user turn. " +
157
+ "Make it self-contained and ACTIONABLE — say what to do and what would make it right to stop " +
158
+ "— because you may receive it with none of the context you have right now.",
159
+ },
160
+ stop_hook: {
161
+ type: "boolean",
162
+ description:
163
+ "Send it every time you come to rest. Defaults to true when neither `--heartbeat-seconds` nor " +
164
+ "`--post-compaction` is given, so a `start` that names no mechanism still does the obvious thing.",
165
+ },
166
+ heartbeat_seconds: {
167
+ type: "integer",
168
+ description:
169
+ "Also send it on this clock, in seconds (minimum 60, maximum 86400). Omit for no heartbeat. A " +
170
+ "delivery is read at your next tool boundary, so a sub-minute cadence buys no promptness and " +
171
+ "only talks over your own work.",
172
+ },
173
+ post_compaction: {
174
+ type: "boolean",
175
+ description:
176
+ "Also send it every time your context is compacted, into the emptied window — useful when the " +
177
+ "prompt links notes you keep in your scratch directory.",
178
+ },
179
+ },
180
+ required: ["action"],
181
+ },
182
+ }
183
+
184
+ // The ONE-OFF TIMER's bounds, mirrored from @frizz/shared (this file is dependency-free by design and
185
+ // ships as a loose .mjs, so it cannot import them). The server validates the same numbers; these exist so
186
+ // a wrong delay is refused HERE, with an explanation, instead of coming back as an HTTP 400.
187
+ const TIMER_MIN_DELAY_SECONDS = 10
188
+ const TIMER_MAX_DELAY_SECONDS = 30 * 24 * 60 * 60
189
+
190
+ const TIMER = {
191
+ name: "timer",
192
+ description:
193
+ "Set a ONE-OFF timer on YOUR OWN thread: a piece of text frizz hands back to you at ONE instant, " +
194
+ "ONCE. Your own alarm clock.\n\n" +
195
+ "It is `goal`'s heartbeat with the repetition taken out, and it shares the property that " +
196
+ "matters: the delivery reaches you MID-TURN — a queued message you read at your next tool boundary — " +
197
+ "so it arrives when you asked for it whether or not you have stopped, and it never aborts what you " +
198
+ "are running. Unlike a goal it fires exactly once and then is gone, so there is nothing " +
199
+ "to switch off afterwards and nothing to sign off from.\n\n" +
200
+ "You may have MANY armed at the same time, each with its own instant and its own text — they are " +
201
+ "independent, unlike the single goal this thread can hold.\n\n" +
202
+ "USE IT for anything you want to come back to at a specific time: re-check a deploy in ten minutes, " +
203
+ "re-read a slow log at the top of the hour, revisit a decision after a build finishes. USE " +
204
+ "`goal` instead when the thing must repeat, and remember that Claude Code's own " +
205
+ "`CronCreate`/`ScheduleWakeup` cannot fire in the runtime frizz runs you in.\n\n" +
206
+ "IT IS NOT A WAY TO POLL SOMETHING YOU COULD WAIT ON. If a background shell, a sub-agent or a " +
207
+ "monitor can tell you the moment a thing happens, use that — an alarm every N seconds asking \"is it " +
208
+ "done yet\" is strictly worse than being woken when it is.\n\n" +
209
+ "The text arrives VERBATIM as an ordinary user turn, so write it as an instruction to your future " +
210
+ "self — self-contained and actionable, because you may receive it with none of the context you have " +
211
+ "now. Give exactly one of `--in-seconds` or `--at`. You can only ever set a timer on your OWN thread.",
212
+ inputSchema: {
213
+ type: "object",
214
+ properties: {
215
+ action: {
216
+ type: "string",
217
+ enum: ["set", "cancel", "list"],
218
+ description:
219
+ "`set` arms a new one-off timer (it never replaces an existing one); `cancel` withdraws one by " +
220
+ "`id`; `list` returns the timers currently armed on this thread. Every action answers with the " +
221
+ "resulting armed list.",
222
+ },
223
+ prompt: {
224
+ type: "string",
225
+ description: "Required for `set`. The text delivered to you when it fires, verbatim, as a user turn.",
226
+ },
227
+ in_seconds: {
228
+ type: "integer",
229
+ description:
230
+ `For \`set\`: fire this many seconds from now (minimum ${TIMER_MIN_DELAY_SECONDS}, maximum ` +
231
+ `${TIMER_MAX_DELAY_SECONDS} — thirty days). Give this OR \`--at\`, not both. Sub-minute precision ` +
232
+ "is not real: the delivery is read at your next tool boundary.",
233
+ },
234
+ at: {
235
+ type: "string",
236
+ description:
237
+ "For `set`: fire at this exact instant, as an ISO-8601 timestamp (e.g. `2026-08-04T15:00:00Z`). " +
238
+ "Give this OR `--in-seconds`, not both. Must be in the future and within thirty days.",
239
+ },
240
+ id: {
241
+ type: "string",
242
+ description: "Required for `cancel`. The timer id returned by `set` (or listed by `list`).",
243
+ },
244
+ },
245
+ required: ["action"],
246
+ },
247
+ }
248
+
249
+
250
+ const WATCH_PR = {
251
+ name: "watch_pr",
252
+ description:
253
+ "REGISTER A PULL REQUEST and frizz brings you back whenever something happens on it — CI turning " +
254
+ "green or red, and every later review, approval or comment, from a human or a bot alike. Register " +
255
+ "it, come to rest, and you are woken. Drop it when it stops mattering.\n\n" +
256
+ "IT REPORTS REPEATEDLY, unlike a timer. One registration covers the whole life of the PR: CI goes " +
257
+ "red, you push a fix, CI goes green, a reviewer comments — that is four wakes from one registration, and you " +
258
+ "never have to re-register between them. It settles itself when the PR merges or closes, because " +
259
+ "there is then nothing left to report.\n\n" +
260
+ "REGISTER IT THE MOMENT YOU OPEN OR PUSH A PR. Nothing else watches for you: your runtime knows " +
261
+ "nothing about GitHub, and an ```awaiting fence STATES what you are waiting on without creating any " +
262
+ "wait at all. This command is the wait.\n\n" +
263
+ "THE ```awaiting FENCE IS STILL WORTH WRITING, and it is a different job: it is how you come to REST " +
264
+ "without frizz asking you for a handoff, and how the human sees what you are waiting for. Register " +
265
+ "the watcher with this command, then name the same PR in your fence's `prs:` list — and give the fence " +
266
+ "the same long `for:` you gave the watcher, or the fence expires first and bumps you anyway.\n\n" +
267
+ "GIVE AN EXTERNAL PR A LONG `--for` — MONTHS, up to a year. A pull request into a repo nobody here " +
268
+ "controls moves on its maintainers' clock, not yours, and a short watcher on one expires against a " +
269
+ "PR that has not changed: a wake with nothing in it, and a re-arm. Long costs nothing — real " +
270
+ "activity still wakes you the instant it lands, and the human snoozes or archives the thread if " +
271
+ "they want it off the board.\n\n" +
272
+ "REGISTERING IS IDEMPOTENT per pull request: asking twice returns the SAME id and tells you it was " +
273
+ "already armed, so re-registering after a compaction is safe and is the right instinct. Use " +
274
+ "`frizzctl watch-pr list` when you want to know what you are holding without changing anything — it " +
275
+ "answers with each PR's current check state too.\n\n" +
276
+ "You can only ever watch a PR on your OWN thread — there is no argument for anyone else's.",
277
+ inputSchema: {
278
+ type: "object",
279
+ properties: {
280
+ action: {
281
+ type: "string",
282
+ enum: ["add", "list", "drop"],
283
+ description:
284
+ "`add` registers a watcher (idempotent per PR); `drop` withdraws one by id; `list` reads back " +
285
+ "everything armed on this thread, with each PR's latest check state, without changing " +
286
+ "anything. Every action answers with the full armed set.",
287
+ },
288
+ target: {
289
+ type: "string",
290
+ description:
291
+ "Required for `add`. The pull request, as `owner/repo#123` or a GitHub PR URL. A ref that " +
292
+ "cannot be parsed, or a PR the server's own `gh` cannot read, is REFUSED rather than stored — " +
293
+ "a watcher that can never fire is worse than no watcher, because you would come to rest " +
294
+ "believing you were covered. A refusal names the reason; a transient one is worth one retry.",
295
+ },
296
+ for: {
297
+ type: "string",
298
+ description:
299
+ "REQUIRED for `add`. How long to watch, as a DURATION — `2h`, `3d`, `180d` (max 365d). Never " +
300
+ "an instant, and there is no default. The watcher settles itself when this runs out and tells " +
301
+ "you, and you re-register if you still care.\n\n" +
302
+ "MATCH IT TO WHOSE PR IT IS, and the two cases are far apart. Your own PR, waiting on CI or on " +
303
+ "a review you expect today: hours. A PULL REQUEST IN A REPO NOBODY HERE CONTROLS — an upstream " +
304
+ "project, someone else's maintainers — takes as long as it takes, so give it MONTHS (`90d`, " +
305
+ "`180d`, `365d`). A short `--for` on one of those does not make it get reviewed sooner; it just " +
306
+ "expires against a PR nothing has touched, wakes you for nothing, and costs a re-arm. That is " +
307
+ "not hypothetical — a watcher on an external PR re-armed at the old 24h ceiling four days " +
308
+ "running, with zero maintainer activity in between. Long is FREE here: real activity wakes you " +
309
+ "the moment it happens either way, and the human can snooze or archive the thread whenever " +
310
+ "they want it gone.",
311
+ },
312
+ id: {
313
+ type: "string",
314
+ description: "Required for `drop`. The watcher id returned by `add` (or listed by `list`).",
315
+ },
316
+ },
317
+ required: ["action"],
318
+ },
319
+ }
320
+
321
+ const WATCH_ISSUE = {
322
+ name: "watch_issue",
323
+ description:
324
+ "REGISTER A GITHUB ISSUE and frizz brings you back whenever something happens on it — every later " +
325
+ "comment, from a human or a bot alike, a label added or removed, someone assigned, and the issue " +
326
+ "closing. Register it, come to rest, and you are woken. Drop it when it stops mattering.\n\n" +
327
+ "IT REPORTS REPEATEDLY, like `watch-pr` and unlike a timer: one registration covers the whole life " +
328
+ "of the issue, and it settles itself when the issue closes, because there is then nothing left to " +
329
+ "report. There is no CI and no merge on an issue, so those are the only things it can say.\n\n" +
330
+ "USE IT WHEN THE NEXT STEP IS SOMEONE ELSE'S REPLY ON AN ISSUE — a reporter you asked for a " +
331
+ "reproduction, a maintainer you asked to triage, a discussion you are waiting to see resolved. " +
332
+ "Nothing else watches for you: your runtime knows nothing about GitHub, and an ```awaiting fence " +
333
+ "STATES what you are waiting on without creating any wait at all. This command is the wait. A PULL " +
334
+ "REQUEST is `frizzctl watch-pr`, not this — `gh issue view` refuses a PR number, and so does this.\n\n" +
335
+ "THE ```awaiting FENCE IS STILL WORTH WRITING, for the same reason it is beside `watch-pr`: it is " +
336
+ "how you come to REST without frizz asking for a handoff, and how the human sees what you wait for. " +
337
+ "Register the watcher, then name the same issue in the fence's `issues:` list, with the same long " +
338
+ "`for:`.\n\n" +
339
+ "GIVE IT A LONG `--for` — an issue in someone else's repo moves on their clock, and a short watcher " +
340
+ "expires against an issue nobody has touched: a wake with nothing in it. Months are free; real " +
341
+ "activity wakes you the instant it lands either way.\n\n" +
342
+ "REGISTERING IS IDEMPOTENT per issue: asking twice returns the SAME id and says so, so re-registering " +
343
+ "after a compaction is safe. `frizzctl watch-issue list` reads back the issues watched on this thread without changing " +
344
+ "anything. You can only ever watch an issue on your OWN thread.",
345
+ inputSchema: {
346
+ type: "object",
347
+ properties: {
348
+ action: {
349
+ type: "string",
350
+ enum: ["add", "list", "drop"],
351
+ description:
352
+ "`add` registers a watcher (idempotent per issue); `drop` withdraws one by id; `list` reads " +
353
+ "back every issue armed on this thread, with its latest state, without changing anything.",
354
+ },
355
+ target: {
356
+ type: "string",
357
+ description:
358
+ "Required for `add`. The issue, as `owner/repo#123` or a GitHub issue URL. A ref that cannot " +
359
+ "be parsed, a PR URL, or an issue the server's own `gh` cannot read, is REFUSED rather than " +
360
+ "stored — a watcher that can never fire is worse than none. A refusal names the reason.",
361
+ },
362
+ for: {
363
+ type: "string",
364
+ description:
365
+ "REQUIRED for `add`. How long to watch, as a DURATION — `2h`, `3d`, `180d` (max 365d). Never " +
366
+ "an instant, and there is no default. The watcher settles itself when this runs out and tells " +
367
+ "you. An issue you are waiting on someone else to answer deserves MONTHS.",
368
+ },
369
+ id: {
370
+ type: "string",
371
+ description: "Required for `drop`. The watcher id returned by `add` (or listed by `list`).",
372
+ },
373
+ },
374
+ required: ["action"],
375
+ },
376
+ }
377
+
378
+ // The registry that replaces a ```awaiting fence's `shells:` line: a wait the worker CREATES rather
379
+ // than one it restates at every rest. Two commands rather than one action-switch, because they are two
380
+ // verbs and a worker reaching for `unwatch` should find `unwatch`. See plans/rest-by-registration.md.
381
+ const WATCH = {
382
+ name: "watch",
383
+ description:
384
+ "REGISTER A WAIT on something this thread already has running — a background shell, a sub-agent — " +
385
+ "and frizz brings you back when it finishes, or when `--for` runs out.\n\n" +
386
+ "IT IS A ROW, NOT A SENTENCE. A ```awaiting fence has the lifetime of the message carrying it; this " +
387
+ "survives your turn ending, a compaction and a frizz restart.\n\n" +
388
+ "IT DOES NOT REPLACE THE FENCE. Where the thread sits while you wait is an answer about each REST, " +
389
+ "so every rest on running work still ends with a ```awaiting fence that names the work and answers " +
390
+ "`status: working|watching|needs_input` — `working` and `watching` keep the thread out of the " +
391
+ "human's queue, and a rest with no fence lands in it.\n\n" +
392
+ "`--for` IS REQUIRED and it is a DURATION, never an instant. When it runs out the row is CANCELLED " +
393
+ "and you are woken to re-decide — that is deliberate, and it is what stops a wait outliving the " +
394
+ "reason you made it. Register again if you still mean it.\n\n" +
395
+ "THE TARGET IS CHECKED AGAINST WHAT IS ACTUALLY RUNNING, not against its shape. A handle nothing " +
396
+ "live answers to is REFUSED rather than stored, and so is a `kind` that disagrees with what frizz " +
397
+ "can see — a sub-agent registered as a shell is refused and told what it actually is. If you have " +
398
+ "lost an id (a compaction, a long turn), run `frizzctl activity` rather than guessing.\n\n" +
399
+ "A SUB-AGENT NEEDS NO REGISTRATION — its return re-invokes you — so the case this exists for is a " +
400
+ "background SHELL: frizz cannot tell a build you are waiting on from a dev server you started and " +
401
+ "moved on from, and only you know which it is.\n\n" +
402
+ "NEVER WATCH SOMETHING YOU INTEND TO OUTLIVE. A dev server, a log tail, a file watcher — those are " +
403
+ "things you started, not things you are waiting for, and registering one is a wait on work that " +
404
+ "will never finish.\n\n" +
405
+ "REGISTERING IS IDEMPOTENT per (kind, target): asking twice returns the SAME id, says it was " +
406
+ "already armed, and leaves the original expiry alone — so re-registering after a compaction is safe " +
407
+ "and is the right instinct. Use `frizzctl unwatch <id>` to withdraw one. A PULL REQUEST is `frizzctl watch-pr`, not this: " +
408
+ "that one polls GitHub and reports repeatedly.\n\n" +
409
+ "You can only ever watch work on your OWN thread.",
410
+ inputSchema: {
411
+ type: "object",
412
+ properties: {
413
+ kind: {
414
+ type: "string",
415
+ enum: ["shell", "agent"],
416
+ description:
417
+ "What the target IS. Checked against live telemetry, not taken on trust — the two kinds of " +
418
+ "handle are both opaque runtime strings and look identical, so frizz answers this exactly " +
419
+ "rather than guessing, and refuses a mismatch by name.",
420
+ },
421
+ target: {
422
+ type: "string",
423
+ description:
424
+ "The handle you were shown. For a shell that is the runtime's own background-task id " +
425
+ "(\"Command running in background with ID: bzvtnt3ig\"); its launch tool_use id and its " +
426
+ "command label are accepted too. For a sub-agent it is the dispatch id or its description. " +
427
+ "`frizzctl activity` prints all of them.",
428
+ },
429
+ for: {
430
+ type: "string",
431
+ description:
432
+ "REQUIRED. How long to hold the wait, as a DURATION — `30m`, `2h`, `3d` (max 24h). Never an " +
433
+ "instant, and there is no default: choose it for THIS wait. When it elapses the row is " +
434
+ "cancelled and you are woken to re-decide, so an over-long guess costs a wait that outlives " +
435
+ "its reason and a too-short one costs one extra turn. The ceiling is a DAY here, unlike " +
436
+ "`watch-pr`'s year: a shell or a sub-agent dies with this session, so a wait on one that has " +
437
+ "stood for a day is a wait on something already gone.",
438
+ },
439
+ },
440
+ required: ["kind", "target", "for"],
441
+ },
442
+ }
443
+
444
+ const UNWATCH = {
445
+ name: "unwatch",
446
+ description:
447
+ "WITHDRAW A WATCH you registered with `frizzctl watch`, by its id. It will not wake you any more.\n\n" +
448
+ "Use it the moment a wait stops mattering — you decided not to wait for that build after all, or " +
449
+ "you are about to end the thread. A watch you no longer care about still wakes you for nothing, and " +
450
+ "it blocks `done` until it is withdrawn.\n\n" +
451
+ "You do NOT need this when the work simply finishes: frizz settles the row itself and wakes you. " +
452
+ "`frizzctl activity` prints the id of everything you hold. A `watch-pr` / `watch-issue` id (`prw_…` / " +
453
+ "`isw_…`), a `timer` id (`tmr_…`) or a `watch-command` id (`cmw_…`) is withdrawn here too.",
454
+ inputSchema: {
455
+ type: "object",
456
+ properties: {
457
+ id: {
458
+ type: "string",
459
+ description: "The watch id `frizzctl watch` printed (or that `frizzctl activity` lists), or a PR, issue, timer or command-watch id. Only your own thread's.",
460
+ },
461
+ },
462
+ required: ["id"],
463
+ },
464
+ }
465
+
466
+ // ---- `ask` / `unask`: a question the human owes an answer to, as a ROW ------------------------------
467
+
468
+ // The question tree, generated rather than written three times over. MCP tool schemas are JSON Schema,
469
+ // and a `$ref` cycle is the natural way to express a recursive shape — but client support for one is
470
+ // uneven, and a schema a client silently drops is a tool a worker cannot call. ASK_MAX_DEPTH is 3, so
471
+ // the nesting is INLINED to exactly that depth: `followUps` simply does not exist on the deepest level,
472
+ // which makes the limit visible in the schema instead of being a refusal the worker meets at runtime.
473
+ const ASK_MAX_DEPTH = 3
474
+ /** @param {number} depth 1 = the root question. @returns {Record<string, unknown>} */
475
+ function questionSchema(depth) {
476
+ const option = {
477
+ type: "object",
478
+ properties: {
479
+ label: { type: "string", description: "The choice itself, short — this is what the answer hands back to you." },
480
+ description: {
481
+ type: "string",
482
+ description:
483
+ "The trade-off, or the evidence — and it renders INSIDE the option, always visible, so this " +
484
+ "is where the human learns what they are choosing BEFORE they pick anything. ONE LINE is the " +
485
+ "default and it is right most of the time: name the trade-off and stop. Earn more than that " +
486
+ "and spend it on a shape they can SCAN — a short list, a table, a code block, the diff the " +
487
+ "option would produce, the exact message that would be posted. Never on a RUN OF " +
488
+ "ONE-SENTENCE PARAGRAPHS: four single sentences stacked with blank lines between them is the " +
489
+ "shape that keeps arriving, and it is the least readable one in a card this narrow. An " +
490
+ "option with no trade-off makes the human reconstruct your reasoning before they can choose; " +
491
+ "an option with four paragraphs makes them read an essay to answer one question.",
492
+ },
493
+ recommended: {
494
+ type: "boolean",
495
+ description:
496
+ "Mark the ONE option you would take, and put it first. At most one per pick-one question — a " +
497
+ "recommendation on two of three choices says nothing. ON A `multi`, MARK EVERY OPTION YOU " +
498
+ "WOULD TICK, or none: the human ticks several there, so one mark alone reads as \"pick only " +
499
+ "this\". IF YOU CAN MARK ONE, ASK YOURSELF WHY " +
500
+ "YOU ARE ASKING: you already know the answer, so implement it and say which way you went. " +
501
+ "This is for the fork you genuinely cannot take yourself.",
502
+ },
503
+ // `preview` (markdown revealed under the option once picked) is RETIRED from this schema
504
+ // (2026-09-01): detail that decides a choice must be visible before the choice, so it belongs in
505
+ // a rich `description` now. The server still ACCEPTS the field — an in-flight worker dispatched
506
+ // against the old schema keeps working, and the card folds it into the same always-visible body.
507
+ ...(depth < ASK_MAX_DEPTH
508
+ ? {
509
+ // No `maxItems` here either (four until 2026-09-03): the tree is bounded by its DEPTH, not
510
+ // by how many branches hang off one option.
511
+ followUps: {
512
+ type: "array",
513
+ description:
514
+ "Questions that become live ONLY if the human picks this option — the conditional " +
515
+ "branch. A branch nobody takes is never asked and never answered, so this is how you " +
516
+ "ask \"and if so, which?\" without asking it of somebody who said no. A `multi` " +
517
+ "question cannot carry these (several picked options would open several branches at " +
518
+ "once) and neither can a free-text one (there is no answer to branch on).",
519
+ items: questionSchema(depth + 1),
520
+ },
521
+ }
522
+ : {}),
523
+ },
524
+ required: ["label"],
525
+ }
526
+ return {
527
+ type: "object",
528
+ properties: {
529
+ question: {
530
+ type: "string",
531
+ description:
532
+ "THE QUESTION, on one line, in the human's own vocabulary. They have their original prompt " +
533
+ "and nothing else — not your plan, not your notes, not the names you coined while working. " +
534
+ "Lead with the behaviour, not the identifier. NO \"I\" AND NO \"you\": clicking an option is " +
535
+ "the HUMAN speaking, so first and second person flip between writer and reader. Name the " +
536
+ "actor outright instead.",
537
+ },
538
+ header: { type: "string", description: "A very short chip label for the card, 12 characters or so — \"Auth method\", \"Storage\"." },
539
+ kind: {
540
+ type: "string",
541
+ enum: ["question", "multi"],
542
+ description:
543
+ "`question` = pick ONE. `multi` = pick SEVERAL, for choices that are not mutually exclusive. " +
544
+ "A question with NO options at all is a free-text box, which is the right shape when you need " +
545
+ "a name, a value or a sentence rather than a decision between things you have enumerated.",
546
+ },
547
+ danger: {
548
+ type: "boolean",
549
+ description:
550
+ "The DESTRUCTIVE gate, and nothing softer: a force-push, a deletion, a history rewrite, a " +
551
+ "production rollback. It changes two things — the card wears the risk tone, and the human's " +
552
+ "x cannot dismiss it, because a generic close icon is not consent for something irreversible. " +
553
+ "Declining must therefore be one of your own options.",
554
+ },
555
+ // No `maxItems`: the count is the worker's to choose (maintainer 2026-09-03 — "allow arbitrary
556
+ // numbers of options"). A `multi` over a long list is a real shape; the card numbers every row (1–9 are its keys).
557
+ options: {
558
+ type: "array",
559
+ description:
560
+ "As many as the choice actually has — a fork of two, or a `multi` over twenty findings. " +
561
+ "Omit entirely for a free-text question.",
562
+ items: option,
563
+ },
564
+ },
565
+ required: ["question", "kind"],
566
+ }
567
+ }
568
+
569
+ const ASK = {
570
+ name: "ask",
571
+ description:
572
+ "ASK THE HUMAN SOMETHING YOU CANNOT DECIDE, as a ROW they still owe an answer to. THIS IS THE ONLY " +
573
+ "WAY TO ASK: the free-form ```question fence is retired (2026-09-11), and a fence with a question " +
574
+ "written in its body is plain prose — no card, no answer, no sign-off. The row renders as an " +
575
+ "answerable card on the board and in the thread, and it STAYS there: it survives your turn ending, " +
576
+ "a compaction, a restart, and the transcript scrolling past. A fence has the lifetime of the " +
577
+ "message carrying it, which is why a question written into one was unanswerable an hour later.\n\n" +
578
+ "YOUR DEFAULT IS TO DECIDE, AND THIS COMMAND DOES NOT CHANGE THAT. A reversible call costs minutes to " +
579
+ "redo; a round-trip to the human costs hours with the whole effort idle. Anything derivable from " +
580
+ "the code, the conventions or ordinary engineering judgement is yours: make it, say which way you " +
581
+ "went, and keep moving. THE TEST THAT CATCHES ALMOST EVERY BAD QUESTION: if you are about to mark " +
582
+ "one option `recommended`, you already know the answer — so implement it instead of asking.\n\n" +
583
+ "ASK WHEN A WRONG GUESS WOULD BE BOTH COSTLY AND HARD TO UNDO — something destructive or " +
584
+ "irreversible, an external-facing commitment, a security posture with real exposure, product or UX " +
585
+ "direction that is genuinely the human's taste to set. And ask when you KNOW the answer but cannot " +
586
+ "ACT on it: a merge, a publish, a spend, a comment that goes out under their name. Then the " +
587
+ "recommendation is the point, and it goes first.\n\n" +
588
+ "ASKING DOES NOT END YOUR TURN. A question waits on a person, so it carries no timeout and expires " +
589
+ "never — but you keep working. Do everything that does NOT depend on the answer first, and register " +
590
+ "the question at the moment you find it rather than saving it for the end.\n\n" +
591
+ "AND WHEN YOU DO STOP, THE QUESTION IS THAT REST'S SIGN-OFF — rest normally. Frizz draws the card " +
592
+ "at the rest that asked it whether you mention it or not, so nothing you write can hide one. The " +
593
+ "card draws itself at the tail of the rest the question was asked — never write the question into " +
594
+ "your handoff (one question, one card). AT EVERY LATER REST it is no longer your sign-off and frizz " +
595
+ "does not redraw it under your newer handoff: name it under `questions:` in an ```awaiting fence " +
596
+ "while you still need the answer (its card is drawn there), or withdraw it with `frizzctl unask <id>`. TO PLACE IT " +
597
+ "INSIDE YOUR PROSE instead, write " +
598
+ "an EMPTY fence naming the id this command printed — ```question qst_ab12cd34 on one line, ``` on " +
599
+ "the next — and the card renders there. One marker per question; nothing in the body; placement is " +
600
+ "optional, and every answer of the rest still sends together.\n\n" +
601
+ "SEVERAL AT ONCE IS ONE COMMAND. The card sends every answer as a unit, so a second `frizzctl ask` for a second " +
602
+ "question just makes the human send twice. Register them together.\n\n" +
603
+ "The answer comes back to you as its own wake, restating what was asked. Withdraw one you no longer " +
604
+ "need with `frizzctl unask <id>` — a question you have since answered yourself, still sitting on the human's " +
605
+ "board, is worse than never having asked it.\n\n" +
606
+ "ON AN AUTONOMOUS THREAD THIS REFUSES, and tells you the standing instruction you are working " +
607
+ "under. A thread carrying a rest Goal has already been told to keep going and decide for itself, " +
608
+ "so the refusal is that instruction arriving at the moment it matters. Decide, and say which way " +
609
+ "you went in your write-up. If the call is genuinely the human's — destructive or irreversible — " +
610
+ "put it in your FINAL MESSAGE instead of here; autonomous does not mean nobody is reading.\n\n" +
611
+ "AN ACT IS NOT A QUESTION. When the human must PERFORM something you cannot — sign in, approve, " +
612
+ "merge, press a button you may not — do not ask whether they will: list the steps under `steps:` " +
613
+ "in your ```awaiting fence. That works on an autonomous thread too, and their reply wakes you.",
614
+ inputSchema: {
615
+ type: "object",
616
+ properties: {
617
+ // No `maxItems` (four until 2026-09-03): "several at once is one command" above, and a cap here told
618
+ // a worker with six to batch them and then refused the batch.
619
+ questions: {
620
+ type: "array",
621
+ minItems: 1,
622
+ description: "The questions to register, together. Each becomes its own card and its own row.",
623
+ items: questionSchema(1),
624
+ },
625
+ },
626
+ required: ["questions"],
627
+ },
628
+ }
629
+
630
+ const UNASK = {
631
+ name: "unask",
632
+ description:
633
+ "WITHDRAW A QUESTION you registered with `frizzctl ask`, by its id. Its card disappears and the human is " +
634
+ "never asked.\n\n" +
635
+ "Use it the moment the question stops mattering: you worked out the answer yourself, the code moved " +
636
+ "and the fork is gone, or you are about to finish. A stale question on someone's board is worse " +
637
+ "than no question — they answer it, and the answer is about a decision that no longer exists.\n\n" +
638
+ "You do NOT need this for a question that gets answered; that settles itself and wakes you. " +
639
+ "Withdrawing is YOUR move and is never reported back to you as news.",
640
+ inputSchema: {
641
+ type: "object",
642
+ properties: {
643
+ id: { type: "string", description: "The question id `frizzctl ask` printed, or that `frizzctl activity` lists. Only your own thread's." },
644
+ },
645
+ required: ["id"],
646
+ },
647
+ }
648
+
649
+ const SECRET = {
650
+ name: "secret",
651
+ description:
652
+ "ASK THE HUMAN FOR A SECRET VALUE — a one-time 2FA code, an API token, a password — that a command " +
653
+ "needs and you must never see. The human may be on a phone, far from this machine: this is how a " +
654
+ "code reaches a command without anyone typing into a terminal here.\n\n" +
655
+ "It registers a question like `frizzctl ask`, drawn as a card with ONE MASKED BOX. What the human pastes is " +
656
+ "held in Frizz's memory — never on disk — and served through a private named pipe whose path this " +
657
+ "command prints NOW. It never enters the transcript, the database or your context: the answer you are " +
658
+ "woken with names the path, not the value.\n\n" +
659
+ "THE PATH CAN BE READ ONCE, within 15 minutes of the answer; then it is gone. So read it INSIDE the " +
660
+ "command that uses it: `npm publish --otp \"$(cat '<path>')\"`, `<cmd> < '<path>'`. A value several " +
661
+ "commands need goes into a variable at the start of ONE Bash call: " +
662
+ "`T=\"$(cat '<path>')\"; cmd1 --token \"$T\"; cmd2 --token \"$T\"`. Never `cat` it bare, echo it, " +
663
+ "copy it into a file, or paste it into a message — any of those spends the one read and puts the " +
664
+ "value in the transcript. To discard an unread value, `rm` the path.\n\n" +
665
+ "A one-time code expires in about thirty seconds, so have the command ready BEFORE you rest: when the " +
666
+ "answer wakes you, run it at once.\n\n" +
667
+ "Like a question, it is your rest's sign-off and it gates `done` until answered; name it under " +
668
+ "`questions:` at later rests, withdraw it with `frizzctl unask <id>`. It is allowed on an autonomous thread — a " +
669
+ "credential is not something you can decide.",
670
+ inputSchema: {
671
+ type: "object",
672
+ properties: {
673
+ question: {
674
+ type: "string",
675
+ description:
676
+ "What to paste, said so the human can answer cold: which account or service, which kind of " +
677
+ "value, and what it unlocks — \"The npm one-time code for the maintainer account, to publish " +
678
+ "frizz 0.16.0.\" NO \"I\" AND NO \"you\": name the actor outright.",
679
+ },
680
+ header: { type: "string", description: "A very short chip label for the card, 12 characters or so — \"npm OTP\", \"API token\"." },
681
+ },
682
+ required: ["question"],
683
+ },
684
+ }
685
+
686
+ // STEER (2026-10-09): one thread messaging ANOTHER thread of the same project. Claude Code's own
687
+ // cross-session `SendMessage` reaches a session only while its process is live, so a worker told to hand
688
+ // its results to a resting thread had no tool for it, and went looking for the board's `followUp` route
689
+ // to curl by hand — sixteen tool calls to find the route, the slug and the session id. This is that
690
+ // delivery, as a command: the server reads the target's current session, wraps the body so the receiver
691
+ // and the chat both know who sent it, and wakes, queues into, or reopens the target as a human
692
+ // follow-up would.
693
+ const STEER = {
694
+ name: "steer",
695
+ description:
696
+ "SEND A MESSAGE TO ANOTHER THREAD on this board, running or resting, and it reads it as its next " +
697
+ "input. A running thread reads it at its next tool boundary; a resting thread is WOKEN by it and " +
698
+ "starts a turn on it; a thread marked done is reopened. The receiver sees which thread sent it and " +
699
+ "can answer with this same command.\n\n" +
700
+ "USE IT INSTEAD OF RELAYING THROUGH THE HUMAN. Whenever another thread needs what you have, send it " +
701
+ "there yourself: results a thread is waiting on (\"when the audit is done, send the results to " +
702
+ "<thread>\"), a finding that changes another thread's work (it is chasing the bug you just fixed, it " +
703
+ "depends on a commit you just landed, it is about to redo what you already did), or the answer to a " +
704
+ "message another thread sent you. A finding that sits in your handoff until the human copies it " +
705
+ "across costs them a trip and the other thread hours; sending it costs one command. A thread you spawned " +
706
+ "with `frizzctl spawn-thread`, or the one that spawned you, is reachable the same way.\n\n" +
707
+ "WRITE IT TO BE READ COLD. The receiver has none of your context: say what you found or what you " +
708
+ "need, with the evidence, paths, commits and links it needs to act — in one complete message, not a " +
709
+ "drip of partial ones. Every steer costs the receiver a turn on its full context, so do not send " +
710
+ "progress chatter, and do not send what it did not need.\n\n" +
711
+ "IT IS A COLLEAGUE'S MESSAGE, NOT THE HUMAN'S: it does not override the human's instructions to that " +
712
+ "thread, and it is not a way to hand off work the human did not ask for. Nothing waits on a reply " +
713
+ "unless your message asks for one, and you do not need to rest waiting for it: a reply wakes you " +
714
+ "whenever it comes, even after `done`.\n\n" +
715
+ "NOT FOR YOUR OWN SUB-AGENTS — those are inside your session; reach them with `SendMessage` to " +
716
+ "their agent id. Not for your own thread, and not across projects: `to` names a thread on THIS " +
717
+ "project's board.",
718
+ inputSchema: {
719
+ type: "object",
720
+ properties: {
721
+ to: {
722
+ type: "string",
723
+ description:
724
+ "The thread to message: its slug (`fix-queue-focus`), or any link naming it — the " +
725
+ "`[title](/thread/<slug>)` link `frizzctl spawn-thread` prints, or a full board URL such as " +
726
+ "`http://localhost:9393/project/frizz/thread/<slug>/full`.",
727
+ },
728
+ message: {
729
+ type: "string",
730
+ description:
731
+ "The message, as markdown. Self-contained: the receiver has none of your context. It arrives " +
732
+ "wrapped with your thread's name and slug, so do not sign it.",
733
+ },
734
+ },
735
+ required: ["to", "message"],
736
+ },
737
+ }
738
+
739
+ const DONE = {
740
+ name: "done",
741
+ description:
742
+ "DECLARE THIS EFFORT FINISHED, with the write-up the human reads. Your thread cards as a checked " +
743
+ "success in their queue and stays there until they archive it — marking done is not dismissal, and " +
744
+ "it does not close, archive or hide anything.\n\n" +
745
+ "FRIZZ CAN REFUSE THIS, which is the whole reason it is a command rather than a fence. An OPEN " +
746
+ "QUESTION or an ARMED REGISTRATION blocks it, and the refusal names each one by id: a question " +
747
+ "nobody answered dies with the card, and a live wait means the thing you were waiting for has not " +
748
+ "happened yet. Resolve them for real — answer it yourself and `unask`, or `unwatch` the wait you no " +
749
+ "longer need — then run it again. There is no force flag and there will not be one.\n\n" +
750
+ "IT ONLY COUNTS WHAT IS REGISTERED. A background shell or a sub-agent you never registered does " +
751
+ "not block this, because frizz cannot tell a build you are waiting on from a dev server you walked " +
752
+ "away from. That judgement is yours, and registering it is how you make it.\n\n" +
753
+ "DONE MEANS THE WORK LANDED, NOT THAT YOU STOPPED. Code committed to the project's mainline; a " +
754
+ "plan, doc or commissioned report written INTO A FILE. An open pull request is not done — the " +
755
+ "merge is. An investigation headed for a fix is not done — the fix is. And a verdict that ends in " +
756
+ "SOMEBODY SHOULD NOW DO SOMETHING (merge it, post this, pick one of these) is not done either: " +
757
+ "that is a `frizzctl ask`, carrying your recommendation as the first option.\n\n" +
758
+ "THE TEST IS NEVER \"HAVE I STOPPED WORKING\". It is: WHAT IS LOST IF NOBODY EVER OPENS THIS " +
759
+ "THREAD AGAIN? Name one thing and you are not done. Uncertain is not done.",
760
+ inputSchema: {
761
+ type: "object",
762
+ properties: {
763
+ body: {
764
+ type: "string",
765
+ description:
766
+ "THE CARD, as markdown. One to three sentences, then a bullet per deliverable, each opening " +
767
+ "with a bolded verb phrase naming what shipped and where. Backtick every path, identifier and " +
768
+ "command, and make file references real links. It is a LEDGER, not a summary: reasoning, " +
769
+ "caveats and anything the human must do belong in your final message instead, because a " +
770
+ "sentence that would read the same in both places belongs in exactly one of them. Nothing " +
771
+ "here may point vaguely forward — no \"a follow-up could…\". Do it, ask about it, or drop it.",
772
+ },
773
+ },
774
+ required: ["body"],
775
+ },
776
+ }
777
+
778
+ const TITLE = {
779
+ name: "title",
780
+ description:
781
+ "NAME THIS THREAD on the human's board, once you actually know what the work is.\n\n" +
782
+ "WHY IT EXISTS: the name your thread is wearing right now was minted the instant you were " +
783
+ "dispatched, from the raw text of the prompt, before you had read a single file. It can only ever " +
784
+ "paraphrase what the operator typed — so it inherits their shorthand, their ambiguity and their " +
785
+ "typos. One zod thread went onto the board as \"Zon4.5 features and z.properties documentation " +
786
+ "audit\" because the operator typed \"Zon4.5\" and nothing in the session yet knew the product is " +
787
+ "called Zod. You know. That is the entire point of this command.\n\n" +
788
+ "WHEN TO RUN IT: after you have oriented — read the issue, opened the code, found the bug — and " +
789
+ "can name the actual work in your own words. Not on arrival: a name you register before you " +
790
+ "understand the task is the same guess the board already has. Once is normally enough; run it " +
791
+ "again only if the work turns out to be genuinely something else.\n\n" +
792
+ "NAME THE WORK, NOT THE PROMPT. \"Is this true? We should probably…\" is what the human said, not " +
793
+ "what you are doing. A good name is the thing a reader picking one card out of thirty needs: the " +
794
+ "subject and the verb.\n\n" +
795
+ "A HUMAN RENAME OUTRANKS YOU, always. If the human has already named this thread, frizz refuses " +
796
+ "this and tells you so — that is a correct answer, not a failure, and you should not retry it.",
797
+ inputSchema: {
798
+ type: "object",
799
+ properties: {
800
+ title: {
801
+ type: "string",
802
+ description:
803
+ "The thread's name: 3-8 words, SENTENCE case (capitalize only the first word and proper " +
804
+ "nouns — \"Fix queue focus\", never \"Fix Queue Focus\"). No trailing period, no ticks, no " +
805
+ "issue-body quoting. Spell every product, file and identifier the way the PROJECT spells it, " +
806
+ "not the way the prompt did.",
807
+ },
808
+ },
809
+ required: ["title"],
810
+ },
811
+ }
812
+
813
+ // The registry: `frizzctl` lists these and routes a command by name (and the legacy MCP front-end
814
+ // answers `tools/list` and `tools/call` from the same list). Adding a worker-facing capability = one
815
+ // entry here + one handler in `HANDLERS` + its spelling in `CLI` — never a second surface.
816
+ const MIN_INTERVAL_SECONDS = 60
817
+ const MAX_INTERVAL_SECONDS = 24 * 60 * 60
818
+
819
+ const ACTIVITY = {
820
+ name: "activity",
821
+ description:
822
+ "EVERYTHING YOU CURRENTLY HAVE OUT, with the id each one is named by — your background shells, your " +
823
+ "sub-agents, your armed timers, the pull requests you registered, the `wch_…` of every watch holding " +
824
+ "one of them, every QUESTION still owed an answer, and saved links/files with their lnk_ ids.\n\n" +
825
+ "WHY YOU NEED IT: an ```awaiting fence names what you are waiting on BY ID, and frizz checks every " +
826
+ "one against what is actually live. A name that matches nothing is not a park — you are bumped and " +
827
+ "your thread queues. The same goes for the ids `unwatch` and `unask` take, and for the id you put in " +
828
+ "an EMPTY ```question fence to PLACE a registered question in your handoff (a marker naming no open " +
829
+ "question of yours draws nothing). So if you have lost one (a " +
830
+ "compaction, a long turn, a wake you did not expect), run this rather than guessing. Guessing is " +
831
+ "the failure this command exists to remove — and it is the only way to read your open questions " +
832
+ "WITHOUT registering or withdrawing one.\n\n" +
833
+ "It takes nothing and changes nothing. You can only ever read your OWN thread.",
834
+ inputSchema: { type: "object", properties: {}, required: [] },
835
+ }
836
+
837
+ const LINK = {
838
+ name: "link",
839
+ description: "Register a labeled URL or local file underneath this thread's prompt, alongside agents and shells. " +
840
+ "Use for dev servers, working documents, reports, and downloads the human will need again. " +
841
+ "The same label updates its existing row without duplicating or reordering it. " +
842
+ "HTTP(S) destinations render as Link; local files render as File and use Frizz's existing file reader/opener. " +
843
+ "Files must exist. Relative paths resolve from the project root; use absolute paths for worktrees. " +
844
+ "Registration survives rests and restarts, but does not start, monitor, or verify a server. " +
845
+ "It never blocks done or parks the thread. Read registrations with `frizzctl activity`; remove one with `frizzctl unlink <id>`.",
846
+ inputSchema: {
847
+ type: "object",
848
+ properties: {
849
+ label: { type: "string", description: "Short destination label, such as Open dev server or Working plan. Reuse it to update that row." },
850
+ target: { type: "string", description: "An HTTP(S) URL, file:// URL, or existing local file path. No credentials in URLs." },
851
+ },
852
+ required: ["label", "target"],
853
+ additionalProperties: false,
854
+ },
855
+ }
856
+ const UNLINK = {
857
+ name: "unlink",
858
+ description: "Remove one saved link/file from this thread by its lnk_ id (printed by `frizzctl link` or listed by `frizzctl activity`). " +
859
+ "Only removes the registration: it never deletes a file or stops a server.",
860
+ inputSchema: {
861
+ type: "object", properties: { id: { type: "string", description: "The lnk_ id of a registration on this thread." } },
862
+ required: ["id"], additionalProperties: false,
863
+ },
864
+ }
865
+
866
+ // A wait on an OUTSIDE condition that a command can check, run by the SERVER (2026-10-11). It exists
867
+ // because the alternative was a polling loop in a background shell, which pins the thread's whole
868
+ // process tree: hibernation refuses a thread holding one. See packages/server/src/command-watch.ts.
869
+ const WATCH_COMMAND = {
870
+ name: "watch_command",
871
+ description:
872
+ "WAIT ON AN OUTSIDE CONDITION WITHOUT HOLDING A PROCESS. Give Frizz a shell command that exits 0 once " +
873
+ "the thing you are waiting for is true — a package version npm serves, a release run that finished, a " +
874
+ "URL that answers — and the SERVER runs it every `--every` until it does, then wakes you ONCE with " +
875
+ "the command and the end of its output. Nothing of yours runs meanwhile, so your thread can be " +
876
+ "hibernated while it rests. A polling loop in a background shell (`until …; do sleep 300; done`) pins " +
877
+ "your whole process tree, ~250 MB, for as long as it loops; this is that loop without the cost.\n\n" +
878
+ "THE CONDITION IS EXIT STATUS 0, and nothing else. Write the check so it FAILS until the condition " +
879
+ "holds: `npm view frizz@0.13.12 version` (non-zero while npm does not serve it), `gh run view 123 " +
880
+ "--json conclusion -q .conclusion | grep -qx success`, `curl -fsS http://localhost:5173/health`.\n\n" +
881
+ "IT RUNS ON THE SERVER, NOT IN YOUR SHELL: `/bin/sh -c` in the project root, with the server's " +
882
+ "environment and PATH, stdin closed, killed after 2m. Nothing your session exported, no aliases, no " +
883
+ "functions. A first check runs at registration: exit 0 means the condition already holds and nothing " +
884
+ "is armed, and a command the server cannot run at all (`command not found`) is refused.\n\n" +
885
+ "ONLY ON A THREAD WITH NO PERMISSION CHECKS. The server runs the command with no permission prompt and " +
886
+ "outside any sandbox, so Frizz refuses it unless this thread runs in `bypassPermissions` mode with " +
887
+ "Claude Code's sandbox off, and refuses it on Codex. The refusal says why; wait in a background shell " +
888
+ "then.\n\n" +
889
+ "ONE-SHOT: the first passing check settles it. When `--for` runs out it is cancelled and you are woken " +
890
+ "to re-decide. Name its id in your ```awaiting fence — `commands: [cmw_…]` with `status: watching`. " +
891
+ "Registering is idempotent on the exact command text. `list` reads back what is armed, with each " +
892
+ "watch's last check; `drop` withdraws one (so does `frizzctl unwatch <id>`). Only ever your OWN thread's.",
893
+ inputSchema: {
894
+ type: "object",
895
+ properties: {
896
+ action: {
897
+ type: "string",
898
+ enum: ["add", "list", "drop"],
899
+ description:
900
+ "`add` registers a watch (idempotent per command); `drop` withdraws one by id; `list` reads back " +
901
+ "every watch armed on this thread, with its last check, without changing anything.",
902
+ },
903
+ command: {
904
+ type: "string",
905
+ description:
906
+ "Required for `add`. The check, as one shell command line run with `/bin/sh -c` in the project " +
907
+ "root. Exit 0 means the condition holds. Use absolute paths for anything not on a plain PATH.",
908
+ },
909
+ every: {
910
+ type: "string",
911
+ description:
912
+ "Required for `add`. How often to check, as a DURATION — `1m`, `5m`, `1h` (min 1m, max 24h). " +
913
+ "Pick it for how soon you need to know: a release in npm's review is minutes to hours, so `5m`.",
914
+ },
915
+ for: {
916
+ type: "string",
917
+ description:
918
+ "Required for `add`. How long to keep checking, as a DURATION — `2h`, `1d`, `7d` (max 7d). When it " +
919
+ "runs out the watch is cancelled and you are woken to decide what the silence means.",
920
+ },
921
+ id: {
922
+ type: "string",
923
+ description: "Required for `drop`. The `cmw_…` id `add` printed (or that `list` and `frizzctl activity` show).",
924
+ },
925
+ },
926
+ required: ["action"],
927
+ },
928
+ }
929
+
930
+ // WATCH_ISSUE rides at the END (2026-09-14), and SECRET, STEER and WATCH_COMMAND after it: the tool list is read by position
931
+ // in frizz-mcp.test.ts, and a worker's runtime reads it by name, so the order costs nothing and appending
932
+ // breaks nothing.
933
+ const TOOLS = [SPAWN_THREAD, GOAL, TIMER, WATCH_PR, WATCH, UNWATCH, ASK, UNASK, DONE, TITLE, ACTIVITY, LINK, UNLINK, WATCH_ISSUE, SECRET, STEER, WATCH_COMMAND]
934
+
935
+ /** @type {Record<string, (args: Record<string, unknown>) => Promise<string>>} */
936
+ const HANDLERS = {
937
+ [SPAWN_THREAD.name]: spawnThread,
938
+ [GOAL.name]: goal,
939
+ [TIMER.name]: timer,
940
+ [WATCH_PR.name]: watchPr,
941
+ [WATCH_ISSUE.name]: watchIssue,
942
+ [WATCH.name]: watch,
943
+ [ASK.name]: ask,
944
+ [UNASK.name]: unask,
945
+ [SECRET.name]: secret,
946
+ [DONE.name]: done,
947
+ [TITLE.name]: title,
948
+ [UNWATCH.name]: unwatch,
949
+ [ACTIVITY.name]: activity,
950
+ [LINK.name]: link,
951
+ [UNLINK.name]: unlink,
952
+ [STEER.name]: steer,
953
+ [WATCH_COMMAND.name]: watchCommand,
954
+ }
955
+
956
+ // The slug out of whatever a worker has in hand for a thread: the slug itself, the `[title](/thread/<slug>)`
957
+ // link spawn_thread returns, or a board URL (`…/project/<p>/thread/<slug>/full`).
958
+ const THREAD_LINK = /\/thread\/([^/?#)\s]+)/
959
+
960
+ /** The `steer` handler: deliver a message into ANOTHER thread of this project.
961
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
962
+ async function steer(args) {
963
+ const raw = typeof args.to === "string" ? args.to.trim() : ""
964
+ const message = typeof args.message === "string" ? args.message.trim() : ""
965
+ if (!raw) throw new Error("`to` is required — the slug of the thread to message, or a link naming it")
966
+ if (!message) throw new Error("`message` is required and must be a non-empty string")
967
+ const to = decodeURIComponent(THREAD_LINK.exec(raw)?.[1] ?? raw)
968
+ // THE SENDER IS THE ENV'S, never the model's: the receiver is told which thread sent this, and that
969
+ // has to be true.
970
+ const result = (await callRpc("steerThread", { from: threadSlug(), to, message }))?.result
971
+ if (!result?.slug) throw new Error(`steerThread returned no receipt: ${JSON.stringify(result)?.slice(0, 300)}`)
972
+ const how = result.delivery === "running"
973
+ ? "It is mid-turn, so it reads this at its next tool boundary."
974
+ : result.delivery === "reopened"
975
+ ? "It had been marked done; this message reopened it and started a turn."
976
+ : "It was resting; this message woke it and started a turn."
977
+ return (
978
+ `Delivered to «${result.title}» (\`${result.slug}\`). ${how}\n\n` +
979
+ "Its reply, if it sends one, arrives as your next input and wakes you — do not rest waiting for it. " +
980
+ `Link for your handoff: [${result.title}](/thread/${result.slug})`
981
+ )
982
+ }
983
+
984
+ /** @param {Record<string, unknown>} args @returns {Promise<string>} */
985
+ async function link(args) {
986
+ const label = typeof args.label === "string" ? args.label.trim() : ""
987
+ const target = typeof args.target === "string" ? args.target.trim() : ""
988
+ if (!label || !target) throw new Error("`label` and `target` are required")
989
+ const result = (await callRpc("upsertOwnLink", { slug: threadSlug(), label, target }))?.result
990
+ const saved = result?.link
991
+ if (!saved?.id) throw new Error("Frizz did not return a saved link")
992
+ return `Registered ${saved.kind} ${saved.id}: ${saved.label}\n${saved.target}\n\nThis reference stays underneath the prompt. It does not assert a server is running or block completion. Remove it with \`frizzctl unlink ${saved.id}\`.`
993
+ }
994
+
995
+ /** @param {Record<string, unknown>} args @returns {Promise<string>} */
996
+ async function unlink(args) {
997
+ const id = typeof args.id === "string" ? args.id.trim() : ""
998
+ if (!id) throw new Error("`id` is required — take it from `frizzctl link` or `frizzctl activity`")
999
+ const result = (await callRpc("dropOwnLink", { slug: threadSlug(), id }))?.result
1000
+ return result?.dropped ? `Removed registration ${id}. No file was deleted and no server was stopped.` : `No registration ${id} on this thread.`
1001
+ }
1002
+
1003
+ /** The `title` handler: register this thread's considered name.
1004
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1005
+ async function title(args) {
1006
+ const slug = threadSlug()
1007
+ const wanted = typeof args.title === "string" ? args.title.trim() : ""
1008
+ if (!wanted) throw new Error("`title` is required — 3-8 words naming the work, in sentence case")
1009
+ const result = (await callRpc("setOwnThreadTitle", { slug, title: wanted }))?.result
1010
+ if (result?.accepted) return `This thread is now named "${result.title}" on the board.`
1011
+ // The refusal is REPORTED, never thrown: a human who renamed the thread owns its name, and a worker
1012
+ // told "error" would retry a call that can only ever fail again.
1013
+ if (result?.lockedByHuman) {
1014
+ return (
1015
+ `Not renamed — the human has named this thread "${result.title}" themselves, and their name ` +
1016
+ "outranks yours. Leave it; do not run this again for this thread."
1017
+ )
1018
+ }
1019
+ return `Not renamed — frizz did not accept the write. This thread still reads "${result?.title ?? slug}".`
1020
+ }
1021
+
1022
+ /** Read out every background thing this thread has running, in the shape an awaiting fence names them.
1023
+ * @returns {Promise<string>} */
1024
+ async function activity() {
1025
+ const result = (await callRpc("listOwnThreadActivity", { slug: threadSlug() }))?.result
1026
+ const items = Array.isArray(result?.activity) ? result.activity : []
1027
+ const questions = Array.isArray(result?.questions) ? result.questions : []
1028
+ const links = Array.isArray(result?.links) ? result.links : []
1029
+ const linksBlock = links.length === 0 ? "" : "\n\nSaved links and files (not running work; remove with `frizzctl unlink <id>`):\n" +
1030
+ links.map((link) => ` ${link.id} ${link.kind}: ${link.label}\n ${link.target}`).join("\n")
1031
+ // THE QUESTIONS GET THEIR OWN SECTION, because they are not running work: a question waits on a
1032
+ // person. Since 2026-10-05 a fence names the ones still needed under `questions:` (and must name every
1033
+ // open one), so the ready-to-paste fence below carries them too.
1034
+ const askedBlock = questions.length === 0 ? "" : (
1035
+ `\n\n${questions.length} question${questions.length === 1 ? "" : "s"} still owed an answer:\n\n` +
1036
+ questions.map((q) => ` question: ${q.id}\n ${String(q?.spec?.question ?? "").replace(/\s+/g, " ").slice(0, 160)}`).join("\n") +
1037
+ "\n\nEach one blocks `done` until it is answered or withdrawn, and draws its own card at the rest " +
1038
+ "it was asked — never write it into a handoff; an EMPTY ```question fence naming its id places it " +
1039
+ "inside your prose. `frizzctl unask <id>` the ones since decided. Any ```awaiting fence names EVERY open one " +
1040
+ "under `questions:` or is refused, and at a rest after the one that asked, a question you neither " +
1041
+ "name nor withdraw gets you bumped. In a fence: `questions: [" +
1042
+ questions.map((q) => q.id).join(", ") + "]`"
1043
+ )
1044
+ if (!items.length) {
1045
+ if (questions.length > 0) {
1046
+ return (
1047
+ "Nothing is RUNNING on this thread — no background shells, no sub-agents, no armed timers, no " +
1048
+ "registered PRs. An ```awaiting fence can still wait on your open questions alone — " +
1049
+ "`questions:` names the human as the wait, so it needs no other name and no `for:`." +
1050
+ askedBlock + linksBlock
1051
+ )
1052
+ }
1053
+ return (
1054
+ "Nothing is running on this thread — no background shells, no sub-agents, no armed timers, no " +
1055
+ "registered PRs, and no open questions.\n\nSo there is nothing to wait on: an ```awaiting fence " +
1056
+ "would have nothing to name, and a fence naming nothing is not a park. End with ```done, or " +
1057
+ "register a question with `frizzctl ask` if you need the human." + linksBlock
1058
+ )
1059
+ }
1060
+ const lines = items.map((i) => {
1061
+ const when = i.until ? ` (fires ${i.until})` : i.since ? ` (since ${i.since})` : ""
1062
+ // The `wch_…` id of the watch holding this item, where one is armed — this readout exists to hand a
1063
+ // worker back the ids it lost, and that includes the one `unwatch` takes.
1064
+ const held = i.watchId ? ` [watched as ${i.watchId}]` : ""
1065
+ return ` ${i.kind}: ${i.id}${when}${held}\n ${i.label}`
1066
+ })
1067
+ // A READY-TO-PASTE FENCE, not a description of one. The frontmatter is YAML since 2026-08-24 and its
1068
+ // keys are PLURAL sequences, so an id printed on its own line is no longer something a worker can copy
1069
+ // into a fence — it has to see the shape. This command is where the contract sends a worker that has lost
1070
+ // an id, so printing the retired one-line-per-item form would teach the very grammar frizz refuses.
1071
+ const byKind = { shell: [], agent: [], timer: [], pr: [], issue: [], command: [] }
1072
+ for (const i of items) if (byKind[i.kind] && i.id) byKind[i.kind].push(i.id)
1073
+ const block = Object.entries({ shells: byKind.shell, agents: byKind.agent, timers: byKind.timer, prs: byKind.pr, issues: byKind.issue, commands: byKind.command })
1074
+ .filter(([, ids]) => ids.length > 0)
1075
+ .map(([key, ids]) => ` ${key}: [${ids.join(", ")}]`)
1076
+ // Every open question rides the fence too: a fence that leaves one out is refused, and a fence on
1077
+ // questions always queues, so its `status:` answer is `needs_input` whatever else it names. Otherwise
1078
+ // the template guesses from the kinds — a shell or a sub-agent is usually work that finishes by itself,
1079
+ // a PR, an issue or a timer a watch — and the prose tells the worker to correct it.
1080
+ const questionIds = questions.map((q) => q.id).filter(Boolean)
1081
+ if (questionIds.length > 0) block.push(` questions: [${questionIds.join(", ")}]`)
1082
+ const status = questionIds.length > 0 ? "needs_input" : byKind.shell.length + byKind.agent.length > 0 ? "working" : "watching"
1083
+ return (
1084
+ `${items.length} thing${items.length === 1 ? "" : "s"} running on this thread:\n\n${lines.join("\n")}\n\n` +
1085
+ "Name the ones you are ACTUALLY waiting on in your ```awaiting fence. The frontmatter is YAML — one " +
1086
+ "PLURAL key per kind, taking a list — plus a required `for:` duration and a required `status:` " +
1087
+ "answer: `working` (the work finishes by itself; the thread shows as running), `watching` (the wait " +
1088
+ "is on something outside the thread; it is snoozed) or `needs_input` (the human can act on something " +
1089
+ "now; it goes in their queue, with what to look at BELOW a `---` line — there is no `reason:` key). " +
1090
+ "`working` and `watching` need no prose at all.\n\nEverything above, as a fence:\n\n" +
1091
+ "```awaiting\n" +
1092
+ `${block.join("\n")}\n status: ${status}\n for: 2h\n` +
1093
+ "```\n\nDrop the lines you are not actually waiting on — a dev server you left running is not a wait " +
1094
+ "— and change `status:` if it names the wrong place." +
1095
+ "\n\nA `frizzctl watch` registration (marked `[watched as …]` above) keeps the WAKE across a compaction and a " +
1096
+ "restart, but it does not replace the fence: name the work in the fence all the same." +
1097
+ askedBlock + linksBlock
1098
+ )
1099
+ }
1100
+
1101
+ /**
1102
+ * Whether a pid is running. EPERM means someone else's live process, which is still ALIVE.
1103
+ *
1104
+ * A lock with NO pid reads as alive: absence of evidence is not evidence of death, and discarding a
1105
+ * record written by an older or foreign publisher would turn a working server into "none found".
1106
+ */
1107
+ function pidAlive(pid) {
1108
+ if (pid === undefined || pid === null) return true
1109
+ if (!Number.isInteger(pid)) return true
1110
+ try {
1111
+ process.kill(pid, 0)
1112
+ return true
1113
+ } catch (err) {
1114
+ return err?.code === "EPERM"
1115
+ }
1116
+ }
1117
+
1118
+ /** A lock file's `{port, pid}`, or undefined if it is missing, malformed, or names a DEAD process. */
1119
+ function liveLock(path) {
1120
+ try {
1121
+ const parsed = JSON.parse(readFileSync(path, "utf8"))
1122
+ if (!Number.isInteger(parsed?.port)) return undefined
1123
+ if (!pidAlive(parsed?.pid)) return undefined
1124
+ return { port: parsed.port, path }
1125
+ } catch {
1126
+ return undefined
1127
+ }
1128
+ }
1129
+
1130
+ /** The caller's environment names no Frizz at all — as opposed to naming one that is not answering. */
1131
+ class NoFrizzState extends Error {}
1132
+
1133
+ /**
1134
+ * This project's state dir (`<frizz root>/projects/<project id>`), from whichever stamp the caller has.
1135
+ *
1136
+ * FRIZZ_STATE_DIR is the explicit one. FRIZZ_PERM_DIR is `<state dir>/perm-requests` (project.ts
1137
+ * `permDir`) and has been in every Claude worker's environment since long before the command line
1138
+ * existed — so a shell inside a worker forked by an OLDER Frizz, which was never handed the state dir,
1139
+ * still finds the server through it.
1140
+ */
1141
+ function projectStateDir() {
1142
+ if (process.env.FRIZZ_STATE_DIR) return process.env.FRIZZ_STATE_DIR
1143
+ const perm = process.env.FRIZZ_PERM_DIR
1144
+ return perm && basename(perm) === "perm-requests" ? dirname(perm) : undefined
1145
+ }
1146
+
1147
+ /**
1148
+ * FIND THE RUNNING FRIZZ — every call, never cached, never frozen at spawn.
1149
+ *
1150
+ * This process is spawned once, inside a DETACHED worker daemon that outlives restart after restart.
1151
+ * An address handed to it in its env is therefore true exactly until the next "Update & Restart", and
1152
+ * a worker whose only address was stale simply lost every frizz tool it had — with no way back short of
1153
+ * restarting the worker itself, which is not what an update button should mean.
1154
+ *
1155
+ * So the env is a HINT and the file is the truth, in this order:
1156
+ * 1. FRIZZ_SERVER_LOCK — the lock this server published when it spawned us. Right almost always.
1157
+ * 2. `<frizz root>/server.lock` — the MACHINE address (frizz-paths.ts `serverAddressPath`), rewritten
1158
+ * by every boot whatever project launched it. This is what makes a live worker survive an update.
1159
+ * 3. `<state dir>/server.lock` — our own project's, for a server that only ever serves one project.
1160
+ * 4. any live `<frizz root>/projects/*​/server.lock` — last resort, since one machine runs one frizz.
1161
+ *
1162
+ * A candidate whose PID IS DEAD IS SKIPPED, which is the difference between a legible failure and the
1163
+ * one that cost an afternoon: a stale lock from a long-dead per-project server sent every call at a port
1164
+ * nothing was listening on, and the tool reported only "fetch failed".
1165
+ *
1166
+ * The frizz root is `../..` from the state dir rather than computed: this file is dependency-free and
1167
+ * the real root is platform-dependent (XDG, `~/Library/Application Support`, a legacy `~/.frizz`).
1168
+ */
1169
+ function serverLockPort() {
1170
+ const stateDir = projectStateDir()
1171
+ const root = stateDir ? dirname(dirname(stateDir)) : undefined
1172
+ const candidates = [
1173
+ process.env.FRIZZ_SERVER_LOCK,
1174
+ root ? join(root, "server.lock") : undefined,
1175
+ stateDir ? join(stateDir, "server.lock") : undefined,
1176
+ ].filter(Boolean)
1177
+ for (const path of candidates) {
1178
+ const live = liveLock(path)
1179
+ if (live) return live.port
1180
+ }
1181
+ // Nothing we were told about is alive. One machine runs one frizz, so any project's live lock names
1182
+ // it — and addressing by project id (rpcPath) means a server that does not serve us answers 404
1183
+ // rather than acting on the wrong board.
1184
+ if (root) {
1185
+ let entries = []
1186
+ try { entries = readdirSync(join(root, "projects")) } catch {}
1187
+ for (const entry of entries) {
1188
+ const live = liveLock(join(root, "projects", entry, "server.lock"))
1189
+ if (live) return live.port
1190
+ }
1191
+ }
1192
+ if (candidates.length === 0) {
1193
+ throw new NoFrizzState(
1194
+ "cannot locate the Frizz server: this environment names no Frizz state (FRIZZ_STATE_DIR, " +
1195
+ "FRIZZ_SERVER_LOCK and FRIZZ_PERM_DIR are all unset). That is what a shell OUTSIDE a Frizz worker " +
1196
+ "looks like; inside one it is a Frizz bug. NOTHING WAS SAVED — this command had no effect.",
1197
+ )
1198
+ }
1199
+ // SAY THAT NOTHING WAS SAVED, and say to retry. A worker reads "is frizz running?" as a fact about the
1200
+ // world rather than as a fact about ITS OWN call, and moves on — so whatever it was arming is silently
1201
+ // gone. Measured 2026-08-17: a worker's `recurring_prompt start` hit a restart window, got this error,
1202
+ // carried on, and its Goal — the thing keeping a long autonomous effort alive — never existed. The
1203
+ // window is ordinary (frizz restarts, and this process outlives every one of them), so the recovery has
1204
+ // to be ordinary too: try again.
1205
+ throw new Error(
1206
+ `no running frizz server found (looked at ${candidates.join(", ")} and every project lock under ` +
1207
+ `${root ? join(root, "projects") : "the frizz root"}; each was missing, malformed, or written by a process that is gone). ` +
1208
+ `NOTHING WAS SAVED — this command had no effect. frizz is probably mid-restart, which is ordinary and ` +
1209
+ `brief; RETRY this exact command before you do anything else, and do not come to rest assuming it took.`,
1210
+ )
1211
+ }
1212
+
1213
+ /**
1214
+ * The RPC base for OUR project.
1215
+ *
1216
+ * One frizz serves every project on the machine, and an unprefixed `/_frizz/rpc/…` is the project it
1217
+ * was LAUNCHED from — so without the prefix a worker in any other project acted on the launcher's
1218
+ * board (spawn_thread put its new thread there; the thread-scoped tools looked for a slug that lives
1219
+ * in a different registry). FRIZZ_PROJECT_ID is the immutable registry id rather than the slug,
1220
+ * because the value is handed over once at spawn and then held for the life of a detached daemon,
1221
+ * and a project can be renamed under it. Unset ⇒ unprefixed, which is what a server that only ever
1222
+ * serves one project passes, and what the launching project's own workers get.
1223
+ * @param {string} procedure
1224
+ */
1225
+ function rpcPath(procedure) {
1226
+ const project = projectSegment()
1227
+ return `${project ? `/_frizz/${encodeURIComponent(project)}` : "/_frizz"}/rpc/${procedure}`
1228
+ }
1229
+
1230
+ /**
1231
+ * WHICH PROJECT WE ACT ON — always the one this worker is actually running in.
1232
+ *
1233
+ * There is deliberately no tool parameter for it and no way to name another project: the id comes from
1234
+ * the server's stamp, or failing that from the tree we are standing in (`<root>/.frizz/.id`, the same
1235
+ * file project-root.ts treats as identity). Spawning a thread onto somebody else's board is therefore
1236
+ * not something a model can express, rather than something it is asked not to do.
1237
+ *
1238
+ * The walk-up is what makes this work for a worker spawned by a server that predates the stamp, and it
1239
+ * is the honest source anyway: a worker's project is wherever its cwd is, and that cannot go stale.
1240
+ */
1241
+ function projectSegment() {
1242
+ const stamped = process.env.FRIZZ_PROJECT_ID
1243
+ if (stamped) return stamped
1244
+ // The state dir IS `<frizz root>/projects/<project id>` (frizz-paths.ts `projectStateDir`), so a
1245
+ // caller that knows its state dir knows its project — which is the thread's own project even when
1246
+ // the worker has `cd`'d somewhere with no `.frizz/` above it, or into another project's tree.
1247
+ const stateDir = projectStateDir()
1248
+ if (stateDir && basename(dirname(stateDir)) === "projects") return basename(stateDir)
1249
+ let dir = process.cwd()
1250
+ for (;;) {
1251
+ try {
1252
+ const id = readFileSync(join(dir, ".frizz", ".id"), "utf8").trim()
1253
+ if (id) return id
1254
+ } catch {}
1255
+ const parent = dirname(dir)
1256
+ if (parent === dir) return undefined
1257
+ dir = parent
1258
+ }
1259
+ }
1260
+
1261
+ /** The `spawn_thread` handler: POST /_frizz/rpc/dispatch, return the worker-facing result text.
1262
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1263
+ async function spawnThread(args) {
1264
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
1265
+ if (!prompt) throw new Error("`prompt` is required and must be a non-empty string")
1266
+ // model + effort are REQUIRED (no default) so the caller must choose by task complexity — a defaulted
1267
+ // model (e.g. the project's cheap default) is exactly the bug this guards. Enforced server-side too,
1268
+ // not only in the tool schema, so a lenient client can't skip the decision.
1269
+ const model = typeof args.model === "string" ? args.model.trim() : ""
1270
+ if (!model) throw new Error("`model` is required — choose one by the new task's complexity (claude: opus/sonnet/haiku, opus being the top tier; codex: a gpt-5.6 model id). There is no default.")
1271
+ const effort = typeof args.effort === "string" ? args.effort.trim() : ""
1272
+ if (!effort) throw new Error("`effort` is required — choose one by complexity (low/medium/high/xhigh/max). There is no default.")
1273
+
1274
+ // THE WAY BACK. A spawned thread starts with nothing but this prompt, so without its spawner's slug it
1275
+ // could not `steer` anything back even when asked to — the footer names it, once, at the end.
1276
+ const parent = process.env.FRIZZ_THREAD_SLUG || process.env.FRIZZ_THREAD
1277
+ const footer = parent
1278
+ ? `\n\n---\nSpawned by the Frizz thread \`${parent}\`. If this prompt asks for results back, or you find ` +
1279
+ `something that thread must know, send it there with \`frizzctl steer ${parent} --message-file <file|->\`.`
1280
+ : ""
1281
+ /** @type {Record<string, unknown>} */
1282
+ const body = { prompt: prompt + footer, model, effort }
1283
+ if (typeof args.title === "string" && args.title.trim()) body.title = args.title.trim()
1284
+ if (args.backend === "claude" || args.backend === "codex") body.backend = args.backend
1285
+
1286
+ const port = serverLockPort()
1287
+ const controller = new AbortController()
1288
+ const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
1289
+ let res
1290
+ try {
1291
+ res = await fetch(`http://127.0.0.1:${port}${rpcPath("dispatch")}`, {
1292
+ method: "POST",
1293
+ // No Origin header (undici omits it for non-browser fetch); `sec-fetch-site: same-origin`
1294
+ // satisfies the server's loopback-origin gate (app.ts isTrustedLocalHttpRequest).
1295
+ headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
1296
+ body: JSON.stringify(body),
1297
+ signal: controller.signal,
1298
+ })
1299
+ } catch (err) {
1300
+ throw unreachable("dispatch", err)
1301
+ } finally {
1302
+ clearTimeout(timer)
1303
+ }
1304
+ if (!res.ok) {
1305
+ const detail = await res.text().catch(() => "")
1306
+ throw new Error(`dispatch returned HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`)
1307
+ }
1308
+ const payload = await res.json().catch(() => null)
1309
+ const slug = payload?.result?.slug
1310
+ if (typeof slug !== "string" || !slug) throw new Error(`dispatch response missing a slug: ${JSON.stringify(payload)?.slice(0, 300)}`)
1311
+ const label = typeof body.title === "string" ? body.title : slug
1312
+ return (
1313
+ `Spawned a new frizz thread \`${slug}\`. It is now on the board driving independently — it reports ` +
1314
+ `to the human via its own final message, so do not wait on a result from it. It knows your slug: if ` +
1315
+ `its prompt asked for results back, they arrive as a \`steer\` that wakes you. You can message it ` +
1316
+ `the same way, with \`frizzctl steer ${slug}\`.\n\n` +
1317
+ `Paste this link to let the human open it in the drawer:\n\n[${label}](/thread/${slug})`
1318
+ )
1319
+ }
1320
+
1321
+ /** POST a frizz RPC procedure and return its parsed payload. Shares spawn_thread's transport rules:
1322
+ * the port comes from server.lock and `sec-fetch-site: same-origin` satisfies the loopback gate.
1323
+ * @param {string} procedure @param {Record<string, unknown>} body @returns {Promise<any>} */
1324
+ // HOW LONG A RESTART WINDOW IS ALLOWED TO BE INVISIBLE. frizz replaces its own server routinely
1325
+ // ("Update & Restart", a dev rebuild), and this process is deliberately still here across every one of
1326
+ // them — so a call landing in that gap is ORDINARY, and failing it is the shim reporting frizz's
1327
+ // housekeeping as the worker's problem. Measured 2026-08-17: a `recurring_prompt start` landed in one,
1328
+ // failed, and the Goal that was keeping a long autonomous effort alive silently never existed.
1329
+ //
1330
+ // Telling the model to retry (which the error also does) is strictly weaker than retrying, because it
1331
+ // only works if the model complies. Bounded and short: a genuinely-down frizz still fails, promptly,
1332
+ // with the same message — this only covers the seconds where a new server is coming up.
1333
+ const LOCK_RETRY_MS = 6_000
1334
+ const LOCK_RETRY_INTERVAL_MS = 400
1335
+
1336
+ /** The port, waiting out a brief restart window rather than failing into one. Rethrows the real
1337
+ * "no running frizz server" error once the budget is spent, so a frizz that is actually down still
1338
+ * says so — and says it with the retry guidance attached. */
1339
+ async function serverLockPortWaiting() {
1340
+ const deadline = Date.now() + LOCK_RETRY_MS
1341
+ for (;;) {
1342
+ try {
1343
+ return serverLockPort()
1344
+ } catch (err) {
1345
+ // No lock to wait FOR is not a restart window: nothing in this environment names a Frizz, and
1346
+ // six seconds of retrying would only delay saying so.
1347
+ if (err instanceof NoFrizzState || Date.now() >= deadline) throw err
1348
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_INTERVAL_MS))
1349
+ }
1350
+ }
1351
+ }
1352
+
1353
+ async function callRpc(procedure, body) {
1354
+ const port = await serverLockPortWaiting()
1355
+ const controller = new AbortController()
1356
+ const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
1357
+ let res
1358
+ try {
1359
+ res = await fetch(`http://127.0.0.1:${port}${rpcPath(procedure)}`, {
1360
+ method: "POST",
1361
+ headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
1362
+ body: JSON.stringify(body),
1363
+ signal: controller.signal,
1364
+ })
1365
+ } catch (err) {
1366
+ throw unreachable(procedure, err)
1367
+ } finally {
1368
+ clearTimeout(timer)
1369
+ }
1370
+ if (!res.ok) {
1371
+ const detail = await res.text().catch(() => "")
1372
+ throw new Error(`${procedure} returned HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`)
1373
+ }
1374
+ return await res.json().catch(() => null)
1375
+ }
1376
+
1377
+ /**
1378
+ * A request that never reached the server, as the error the caller reads.
1379
+ *
1380
+ * ONE CAUSE GETS ITS OWN WORDS: a shell sandboxed off the network. A codex thread narrowed to
1381
+ * `workspace-write` or `read-only` cannot open a socket at all, loopback included (measured on codex
1382
+ * 0.161.0, `_live_codex_shell_identity.mts`: curl exit 7 against a TCP listener and a unix socket
1383
+ * alike), and codex says so in the shell's own environment. "fetch failed" there reads as Frizz being
1384
+ * down, and a worker told that retries — forever, since nothing it can do from inside changes it.
1385
+ * Every thread Frizz creates runs with full access, so this is a thread a human narrowed on purpose.
1386
+ * @param {string} procedure @param {unknown} err
1387
+ */
1388
+ function unreachable(procedure, err) {
1389
+ if (process.env.CODEX_SANDBOX_NETWORK_DISABLED === "1") {
1390
+ return new Error(
1391
+ `${procedure} could not reach Frizz: this shell is sandboxed with network access off ` +
1392
+ "(CODEX_SANDBOX_NETWORK_DISABLED=1), and that includes the Frizz server on this machine. NOTHING " +
1393
+ "WAS SAVED, and no Frizz command can work from this thread while it stays sandboxed — do not " +
1394
+ "retry. Say what you needed in your final message, and say there that this thread needs full " +
1395
+ "access before a Frizz command can run.",
1396
+ )
1397
+ }
1398
+ return new Error(`${procedure} request failed: ${err instanceof Error ? err.message : err}`)
1399
+ }
1400
+
1401
+ /** Which thread is calling. Read from the environment the worker was started with, because nothing
1402
+ * else names the caller: a command run from a worker's shell inherits that worker's environment, and
1403
+ * the MCP protocol carries no caller identity at all.
1404
+ * FRIZZ_THREAD is what a Claude worker's broker daemon is tagged with, so a shell inside one has it.
1405
+ * FRIZZ_THREAD_SLUG is the explicit stamp, and wins: the MCP mount's own env, and the whole identity of
1406
+ * a codex thread's shell and an ACP agent, which must never carry FRIZZ_THREAD — that is the orphan
1407
+ * reaper's ownership tag, and neither has the live `claude`/`codex` root that keeps a tagged process
1408
+ * from being reaped (packages/server/src/backend/types.ts frizzCliEnv).
1409
+ *
1410
+ * This is also the reason a model can never point `goal` at someone else's thread: the slug is
1411
+ * read from HERE, never from the arguments. */
1412
+ function threadSlug() {
1413
+ const slug = process.env.FRIZZ_THREAD_SLUG || process.env.FRIZZ_THREAD
1414
+ if (!slug) {
1415
+ // Ten commands resolve their caller through here, so the message must not name one of them. It said
1416
+ // "so it cannot arm a goal for it" for every single one — which read as a bug in `goal` no matter
1417
+ // which one the worker had actually called, and sent at least one worker off debugging the wrong
1418
+ // thing after `title` failed on a codex thread.
1419
+ throw new Error(
1420
+ "Frizz could not tell which thread is calling (neither FRIZZ_THREAD_SLUG nor FRIZZ_THREAD is in this environment), so it cannot " +
1421
+ "act on the caller's own thread. Inside a Frizz worker this is a Frizz bug — report it rather than " +
1422
+ "working around it. Outside one there is no thread to act on.",
1423
+ )
1424
+ }
1425
+ return slug
1426
+ }
1427
+
1428
+ /** A handler's answer when it did its job by REFUSING: a gate that held, not a fault. A front-end
1429
+ * prints `text` like any other answer; the command line also exits 2, so a script can branch on it. */
1430
+ class Refused {
1431
+ /** @param {string} text */
1432
+ constructor(text) { this.text = text }
1433
+ }
1434
+
1435
+ /** How a heartbeat cadence reads back to the worker. ONE formatter, because `start` and `get` describe
1436
+ * the same stored number and a worker that saw "every 15m" armed must not read "every 900s" back.
1437
+ *
1438
+ * The house duration grammar (`packages/web/src/lib/durationLabels.ts`), matching the trailer
1439
+ * `formatIntervalLabel` writes into the delivery itself — a worker reads both.
1440
+ * @param {number|undefined} seconds */
1441
+ function cadenceLabel(seconds) {
1442
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) return undefined
1443
+ if (seconds % 60 !== 0) return `${seconds}s`
1444
+ const minutes = seconds / 60
1445
+ if (minutes < 60) return `${minutes}m`
1446
+ return minutes % 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${Math.floor(minutes / 60)}h`
1447
+ }
1448
+
1449
+ /** Render an armed goal for the worker to read: which triggers are live, the cadence, when
1450
+ * each last fired, and the text VERBATIM (never truncated — reading back a summary of your own
1451
+ * instruction is exactly as blind as not reading it).
1452
+ * @param {{ prompt: string, stopHook: boolean, heartbeat: boolean, postCompaction: boolean,
1453
+ * intervalSeconds?: number, armedAt: string, lastRestFiredAt?: string,
1454
+ * lastScheduleFiredAt?: string, lastCompactFiredAt?: string }} rp */
1455
+ function goalReport(rp) {
1456
+ const fired = (/** @type {string|undefined} */ at) => (at ? `last fired ${at}` : "never fired yet")
1457
+ const triggers = [
1458
+ rp.stopHook ? ` stop_hook — every time you come to rest (${fired(rp.lastRestFiredAt)})` : null,
1459
+ rp.heartbeat
1460
+ // The SAME cadence form `start` reports (cadenceLabel), or the two readings of one row disagree
1461
+ // about the number they are describing — "every 15 min" armed, "every 900s" read back.
1462
+ ? ` heartbeat — every ${cadenceLabel(rp.intervalSeconds) ?? "?"} (${fired(rp.lastScheduleFiredAt)})`
1463
+ : null,
1464
+ rp.postCompaction ? ` post_compaction — every compaction (${fired(rp.lastCompactFiredAt)})` : null,
1465
+ ].filter(Boolean)
1466
+ // EVERY trigger off is a real, reachable state — the human can switch them off in the footer without
1467
+ // clearing the words — and it is the one a worker would otherwise misread as "armed and running".
1468
+ const head = triggers.length
1469
+ ? `Armed since ${rp.armedAt}, on:\n${triggers.join("\n")}`
1470
+ : `Text is parked (armed ${rp.armedAt}) but EVERY TRIGGER IS OFF — nothing will fire until one is switched back on.`
1471
+ return `${head}\n\nThe text, verbatim:\n\n${rp.prompt}`
1472
+ }
1473
+
1474
+ /** The `goal` handler: arm, disarm, or READ BACK this thread's re-prompt.
1475
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1476
+ async function goal(args) {
1477
+ const slug = threadSlug()
1478
+ const action = typeof args.action === "string" ? args.action.trim() : ""
1479
+ if (action !== "start" && action !== "stop" && action !== "get") {
1480
+ throw new Error("`action` must be one of \"start\", \"stop\" or \"get\"")
1481
+ }
1482
+
1483
+ if (action === "get") {
1484
+ // A frizz server older than this tool has no such procedure and answers 404. Say what that means,
1485
+ // rather than leaving a worker to read a bare HTTP status as "nothing is armed" — the two answers
1486
+ // could not be further apart.
1487
+ let payload
1488
+ try {
1489
+ payload = await callRpc("getOwnThreadRecurringPrompt", { slug })
1490
+ } catch (err) {
1491
+ const message = err instanceof Error ? err.message : String(err)
1492
+ if (/HTTP 404/.test(message)) {
1493
+ throw new Error(
1494
+ "this frizz server predates the read action, so it cannot tell you what is armed. Treat the " +
1495
+ "armed state as UNKNOWN — do not assume it is empty — and check the thread footer instead.",
1496
+ )
1497
+ }
1498
+ throw err
1499
+ }
1500
+ const rp = payload?.result?.recurringPrompt
1501
+ if (!rp) return "No goal is armed on this thread. Nothing will re-prompt you."
1502
+ return goalReport(rp)
1503
+ }
1504
+
1505
+ if (action === "stop") {
1506
+ await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false, postCompaction: false })
1507
+ 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."
1508
+ }
1509
+
1510
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
1511
+ if (!prompt) {
1512
+ throw new Error("`prompt` is required to start a goal — it is the text you will be sent on every trigger")
1513
+ }
1514
+
1515
+ const hasHeartbeat = args.heartbeat_seconds !== undefined && args.heartbeat_seconds !== null
1516
+ let interval
1517
+ if (hasHeartbeat) {
1518
+ interval = typeof args.heartbeat_seconds === "number" ? Math.round(args.heartbeat_seconds) : NaN
1519
+ if (!Number.isFinite(interval)) throw new Error("`heartbeat_seconds` must be a number of seconds")
1520
+ if (interval < MIN_INTERVAL_SECONDS || interval > MAX_INTERVAL_SECONDS) {
1521
+ throw new Error(`\`heartbeat_seconds\` must be between ${MIN_INTERVAL_SECONDS} and ${MAX_INTERVAL_SECONDS}`)
1522
+ }
1523
+ }
1524
+ const postCompaction = args.post_compaction === true
1525
+ // DEFAULTED, not required: a `start` that names no trigger at all is a model asking to be re-prompted
1526
+ // and leaving the mechanism to us, and the rest trigger is the safe reading of that — it cannot talk
1527
+ // over a running turn, and it cannot fire on a thread that has stopped needing it.
1528
+ const stopHook = typeof args.stop_hook === "boolean" ? args.stop_hook : !hasHeartbeat && !postCompaction
1529
+ const heartbeat = hasHeartbeat
1530
+ if (!stopHook && !heartbeat && !postCompaction) {
1531
+ throw new Error("at least one is required: `--stop-hook`, `--heartbeat-seconds <n>`, `--post-compaction`, or any combination")
1532
+ }
1533
+
1534
+ const written = await callRpc("setOwnThreadRecurringPrompt", {
1535
+ slug,
1536
+ prompt,
1537
+ stopHook,
1538
+ heartbeat,
1539
+ postCompaction,
1540
+ ...(heartbeat ? { intervalSeconds: interval } : {}),
1541
+ })
1542
+ // `replaced` is absent against a server that predates it, which is indistinguishable from "there was
1543
+ // nothing" — so the clause only ever appears when the row genuinely carried something.
1544
+ const replaced = written?.result?.replaced
1545
+
1546
+ const every = heartbeat ? cadenceLabel(interval) : null
1547
+ // One clause per armed trigger, joined — with three of them the old nested ternary could no longer say
1548
+ // what was actually armed, and a worker that misreads which trigger it holds waits for a delivery that
1549
+ // is never coming.
1550
+ const clauses = [
1551
+ stopHook ? "every time you come to rest" : null,
1552
+ every ? `every ${every} (the heartbeat reaches you mid-turn)` : null,
1553
+ postCompaction ? "every time your context is compacted, delivered into the emptied window" : null,
1554
+ ].filter(Boolean)
1555
+ const when = clauses.length === 1
1556
+ ? clauses[0]
1557
+ : `${clauses.slice(0, -1).join(", ")} AND ${clauses[clauses.length - 1]}`
1558
+ // Spelled out in full, not summarized: if this overwrote the human's own edit, the words themselves
1559
+ // are the only way the worker can put them back.
1560
+ const superseded = replaced
1561
+ ? `\n\nIT REPLACED an existing goal — check that discarding it was intended, and restore ` +
1562
+ `it with another \`frizzctl goal start\` if it was not:\n\n${goalReport(replaced)}\n`
1563
+ : ""
1564
+ // NO QUESTION HOLD ANY MORE (2026-08-16). Every trigger fires while you are waiting on the human, and
1565
+ // the at-rest one fires over your own unanswered registered question — the delivery says so, and expects
1566
+ // you to decide the question yourself rather than re-ask it. A ```done fence, and an ```awaiting on a
1567
+ // wait frizz itself will deliver, still stop the at-rest trigger.
1568
+ return (
1569
+ `Goal armed — frizz will send you this ${when}.${superseded}\n\n` +
1570
+ "Run `frizzctl goal stop` once the work it drives is finished — one left armed on " +
1571
+ "a finished thread wakes it forever. The human can also edit or switch it off in the thread footer. " +
1572
+ "Signing off with a ```done fence stops it too, but only when there is genuinely nothing left: it " +
1573
+ "files the thread away until the human sends more work."
1574
+ )
1575
+ }
1576
+
1577
+ /** How a timer reads back to the worker: its id, when it fires, and enough of its text to tell two apart.
1578
+ * @param {{ id: string, fireAt: string, prompt: string }} t */
1579
+ function timerLine(t) {
1580
+ const words = t.prompt.replace(/\s+/g, " ").trim()
1581
+ return ` ${t.id} — ${t.fireAt} — ${words.length > 72 ? `${words.slice(0, 72)}…` : words}`
1582
+ }
1583
+
1584
+ /** @param {{ timers?: { id: string, fireAt: string, prompt: string }[] }|null} payload */
1585
+ function armedList(payload) {
1586
+ const timers = payload?.timers ?? []
1587
+ if (!timers.length) return "No timers are armed on this thread."
1588
+ return `Armed timers (${timers.length}):\n${timers.map(timerLine).join("\n")}`
1589
+ }
1590
+
1591
+ /** The `timer` handler: set / cancel / list this thread's ONE-OFF timers.
1592
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1593
+ async function timer(args) {
1594
+ const slug = threadSlug()
1595
+ const action = typeof args.action === "string" ? args.action.trim() : ""
1596
+ if (action !== "set" && action !== "cancel" && action !== "list") {
1597
+ throw new Error("`action` must be one of \"set\", \"cancel\" or \"list\"")
1598
+ }
1599
+
1600
+ if (action === "list") {
1601
+ return armedList((await callRpc("listOwnThreadTimers", { slug }))?.result)
1602
+ }
1603
+
1604
+ if (action === "cancel") {
1605
+ const id = typeof args.id === "string" ? args.id.trim() : ""
1606
+ if (!id) throw new Error("`id` is required to cancel a timer — take it from `frizzctl timer set`'s reply or from `frizzctl timer list`")
1607
+ const result = (await callRpc("cancelOwnThreadTimer", { slug, id }))?.result
1608
+ const head = result?.cancelled
1609
+ ? `Timer ${id} cancelled — it will not fire.`
1610
+ : `No ARMED timer ${id} on this thread (it may have already fired, or already been cancelled).`
1611
+ return `${head}\n\n${armedList(result)}`
1612
+ }
1613
+
1614
+ const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
1615
+ if (!prompt) throw new Error("`prompt` is required to set a timer — it is the text you will be sent when it fires")
1616
+
1617
+ // Exactly one of the two ways to name the instant. Accepting both would mean silently preferring one,
1618
+ // and a worker that gave two different times meant something by each of them.
1619
+ const hasIn = args.in_seconds !== undefined && args.in_seconds !== null
1620
+ const hasAt = typeof args.at === "string" && args.at.trim() !== ""
1621
+ if (hasIn && hasAt) throw new Error("give `--in-seconds` OR `--at`, not both")
1622
+ if (!hasIn && !hasAt) throw new Error("give either `--in-seconds` (fire N seconds from now) or `--at` (an ISO-8601 instant)")
1623
+
1624
+ const nowMs = Date.now()
1625
+ let fireMs
1626
+ if (hasIn) {
1627
+ const seconds = typeof args.in_seconds === "number" ? Math.round(args.in_seconds) : NaN
1628
+ if (!Number.isFinite(seconds)) throw new Error("`in_seconds` must be a number of seconds")
1629
+ if (seconds < TIMER_MIN_DELAY_SECONDS || seconds > TIMER_MAX_DELAY_SECONDS) {
1630
+ throw new Error(`\`in_seconds\` must be between ${TIMER_MIN_DELAY_SECONDS} and ${TIMER_MAX_DELAY_SECONDS} (thirty days)`)
1631
+ }
1632
+ fireMs = nowMs + seconds * 1000
1633
+ } else {
1634
+ fireMs = Date.parse(String(args.at))
1635
+ if (!Number.isFinite(fireMs)) throw new Error("`at` must be an ISO-8601 instant, e.g. `2026-08-04T15:00:00Z`")
1636
+ const delta = Math.round((fireMs - nowMs) / 1000)
1637
+ if (delta < TIMER_MIN_DELAY_SECONDS) {
1638
+ throw new Error(`\`at\` must be at least ${TIMER_MIN_DELAY_SECONDS}s in the future (it reads as ${delta}s from now)`)
1639
+ }
1640
+ if (delta > TIMER_MAX_DELAY_SECONDS) throw new Error("`at` must be within thirty days")
1641
+ }
1642
+
1643
+ // ONE representation crosses the wire — the exact UTC instant — so the stored row, the trailer on the
1644
+ // delivered message and this reply all name the same string.
1645
+ const fireAt = new Date(fireMs).toISOString()
1646
+ const result = (await callRpc("setOwnThreadTimer", { slug, prompt, fireAt }))?.result
1647
+ const id = result?.id ?? "(unknown)"
1648
+ return (
1649
+ `Timer ${id} set for ${fireAt} (${Math.round((fireMs - nowMs) / 1000)}s from now). It fires ONCE and ` +
1650
+ "then is gone — it may reach you mid-turn, so receiving it does not mean you had stopped. Cancel it " +
1651
+ `with \`frizzctl timer cancel ${id}\` if it stops being useful.\n\n${armedList(result)}`
1652
+ )
1653
+ }
1654
+
1655
+
1656
+ /** How the armed PR-watcher set reads back, on every action, so a worker never needs a second call.
1657
+ * @param {{ watches?: Array<{id: string, target: string, github?: {checks: string, running: number, passed: number, failed: number, failing: string[], merge: string, state: string}}> }|undefined} result */
1658
+ function armedPrWatchList(result) {
1659
+ // Only the PULL REQUESTS: the registry holds issues too (`watch_issue`), and each command reads back its
1660
+ // own kind so a worker asking where its PRs stand is not told about an issue it registered elsewhere.
1661
+ const watches = (Array.isArray(result?.watches) ? result.watches : []).filter((w) => w.kind !== "issue")
1662
+ if (!watches.length) return "No pull requests are watched on this thread — nothing will wake you."
1663
+ const lines = watches.map((w) => {
1664
+ const g = w.github
1665
+ // The CHECK STATE rides the read-back because it is the reason a worker is listing at all: "where do
1666
+ // my PRs stand" is one call, not one per PR through `gh`.
1667
+ const state = !g
1668
+ ? "not polled yet"
1669
+ : g.state !== "open"
1670
+ ? g.state
1671
+ : g.checks === "passing" ? `checks green (${g.passed})`
1672
+ : g.checks === "failing" ? `checks FAILING${g.failing.length ? `: ${g.failing.join(", ")}` : ""}`
1673
+ : g.checks === "running" ? `checks running (${g.running} left)`
1674
+ : "no checks"
1675
+ return ` ${w.id} ${w.target} — ${state}${g && g.state === "open" && g.merge === "mergeable" ? ", mergeable" : ""}`
1676
+ })
1677
+ return `Watched on this thread now:\n${lines.join("\n")}`
1678
+ }
1679
+
1680
+ /** The issue twin of armedPrWatchList: the ISSUES watched on this thread, with each one's last-polled state.
1681
+ * @param {{ watches?: Array<{id: string, kind?: string, target: string, issue?: {state: string, stateReason?: string, title?: string, comments: number}}> }|undefined} result */
1682
+ function armedIssueWatchList(result) {
1683
+ const watches = (Array.isArray(result?.watches) ? result.watches : []).filter((w) => w.kind === "issue")
1684
+ if (!watches.length) return "No issues are watched on this thread — nothing will wake you."
1685
+ const lines = watches.map((w) => {
1686
+ const i = w.issue
1687
+ const state = !i
1688
+ ? "not polled yet"
1689
+ : i.state === "closed"
1690
+ ? `closed${i.stateReason ? ` (${i.stateReason.replace(/_/g, " ")})` : ""}`
1691
+ : `open, ${i.comments} comment${i.comments === 1 ? "" : "s"}`
1692
+ return ` ${w.id} ${w.target}${i?.title ? ` "${i.title}"` : ""} — ${state}`
1693
+ })
1694
+ return `Issues watched on this thread now:\n${lines.join("\n")}`
1695
+ }
1696
+
1697
+ /** The armed watches on this thread, as the read-back prints them. */
1698
+ function armedWatchList(result) {
1699
+ const watches = Array.isArray(result?.watches) ? result.watches : []
1700
+ if (!watches.length) return "No watches are armed on this thread — nothing here is holding it out of the queue."
1701
+ const lines = watches.map((w) => {
1702
+ const what = w.kind === "agent" ? "sub-agent" : "shell"
1703
+ // The LABEL is frizz's live reading, not a copy stored at registration — so it names the work as it
1704
+ // stands, and its ABSENCE means the target no longer resolves to anything running.
1705
+ const name = w.label ? `${w.label} (${w.target})` : w.target
1706
+ return ` ${w.id} ${what}: ${name} — expires ${w.expiresAt}`
1707
+ })
1708
+ return `Armed on this thread now:\n${lines.join("\n")}`
1709
+ }
1710
+
1711
+ /** The `watch` handler: register a wait on this thread's own running work.
1712
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1713
+ async function watch(args) {
1714
+ const slug = threadSlug()
1715
+ const kind = typeof args.kind === "string" ? args.kind.trim() : ""
1716
+ if (kind !== "shell" && kind !== "agent") throw new Error("`kind` must be \"shell\" or \"agent\"")
1717
+ const target = typeof args.target === "string" ? args.target.trim() : ""
1718
+ if (!target) throw new Error("`target` is required — the handle you were shown; `frizzctl activity` prints them all")
1719
+ const forValue = typeof args.for === "string" ? args.for.trim() : ""
1720
+ if (!forValue) throw new Error("`for` is required — a DURATION like `30m`, `2h` or `3d` (max 24h), never an instant")
1721
+ const result = (await callRpc("addOwnWatch", { slug, kind, target, for: forValue }))?.result
1722
+ const id = result?.id ?? "(unknown)"
1723
+ const head = result?.alreadyArmed
1724
+ ? `Already watching \`${target}\` as ${id} — nothing new was registered, and its original expiry stands.`
1725
+ : `Watching \`${target}\` as ${id}. Your thread is held out of the queue until it finishes, and the ` +
1726
+ "registration survives your turn ending, a compaction and a frizz restart."
1727
+ // A clamp is news here for the same reason it is on `watch_pr` — see that handler.
1728
+ const clamped = result?.clampedFrom
1729
+ ? `\n\nYOUR \`--for ${result.clampedFrom}\` WAS CAPPED at the 24h ceiling for a shell or a sub-agent — ` +
1730
+ "the expiry listed below is what you actually hold."
1731
+ : ""
1732
+ return (
1733
+ `${head}${clamped}\n\nWHEN \`--for\` RUNS OUT the row is CANCELLED and you are woken to re-decide — register ` +
1734
+ `again if you still mean it.\n\nDROP IT the moment it stops mattering (\`frizzctl unwatch ${id}\`); ` +
1735
+ `you do NOT need to when the work simply finishes.\n\n${armedWatchList(result)}`
1736
+ )
1737
+ }
1738
+
1739
+ /** The `unwatch` handler: withdraw one registered watch by id.
1740
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1741
+ async function unwatch(args) {
1742
+ const slug = threadSlug()
1743
+ const id = typeof args.id === "string" ? args.id.trim() : ""
1744
+ if (!id) throw new Error("`id` is required — take it from `frizzctl watch` or from `frizzctl activity`")
1745
+ // A PR, issue or timer id is not a `watch` row, and dropOwnWatch would report it "already settled"
1746
+ // while it stayed armed. The id prefix names its kind, so the one verb a worker reaches for routes it
1747
+ // to the call that really withdraws it.
1748
+ if (id.startsWith("prw_") || id.startsWith("isw_")) {
1749
+ const dropped = (await callRpc("dropOwnPrWatch", { slug, id }))?.result
1750
+ const kind = id.startsWith("isw_") ? "issue" : "pull request"
1751
+ const head = dropped?.dropped
1752
+ ? `Watcher ${id} dropped. It will not wake you.`
1753
+ : `No ARMED ${kind} watcher ${id} on this thread — it was already settled, or the id is not one of yours.`
1754
+ return `${head}\n\n${id.startsWith("isw_") ? armedIssueWatchList(dropped) : armedPrWatchList(dropped)}`
1755
+ }
1756
+ if (id.startsWith("cmw_")) {
1757
+ const dropped = (await callRpc("dropOwnCommandWatch", { slug, id }))?.result
1758
+ const head = dropped?.dropped
1759
+ ? `Command watch ${id} dropped. Frizz will not run it again, and it will not wake you.`
1760
+ : `No ARMED command watch ${id} on this thread — it already passed, expired or was cancelled, or the id is not one of yours.`
1761
+ return `${head}\n\n${armedCommandWatchList(dropped)}`
1762
+ }
1763
+ if (id.startsWith("tmr_")) {
1764
+ const cancelled = (await callRpc("cancelOwnThreadTimer", { slug, id }))?.result
1765
+ const head = cancelled?.cancelled
1766
+ ? `Timer ${id} cancelled — it will not fire.`
1767
+ : `No ARMED timer ${id} on this thread (it may have already fired, or already been cancelled).`
1768
+ return `${head}\n\n${armedList(cancelled)}`
1769
+ }
1770
+ const result = (await callRpc("dropOwnWatch", { slug, id }))?.result
1771
+ // A drop that matched nothing is reported rather than swallowed: the id was wrong, already settled, or
1772
+ // another thread's — and a worker that believes it withdrew a wait it still holds will rest on it.
1773
+ const head = result?.dropped
1774
+ ? `Watch ${id} dropped. It is no longer holding your thread, and it will not wake you.`
1775
+ : `No ARMED watch ${id} on this thread — it was already settled, or the id is not one of yours.`
1776
+ return `${head}\n\n${armedWatchList(result)}`
1777
+ }
1778
+
1779
+ /** Read back what the human still owes an answer on, so a worker never needs a second call to find out.
1780
+ * @param {Record<string, unknown> | undefined} result @returns {string} */
1781
+ function openQuestionList(result) {
1782
+ const open = Array.isArray(result?.open) ? result.open : []
1783
+ if (!open.length) return "Nothing else is open on this thread — the human owes you no answer."
1784
+ const lines = open.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
1785
+ return `Open on this thread now:\n${lines.join("\n")}`
1786
+ }
1787
+
1788
+ /** The `ask` handler: register one or more questions the human owes an answer to.
1789
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1790
+ async function ask(args) {
1791
+ const slug = threadSlug()
1792
+ const questions = Array.isArray(args.questions) ? args.questions : []
1793
+ if (!questions.length) throw new Error("`questions` is required — at least one question to register")
1794
+ const result = (await callRpc("ask", { slug, questions }))?.result
1795
+ const registered = Array.isArray(result?.registered) ? result.registered : []
1796
+ const lines = registered.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
1797
+ const head = registered.length === 1
1798
+ ? `Registered 1 question. It is on the human's board now and it will stay there until they answer it.`
1799
+ : `Registered ${registered.length} questions. They are on the human's board now and they will stay ` +
1800
+ "there until answered — the card sends every answer as one batch."
1801
+ return (
1802
+ `${head}\n${lines.join("\n")}\n\n` +
1803
+ "KEEP WORKING. A question waits on a person, carries no timeout and does not end your turn — do " +
1804
+ "everything that does not depend on the answer while it sits there. The answer arrives as its own " +
1805
+ "wake, restating what was asked.\n\n" +
1806
+ "WITHDRAW ONE THE MOMENT IT STOPS MATTERING (`frizzctl unask <id>`), above all if you work the answer out " +
1807
+ `yourself.\n\n${openQuestionList(result)}`
1808
+ )
1809
+ }
1810
+
1811
+ /** The `secret` handler: register ONE masked question, and hand back the pipe its value will be read from.
1812
+ * It rides the `ask` RPC with `secret: true` — the card, the wake, the `done` gate and the `questions:`
1813
+ * naming are all a question's — and the server is what keeps the value out of every one of them.
1814
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1815
+ async function secret(args) {
1816
+ const slug = threadSlug()
1817
+ const question = typeof args.question === "string" ? args.question.trim() : ""
1818
+ if (!question) throw new Error("`question` is required — say what the human should paste, and for what")
1819
+ const header = typeof args.header === "string" && args.header.trim() ? args.header.trim() : undefined
1820
+ const result = (await callRpc("ask", { slug, questions: [{ question, kind: "question", secret: true, ...(header ? { header } : {}) }] }))?.result
1821
+ const registered = Array.isArray(result?.registered) ? result.registered[0] : undefined
1822
+ const path = typeof registered?.secretPath === "string" ? registered.secretPath : ""
1823
+ if (!registered?.id || !path) throw new Error("Frizz did not return the secret's file — this server predates `secret`; restart Frizz and retry")
1824
+ // Quoted, because the macOS state dir is `~/Library/Application Support/…` and an unquoted path
1825
+ // splits at the space.
1826
+ const quoted = `'${path.replace(/'/g, `'\\''`)}'`
1827
+ return (
1828
+ `Registered secret request ${registered.id}. The human sees a masked box on the board.\n\n` +
1829
+ `Once answered, the value can be read ONCE, within 15 minutes, at:\n ${path}\n\n` +
1830
+ `Read it INSIDE the command that uses it — \`--otp "$(cat ${quoted})"\` — and never cat, echo or copy ` +
1831
+ "it on its own: that spends the one read. Several commands share it through a variable set at the " +
1832
+ "start of ONE Bash call. Get the command ready now: the answer wakes you, and a one-time code expires " +
1833
+ "within a minute.\n\n" +
1834
+ `${openQuestionList(result)}`
1835
+ )
1836
+ }
1837
+
1838
+ /** The `unask` handler: withdraw one registered question by id.
1839
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1840
+ async function unask(args) {
1841
+ const slug = threadSlug()
1842
+ const id = typeof args.id === "string" ? args.id.trim() : ""
1843
+ if (!id) throw new Error("`id` is required — take it from `frizzctl ask` or `frizzctl activity`")
1844
+ const result = (await callRpc("unask", { slug, id }))?.result
1845
+ // A withdrawal that matched nothing is reported rather than swallowed: the id was wrong, the human
1846
+ // already answered it, or it is another thread's — and a worker that believes it withdrew a question
1847
+ // the human is still looking at will get an answer it has stopped expecting.
1848
+ const head = result?.withdrawn
1849
+ ? `Question ${id} withdrawn. Its card is gone and the human will not be asked.`
1850
+ : `No OPEN question ${id} on this thread — it was already answered or dismissed, or the id is not one of yours.`
1851
+ return `${head}\n\n${openQuestionList(result)}`
1852
+ }
1853
+
1854
+ /** The `done` handler: declare the effort finished, or report exactly what refuses to let it.
1855
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1856
+ async function done(args) {
1857
+ const slug = threadSlug()
1858
+ const body = typeof args.body === "string" ? args.body.trim() : ""
1859
+ if (!body) throw new Error("`body` is required — the write-up the human reads on the card")
1860
+ const result = (await callRpc("markOwnDone", { slug, body }))?.result
1861
+ if (result?.done) {
1862
+ return (
1863
+ "Marked done. Your thread cards as a checked success in the human's queue and stays there until " +
1864
+ "they archive it.\n\nNOTHING WAS CLOSED, HIDDEN OR ARCHIVED — if there is more to say, say it in " +
1865
+ "your final message; if more work appears, keep going and run this again."
1866
+ )
1867
+ }
1868
+ // REFUSED, with everything that refuses it named by id, so the next move is a command and not a
1869
+ // guess. Reported as an ordinary result rather than thrown: this is a gate doing its job, not a fault.
1870
+ const questions = (result?.blockingQuestions ?? []).map((q) => ` ${q.id} ${(q.question ?? "").split("\n")[0]}`)
1871
+ const watches = (result?.blockingWatches ?? []).map((w) => ` ${w.id} ${w.what}`)
1872
+ const parts = ["NOT marked done. This thread still holds work open."]
1873
+ if (questions.length) {
1874
+ parts.push(
1875
+ `${questions.length} question${questions.length === 1 ? "" : "s"} the human has not answered:\n${questions.join("\n")}\n` +
1876
+ "Each one dies unread with a done card. Decide it yourself and withdraw it (`frizzctl unask <id>`), or leave it " +
1877
+ "open and keep working until it is answered.",
1878
+ )
1879
+ }
1880
+ if (watches.length) {
1881
+ parts.push(
1882
+ `${watches.length} registration${watches.length === 1 ? "" : "s"} still armed:\n${watches.join("\n")}\n` +
1883
+ "A live wait means the thing you were waiting for has not happened. Wait for it, or drop the ones " +
1884
+ "that stopped mattering (`frizzctl unwatch <id>` takes a watch, a PR or issue watcher, a timer, or a command watch).",
1885
+ )
1886
+ }
1887
+ parts.push("There is no force flag. Resolve them and run `frizzctl done` again.")
1888
+ return new Refused(parts.join("\n\n"))
1889
+ }
1890
+
1891
+ /** The `watch_pr` handler: register, withdraw, or read back this thread's PR watchers.
1892
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1893
+ async function watchPr(args) {
1894
+ const slug = threadSlug()
1895
+ const action = typeof args.action === "string" ? args.action.trim() : ""
1896
+ if (action !== "add" && action !== "list" && action !== "drop") {
1897
+ throw new Error("`action` must be one of \"add\", \"list\" or \"drop\"")
1898
+ }
1899
+
1900
+ if (action === "list") {
1901
+ return armedPrWatchList((await callRpc("listOwnPrWatches", { slug }))?.result)
1902
+ }
1903
+
1904
+ if (action === "drop") {
1905
+ const id = typeof args.id === "string" ? args.id.trim() : ""
1906
+ if (!id) throw new Error("`id` is required to drop a watcher — take it from `add` or from `list`")
1907
+ const result = (await callRpc("dropOwnPrWatch", { slug, id }))?.result
1908
+ // A drop that matched nothing is reported rather than swallowed: the id was wrong, already settled,
1909
+ // or another thread's — and a worker that believes it withdrew a wait it still holds will rest.
1910
+ const head = result?.dropped
1911
+ ? `Watcher ${id} dropped. It will not wake you.`
1912
+ : `No ARMED watcher ${id} on this thread — it was already settled, or the id is not one of yours.`
1913
+ return `${head}\n\n${armedPrWatchList(result)}`
1914
+ }
1915
+
1916
+ const target = typeof args.target === "string" ? args.target.trim() : ""
1917
+ if (!target) throw new Error("`target` is required — the pull request, as `owner/repo#123` or a PR URL")
1918
+ const result = (await callRpc("addOwnPrWatch", { slug, target, for: typeof args.for === "string" ? args.for.trim() : "" }))?.result
1919
+ const id = result?.id ?? "(unknown)"
1920
+ const ref = result?.target ?? target
1921
+ const until = result?.expiresAt ? ` until ${result.expiresAt}` : ""
1922
+ const head = result?.alreadyArmed
1923
+ ? `Already watching ${ref} as ${id}${until} — nothing new was registered, its original expiry stands, and you will be woken once per event.`
1924
+ : `Watching ${ref} as ${id}${until}. Frizz wakes you when CI passes or fails and on every later ` +
1925
+ "review or comment, and the registration survives your turn ending, a compaction and a frizz restart."
1926
+ // A CLAMP IS NEWS. Silently handing back less coverage than was asked for is how a worker comes to
1927
+ // rest believing it is watched for a year when it is watched for one day.
1928
+ const clamped = result?.clampedFrom
1929
+ ? `\n\nYOUR \`--for ${result.clampedFrom}\` WAS CAPPED at the ceiling — the expiry above is what you ` +
1930
+ "actually hold. Nothing else about the watcher changed."
1931
+ : ""
1932
+ return (
1933
+ `${head}${clamped}\n\nNAME IT IN YOUR \`\`\`awaiting FENCE TOO (\`prs: [${ref}]\`) — the watcher does the ` +
1934
+ `waking, the fence is what lets you come to rest and shows the human what you are waiting for.\n\n` +
1935
+ `DROP IT when it stops mattering (\`frizzctl watch-pr drop ${id}\`).\n\n${armedPrWatchList(result)}`
1936
+ )
1937
+ }
1938
+
1939
+ /** The `watch_issue` handler: register, withdraw, or read back this thread's issue watchers. The same
1940
+ * three RPCs as `watchPr` — one registry — with `kind: "issue"` on the add.
1941
+ * @param {Record<string, unknown>} args @returns {Promise<string>} */
1942
+ async function watchIssue(args) {
1943
+ const slug = threadSlug()
1944
+ const action = typeof args.action === "string" ? args.action.trim() : ""
1945
+ if (action !== "add" && action !== "list" && action !== "drop") {
1946
+ throw new Error("`action` must be one of \"add\", \"list\" or \"drop\"")
1947
+ }
1948
+
1949
+ if (action === "list") {
1950
+ return armedIssueWatchList((await callRpc("listOwnPrWatches", { slug }))?.result)
1951
+ }
1952
+
1953
+ if (action === "drop") {
1954
+ const id = typeof args.id === "string" ? args.id.trim() : ""
1955
+ if (!id) throw new Error("`id` is required to drop a watcher — take it from `add` or from `list`")
1956
+ const result = (await callRpc("dropOwnPrWatch", { slug, id }))?.result
1957
+ const head = result?.dropped
1958
+ ? `Watcher ${id} dropped. It will not wake you.`
1959
+ : `No ARMED watcher ${id} on this thread — it was already settled, or the id is not one of yours.`
1960
+ return `${head}\n\n${armedIssueWatchList(result)}`
1961
+ }
1962
+
1963
+ const target = typeof args.target === "string" ? args.target.trim() : ""
1964
+ if (!target) throw new Error("`target` is required — the issue, as `owner/repo#123` or a GitHub issue URL")
1965
+ const forValue = typeof args.for === "string" ? args.for.trim() : ""
1966
+ if (!forValue) throw new Error("`for` is required — a DURATION like `3d`, `30d` or `180d` (max 365d), never an instant")
1967
+ const result = (await callRpc("addOwnPrWatch", { slug, target, for: forValue, kind: "issue" }))?.result
1968
+ const id = result?.id ?? "(unknown)"
1969
+ const ref = result?.target ?? target
1970
+ const until = result?.expiresAt ? ` until ${result.expiresAt}` : ""
1971
+ const head = result?.alreadyArmed
1972
+ ? `Already watching ${ref} as ${id}${until} — nothing new was registered, its original expiry stands, and you will be woken once per event.`
1973
+ : `Watching issue ${ref} as ${id}${until}. Frizz wakes you on every later comment, on a label or ` +
1974
+ "assignee change, and when the issue closes; the registration survives your turn ending, a compaction and a frizz restart."
1975
+ const clamped = result?.clampedFrom
1976
+ ? `\n\nYOUR \`--for ${result.clampedFrom}\` WAS CAPPED at the ceiling — the expiry above is what you ` +
1977
+ "actually hold. Nothing else about the watcher changed."
1978
+ : ""
1979
+ return (
1980
+ `${head}${clamped}\n\nNAME IT IN YOUR \`\`\`awaiting FENCE TOO (\`issues: [${ref}]\`) — the watcher does the ` +
1981
+ `waking, the fence is what lets you come to rest and shows the human what you are waiting for.\n\n` +
1982
+ `DROP IT when it stops mattering (\`frizzctl watch-issue drop ${id}\`).\n\n${armedIssueWatchList(result)}`
1983
+ )
1984
+ }
1985
+
1986
+ /** The command watches armed on this thread, as every `watch-command` action reads them back.
1987
+ * @param {{ watches?: Array<{id: string, command: string, every: string, expiresAt: string, runs: number, lastRun?: {outcome: string, exitCode: number|null}}> }|undefined} result */
1988
+ function armedCommandWatchList(result) {
1989
+ const watches = Array.isArray(result?.watches) ? result.watches : []
1990
+ if (!watches.length) return "No command watches are armed on this thread — none will wake you."
1991
+ const lines = watches.map((w) => {
1992
+ const last = !w.lastRun
1993
+ ? "no check finished yet"
1994
+ : `last check ${commandRunClause(w.lastRun)}`
1995
+ return ` ${w.id} every ${w.every}, until ${w.expiresAt} — ${w.runs} check${w.runs === 1 ? "" : "s"}, ${last}\n $ ${w.command}`
1996
+ })
1997
+ return `Command watches on this thread now:\n${lines.join("\n")}`
1998
+ }
1999
+
2000
+ /** "exited 1", "timed out", … — how one check ended. @param {{outcome: string, exitCode: number|null}} run */
2001
+ function commandRunClause(run) {
2002
+ if (run.outcome === "exit") return `exited ${run.exitCode}`
2003
+ if (run.outcome === "timeout") return "timed out"
2004
+ if (run.outcome === "signal") return "was killed by a signal"
2005
+ return "could not start"
2006
+ }
2007
+
2008
+ /** The end of a check's output, short enough to read inline. @param {string|undefined} output */
2009
+ function outputTail(output) {
2010
+ const text = String(output ?? "").trim()
2011
+ if (!text) return "It printed nothing."
2012
+ const tail = text.length > 800 ? `…${text.slice(-800)}` : text
2013
+ return `Its output ended:\n${tail.split("\n").map((l) => ` ${l}`).join("\n")}`
2014
+ }
2015
+
2016
+ /** The `watch_command` handler: register, withdraw, or read back this thread's command watches.
2017
+ * @param {Record<string, unknown>} args @returns {Promise<string|Refused>} */
2018
+ async function watchCommand(args) {
2019
+ const slug = threadSlug()
2020
+ const action = typeof args.action === "string" ? args.action.trim() : ""
2021
+ if (action !== "add" && action !== "list" && action !== "drop") {
2022
+ throw new Error("`action` must be one of \"add\", \"list\" or \"drop\"")
2023
+ }
2024
+
2025
+ if (action === "list") {
2026
+ return armedCommandWatchList((await callRpc("listOwnCommandWatches", { slug }))?.result)
2027
+ }
2028
+
2029
+ if (action === "drop") {
2030
+ const id = typeof args.id === "string" ? args.id.trim() : ""
2031
+ if (!id) throw new Error("`id` is required to drop a command watch — take it from `add` or from `list`")
2032
+ const result = (await callRpc("dropOwnCommandWatch", { slug, id }))?.result
2033
+ const head = result?.dropped
2034
+ ? `Command watch ${id} dropped. Frizz will not run it again, and it will not wake you.`
2035
+ : `No ARMED command watch ${id} on this thread — it already passed, expired or was cancelled, or the id is not one of yours.`
2036
+ return `${head}\n\n${armedCommandWatchList(result)}`
2037
+ }
2038
+
2039
+ const command = typeof args.command === "string" ? args.command.trim() : ""
2040
+ if (!command) throw new Error("`command` is required — a shell command that exits 0 once the condition holds")
2041
+ const every = typeof args.every === "string" ? args.every.trim() : ""
2042
+ if (!every) throw new Error("`every` is required — how often to check, a DURATION like `5m` (min 1m, max 24h)")
2043
+ const forValue = typeof args.for === "string" ? args.for.trim() : ""
2044
+ if (!forValue) throw new Error("`for` is required — how long to keep checking, a DURATION like `6h` or `3d` (max 7d)")
2045
+ const result = (await callRpc("addOwnCommandWatch", { slug, command, every, for: forValue }))?.result
2046
+ // A REFUSAL is the gate doing its job, not a fault: exit 2, with the reason and the background-shell
2047
+ // fallback in the server's own words.
2048
+ if (result?.refused) {
2049
+ const ran = result.firstRun ? `\n\n${outputTail(result.firstRun.output)}` : ""
2050
+ return new Refused(`NOT registered. ${result.refused}${ran}`)
2051
+ }
2052
+ if (result?.passed) {
2053
+ return (
2054
+ "NOTHING WAS ARMED: your command exited 0 on its first check, so the condition already holds. Carry on " +
2055
+ `with whatever you were going to wait for.\n\n${outputTail(result.firstRun?.output)}`
2056
+ )
2057
+ }
2058
+ const id = result?.id ?? "(unknown)"
2059
+ if (result?.alreadyArmed) {
2060
+ return (
2061
+ `Already watching this command as ${id} (every ${result.every}, until ${result.expiresAt}) — nothing new was ` +
2062
+ `registered, and its original expiry stands.\n\n${armedCommandWatchList(result)}`
2063
+ )
2064
+ }
2065
+ const first = result?.firstRun
2066
+ ? result.firstRun.outcome === "timeout"
2067
+ ? "Its first check did not finish in time, so Frizz runs it again now with the full 2m."
2068
+ : `Its first check ${commandRunClause(result.firstRun)}. ${outputTail(result.firstRun.output)}`
2069
+ : "Frizz runs its first check within the next minute."
2070
+ // A CLAMP IS NEWS, as on every other `for` — a worker told nothing rests on coverage it does not have.
2071
+ const clamps = []
2072
+ if (result?.clamped?.every) clamps.push(`\`--every ${result.clamped.every}\` was clamped to ${result.every}`)
2073
+ if (result?.clamped?.for) clamps.push(`\`--for ${result.clamped.for}\` was capped at the 7d ceiling`)
2074
+ const clamped = clamps.length ? `\n\nYOUR ${clamps.join(", and ")} — what is listed below is what you hold.` : ""
2075
+ return (
2076
+ `Watching as ${id}: Frizz runs it every ${result?.every} until it exits 0, or until ${result?.expiresAt}, and ` +
2077
+ `then wakes you once with its output. Nothing of yours runs meanwhile.\n\n${first}${clamped}\n\n` +
2078
+ `NAME IT IN YOUR \`\`\`awaiting FENCE (\`commands: [${id}]\`, \`status: watching\`) and come to rest. DROP IT ` +
2079
+ `when it stops mattering (\`frizzctl watch-command drop ${id}\`).\n\n${armedCommandWatchList(result)}`
2080
+ )
2081
+ }
2082
+
2083
+ /**
2084
+ * HOW EACH COMMAND IS SPELLED ON A COMMAND LINE. The schema above already says what a command takes;
2085
+ * this says the three things a schema cannot:
2086
+ *
2087
+ * summary — the one line `frizzctl --help` lists it under. The description is the full reference
2088
+ * and is printed only by `frizzctl <command> --help`, so a worker reads the long text of
2089
+ * the command it is about to run and of no other.
2090
+ * positional — which arguments may be given bare, in order (`frizzctl title "Fix queue focus"`).
2091
+ * Every argument is ALSO a `--flag`, and the whole set a JSON object through `--input`.
2092
+ * then — further positionals that depend on the first one's value, for the commands that
2093
+ * switch on an `action` (`frizzctl timer cancel tmr_ab12`).
2094
+ * wrap — the one array argument a bare JSON array, or a single item, is taken to be
2095
+ * (`ask` with one question object, or a list of them).
2096
+ * examples — real invocations, shown under the command's own help.
2097
+ *
2098
+ * `group` only orders the top-level listing.
2099
+ */
2100
+ const CLI = {
2101
+ ask: {
2102
+ group: "Sign off",
2103
+ summary: "Ask the human a question; registers an answerable card",
2104
+ wrap: "questions",
2105
+ examples: [
2106
+ "frizzctl ask --input - <<'JSON'\n" +
2107
+ '{"questions": [{\n' +
2108
+ ' "question": "Should the settings store use SQLite or a JSON file?",\n' +
2109
+ ' "kind": "question",\n' +
2110
+ ' "options": [\n' +
2111
+ ' {"label": "SQLite", "description": "transactional, matches how sessions are stored", "recommended": true},\n' +
2112
+ ' {"label": "JSON file", "description": "zero deps, human-editable, racy under concurrent writes"}\n' +
2113
+ " ]\n" +
2114
+ "}]}\n" +
2115
+ "JSON",
2116
+ "frizzctl ask --input questions.json",
2117
+ ],
2118
+ },
2119
+ unask: {
2120
+ group: "Sign off",
2121
+ summary: "Withdraw a question you asked",
2122
+ positional: ["id"],
2123
+ examples: ["frizzctl unask qst_ab12cd34"],
2124
+ },
2125
+ secret: {
2126
+ group: "Sign off",
2127
+ summary: "Ask the human for a value only they hold (a token, a 2FA code)",
2128
+ positional: ["question"],
2129
+ examples: ['frizzctl secret "The npm one-time code for publishing frizz 0.14.0" --header "npm OTP"'],
2130
+ },
2131
+ done: {
2132
+ group: "Sign off",
2133
+ summary: "Declare the effort finished, with the write-up the human reads (exits 2 if refused)",
2134
+ examples: [
2135
+ "frizzctl done --body-file - <<'EOF'\n" +
2136
+ "Fixed the cache collision in the resolver.\n\n" +
2137
+ "- **Fixed the lookup** in `src/resolver.ts` — it now keys on the normalized id.\n" +
2138
+ "- **Added a regression test**; `npm test` green.\n" +
2139
+ "EOF",
2140
+ "frizzctl done --body-file handoff.md",
2141
+ ],
2142
+ },
2143
+ activity: {
2144
+ group: "Waiting",
2145
+ summary: "List everything this thread has out, with the id each one is named by",
2146
+ examples: ["frizzctl activity"],
2147
+ },
2148
+ watch: {
2149
+ group: "Waiting",
2150
+ summary: "Register a wait on a background shell or a sub-agent",
2151
+ positional: ["kind", "target"],
2152
+ examples: ["frizzctl watch shell bzvtnt3ig --for 2h", "frizzctl watch agent a01b2d20b32feab11 --for 1h"],
2153
+ },
2154
+ unwatch: {
2155
+ group: "Waiting",
2156
+ summary: "Withdraw a registered wait",
2157
+ positional: ["id"],
2158
+ examples: ["frizzctl unwatch wch_ab12cd34"],
2159
+ },
2160
+ watch_pr: {
2161
+ group: "Waiting",
2162
+ summary: "Wake on a pull request's CI, reviews, comments and labels",
2163
+ positional: ["action"],
2164
+ then: { add: ["target"], drop: ["id"] },
2165
+ examples: ["frizzctl watch-pr add acme/app#391 --for 180d", "frizzctl watch-pr list", "frizzctl watch-pr drop prw_ab12cd34"],
2166
+ },
2167
+ watch_issue: {
2168
+ group: "Waiting",
2169
+ summary: "Wake on a GitHub issue's comments, labels, assignees and close",
2170
+ positional: ["action"],
2171
+ then: { add: ["target"], drop: ["id"] },
2172
+ examples: ["frizzctl watch-issue add acme/app#482 --for 30d", "frizzctl watch-issue list", "frizzctl watch-issue drop isw_ab12cd34"],
2173
+ },
2174
+ watch_command: {
2175
+ group: "Waiting",
2176
+ summary: "Wake when a shell command the server re-runs exits 0 (a version published, a URL up)",
2177
+ positional: ["action"],
2178
+ then: { add: ["command"], drop: ["id"] },
2179
+ examples: [
2180
+ 'frizzctl watch-command add "npm view frizz@0.13.12 version" --every 5m --for 6h',
2181
+ "frizzctl watch-command list",
2182
+ "frizzctl watch-command drop cmw_ab12cd34ef56",
2183
+ ],
2184
+ },
2185
+ timer: {
2186
+ group: "Waiting",
2187
+ summary: "Set, list or cancel one-off timed prompts to yourself",
2188
+ positional: ["action"],
2189
+ then: { cancel: ["id"] },
2190
+ examples: [
2191
+ 'frizzctl timer set --in-seconds 600 --prompt "Re-check the deploy and report its status"',
2192
+ "frizzctl timer list",
2193
+ "frizzctl timer cancel tmr_a1b2c3d4e5f6",
2194
+ ],
2195
+ },
2196
+ goal: {
2197
+ group: "Waiting",
2198
+ summary: "Arm, read back or stop the text Frizz re-sends this thread",
2199
+ positional: ["action"],
2200
+ examples: [
2201
+ "frizzctl goal get",
2202
+ "frizzctl goal start --post-compaction --no-stop-hook --prompt-file resume-prompt.md",
2203
+ "frizzctl goal stop",
2204
+ ],
2205
+ },
2206
+ title: {
2207
+ group: "This thread",
2208
+ summary: "Name this thread on the board, once you know what the work is",
2209
+ positional: ["title"],
2210
+ examples: ['frizzctl title "Fix queue focus"'],
2211
+ },
2212
+ link: {
2213
+ group: "This thread",
2214
+ summary: "Save a link or a file under the prompt box",
2215
+ positional: ["label", "target"],
2216
+ examples: ['frizzctl link "Dev server" http://localhost:5173', 'frizzctl link "Audit report" /abs/path/report.md'],
2217
+ },
2218
+ unlink: {
2219
+ group: "This thread",
2220
+ summary: "Remove a saved link or file",
2221
+ positional: ["id"],
2222
+ examples: ["frizzctl unlink lnk_ab12cd34"],
2223
+ },
2224
+ steer: {
2225
+ group: "Other threads",
2226
+ summary: "Send a message into another thread of this project",
2227
+ positional: ["to"],
2228
+ examples: [
2229
+ "frizzctl steer fix-the-resolver-cache --message-file - <<'EOF'\n" +
2230
+ "The audit found the same root cause: `normalizeId` drops the scope. Details in `audits/resolver.md`.\n" +
2231
+ "EOF",
2232
+ ],
2233
+ },
2234
+ spawn_thread: {
2235
+ group: "Other threads",
2236
+ summary: "Dispatch a new, separate top-level thread (the last resort)",
2237
+ examples: ['frizzctl spawn-thread --model opus --effort high --title "Port the relay to Bun" --prompt-file prompt.md'],
2238
+ },
2239
+ }
2240
+
2241
+ export { TOOLS, HANDLERS, CLI, Refused }