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