frizz-server 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-agent-broker.js +35911 -0
- package/dist/codex-app-server-daemon.js +423 -0
- package/dist/dev-child.js +56233 -0
- package/package.json +34 -0
- package/runtime/board/agent-bindings.mjs +287 -0
- package/runtime/board/agent-liveness.mjs +367 -0
- package/runtime/board/agent-status.mjs +178 -0
- package/runtime/board/config.mjs +993 -0
- package/runtime/board/decisions.mjs +97 -0
- package/runtime/board/index.mjs +704 -0
- package/runtime/board/notify-shared.mjs +90 -0
- package/runtime/board/notify.mjs +81 -0
- package/runtime/board/ownership.mjs +120 -0
- package/runtime/board/rest-detect.mjs +213 -0
- package/runtime/board/thread-excerpt.mjs +162 -0
- package/runtime/board/thread-update.mjs +289 -0
- package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
- package/runtime/cc-worker/DECISIONS.md +1172 -0
- package/runtime/cc-worker/LICENSE +21 -0
- package/runtime/cc-worker/agents/high.md +7 -0
- package/runtime/cc-worker/agents/low.md +7 -0
- package/runtime/cc-worker/agents/max.md +7 -0
- package/runtime/cc-worker/agents/medium.md +7 -0
- package/runtime/cc-worker/agents/xhigh.md +7 -0
- package/runtime/cc-worker/bin/frizz +17 -0
- package/runtime/cc-worker/bin/frizz-mcp.mjs +1612 -0
- package/runtime/cc-worker/bin/frizz-update +18 -0
- package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +121 -0
- package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
- package/runtime/cc-worker/hooks/bash-background.mjs +247 -0
- package/runtime/cc-worker/hooks/deny-ask.mjs +39 -0
- package/runtime/cc-worker/hooks/deny-plan.mjs +62 -0
- package/runtime/cc-worker/hooks/hooks.json +102 -0
- package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
- package/runtime/cc-worker/hooks/scratchpad.mjs +417 -0
- package/runtime/cc-worker/hooks/session-seed.mjs +107 -0
- package/runtime/cc-worker/scripts/frizz/agent-bindings.mjs +9 -0
- package/runtime/cc-worker/scripts/frizz/config.mjs +12 -0
- package/runtime/cc-worker/skills/gh/SKILL.md +141 -0
- package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
- package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
- package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/TerminalPane-DyLvW_rQ.js +7 -0
- package/web-dist/assets/abnfDiagram-VRR7QNED-DIPgkiM8.js +1 -0
- package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
- package/web-dist/assets/architecture-TIHT7OUA-B8qUD5-C.js +1 -0
- package/web-dist/assets/architectureDiagram-ZJ3FMSHR-DBAKToiy.js +36 -0
- package/web-dist/assets/array-BifhSqXX.js +1 -0
- package/web-dist/assets/blockDiagram-677ZJIJ3-Ba0xt8st.js +132 -0
- package/web-dist/assets/c4Diagram-LMCZKHZV-DFham1h_.js +10 -0
- package/web-dist/assets/channel-5l10tOPT.js +1 -0
- package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web-dist/assets/chunk-32BRIVSS-Bl-817K-.js +1 -0
- package/web-dist/assets/chunk-52WLFC77-DBLTDz2W.js +10 -0
- package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- package/web-dist/assets/chunk-C7G6YPKG-ClL6Ebv8.js +1 -0
- package/web-dist/assets/chunk-EX3LRPZG--3vJLCZP.js +231 -0
- package/web-dist/assets/chunk-FWX5IMBZ-DMOdhcCP.js +2 -0
- package/web-dist/assets/chunk-HOUHSVGY-DkgTGLCa.js +1 -0
- package/web-dist/assets/chunk-ICXQ74PX-7X6iir1H.js +2 -0
- package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
- package/web-dist/assets/chunk-MOJQB5TN-OpO5flE4.js +88 -0
- package/web-dist/assets/chunk-OGEWGWER-BbAMAzTZ.js +1 -0
- package/web-dist/assets/chunk-PUDLZKDR-avcvDgZl.js +156 -0
- package/web-dist/assets/chunk-Q4XR5HBZ-BaiGN1cd.js +70 -0
- package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
- package/web-dist/assets/chunk-V7JOEXUC-CKVakdOJ.js +206 -0
- package/web-dist/assets/chunk-VAUOI2AC-DzG-rM3_.js +1 -0
- package/web-dist/assets/chunk-VR4S4FIN-t3j3HHQF.js +1 -0
- package/web-dist/assets/chunk-WYO6CB5R-SnP0NDTw.js +127 -0
- package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
- package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web-dist/assets/chunk-ZGVPDNZ5-pXn3giwS.js +62 -0
- package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
- package/web-dist/assets/classDiagram-OUVF2IWQ-vIfzHupB.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-vIfzHupB.js +1 -0
- package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
- package/web-dist/assets/cynefin-VYW2F7L2-C4qNLMkm.js +1 -0
- package/web-dist/assets/cynefinDiagram-TSTJHNR4-2vzWUUfl.js +62 -0
- package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
- package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
- package/web-dist/assets/dagre-VKFMJZFB-DUdNHEM9.js +4 -0
- package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/web-dist/assets/diagram-FQU43EPY-C_EHNL09.js +3 -0
- package/web-dist/assets/diagram-G47NLZAW-DOt98NB-.js +24 -0
- package/web-dist/assets/diagram-NH7WQ7WH-uIgVP9iZ.js +24 -0
- package/web-dist/assets/diagram-OA4YK3LP-CGWe4oxq.js +30 -0
- package/web-dist/assets/diagram-WEI45ONY-C-5f7o9T.js +41 -0
- package/web-dist/assets/dist-DoH_9pyS.js +1 -0
- package/web-dist/assets/ebnfDiagram-CCIWWBDH-DoSFLtL-.js +1 -0
- package/web-dist/assets/erDiagram-Q63AITRT-DsCLMzEE.js +85 -0
- package/web-dist/assets/eventmodeling-45OFAUF4-D7GQYhiK.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-CR371xZs.js +1 -0
- package/web-dist/assets/ganttDiagram-NO4QXBWP-D8UNGcBR.js +292 -0
- package/web-dist/assets/gitGraph-TEB2WS4Q-mC-XQzTE.js +1 -0
- package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CkpPggS7.js +106 -0
- package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
- package/web-dist/assets/index-CT6k_A5y.css +1 -0
- package/web-dist/assets/index-Dmo0zJc8.js +319 -0
- package/web-dist/assets/info-DKCQHKI2-Drg-xVbr.js +1 -0
- package/web-dist/assets/infoDiagram-FWYZ7A6U-CsGMTGpl.js +2 -0
- package/web-dist/assets/init-D6jRqBbL.js +1 -0
- package/web-dist/assets/ishikawaDiagram-FXEZZL3T-DNgGBlL6.js +70 -0
- package/web-dist/assets/journeyDiagram-5HDEW3XC-BFN2bObi.js +139 -0
- package/web-dist/assets/kanban-definition-HUTT4EX6-BnDPclXf.js +89 -0
- package/web-dist/assets/katex-CddkPoXu.js +257 -0
- package/web-dist/assets/line-DmLw74JM.js +1 -0
- package/web-dist/assets/linear-z2V0wJk9.js +1 -0
- package/web-dist/assets/map-DsCK-0Cs.js +1 -0
- package/web-dist/assets/mermaid-parser.core-DGJk39E-.js +7 -0
- package/web-dist/assets/mermaid.core-8aee8nsf.js +11 -0
- package/web-dist/assets/mindmap-definition-LN4V7U3C-CGWK_Qbm.js +96 -0
- package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
- package/web-dist/assets/packet-7NZHBO7P-C5HYQyS5.js +1 -0
- package/web-dist/assets/path-BWPyau1x.js +1 -0
- package/web-dist/assets/pegDiagram-2B236MQR-WiQm887Q.js +1 -0
- package/web-dist/assets/pie-RZYD4A2V-DKBNMtMn.js +1 -0
- package/web-dist/assets/pieDiagram-ENE6RG2P-F1A8_3DO.js +39 -0
- package/web-dist/assets/quadrantDiagram-ABIIQ3AL-bf6a3f_f.js +7 -0
- package/web-dist/assets/radar-I7S5WNFK-AOKDUn-C.js +1 -0
- package/web-dist/assets/railroad-3IZDKUUU-DNHhkFAC.js +1 -0
- package/web-dist/assets/railroad-abnf-AHOZXSZD-DOXbu4iv.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-C1oE2RHD.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-B4GD-bq-.js +1 -0
- package/web-dist/assets/railroadDiagram-RFXS5EU6-Be52T90z.js +1 -0
- package/web-dist/assets/requirementDiagram-TGXJPOKE-BqdMvEGK.js +84 -0
- package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
- package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
- package/web-dist/assets/sankeyDiagram-HTMAVEWB-BP3X6Ofp.js +40 -0
- package/web-dist/assets/sequenceDiagram-DBY2YBRQ-yDHhaUzc.js +162 -0
- package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/web-dist/assets/src-C4XfhTaE.js +1 -0
- package/web-dist/assets/stateDiagram-2N3HPSRC-xvctsgCU.js +1 -0
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-xFk0N3Cq.js +1 -0
- package/web-dist/assets/swimlanes-5IMT3BWC-DZMLgrjk.js +2 -0
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-BoWrxkxy.js +8 -0
- package/web-dist/assets/timeline-definition-FHXFAJF6-n8sU0qlT.js +120 -0
- package/web-dist/assets/treeView-QDETBFTQ-Su8KloaY.js +1 -0
- package/web-dist/assets/treemap-6X3UGDF4-CNgRuVWf.js +1 -0
- package/web-dist/assets/vennDiagram-L72KCM5P-B5I9YxaY.js +34 -0
- package/web-dist/assets/wardley-OPB4EBWU-khMe_Wbq.js +1 -0
- package/web-dist/assets/wardleyDiagram-EHGQE667-d8LsqhTO.js +78 -0
- package/web-dist/assets/xychartDiagram-FW5EYKEG-b0CH_-wy.js +7 -0
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +34 -0
- package/web-dist/icon-192.png +0 -0
- package/web-dist/icon-512.png +0 -0
- package/web-dist/icon-maskable-512.png +0 -0
- package/web-dist/index.html +44 -0
- package/web-dist/manifest.webmanifest +16 -0
|
@@ -0,0 +1,1612 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
/**
|
|
4
|
+
* frizz-mcp — THE frizz MCP server: one unified, dependency-free MCP stdio server (mounted as `frizz`,
|
|
5
|
+
* so its tools are `mcp__frizz__<tool>`) carrying every capability frizz hands its own WORKERS:
|
|
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.
|
|
32
|
+
*
|
|
33
|
+
* Protocol: MCP over stdio = newline-delimited JSON-RPC 2.0. We implement exactly the four methods a
|
|
34
|
+
* client drives (initialize, tools/list, tools/call, ping) plus the initialized notification. Hand-
|
|
35
|
+
* rolled rather than pulling @modelcontextprotocol/sdk: the surface is tiny, it ships as one loose
|
|
36
|
+
* .mjs next to bin/frizz (no build/bundle/resolution concerns), and it matches this repo's own
|
|
37
|
+
* hand-rolled-RPC aesthetic. The server NEVER crashes on a bad tool call: failures come back as an
|
|
38
|
+
* isError tool result so the worker sees a message instead of a dead tool.
|
|
39
|
+
*/
|
|
40
|
+
import { readFileSync, readdirSync } from "node:fs"
|
|
41
|
+
import { dirname, join } from "node:path"
|
|
42
|
+
|
|
43
|
+
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 NEVER come back to you, the caller. It is NOT an in-session " +
|
|
58
|
+
"sub-agent. It returns only the new thread's slug and a ready-to-paste markdown link " +
|
|
59
|
+
"`[title](/thread/<slug>)` that opens the thread in the frizz drawer — put that link in your handoff. " +
|
|
60
|
+
"USE IT ONLY for a distinct, self-contained effort that belongs on the board in its own right and whose " +
|
|
61
|
+
"output you do NOT need to read. Do NOT use it for a helper whose result you must COLLECT and fold into " +
|
|
62
|
+
"your own work — a self-review, a verification pass, a research prong, a critic, any collect-back helper: " +
|
|
63
|
+
"those are in-session sub-agents (Claude: the Agent tool with `run_in_background`; Codex: native " +
|
|
64
|
+
"delegation), which return their findings to you. Spawning such a helper here STRANDS it — its work lands " +
|
|
65
|
+
"on another card and never reaches you, so you gain nothing. " +
|
|
66
|
+
"Because nothing it learns ever returns to you OR to its siblings, a chain of spawned threads re-derives " +
|
|
67
|
+
"the same facts in parallel and nobody notices — measured here: one thread spawned four, three of those " +
|
|
68
|
+
"spawned more, and three descendants independently rediscovered the same root cause over twenty hours. " +
|
|
69
|
+
"Spawn only when the work genuinely cannot ride on your own card: a different repo, a different long-lived " +
|
|
70
|
+
"runtime, an effort that must outlive yours. Never spawn merely to clear your own `done` fence. " +
|
|
71
|
+
"You MUST deliberately choose `model` and `effort` to match the NEW thread's task complexity — they are " +
|
|
72
|
+
"required, there is NO default. Do not reflexively pick the cheapest; a hard task on a weak model/effort " +
|
|
73
|
+
"wastes the whole thread.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
prompt: {
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "The full task/prompt for the new thread's worker. Be self-contained — the new thread starts with empty context.",
|
|
80
|
+
},
|
|
81
|
+
model: {
|
|
82
|
+
type: "string",
|
|
83
|
+
description:
|
|
84
|
+
"REQUIRED — pick by the NEW task's complexity; there is no default. For the `claude` backend: " +
|
|
85
|
+
"`opus` (the TOP tier — hardest reasoning, architecture, subtle correctness/security, adversarial " +
|
|
86
|
+
"review, the fix that must land), `sonnet` (ordinary substantive implementation/research), `haiku` " +
|
|
87
|
+
"(simple, fully-specified mechanical work). Do NOT pick `fable`: Opus 5 is just as good and cheaper, " +
|
|
88
|
+
"so a high-intensity task takes `opus` at a higher `effort`, not a different model — `fable` only " +
|
|
89
|
+
"when the human explicitly asks for it. For " +
|
|
90
|
+
"the `codex` backend use a codex model id instead (e.g. `gpt-5.6-sol`/`gpt-5.6-terra`/`gpt-5.6-luna`). " +
|
|
91
|
+
"Match the model to the backend you choose. Bias toward Opus/a strong model when the task is " +
|
|
92
|
+
"non-trivial or its outcome is load-bearing.",
|
|
93
|
+
},
|
|
94
|
+
effort: {
|
|
95
|
+
type: "string",
|
|
96
|
+
enum: ["low", "medium", "high", "xhigh", "max"],
|
|
97
|
+
description:
|
|
98
|
+
"REQUIRED — reasoning effort, pick by complexity; no default. `low` only for trivial tasks; " +
|
|
99
|
+
"`medium` for routine work; `high` for ordinary substantive work; `xhigh` for hard coding/agentic " +
|
|
100
|
+
"work; `max` for the single hardest problems. (Codex also accepts `ultra`.)",
|
|
101
|
+
},
|
|
102
|
+
backend: {
|
|
103
|
+
type: "string",
|
|
104
|
+
enum: ["claude", "codex"],
|
|
105
|
+
description: "Optional agent backend (default `claude`). If `codex`, `model` must be a codex model id.",
|
|
106
|
+
},
|
|
107
|
+
title: { type: "string", description: "Optional short title for the new thread (else derived from the prompt)." },
|
|
108
|
+
},
|
|
109
|
+
required: ["prompt", "model", "effort"],
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const GOAL = {
|
|
114
|
+
name: "goal",
|
|
115
|
+
description:
|
|
116
|
+
"Arm a GOAL on YOUR OWN thread: one piece of text that frizz re-sends you, on any or all of three " +
|
|
117
|
+
"triggers, for as long as it is armed. The board shows it as the thread's Goal. (This tool was " +
|
|
118
|
+
"named `goal` until 2026-08-28 — a summary or note that says so means this one.)\n\n" +
|
|
119
|
+
" stop_hook — every time you come to REST. Use it to keep a long autonomous effort moving " +
|
|
120
|
+
"without the human driving every step, and to rescue yourself from a wait that may never resolve.\n" +
|
|
121
|
+
" heartbeat_seconds — on a CLOCK, whatever you are doing. This one reaches you MID-TURN: it arrives as " +
|
|
122
|
+
"a queued message you read at your next tool boundary rather than waiting for you to stop, and it " +
|
|
123
|
+
"never aborts what you are running. Use it for something that must be revisited on a schedule no " +
|
|
124
|
+
"matter what you happen to believe at the time.\n" +
|
|
125
|
+
" post_compaction — every time your CONTEXT IS COMPACTED, delivered into the emptied window. If " +
|
|
126
|
+
"you keep notes in your scratch directory, a prompt that LINKS them comes back at the exact moment " +
|
|
127
|
+
"you have lost everything else. Also mid-turn — a compaction happens while you are working.\n\n" +
|
|
128
|
+
"Set at least one; any combination is fine.\n\n" +
|
|
129
|
+
"USE THIS RATHER THAN `CronCreate` or `ScheduleWakeup`. Those are Claude Code's own in-session " +
|
|
130
|
+
"schedulers and they CANNOT fire in the runtime frizz runs you in: their gate stays shut for as long " +
|
|
131
|
+
"as ANY background task of yours is outstanding, so the moment you are parked behind a background " +
|
|
132
|
+
"shell or a sub-agent — exactly when you most need waking — they go silent. This one is delivered by " +
|
|
133
|
+
"frizz itself and is unaffected.\n\n" +
|
|
134
|
+
"READ IT BACK WITH `action: \"get\"` — and do that BEFORE any `start` that is not a fresh arming. A " +
|
|
135
|
+
"thread has AT MOST ONE goal, so a `start` REPLACES whatever is there, triggers and all, " +
|
|
136
|
+
"and the text you are about to destroy may not be yours: the HUMAN can edit it in the thread footer, " +
|
|
137
|
+
"and a compaction can take your own memory of arming it. `get` answers with the exact text currently " +
|
|
138
|
+
"armed, which triggers are on, the cadence, and when each trigger last fired. Reach for it whenever " +
|
|
139
|
+
"you are about to change one trigger and keep the rest, whenever you are unsure whether you are armed " +
|
|
140
|
+
"at all, and after a compaction. (A `start` also reports what it replaced, so a blind overwrite is at " +
|
|
141
|
+
"least a visible one.)\n\n" +
|
|
142
|
+
"The text arrives VERBATIM as an ordinary user turn, so write it as an instruction to your future " +
|
|
143
|
+
"self. At most one scheduled delivery is ever outstanding and its clock runs from the last one " +
|
|
144
|
+
"DELIVERED, so you can never be handed a backlog at once.\n\n" +
|
|
145
|
+
"STOP IT when the work it drives is done (`action: \"stop\"`) — one left armed on a finished thread " +
|
|
146
|
+
"wakes it forever. The human sees it in the thread footer and can edit or switch it off there. " +
|
|
147
|
+
"Signing off with a ```done fence stops it too, every trigger at once — but only when the work is " +
|
|
148
|
+
"genuinely finished, because that files the thread away and a thread nobody is watching does not " +
|
|
149
|
+
"restart itself.\n\n" +
|
|
150
|
+
"You can only ever arm your OWN thread — there is no parameter for anyone else's.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
action: {
|
|
155
|
+
type: "string",
|
|
156
|
+
enum: ["start", "stop", "get"],
|
|
157
|
+
description:
|
|
158
|
+
"`start` arms (or replaces) this thread's goal; `stop` disarms it; `get` reads back " +
|
|
159
|
+
"what is armed right now — the text, the triggers, the cadence and each trigger's last delivery " +
|
|
160
|
+
"— without changing anything. `get` takes no other argument.",
|
|
161
|
+
},
|
|
162
|
+
prompt: {
|
|
163
|
+
type: "string",
|
|
164
|
+
description:
|
|
165
|
+
"Required for `start`. The text delivered to you on every trigger, verbatim, as a user turn. " +
|
|
166
|
+
"Make it self-contained and ACTIONABLE — say what to do and what would make it right to stop " +
|
|
167
|
+
"— because you may receive it with none of the context you have right now.",
|
|
168
|
+
},
|
|
169
|
+
stop_hook: {
|
|
170
|
+
type: "boolean",
|
|
171
|
+
description:
|
|
172
|
+
"Send it every time you come to rest. Defaults to true when neither `heartbeat_seconds` nor " +
|
|
173
|
+
"`post_compaction` is given, so a `start` that names no mechanism still does the obvious thing.",
|
|
174
|
+
},
|
|
175
|
+
heartbeat_seconds: {
|
|
176
|
+
type: "integer",
|
|
177
|
+
description:
|
|
178
|
+
"Also send it on this clock, in seconds (minimum 60, maximum 86400). Omit for no heartbeat. A " +
|
|
179
|
+
"delivery is read at your next tool boundary, so a sub-minute cadence buys no promptness and " +
|
|
180
|
+
"only talks over your own work.",
|
|
181
|
+
},
|
|
182
|
+
post_compaction: {
|
|
183
|
+
type: "boolean",
|
|
184
|
+
description:
|
|
185
|
+
"Also send it every time your context is compacted, into the emptied window — useful when the " +
|
|
186
|
+
"prompt links notes you keep in your scratch directory.",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
required: ["action"],
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The ONE-OFF TIMER's bounds, mirrored from @frizz/shared (this file is dependency-free by design and
|
|
194
|
+
// ships as a loose .mjs, so it cannot import them). The server validates the same numbers; these exist so
|
|
195
|
+
// a wrong delay is refused HERE, with an explanation, instead of coming back as an HTTP 400.
|
|
196
|
+
const TIMER_MIN_DELAY_SECONDS = 10
|
|
197
|
+
const TIMER_MAX_DELAY_SECONDS = 30 * 24 * 60 * 60
|
|
198
|
+
|
|
199
|
+
const TIMER = {
|
|
200
|
+
name: "timer",
|
|
201
|
+
description:
|
|
202
|
+
"Set a ONE-OFF timer on YOUR OWN thread: a piece of text frizz hands back to you at ONE instant, " +
|
|
203
|
+
"ONCE. Your own alarm clock.\n\n" +
|
|
204
|
+
"It is `goal`'s heartbeat with the repetition taken out, and it shares the property that " +
|
|
205
|
+
"matters: the delivery reaches you MID-TURN — a queued message you read at your next tool boundary — " +
|
|
206
|
+
"so it arrives when you asked for it whether or not you have stopped, and it never aborts what you " +
|
|
207
|
+
"are running. Unlike a goal it fires exactly once and then is gone, so there is nothing " +
|
|
208
|
+
"to switch off afterwards and nothing to sign off from.\n\n" +
|
|
209
|
+
"You may have MANY armed at the same time, each with its own instant and its own text — they are " +
|
|
210
|
+
"independent, unlike the single goal this thread can hold.\n\n" +
|
|
211
|
+
"USE IT for anything you want to come back to at a specific time: re-check a deploy in ten minutes, " +
|
|
212
|
+
"re-read a slow log at the top of the hour, revisit a decision after a build finishes. USE " +
|
|
213
|
+
"`goal` instead when the thing must repeat, and remember that Claude Code's own " +
|
|
214
|
+
"`CronCreate`/`ScheduleWakeup` cannot fire in the runtime frizz runs you in.\n\n" +
|
|
215
|
+
"IT IS NOT A WAY TO POLL SOMETHING YOU COULD WAIT ON. If a background shell, a sub-agent or a " +
|
|
216
|
+
"monitor can tell you the moment a thing happens, use that — an alarm every N seconds asking \"is it " +
|
|
217
|
+
"done yet\" is strictly worse than being woken when it is.\n\n" +
|
|
218
|
+
"The text arrives VERBATIM as an ordinary user turn, so write it as an instruction to your future " +
|
|
219
|
+
"self — self-contained and actionable, because you may receive it with none of the context you have " +
|
|
220
|
+
"now. Give exactly one of `in_seconds` or `at`. You can only ever set a timer on your OWN thread.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {
|
|
224
|
+
action: {
|
|
225
|
+
type: "string",
|
|
226
|
+
enum: ["set", "cancel", "list"],
|
|
227
|
+
description:
|
|
228
|
+
"`set` arms a new one-off timer (it never replaces an existing one); `cancel` withdraws one by " +
|
|
229
|
+
"`id`; `list` returns the timers currently armed on this thread. Every action answers with the " +
|
|
230
|
+
"resulting armed list.",
|
|
231
|
+
},
|
|
232
|
+
prompt: {
|
|
233
|
+
type: "string",
|
|
234
|
+
description: "Required for `set`. The text delivered to you when it fires, verbatim, as a user turn.",
|
|
235
|
+
},
|
|
236
|
+
in_seconds: {
|
|
237
|
+
type: "integer",
|
|
238
|
+
description:
|
|
239
|
+
`For \`set\`: fire this many seconds from now (minimum ${TIMER_MIN_DELAY_SECONDS}, maximum ` +
|
|
240
|
+
`${TIMER_MAX_DELAY_SECONDS} — thirty days). Give this OR \`at\`, not both. Sub-minute precision ` +
|
|
241
|
+
"is not real: the delivery is read at your next tool boundary.",
|
|
242
|
+
},
|
|
243
|
+
at: {
|
|
244
|
+
type: "string",
|
|
245
|
+
description:
|
|
246
|
+
"For `set`: fire at this exact instant, as an ISO-8601 timestamp (e.g. `2026-08-04T15:00:00Z`). " +
|
|
247
|
+
"Give this OR `in_seconds`, not both. Must be in the future and within thirty days.",
|
|
248
|
+
},
|
|
249
|
+
id: {
|
|
250
|
+
type: "string",
|
|
251
|
+
description: "Required for `cancel`. The timer id returned by `set` (or listed by `list`).",
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
required: ["action"],
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
const WATCH_PR = {
|
|
260
|
+
name: "watch_pr",
|
|
261
|
+
description:
|
|
262
|
+
"REGISTER A PULL REQUEST and frizz brings you back whenever something happens on it — CI turning " +
|
|
263
|
+
"green or red, and every later review, approval or comment, from a human or a bot alike. Register " +
|
|
264
|
+
"it, come to rest, and you are woken. Drop it when it stops mattering.\n\n" +
|
|
265
|
+
"IT REPORTS REPEATEDLY, unlike a timer. One registration covers the whole life of the PR: CI goes " +
|
|
266
|
+
"red, you push a fix, CI goes green, a reviewer comments — that is four wakes from one call, and you " +
|
|
267
|
+
"never have to re-register between them. It settles itself when the PR merges or closes, because " +
|
|
268
|
+
"there is then nothing left to report.\n\n" +
|
|
269
|
+
"REGISTER IT THE MOMENT YOU OPEN OR PUSH A PR. Nothing else watches for you: your runtime knows " +
|
|
270
|
+
"nothing about GitHub, and an ```awaiting fence STATES what you are waiting on without creating any " +
|
|
271
|
+
"wait at all. This tool is the wait.\n\n" +
|
|
272
|
+
"THE ```awaiting FENCE IS STILL WORTH WRITING, and it is a different job: it is how you come to REST " +
|
|
273
|
+
"without frizz asking you for a handoff, and how the human sees what you are waiting for. Register " +
|
|
274
|
+
"the watcher with this tool, then name the same PR in your fence's `prs:` list — and give the fence " +
|
|
275
|
+
"the same long `for:` you gave the watcher, or the fence expires first and bumps you anyway.\n\n" +
|
|
276
|
+
"GIVE AN EXTERNAL PR A LONG `for` — MONTHS, up to a year. A pull request into a repo nobody here " +
|
|
277
|
+
"controls moves on its maintainers' clock, not yours, and a short watcher on one expires against a " +
|
|
278
|
+
"PR that has not changed: a wake with nothing in it, and a re-arm. Long costs nothing — real " +
|
|
279
|
+
"activity still wakes you the instant it lands, and the human snoozes or archives the thread if " +
|
|
280
|
+
"they want it off the board.\n\n" +
|
|
281
|
+
"REGISTERING IS IDEMPOTENT per pull request: asking twice returns the SAME id and tells you it was " +
|
|
282
|
+
"already armed, so re-registering after a compaction is safe and is the right instinct. Use `list` " +
|
|
283
|
+
"when you want to know what you are holding without changing anything — it answers with each PR's " +
|
|
284
|
+
"current check state too.\n\n" +
|
|
285
|
+
"You can only ever watch a PR on your OWN thread — there is no parameter for anyone else's.",
|
|
286
|
+
inputSchema: {
|
|
287
|
+
type: "object",
|
|
288
|
+
properties: {
|
|
289
|
+
action: {
|
|
290
|
+
type: "string",
|
|
291
|
+
enum: ["add", "list", "drop"],
|
|
292
|
+
description:
|
|
293
|
+
"`add` registers a watcher (idempotent per PR); `drop` withdraws one by id; `list` reads back " +
|
|
294
|
+
"everything armed on this thread, with each PR's latest check state, without changing " +
|
|
295
|
+
"anything. Every action answers with the full armed set.",
|
|
296
|
+
},
|
|
297
|
+
target: {
|
|
298
|
+
type: "string",
|
|
299
|
+
description:
|
|
300
|
+
"Required for `add`. The pull request, as `owner/repo#123` or a GitHub PR URL. A ref that " +
|
|
301
|
+
"cannot be parsed, or a PR the server's own `gh` cannot read, is REFUSED rather than stored — " +
|
|
302
|
+
"a watcher that can never fire is worse than no watcher, because you would come to rest " +
|
|
303
|
+
"believing you were covered. A refusal names the reason; a transient one is worth one retry.",
|
|
304
|
+
},
|
|
305
|
+
for: {
|
|
306
|
+
type: "string",
|
|
307
|
+
description:
|
|
308
|
+
"REQUIRED for `add`. How long to watch, as a DURATION — `2h`, `3d`, `180d` (max 365d). Never " +
|
|
309
|
+
"an instant, and there is no default. The watcher settles itself when this runs out and tells " +
|
|
310
|
+
"you, and you re-register if you still care.\n\n" +
|
|
311
|
+
"MATCH IT TO WHOSE PR IT IS, and the two cases are far apart. Your own PR, waiting on CI or on " +
|
|
312
|
+
"a review you expect today: hours. A PULL REQUEST IN A REPO NOBODY HERE CONTROLS — an upstream " +
|
|
313
|
+
"project, someone else's maintainers — takes as long as it takes, so give it MONTHS (`90d`, " +
|
|
314
|
+
"`180d`, `365d`). A short `for` on one of those does not make it get reviewed sooner; it just " +
|
|
315
|
+
"expires against a PR nothing has touched, wakes you for nothing, and costs a re-arm. That is " +
|
|
316
|
+
"not hypothetical — a watcher on an external PR re-armed at the old 24h ceiling four days " +
|
|
317
|
+
"running, with zero maintainer activity in between. Long is FREE here: real activity wakes you " +
|
|
318
|
+
"the moment it happens either way, and the human can snooze or archive the thread whenever " +
|
|
319
|
+
"they want it gone.",
|
|
320
|
+
},
|
|
321
|
+
id: {
|
|
322
|
+
type: "string",
|
|
323
|
+
description: "Required for `drop`. The watcher id returned by `add` (or listed by `list`).",
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
required: ["action"],
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The registry that replaces a ```awaiting fence's `shells:` line: a wait the worker CREATES rather
|
|
331
|
+
// than one it restates at every rest. Two tools rather than one action-switch, because they are two
|
|
332
|
+
// verbs and a worker reaching for `unwatch` should find `unwatch`. See plans/rest-by-registration.md.
|
|
333
|
+
const WATCH = {
|
|
334
|
+
name: "watch",
|
|
335
|
+
description:
|
|
336
|
+
"REGISTER A WAIT on something this thread already has running — a background shell, a sub-agent — " +
|
|
337
|
+
"and frizz holds your thread out of the queue until it finishes, then brings you back.\n\n" +
|
|
338
|
+
"IT IS THE WAIT, NOT A STATEMENT ABOUT ONE. A ```awaiting fence NAMES what you are waiting on and " +
|
|
339
|
+
"has the lifetime of the message carrying it, so it has to be rewritten at every single rest and is " +
|
|
340
|
+
"wrong the moment anything changes. This creates a ROW: it survives your turn ending, a compaction " +
|
|
341
|
+
"and a frizz restart, and it keeps holding your thread whatever you say next.\n\n" +
|
|
342
|
+
"`for` IS REQUIRED and it is a DURATION, never an instant. When it runs out the row is CANCELLED " +
|
|
343
|
+
"and you are woken to re-decide — that is deliberate, and it is what stops a wait outliving the " +
|
|
344
|
+
"reason you made it. Register again if you still mean it.\n\n" +
|
|
345
|
+
"THE TARGET IS CHECKED AGAINST WHAT IS ACTUALLY RUNNING, not against its shape. A handle nothing " +
|
|
346
|
+
"live answers to is REFUSED rather than stored, and so is a `kind` that disagrees with what frizz " +
|
|
347
|
+
"can see — a sub-agent registered as a shell is refused and told what it actually is. If you have " +
|
|
348
|
+
"lost an id (a compaction, a long turn), call `activity` rather than guessing.\n\n" +
|
|
349
|
+
"A SUB-AGENT ALREADY HOLDS YOUR THREAD without any registration, so the case this exists for is a " +
|
|
350
|
+
"background SHELL: frizz cannot tell a build you are waiting on from a dev server you started and " +
|
|
351
|
+
"moved on from, and only you know which it is.\n\n" +
|
|
352
|
+
"NEVER WATCH SOMETHING YOU INTEND TO OUTLIVE. A dev server, a log tail, a file watcher — those are " +
|
|
353
|
+
"things you started, not things you are waiting for, and registering one parks your thread on work " +
|
|
354
|
+
"that will never finish.\n\n" +
|
|
355
|
+
"REGISTERING IS IDEMPOTENT per (kind, target): asking twice returns the SAME id, says it was " +
|
|
356
|
+
"already armed, and leaves the original expiry alone — so re-registering after a compaction is safe " +
|
|
357
|
+
"and is the right instinct. Use `unwatch` to withdraw one. A PULL REQUEST is `watch_pr`, not this: " +
|
|
358
|
+
"that one polls GitHub and reports repeatedly.\n\n" +
|
|
359
|
+
"You can only ever watch work on your OWN thread.",
|
|
360
|
+
inputSchema: {
|
|
361
|
+
type: "object",
|
|
362
|
+
properties: {
|
|
363
|
+
kind: {
|
|
364
|
+
type: "string",
|
|
365
|
+
enum: ["shell", "agent"],
|
|
366
|
+
description:
|
|
367
|
+
"What the target IS. Checked against live telemetry, not taken on trust — the two kinds of " +
|
|
368
|
+
"handle are both opaque runtime strings and look identical, so frizz answers this exactly " +
|
|
369
|
+
"rather than guessing, and refuses a mismatch by name.",
|
|
370
|
+
},
|
|
371
|
+
target: {
|
|
372
|
+
type: "string",
|
|
373
|
+
description:
|
|
374
|
+
"The handle you were shown. For a shell that is the runtime's own background-task id " +
|
|
375
|
+
"(\"Command running in background with ID: bzvtnt3ig\"); its launch tool_use id and its " +
|
|
376
|
+
"command label are accepted too. For a sub-agent it is the dispatch id or its description. " +
|
|
377
|
+
"`activity` prints all of them.",
|
|
378
|
+
},
|
|
379
|
+
for: {
|
|
380
|
+
type: "string",
|
|
381
|
+
description:
|
|
382
|
+
"REQUIRED. How long to hold the wait, as a DURATION — `30m`, `2h`, `3d` (max 24h). Never an " +
|
|
383
|
+
"instant, and there is no default: choose it for THIS wait. When it elapses the row is " +
|
|
384
|
+
"cancelled and you are woken to re-decide, so an over-long guess costs a wait that outlives " +
|
|
385
|
+
"its reason and a too-short one costs one extra turn. The ceiling is a DAY here, unlike " +
|
|
386
|
+
"`watch_pr`'s year: a shell or a sub-agent dies with this session, so a wait on one that has " +
|
|
387
|
+
"stood for a day is a wait on something already gone.",
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
required: ["kind", "target", "for"],
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const UNWATCH = {
|
|
395
|
+
name: "unwatch",
|
|
396
|
+
description:
|
|
397
|
+
"WITHDRAW A WATCH you registered with `watch`, by its id. It stops holding your thread out of the " +
|
|
398
|
+
"queue and it will not wake you.\n\n" +
|
|
399
|
+
"Use it the moment a wait stops mattering — you decided not to wait for that build after all, or " +
|
|
400
|
+
"you are about to end the thread. A watch you no longer care about still parks you, and a thread " +
|
|
401
|
+
"parked on a wait nobody is waiting for is invisible to the human.\n\n" +
|
|
402
|
+
"You do NOT need this when the work simply finishes: frizz settles the row itself and wakes you. " +
|
|
403
|
+
"`activity` prints the id of everything you hold.",
|
|
404
|
+
inputSchema: {
|
|
405
|
+
type: "object",
|
|
406
|
+
properties: {
|
|
407
|
+
id: {
|
|
408
|
+
type: "string",
|
|
409
|
+
description: "The watch id `watch` returned (or that `activity` lists). Only your own thread's.",
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
required: ["id"],
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---- `ask` / `unask`: a question the human owes an answer to, as a ROW ------------------------------
|
|
417
|
+
|
|
418
|
+
// The question tree, generated rather than written three times over. MCP tool schemas are JSON Schema,
|
|
419
|
+
// and a `$ref` cycle is the natural way to express a recursive shape — but client support for one is
|
|
420
|
+
// uneven, and a schema a client silently drops is a tool a worker cannot call. ASK_MAX_DEPTH is 3, so
|
|
421
|
+
// the nesting is INLINED to exactly that depth: `followUps` simply does not exist on the deepest level,
|
|
422
|
+
// which makes the limit visible in the schema instead of being a refusal the worker meets at runtime.
|
|
423
|
+
const ASK_MAX_DEPTH = 3
|
|
424
|
+
/** @param {number} depth 1 = the root question. @returns {Record<string, unknown>} */
|
|
425
|
+
function questionSchema(depth) {
|
|
426
|
+
const option = {
|
|
427
|
+
type: "object",
|
|
428
|
+
properties: {
|
|
429
|
+
label: { type: "string", description: "The choice itself, short — this is what the answer hands back to you." },
|
|
430
|
+
description: {
|
|
431
|
+
type: "string",
|
|
432
|
+
description:
|
|
433
|
+
"The trade-off, or the evidence — and it renders INSIDE the option, always visible, so this " +
|
|
434
|
+
"is where the human learns what they are choosing BEFORE they pick anything. ONE LINE is the " +
|
|
435
|
+
"default and it is right most of the time: name the trade-off and stop. Earn more than that " +
|
|
436
|
+
"and spend it on a shape they can SCAN — a short list, a table, a code block, the diff the " +
|
|
437
|
+
"option would produce, the exact message that would be posted. Never on a RUN OF " +
|
|
438
|
+
"ONE-SENTENCE PARAGRAPHS: four single sentences stacked with blank lines between them is the " +
|
|
439
|
+
"shape that keeps arriving, and it is the least readable one in a card this narrow. An " +
|
|
440
|
+
"option with no trade-off makes the human reconstruct your reasoning before they can choose; " +
|
|
441
|
+
"an option with four paragraphs makes them read an essay to answer one question.",
|
|
442
|
+
},
|
|
443
|
+
recommended: {
|
|
444
|
+
type: "boolean",
|
|
445
|
+
description:
|
|
446
|
+
"Mark the ONE option you would take, and put it first. At most one per question — a " +
|
|
447
|
+
"recommendation on two of three choices says nothing. IF YOU CAN MARK ONE, ASK YOURSELF WHY " +
|
|
448
|
+
"YOU ARE ASKING: you already know the answer, so implement it and say which way you went. " +
|
|
449
|
+
"This is for the fork you genuinely cannot take yourself.",
|
|
450
|
+
},
|
|
451
|
+
// `preview` (markdown revealed under the option once picked) is RETIRED from this schema
|
|
452
|
+
// (2026-09-01): detail that decides a choice must be visible before the choice, so it belongs in
|
|
453
|
+
// a rich `description` now. The server still ACCEPTS the field — an in-flight worker dispatched
|
|
454
|
+
// against the old schema keeps working, and the card folds it into the same always-visible body.
|
|
455
|
+
...(depth < ASK_MAX_DEPTH
|
|
456
|
+
? {
|
|
457
|
+
// No `maxItems` here either (four until 2026-09-03): the tree is bounded by its DEPTH, not
|
|
458
|
+
// by how many branches hang off one option.
|
|
459
|
+
followUps: {
|
|
460
|
+
type: "array",
|
|
461
|
+
description:
|
|
462
|
+
"Questions that become live ONLY if the human picks this option — the conditional " +
|
|
463
|
+
"branch. A branch nobody takes is never asked and never answered, so this is how you " +
|
|
464
|
+
"ask \"and if so, which?\" without asking it of somebody who said no. A `multi` " +
|
|
465
|
+
"question cannot carry these (several picked options would open several branches at " +
|
|
466
|
+
"once) and neither can a free-text one (there is no answer to branch on).",
|
|
467
|
+
items: questionSchema(depth + 1),
|
|
468
|
+
},
|
|
469
|
+
}
|
|
470
|
+
: {}),
|
|
471
|
+
},
|
|
472
|
+
required: ["label"],
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
type: "object",
|
|
476
|
+
properties: {
|
|
477
|
+
question: {
|
|
478
|
+
type: "string",
|
|
479
|
+
description:
|
|
480
|
+
"THE QUESTION, on one line, in the human's own vocabulary. They have their original prompt " +
|
|
481
|
+
"and nothing else — not your plan, not your notes, not the names you coined while working. " +
|
|
482
|
+
"Lead with the behaviour, not the identifier. NO \"I\" AND NO \"you\": clicking an option is " +
|
|
483
|
+
"the HUMAN speaking, so first and second person flip between writer and reader. Name the " +
|
|
484
|
+
"actor outright instead.",
|
|
485
|
+
},
|
|
486
|
+
header: { type: "string", description: "A very short chip label for the card, 12 characters or so — \"Auth method\", \"Storage\"." },
|
|
487
|
+
kind: {
|
|
488
|
+
type: "string",
|
|
489
|
+
enum: ["question", "multi"],
|
|
490
|
+
description:
|
|
491
|
+
"`question` = pick ONE. `multi` = pick SEVERAL, for choices that are not mutually exclusive. " +
|
|
492
|
+
"A question with NO options at all is a free-text box, which is the right shape when you need " +
|
|
493
|
+
"a name, a value or a sentence rather than a decision between things you have enumerated.",
|
|
494
|
+
},
|
|
495
|
+
danger: {
|
|
496
|
+
type: "boolean",
|
|
497
|
+
description:
|
|
498
|
+
"The DESTRUCTIVE gate, and nothing softer: a force-push, a deletion, a history rewrite, a " +
|
|
499
|
+
"production rollback. It changes two things — the card wears the risk tone, and the human's " +
|
|
500
|
+
"x cannot dismiss it, because a generic close icon is not consent for something irreversible. " +
|
|
501
|
+
"Declining must therefore be one of your own options.",
|
|
502
|
+
},
|
|
503
|
+
// No `maxItems`: the count is the worker's to choose (maintainer 2026-09-03 — "allow arbitrary
|
|
504
|
+
// numbers of options"). A `multi` over a long list is a real shape, and the card letters past 26.
|
|
505
|
+
options: {
|
|
506
|
+
type: "array",
|
|
507
|
+
description:
|
|
508
|
+
"As many as the choice actually has — a fork of two, or a `multi` over twenty findings. " +
|
|
509
|
+
"Omit entirely for a free-text question.",
|
|
510
|
+
items: option,
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
required: ["question", "kind"],
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const ASK = {
|
|
518
|
+
name: "ask",
|
|
519
|
+
description:
|
|
520
|
+
"ASK THE HUMAN SOMETHING YOU CANNOT DECIDE, as a ROW they still owe an answer to. THIS IS THE ONLY " +
|
|
521
|
+
"WAY TO ASK: the free-form ```question fence is retired (2026-09-11), and a fence with a question " +
|
|
522
|
+
"written in its body is plain prose — no card, no answer, no sign-off. The row renders as an " +
|
|
523
|
+
"answerable card on the board and in the thread, and it STAYS there: it survives your turn ending, " +
|
|
524
|
+
"a compaction, a restart, and the transcript scrolling past. A fence has the lifetime of the " +
|
|
525
|
+
"message carrying it, which is why a question written into one was unanswerable an hour later.\n\n" +
|
|
526
|
+
"YOUR DEFAULT IS TO DECIDE, AND THIS TOOL DOES NOT CHANGE THAT. A reversible call costs minutes to " +
|
|
527
|
+
"redo; a round-trip to the human costs hours with the whole effort idle. Anything derivable from " +
|
|
528
|
+
"the code, the conventions or ordinary engineering judgement is yours: make it, say which way you " +
|
|
529
|
+
"went, and keep moving. THE TEST THAT CATCHES ALMOST EVERY BAD QUESTION: if you are about to mark " +
|
|
530
|
+
"one option `recommended`, you already know the answer — so implement it instead of asking.\n\n" +
|
|
531
|
+
"ASK WHEN A WRONG GUESS WOULD BE BOTH COSTLY AND HARD TO UNDO — something destructive or " +
|
|
532
|
+
"irreversible, an external-facing commitment, a security posture with real exposure, product or UX " +
|
|
533
|
+
"direction that is genuinely the human's taste to set. And ask when you KNOW the answer but cannot " +
|
|
534
|
+
"ACT on it: a merge, a publish, a spend, a comment that goes out under their name. Then the " +
|
|
535
|
+
"recommendation is the point, and it goes first.\n\n" +
|
|
536
|
+
"ASKING DOES NOT END YOUR TURN. A question waits on a person, so it carries no timeout and expires " +
|
|
537
|
+
"never — but you keep working. Do everything that does NOT depend on the answer first, and register " +
|
|
538
|
+
"the question at the moment you find it rather than saving it for the end.\n\n" +
|
|
539
|
+
"AND WHEN YOU DO STOP, THE OPEN QUESTION IS YOUR SIGN-OFF — rest normally. Frizz draws every open " +
|
|
540
|
+
"question at the rest you stopped at whether you mention it or not, so nothing you write can hide " +
|
|
541
|
+
"one. The card draws itself at the tail of the rest the question was asked — never write the " +
|
|
542
|
+
"question into your handoff (one question, one card). TO PLACE IT INSIDE YOUR PROSE instead, write " +
|
|
543
|
+
"an EMPTY fence naming the id this tool returned — ```question qst_ab12cd34 on one line, ``` on " +
|
|
544
|
+
"the next — and the card renders there. One marker per question; nothing in the body; placement is " +
|
|
545
|
+
"optional, and every answer of the rest still sends together.\n\n" +
|
|
546
|
+
"SEVERAL AT ONCE IS ONE CALL. The card sends every answer as a unit, so a second `ask` for a second " +
|
|
547
|
+
"question just makes the human send twice. Register them together.\n\n" +
|
|
548
|
+
"The answer comes back to you as its own wake, restating what was asked. Withdraw one you no longer " +
|
|
549
|
+
"need with `unask` — a question you have since answered yourself, still sitting on the human's " +
|
|
550
|
+
"board, is worse than never having asked it.\n\n" +
|
|
551
|
+
"ON AN AUTONOMOUS THREAD THIS REFUSES, and tells you the standing instruction you are working " +
|
|
552
|
+
"under. A thread carrying a rest Goal has already been told to keep going and decide for itself, " +
|
|
553
|
+
"so the refusal is that instruction arriving at the moment it matters. Decide, and say which way " +
|
|
554
|
+
"you went in your write-up. If the call is genuinely the human's — destructive, irreversible, or " +
|
|
555
|
+
"an act you are not permitted to take — put it in your FINAL MESSAGE instead of here; autonomous " +
|
|
556
|
+
"does not mean nobody is reading.",
|
|
557
|
+
inputSchema: {
|
|
558
|
+
type: "object",
|
|
559
|
+
properties: {
|
|
560
|
+
// No `maxItems` (four until 2026-09-03): "several at once is one call" above, and a cap here told
|
|
561
|
+
// a worker with six to batch them and then refused the batch.
|
|
562
|
+
questions: {
|
|
563
|
+
type: "array",
|
|
564
|
+
minItems: 1,
|
|
565
|
+
description: "The questions to register, together. Each becomes its own card and its own row.",
|
|
566
|
+
items: questionSchema(1),
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
required: ["questions"],
|
|
570
|
+
},
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const UNASK = {
|
|
574
|
+
name: "unask",
|
|
575
|
+
description:
|
|
576
|
+
"WITHDRAW A QUESTION you registered with `ask`, by its id. Its card disappears and the human is " +
|
|
577
|
+
"never asked.\n\n" +
|
|
578
|
+
"Use it the moment the question stops mattering: you worked out the answer yourself, the code moved " +
|
|
579
|
+
"and the fork is gone, or you are about to finish. A stale question on someone's board is worse " +
|
|
580
|
+
"than no question — they answer it, and the answer is about a decision that no longer exists.\n\n" +
|
|
581
|
+
"You do NOT need this for a question that gets answered; that settles itself and wakes you. " +
|
|
582
|
+
"Withdrawing is YOUR move and is never reported back to you as news.",
|
|
583
|
+
inputSchema: {
|
|
584
|
+
type: "object",
|
|
585
|
+
properties: {
|
|
586
|
+
id: { type: "string", description: "The question id `ask` returned, or that `activity` lists. Only your own thread's." },
|
|
587
|
+
},
|
|
588
|
+
required: ["id"],
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const DONE = {
|
|
593
|
+
name: "done",
|
|
594
|
+
description:
|
|
595
|
+
"DECLARE THIS EFFORT FINISHED, with the write-up the human reads. Your thread cards as a checked " +
|
|
596
|
+
"success in their queue and stays there until they archive it — marking done is not dismissal, and " +
|
|
597
|
+
"it does not close, archive or hide anything.\n\n" +
|
|
598
|
+
"FRIZZ CAN REFUSE THIS, which is the whole reason it is a tool rather than a fence. An OPEN " +
|
|
599
|
+
"QUESTION or an ARMED REGISTRATION blocks it, and the refusal names each one by id: a question " +
|
|
600
|
+
"nobody answered dies with the card, and a live wait means the thing you were waiting for has not " +
|
|
601
|
+
"happened yet. Resolve them for real — answer it yourself and `unask`, or `unwatch` the wait you no " +
|
|
602
|
+
"longer need — then call again. There is no force parameter and there will not be one.\n\n" +
|
|
603
|
+
"IT ONLY COUNTS WHAT IS REGISTERED. A background shell or a sub-agent you never registered does " +
|
|
604
|
+
"not block this, because frizz cannot tell a build you are waiting on from a dev server you walked " +
|
|
605
|
+
"away from. That judgement is yours, and registering it is how you make it.\n\n" +
|
|
606
|
+
"DONE MEANS THE WORK LANDED, NOT THAT YOU STOPPED. Code committed to the project's mainline; a " +
|
|
607
|
+
"plan, doc or commissioned report written INTO A FILE. An open pull request is not done — the " +
|
|
608
|
+
"merge is. An investigation headed for a fix is not done — the fix is. And a verdict that ends in " +
|
|
609
|
+
"SOMEBODY SHOULD NOW DO SOMETHING (merge it, post this, pick one of these) is not done either: " +
|
|
610
|
+
"that is an `ask`, carrying your recommendation as the first option.\n\n" +
|
|
611
|
+
"THE TEST IS NEVER \"HAVE I STOPPED WORKING\". It is: WHAT IS LOST IF NOBODY EVER OPENS THIS " +
|
|
612
|
+
"THREAD AGAIN? Name one thing and you are not done. Uncertain is not done.",
|
|
613
|
+
inputSchema: {
|
|
614
|
+
type: "object",
|
|
615
|
+
properties: {
|
|
616
|
+
body: {
|
|
617
|
+
type: "string",
|
|
618
|
+
description:
|
|
619
|
+
"THE CARD, as markdown. One to three sentences, then a bullet per deliverable, each opening " +
|
|
620
|
+
"with a bolded verb phrase naming what shipped and where. Backtick every path, identifier and " +
|
|
621
|
+
"command, and make file references real links. It is a LEDGER, not a summary: reasoning, " +
|
|
622
|
+
"caveats and anything the human must do belong in your final message instead, because a " +
|
|
623
|
+
"sentence that would read the same in both places belongs in exactly one of them. Nothing " +
|
|
624
|
+
"here may point vaguely forward — no \"a follow-up could…\". Do it, ask about it, or drop it.",
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
required: ["body"],
|
|
628
|
+
},
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const TITLE = {
|
|
632
|
+
name: "title",
|
|
633
|
+
description:
|
|
634
|
+
"NAME THIS THREAD on the human's board, once you actually know what the work is.\n\n" +
|
|
635
|
+
"WHY IT EXISTS: the name your thread is wearing right now was minted the instant you were " +
|
|
636
|
+
"dispatched, from the raw text of the prompt, before you had read a single file. It can only ever " +
|
|
637
|
+
"paraphrase what the operator typed — so it inherits their shorthand, their ambiguity and their " +
|
|
638
|
+
"typos. One zod thread went onto the board as \"Zon4.5 features and z.properties documentation " +
|
|
639
|
+
"audit\" because the operator typed \"Zon4.5\" and nothing in the session yet knew the product is " +
|
|
640
|
+
"called Zod. You know. That is the entire point of this tool.\n\n" +
|
|
641
|
+
"WHEN TO CALL IT: after you have oriented — read the issue, opened the code, found the bug — and " +
|
|
642
|
+
"can name the actual work in your own words. Not on arrival: a name you register before you " +
|
|
643
|
+
"understand the task is the same guess the board already has. Once is normally enough; call it " +
|
|
644
|
+
"again only if the work turns out to be genuinely something else.\n\n" +
|
|
645
|
+
"NAME THE WORK, NOT THE PROMPT. \"Is this true? We should probably…\" is what the human said, not " +
|
|
646
|
+
"what you are doing. A good name is the thing a reader picking one card out of thirty needs: the " +
|
|
647
|
+
"subject and the verb.\n\n" +
|
|
648
|
+
"A HUMAN RENAME OUTRANKS YOU, always. If the human has already named this thread, frizz refuses " +
|
|
649
|
+
"this and tells you so — that is a correct answer, not a failure, and you should not retry it.",
|
|
650
|
+
inputSchema: {
|
|
651
|
+
type: "object",
|
|
652
|
+
properties: {
|
|
653
|
+
title: {
|
|
654
|
+
type: "string",
|
|
655
|
+
description:
|
|
656
|
+
"The thread's name: 3-8 words, SENTENCE case (capitalize only the first word and proper " +
|
|
657
|
+
"nouns — \"Fix queue focus\", never \"Fix Queue Focus\"). No trailing period, no ticks, no " +
|
|
658
|
+
"issue-body quoting. Spell every product, file and identifier the way the PROJECT spells it, " +
|
|
659
|
+
"not the way the prompt did.",
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
required: ["title"],
|
|
663
|
+
},
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// The unified server's tool registry: `tools/list` returns these and `tools/call` routes by name.
|
|
667
|
+
// Adding a worker-facing frizz tool = one entry here + one handler in `HANDLERS` — never a second
|
|
668
|
+
// MCP server, so every frizz tool stays under the same `mcp__frizz__*` namespace and the same
|
|
669
|
+
// server-level pre-approval the dispatch layer already grants.
|
|
670
|
+
const MIN_INTERVAL_SECONDS = 60
|
|
671
|
+
const MAX_INTERVAL_SECONDS = 24 * 60 * 60
|
|
672
|
+
|
|
673
|
+
const ACTIVITY = {
|
|
674
|
+
name: "activity",
|
|
675
|
+
description:
|
|
676
|
+
"EVERYTHING YOU CURRENTLY HAVE OUT, with the id each one is named by — your background shells, your " +
|
|
677
|
+
"sub-agents, your armed timers, the pull requests you registered, the `wch_…` of every watch holding " +
|
|
678
|
+
"one of them, every QUESTION still owed an answer, and saved links/files with their lnk_ ids.\n\n" +
|
|
679
|
+
"WHY YOU NEED IT: an ```awaiting fence names what you are waiting on BY ID, and frizz checks every " +
|
|
680
|
+
"one against what is actually live. A name that matches nothing is not a park — you are bumped and " +
|
|
681
|
+
"your thread queues. The same goes for the ids `unwatch` and `unask` take, and for the id you put in " +
|
|
682
|
+
"an EMPTY ```question fence to PLACE a registered question in your handoff (a marker naming no open " +
|
|
683
|
+
"question of yours draws nothing). So if you have lost one (a " +
|
|
684
|
+
"compaction, a long turn, a wake you did not expect), call this rather than guessing. Guessing is " +
|
|
685
|
+
"the failure this tool exists to remove — and it is the only way to read your open questions " +
|
|
686
|
+
"WITHOUT registering or withdrawing one.\n\n" +
|
|
687
|
+
"It takes nothing and changes nothing. You can only ever read your OWN thread.",
|
|
688
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const LINK = {
|
|
692
|
+
name: "link",
|
|
693
|
+
description: "Register a labeled URL or local file underneath this thread's prompt, alongside agents and shells. " +
|
|
694
|
+
"Use for dev servers, working documents, reports, and downloads the human will need again. " +
|
|
695
|
+
"The same label updates its existing row without duplicating or reordering it. " +
|
|
696
|
+
"HTTP(S) destinations render as Link; local files render as File and use Frizz's existing file reader/opener. " +
|
|
697
|
+
"Files must exist. Relative paths resolve from the project root; use absolute paths for worktrees. " +
|
|
698
|
+
"Registration survives rests and restarts, but does not start, monitor, or verify a server. " +
|
|
699
|
+
"It never blocks done or parks the thread. Read registrations with activity; remove one with unlink.",
|
|
700
|
+
inputSchema: {
|
|
701
|
+
type: "object",
|
|
702
|
+
properties: {
|
|
703
|
+
label: { type: "string", description: "Short destination label, such as Open dev server or Working plan. Reuse it to update that row." },
|
|
704
|
+
target: { type: "string", description: "An HTTP(S) URL, file:// URL, or existing local file path. No credentials in URLs." },
|
|
705
|
+
},
|
|
706
|
+
required: ["label", "target"],
|
|
707
|
+
additionalProperties: false,
|
|
708
|
+
},
|
|
709
|
+
}
|
|
710
|
+
const UNLINK = {
|
|
711
|
+
name: "unlink",
|
|
712
|
+
description: "Remove one saved link/file from this thread by its lnk_ id (returned by link or listed by activity). " +
|
|
713
|
+
"Only removes the registration: it never deletes a file or stops a server.",
|
|
714
|
+
inputSchema: {
|
|
715
|
+
type: "object", properties: { id: { type: "string", description: "The lnk_ id of a registration on this thread." } },
|
|
716
|
+
required: ["id"], additionalProperties: false,
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const TOOLS = [SPAWN_THREAD, GOAL, TIMER, WATCH_PR, WATCH, UNWATCH, ASK, UNASK, DONE, TITLE, ACTIVITY, LINK, UNLINK]
|
|
721
|
+
|
|
722
|
+
/** @type {Record<string, (args: Record<string, unknown>) => Promise<string>>} */
|
|
723
|
+
const HANDLERS = {
|
|
724
|
+
[SPAWN_THREAD.name]: spawnThread,
|
|
725
|
+
[GOAL.name]: goal,
|
|
726
|
+
[TIMER.name]: timer,
|
|
727
|
+
[WATCH_PR.name]: watchPr,
|
|
728
|
+
[WATCH.name]: watch,
|
|
729
|
+
[ASK.name]: ask,
|
|
730
|
+
[UNASK.name]: unask,
|
|
731
|
+
[DONE.name]: done,
|
|
732
|
+
[TITLE.name]: title,
|
|
733
|
+
[UNWATCH.name]: unwatch,
|
|
734
|
+
[ACTIVITY.name]: activity,
|
|
735
|
+
[LINK.name]: link,
|
|
736
|
+
[UNLINK.name]: unlink,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
740
|
+
async function link(args) {
|
|
741
|
+
const label = typeof args.label === "string" ? args.label.trim() : ""
|
|
742
|
+
const target = typeof args.target === "string" ? args.target.trim() : ""
|
|
743
|
+
if (!label || !target) throw new Error("`label` and `target` are required")
|
|
744
|
+
const result = (await callRpc("upsertOwnLink", { slug: threadSlug(), label, target }))?.result
|
|
745
|
+
const saved = result?.link
|
|
746
|
+
if (!saved?.id) throw new Error("Frizz did not return a saved link")
|
|
747
|
+
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.`
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
751
|
+
async function unlink(args) {
|
|
752
|
+
const id = typeof args.id === "string" ? args.id.trim() : ""
|
|
753
|
+
if (!id) throw new Error("`id` is required — take it from link or activity")
|
|
754
|
+
const result = (await callRpc("dropOwnLink", { slug: threadSlug(), id }))?.result
|
|
755
|
+
return result?.dropped ? `Removed registration ${id}. No file was deleted and no server was stopped.` : `No registration ${id} on this thread.`
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** The `title` handler: register this thread's considered name.
|
|
759
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
760
|
+
async function title(args) {
|
|
761
|
+
const slug = threadSlug()
|
|
762
|
+
const wanted = typeof args.title === "string" ? args.title.trim() : ""
|
|
763
|
+
if (!wanted) throw new Error("`title` is required — 3-8 words naming the work, in sentence case")
|
|
764
|
+
const result = (await callRpc("setOwnThreadTitle", { slug, title: wanted }))?.result
|
|
765
|
+
if (result?.accepted) return `This thread is now named "${result.title}" on the board.`
|
|
766
|
+
// The refusal is REPORTED, never thrown: a human who renamed the thread owns its name, and a worker
|
|
767
|
+
// told "error" would retry a call that can only ever fail again.
|
|
768
|
+
if (result?.lockedByHuman) {
|
|
769
|
+
return (
|
|
770
|
+
`Not renamed — the human has named this thread "${result.title}" themselves, and their name ` +
|
|
771
|
+
"outranks yours. Leave it; do not call this again for this thread."
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
return `Not renamed — frizz did not accept the write. This thread still reads "${result?.title ?? slug}".`
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Read out every background thing this thread has running, in the shape an awaiting fence names them.
|
|
778
|
+
* @returns {Promise<string>} */
|
|
779
|
+
async function activity() {
|
|
780
|
+
const result = (await callRpc("listOwnThreadActivity", { slug: threadSlug() }))?.result
|
|
781
|
+
const items = Array.isArray(result?.activity) ? result.activity : []
|
|
782
|
+
const questions = Array.isArray(result?.questions) ? result.questions : []
|
|
783
|
+
const links = Array.isArray(result?.links) ? result.links : []
|
|
784
|
+
const linksBlock = links.length === 0 ? "" : "\n\nSaved links and files (not running work; remove with unlink):\n" +
|
|
785
|
+
links.map((link) => ` ${link.id} ${link.kind}: ${link.label}\n ${link.target}`).join("\n")
|
|
786
|
+
// THE QUESTIONS ARE NOT PART OF THE FENCE, so they are printed in their own section and never fed to
|
|
787
|
+
// the fence builder below. A question waits on a person; there is no `questions:` key to write it into.
|
|
788
|
+
const askedBlock = questions.length === 0 ? "" : (
|
|
789
|
+
`\n\n${questions.length} question${questions.length === 1 ? "" : "s"} still owed an answer:\n\n` +
|
|
790
|
+
questions.map((q) => ` question: ${q.id}\n ${String(q?.spec?.question ?? "").replace(/\s+/g, " ").slice(0, 160)}`).join("\n") +
|
|
791
|
+
"\n\nEach one blocks `done` until it is answered or withdrawn, and draws its own card at the rest " +
|
|
792
|
+
"it was asked — never write it into a handoff; an EMPTY ```question fence naming its id places it " +
|
|
793
|
+
"inside your prose. `unask` the ones since decided. A question is never named in an ```awaiting " +
|
|
794
|
+
"fence."
|
|
795
|
+
)
|
|
796
|
+
if (!items.length) {
|
|
797
|
+
if (questions.length > 0) {
|
|
798
|
+
return (
|
|
799
|
+
"Nothing is RUNNING on this thread — no background shells, no sub-agents, no armed timers, no " +
|
|
800
|
+
"registered PRs. So an ```awaiting fence would have nothing to name, and a fence naming nothing " +
|
|
801
|
+
"is not a park." + askedBlock + linksBlock
|
|
802
|
+
)
|
|
803
|
+
}
|
|
804
|
+
return (
|
|
805
|
+
"Nothing is running on this thread — no background shells, no sub-agents, no armed timers, no " +
|
|
806
|
+
"registered PRs, and no open questions.\n\nSo there is nothing to wait on: an ```awaiting fence " +
|
|
807
|
+
"would have nothing to name, and a fence naming nothing is not a park. End with ```done, or " +
|
|
808
|
+
"register a question with `ask` if you need the human." + linksBlock
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
const lines = items.map((i) => {
|
|
812
|
+
const when = i.until ? ` (fires ${i.until})` : i.since ? ` (since ${i.since})` : ""
|
|
813
|
+
// The `wch_…` id of the watch holding this item, where one is armed — this readout exists to hand a
|
|
814
|
+
// worker back the ids it lost, and that includes the one `unwatch` takes.
|
|
815
|
+
const held = i.watchId ? ` [watched as ${i.watchId}]` : ""
|
|
816
|
+
return ` ${i.kind}: ${i.id}${when}${held}\n ${i.label}`
|
|
817
|
+
})
|
|
818
|
+
// A READY-TO-PASTE FENCE, not a description of one. The frontmatter is YAML since 2026-08-24 and its
|
|
819
|
+
// keys are PLURAL sequences, so an id printed on its own line is no longer something a worker can copy
|
|
820
|
+
// into a fence — it has to see the shape. This tool is where the contract sends a worker that has lost
|
|
821
|
+
// an id, so printing the retired one-line-per-item form would teach the very grammar frizz refuses.
|
|
822
|
+
const byKind = { shell: [], agent: [], timer: [], pr: [] }
|
|
823
|
+
for (const i of items) if (byKind[i.kind] && i.id) byKind[i.kind].push(i.id)
|
|
824
|
+
const block = Object.entries({ shells: byKind.shell, agents: byKind.agent, timers: byKind.timer, prs: byKind.pr })
|
|
825
|
+
.filter(([, ids]) => ids.length > 0)
|
|
826
|
+
.map(([key, ids]) => ` ${key}: [${ids.join(", ")}]`)
|
|
827
|
+
return (
|
|
828
|
+
`${items.length} thing${items.length === 1 ? "" : "s"} running on this thread:\n\n${lines.join("\n")}\n\n` +
|
|
829
|
+
"Name the ones you are ACTUALLY waiting on in your ```awaiting fence. The frontmatter is YAML — one " +
|
|
830
|
+
"PLURAL key per kind, taking a list — plus a required `for:` duration, and your handoff prose BELOW " +
|
|
831
|
+
"the `---` (there is no `reason:` key).\n\nEverything above, as a fence:\n\n```awaiting\n" +
|
|
832
|
+
`${block.join("\n")}\n for: 2h\n ---\n <what you are waiting for, and what you will do when it lands>\n` +
|
|
833
|
+
"```\n\nDrop the lines you are not actually waiting on — a dev server you left running is not a wait." +
|
|
834
|
+
"\n\nBETTER THAN NAMING A SHELL IN THE FENCE: `watch` REGISTERS the wait, so it survives your turn " +
|
|
835
|
+
"ending and you never restate it. Anything already marked `[watched as …]` above needs no fence line." +
|
|
836
|
+
askedBlock + linksBlock
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** @param {unknown} obj */
|
|
841
|
+
function send(obj) {
|
|
842
|
+
process.stdout.write(JSON.stringify(obj) + "\n")
|
|
843
|
+
}
|
|
844
|
+
/** @param {string|number} id @param {unknown} result */
|
|
845
|
+
function reply(id, result) {
|
|
846
|
+
send({ jsonrpc: "2.0", id, result })
|
|
847
|
+
}
|
|
848
|
+
/** @param {string|number} id @param {number} code @param {string} message */
|
|
849
|
+
function replyError(id, code, message) {
|
|
850
|
+
send({ jsonrpc: "2.0", id, error: { code, message } })
|
|
851
|
+
}
|
|
852
|
+
/** @param {string|number} id @param {string} text @param {boolean} [isError] */
|
|
853
|
+
function replyTool(id, text, isError) {
|
|
854
|
+
reply(id, { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) })
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Whether a pid is running. EPERM means someone else's live process, which is still ALIVE.
|
|
859
|
+
*
|
|
860
|
+
* A lock with NO pid reads as alive: absence of evidence is not evidence of death, and discarding a
|
|
861
|
+
* record written by an older or foreign publisher would turn a working server into "none found".
|
|
862
|
+
*/
|
|
863
|
+
function pidAlive(pid) {
|
|
864
|
+
if (pid === undefined || pid === null) return true
|
|
865
|
+
if (!Number.isInteger(pid)) return true
|
|
866
|
+
try {
|
|
867
|
+
process.kill(pid, 0)
|
|
868
|
+
return true
|
|
869
|
+
} catch (err) {
|
|
870
|
+
return err?.code === "EPERM"
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** A lock file's `{port, pid}`, or undefined if it is missing, malformed, or names a DEAD process. */
|
|
875
|
+
function liveLock(path) {
|
|
876
|
+
try {
|
|
877
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"))
|
|
878
|
+
if (!Number.isInteger(parsed?.port)) return undefined
|
|
879
|
+
if (!pidAlive(parsed?.pid)) return undefined
|
|
880
|
+
return { port: parsed.port, path }
|
|
881
|
+
} catch {
|
|
882
|
+
return undefined
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* FIND THE RUNNING FRIZZ — every call, never cached, never frozen at spawn.
|
|
888
|
+
*
|
|
889
|
+
* This process is spawned once, inside a DETACHED worker daemon that outlives restart after restart.
|
|
890
|
+
* An address handed to it in its env is therefore true exactly until the next "Update & Restart", and
|
|
891
|
+
* a worker whose only address was stale simply lost every frizz tool it had — with no way back short of
|
|
892
|
+
* restarting the worker itself, which is not what an update button should mean.
|
|
893
|
+
*
|
|
894
|
+
* So the env is a HINT and the file is the truth, in this order:
|
|
895
|
+
* 1. FRIZZ_SERVER_LOCK — the lock this server published when it spawned us. Right almost always.
|
|
896
|
+
* 2. `<frizz root>/server.lock` — the MACHINE address (frizz-paths.ts `serverAddressPath`), rewritten
|
|
897
|
+
* by every boot whatever project launched it. This is what makes a live worker survive an update.
|
|
898
|
+
* 3. `<state dir>/server.lock` — our own project's, for a server that only ever serves one project.
|
|
899
|
+
* 4. any live `<frizz root>/projects/*/server.lock` — last resort, since one machine runs one frizz.
|
|
900
|
+
*
|
|
901
|
+
* A candidate whose PID IS DEAD IS SKIPPED, which is the difference between a legible failure and the
|
|
902
|
+
* one that cost an afternoon: a stale lock from a long-dead per-project server sent every call at a port
|
|
903
|
+
* nothing was listening on, and the tool reported only "fetch failed".
|
|
904
|
+
*
|
|
905
|
+
* The frizz root is `../..` from the state dir rather than computed: this file is dependency-free and
|
|
906
|
+
* the real root is platform-dependent (XDG, `~/Library/Application Support`, a legacy `~/.frizz`).
|
|
907
|
+
*/
|
|
908
|
+
function serverLockPort() {
|
|
909
|
+
const stateDir = process.env.FRIZZ_STATE_DIR
|
|
910
|
+
const root = stateDir ? dirname(dirname(stateDir)) : undefined
|
|
911
|
+
const candidates = [
|
|
912
|
+
process.env.FRIZZ_SERVER_LOCK,
|
|
913
|
+
root ? join(root, "server.lock") : undefined,
|
|
914
|
+
stateDir ? join(stateDir, "server.lock") : undefined,
|
|
915
|
+
].filter(Boolean)
|
|
916
|
+
for (const path of candidates) {
|
|
917
|
+
const live = liveLock(path)
|
|
918
|
+
if (live) return live.port
|
|
919
|
+
}
|
|
920
|
+
// Nothing we were told about is alive. One machine runs one frizz, so any project's live lock names
|
|
921
|
+
// it — and addressing by project id (rpcPath) means a server that does not serve us answers 404
|
|
922
|
+
// rather than acting on the wrong board.
|
|
923
|
+
if (root) {
|
|
924
|
+
let entries = []
|
|
925
|
+
try { entries = readdirSync(join(root, "projects")) } catch {}
|
|
926
|
+
for (const entry of entries) {
|
|
927
|
+
const live = liveLock(join(root, "projects", entry, "server.lock"))
|
|
928
|
+
if (live) return live.port
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (candidates.length === 0) throw new Error("FRIZZ_STATE_DIR / FRIZZ_SERVER_LOCK not set — cannot locate the frizz server")
|
|
932
|
+
// SAY THAT NOTHING WAS SAVED, and say to retry. A worker reads "is frizz running?" as a fact about the
|
|
933
|
+
// world rather than as a fact about ITS OWN call, and moves on — so whatever it was arming is silently
|
|
934
|
+
// gone. Measured 2026-08-17: a worker's `recurring_prompt start` hit a restart window, got this error,
|
|
935
|
+
// carried on, and its Goal — the thing keeping a long autonomous effort alive — never existed. The
|
|
936
|
+
// window is ordinary (frizz restarts, and this process outlives every one of them), so the recovery has
|
|
937
|
+
// to be ordinary too: try again.
|
|
938
|
+
throw new Error(
|
|
939
|
+
`no running frizz server found (looked at ${candidates.join(", ")} and every project lock under ` +
|
|
940
|
+
`${root ? join(root, "projects") : "the frizz root"}; each was missing, malformed, or written by a process that is gone). ` +
|
|
941
|
+
`NOTHING WAS SAVED — this call had no effect. frizz is probably mid-restart, which is ordinary and ` +
|
|
942
|
+
`brief; RETRY this exact call before you do anything else, and do not come to rest assuming it took.`,
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* The RPC base for OUR project.
|
|
948
|
+
*
|
|
949
|
+
* One frizz serves every project on the machine, and an unprefixed `/_frizz/rpc/…` is the project it
|
|
950
|
+
* was LAUNCHED from — so without the prefix a worker in any other project acted on the launcher's
|
|
951
|
+
* board (spawn_thread put its new thread there; the thread-scoped tools looked for a slug that lives
|
|
952
|
+
* in a different registry). FRIZZ_PROJECT_ID is the immutable registry id rather than the slug,
|
|
953
|
+
* because the value is handed over once at spawn and then held for the life of a detached daemon,
|
|
954
|
+
* and a project can be renamed under it. Unset ⇒ unprefixed, which is what a server that only ever
|
|
955
|
+
* serves one project passes, and what the launching project's own workers get.
|
|
956
|
+
* @param {string} procedure
|
|
957
|
+
*/
|
|
958
|
+
function rpcPath(procedure) {
|
|
959
|
+
const project = projectSegment()
|
|
960
|
+
return `${project ? `/_frizz/${encodeURIComponent(project)}` : "/_frizz"}/rpc/${procedure}`
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* WHICH PROJECT WE ACT ON — always the one this worker is actually running in.
|
|
965
|
+
*
|
|
966
|
+
* There is deliberately no tool parameter for it and no way to name another project: the id comes from
|
|
967
|
+
* the server's stamp, or failing that from the tree we are standing in (`<root>/.frizz/.id`, the same
|
|
968
|
+
* file project-root.ts treats as identity). Spawning a thread onto somebody else's board is therefore
|
|
969
|
+
* not something a model can express, rather than something it is asked not to do.
|
|
970
|
+
*
|
|
971
|
+
* The walk-up is what makes this work for a worker spawned by a server that predates the stamp, and it
|
|
972
|
+
* is the honest source anyway: a worker's project is wherever its cwd is, and that cannot go stale.
|
|
973
|
+
*/
|
|
974
|
+
function projectSegment() {
|
|
975
|
+
const stamped = process.env.FRIZZ_PROJECT_ID
|
|
976
|
+
if (stamped) return stamped
|
|
977
|
+
let dir = process.cwd()
|
|
978
|
+
for (;;) {
|
|
979
|
+
try {
|
|
980
|
+
const id = readFileSync(join(dir, ".frizz", ".id"), "utf8").trim()
|
|
981
|
+
if (id) return id
|
|
982
|
+
} catch {}
|
|
983
|
+
const parent = dirname(dir)
|
|
984
|
+
if (parent === dir) return undefined
|
|
985
|
+
dir = parent
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** The `spawn_thread` handler: POST /_frizz/rpc/dispatch, return the worker-facing result text.
|
|
990
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
991
|
+
async function spawnThread(args) {
|
|
992
|
+
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
|
|
993
|
+
if (!prompt) throw new Error("`prompt` is required and must be a non-empty string")
|
|
994
|
+
// model + effort are REQUIRED (no default) so the caller must choose by task complexity — a defaulted
|
|
995
|
+
// model (e.g. the project's cheap default) is exactly the bug this guards. Enforced server-side too,
|
|
996
|
+
// not only in the tool schema, so a lenient client can't skip the decision.
|
|
997
|
+
const model = typeof args.model === "string" ? args.model.trim() : ""
|
|
998
|
+
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.")
|
|
999
|
+
const effort = typeof args.effort === "string" ? args.effort.trim() : ""
|
|
1000
|
+
if (!effort) throw new Error("`effort` is required — choose one by complexity (low/medium/high/xhigh/max). There is no default.")
|
|
1001
|
+
|
|
1002
|
+
/** @type {Record<string, unknown>} */
|
|
1003
|
+
const body = { prompt, model, effort }
|
|
1004
|
+
if (typeof args.title === "string" && args.title.trim()) body.title = args.title.trim()
|
|
1005
|
+
if (args.backend === "claude" || args.backend === "codex") body.backend = args.backend
|
|
1006
|
+
|
|
1007
|
+
const port = serverLockPort()
|
|
1008
|
+
const controller = new AbortController()
|
|
1009
|
+
const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
|
|
1010
|
+
let res
|
|
1011
|
+
try {
|
|
1012
|
+
res = await fetch(`http://127.0.0.1:${port}${rpcPath("dispatch")}`, {
|
|
1013
|
+
method: "POST",
|
|
1014
|
+
// No Origin header (undici omits it for non-browser fetch); `sec-fetch-site: same-origin`
|
|
1015
|
+
// satisfies the server's loopback-origin gate (app.ts isTrustedLocalHttpRequest).
|
|
1016
|
+
headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
|
|
1017
|
+
body: JSON.stringify(body),
|
|
1018
|
+
signal: controller.signal,
|
|
1019
|
+
})
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
throw new Error(`dispatch request failed: ${err instanceof Error ? err.message : err}`)
|
|
1022
|
+
} finally {
|
|
1023
|
+
clearTimeout(timer)
|
|
1024
|
+
}
|
|
1025
|
+
if (!res.ok) {
|
|
1026
|
+
const detail = await res.text().catch(() => "")
|
|
1027
|
+
throw new Error(`dispatch returned HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`)
|
|
1028
|
+
}
|
|
1029
|
+
const payload = await res.json().catch(() => null)
|
|
1030
|
+
const slug = payload?.result?.slug
|
|
1031
|
+
if (typeof slug !== "string" || !slug) throw new Error(`dispatch response missing a slug: ${JSON.stringify(payload)?.slice(0, 300)}`)
|
|
1032
|
+
const label = typeof body.title === "string" ? body.title : slug
|
|
1033
|
+
return (
|
|
1034
|
+
`Spawned a new frizz thread \`${slug}\`. It is now on the board driving independently — it reports ` +
|
|
1035
|
+
`to the human via its own final message, NOT back to you, so do not wait on a result from it.\n\n` +
|
|
1036
|
+
`Paste this link to let the human open it in the drawer:\n\n[${label}](/thread/${slug})`
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** POST a frizz RPC procedure and return its parsed payload. Shares spawn_thread's transport rules:
|
|
1041
|
+
* the port comes from server.lock and `sec-fetch-site: same-origin` satisfies the loopback gate.
|
|
1042
|
+
* @param {string} procedure @param {Record<string, unknown>} body @returns {Promise<any>} */
|
|
1043
|
+
// HOW LONG A RESTART WINDOW IS ALLOWED TO BE INVISIBLE. frizz replaces its own server routinely
|
|
1044
|
+
// ("Update & Restart", a dev rebuild), and this process is deliberately still here across every one of
|
|
1045
|
+
// them — so a call landing in that gap is ORDINARY, and failing it is the shim reporting frizz's
|
|
1046
|
+
// housekeeping as the worker's problem. Measured 2026-08-17: a `recurring_prompt start` landed in one,
|
|
1047
|
+
// failed, and the Goal that was keeping a long autonomous effort alive silently never existed.
|
|
1048
|
+
//
|
|
1049
|
+
// Telling the model to retry (which the error also does) is strictly weaker than retrying, because it
|
|
1050
|
+
// only works if the model complies. Bounded and short: a genuinely-down frizz still fails, promptly,
|
|
1051
|
+
// with the same message — this only covers the seconds where a new server is coming up.
|
|
1052
|
+
const LOCK_RETRY_MS = 6_000
|
|
1053
|
+
const LOCK_RETRY_INTERVAL_MS = 400
|
|
1054
|
+
|
|
1055
|
+
/** The port, waiting out a brief restart window rather than failing into one. Rethrows the real
|
|
1056
|
+
* "no running frizz server" error once the budget is spent, so a frizz that is actually down still
|
|
1057
|
+
* says so — and says it with the retry guidance attached. */
|
|
1058
|
+
async function serverLockPortWaiting() {
|
|
1059
|
+
const deadline = Date.now() + LOCK_RETRY_MS
|
|
1060
|
+
for (;;) {
|
|
1061
|
+
try {
|
|
1062
|
+
return serverLockPort()
|
|
1063
|
+
} catch (err) {
|
|
1064
|
+
if (Date.now() >= deadline) throw err
|
|
1065
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_INTERVAL_MS))
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function callRpc(procedure, body) {
|
|
1071
|
+
const port = await serverLockPortWaiting()
|
|
1072
|
+
const controller = new AbortController()
|
|
1073
|
+
const timer = setTimeout(() => controller.abort(), DISPATCH_TIMEOUT_MS)
|
|
1074
|
+
let res
|
|
1075
|
+
try {
|
|
1076
|
+
res = await fetch(`http://127.0.0.1:${port}${rpcPath(procedure)}`, {
|
|
1077
|
+
method: "POST",
|
|
1078
|
+
headers: { "content-type": "application/json", "sec-fetch-site": "same-origin" },
|
|
1079
|
+
body: JSON.stringify(body),
|
|
1080
|
+
signal: controller.signal,
|
|
1081
|
+
})
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
throw new Error(`${procedure} request failed: ${err instanceof Error ? err.message : err}`)
|
|
1084
|
+
} finally {
|
|
1085
|
+
clearTimeout(timer)
|
|
1086
|
+
}
|
|
1087
|
+
if (!res.ok) {
|
|
1088
|
+
const detail = await res.text().catch(() => "")
|
|
1089
|
+
throw new Error(`${procedure} returned HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`)
|
|
1090
|
+
}
|
|
1091
|
+
return await res.json().catch(() => null)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Which thread this MCP server belongs to. Stamped into our env at spawn (the broker bridge, on the
|
|
1095
|
+
* Claude SDK path) because the MCP protocol carries no caller identity.
|
|
1096
|
+
* FRIZZ_THREAD is the fallback: every frizz worker process is tagged with it, so it is right
|
|
1097
|
+
* whenever the env is inherited — but it is not relied upon, hence the explicit var first.
|
|
1098
|
+
*
|
|
1099
|
+
* This is also the reason a model can never point `goal` at someone else's thread: the slug is
|
|
1100
|
+
* read from HERE, never from the tool arguments. */
|
|
1101
|
+
function threadSlug() {
|
|
1102
|
+
const slug = process.env.FRIZZ_THREAD_SLUG || process.env.FRIZZ_THREAD
|
|
1103
|
+
if (!slug) {
|
|
1104
|
+
// Ten tools resolve their caller through here, so the message must not name one of them. It said
|
|
1105
|
+
// "so it cannot arm a goal for it" for every single one — which read as a bug in `goal` no matter
|
|
1106
|
+
// which tool the worker had actually called, and sent at least one worker off debugging the wrong
|
|
1107
|
+
// thing after `title` failed on a codex thread.
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
"this frizz MCP server was not told which thread it belongs to (no FRIZZ_THREAD_SLUG), so it cannot " +
|
|
1110
|
+
"act on the caller's own thread. This is a frizz bug — report it rather than working around it.",
|
|
1111
|
+
)
|
|
1112
|
+
}
|
|
1113
|
+
return slug
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** How a heartbeat cadence reads back to the worker. ONE formatter, because `start` and `get` describe
|
|
1117
|
+
* the same stored number and a worker that saw "every 15m" armed must not read "every 900s" back.
|
|
1118
|
+
*
|
|
1119
|
+
* The house duration grammar (`packages/web/src/lib/durationLabels.ts`), matching the trailer
|
|
1120
|
+
* `formatIntervalLabel` writes into the delivery itself — a worker reads both.
|
|
1121
|
+
* @param {number|undefined} seconds */
|
|
1122
|
+
function cadenceLabel(seconds) {
|
|
1123
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return undefined
|
|
1124
|
+
if (seconds % 60 !== 0) return `${seconds}s`
|
|
1125
|
+
const minutes = seconds / 60
|
|
1126
|
+
if (minutes < 60) return `${minutes}m`
|
|
1127
|
+
return minutes % 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${Math.floor(minutes / 60)}h`
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Render an armed goal for the worker to read: which triggers are live, the cadence, when
|
|
1131
|
+
* each last fired, and the text VERBATIM (never truncated — reading back a summary of your own
|
|
1132
|
+
* instruction is exactly as blind as not reading it).
|
|
1133
|
+
* @param {{ prompt: string, stopHook: boolean, heartbeat: boolean, postCompaction: boolean,
|
|
1134
|
+
* intervalSeconds?: number, armedAt: string, lastRestFiredAt?: string,
|
|
1135
|
+
* lastScheduleFiredAt?: string, lastCompactFiredAt?: string }} rp */
|
|
1136
|
+
function goalReport(rp) {
|
|
1137
|
+
const fired = (/** @type {string|undefined} */ at) => (at ? `last fired ${at}` : "never fired yet")
|
|
1138
|
+
const triggers = [
|
|
1139
|
+
rp.stopHook ? ` stop_hook — every time you come to rest (${fired(rp.lastRestFiredAt)})` : null,
|
|
1140
|
+
rp.heartbeat
|
|
1141
|
+
// The SAME cadence form `start` reports (cadenceLabel), or the two readings of one row disagree
|
|
1142
|
+
// about the number they are describing — "every 15 min" armed, "every 900s" read back.
|
|
1143
|
+
? ` heartbeat — every ${cadenceLabel(rp.intervalSeconds) ?? "?"} (${fired(rp.lastScheduleFiredAt)})`
|
|
1144
|
+
: null,
|
|
1145
|
+
rp.postCompaction ? ` post_compaction — every compaction (${fired(rp.lastCompactFiredAt)})` : null,
|
|
1146
|
+
].filter(Boolean)
|
|
1147
|
+
// EVERY trigger off is a real, reachable state — the human can switch them off in the footer without
|
|
1148
|
+
// clearing the words — and it is the one a worker would otherwise misread as "armed and running".
|
|
1149
|
+
const head = triggers.length
|
|
1150
|
+
? `Armed since ${rp.armedAt}, on:\n${triggers.join("\n")}`
|
|
1151
|
+
: `Text is parked (armed ${rp.armedAt}) but EVERY TRIGGER IS OFF — nothing will fire until one is switched back on.`
|
|
1152
|
+
return `${head}\n\nThe text, verbatim:\n\n${rp.prompt}`
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** The `goal` handler: arm, disarm, or READ BACK this thread's re-prompt.
|
|
1156
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1157
|
+
async function goal(args) {
|
|
1158
|
+
const slug = threadSlug()
|
|
1159
|
+
const action = typeof args.action === "string" ? args.action.trim() : ""
|
|
1160
|
+
if (action !== "start" && action !== "stop" && action !== "get") {
|
|
1161
|
+
throw new Error("`action` must be one of \"start\", \"stop\" or \"get\"")
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (action === "get") {
|
|
1165
|
+
// A frizz server older than this tool has no such procedure and answers 404. Say what that means,
|
|
1166
|
+
// rather than leaving a worker to read a bare HTTP status as "nothing is armed" — the two answers
|
|
1167
|
+
// could not be further apart.
|
|
1168
|
+
let payload
|
|
1169
|
+
try {
|
|
1170
|
+
payload = await callRpc("getOwnThreadRecurringPrompt", { slug })
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1173
|
+
if (/HTTP 404/.test(message)) {
|
|
1174
|
+
throw new Error(
|
|
1175
|
+
"this frizz server predates the read action, so it cannot tell you what is armed. Treat the " +
|
|
1176
|
+
"armed state as UNKNOWN — do not assume it is empty — and check the thread footer instead.",
|
|
1177
|
+
)
|
|
1178
|
+
}
|
|
1179
|
+
throw err
|
|
1180
|
+
}
|
|
1181
|
+
const rp = payload?.result?.recurringPrompt
|
|
1182
|
+
if (!rp) return "No goal is armed on this thread. Nothing will re-prompt you."
|
|
1183
|
+
return goalReport(rp)
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (action === "stop") {
|
|
1187
|
+
await callRpc("setOwnThreadRecurringPrompt", { slug, prompt: null, stopHook: false, heartbeat: false, postCompaction: false })
|
|
1188
|
+
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."
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
|
|
1192
|
+
if (!prompt) {
|
|
1193
|
+
throw new Error("`prompt` is required to start a goal — it is the text you will be sent on every trigger")
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const hasHeartbeat = args.heartbeat_seconds !== undefined && args.heartbeat_seconds !== null
|
|
1197
|
+
let interval
|
|
1198
|
+
if (hasHeartbeat) {
|
|
1199
|
+
interval = typeof args.heartbeat_seconds === "number" ? Math.round(args.heartbeat_seconds) : NaN
|
|
1200
|
+
if (!Number.isFinite(interval)) throw new Error("`heartbeat_seconds` must be a number of seconds")
|
|
1201
|
+
if (interval < MIN_INTERVAL_SECONDS || interval > MAX_INTERVAL_SECONDS) {
|
|
1202
|
+
throw new Error(`\`heartbeat_seconds\` must be between ${MIN_INTERVAL_SECONDS} and ${MAX_INTERVAL_SECONDS}`)
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const postCompaction = args.post_compaction === true
|
|
1206
|
+
// DEFAULTED, not required: a `start` that names no trigger at all is a model asking to be re-prompted
|
|
1207
|
+
// and leaving the mechanism to us, and the rest trigger is the safe reading of that — it cannot talk
|
|
1208
|
+
// over a running turn, and it cannot fire on a thread that has stopped needing it.
|
|
1209
|
+
const stopHook = typeof args.stop_hook === "boolean" ? args.stop_hook : !hasHeartbeat && !postCompaction
|
|
1210
|
+
const heartbeat = hasHeartbeat
|
|
1211
|
+
if (!stopHook && !heartbeat && !postCompaction) {
|
|
1212
|
+
throw new Error("at least one is required: set `stop_hook: true`, give `heartbeat_seconds`, set `post_compaction: true`, or any combination")
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const written = await callRpc("setOwnThreadRecurringPrompt", {
|
|
1216
|
+
slug,
|
|
1217
|
+
prompt,
|
|
1218
|
+
stopHook,
|
|
1219
|
+
heartbeat,
|
|
1220
|
+
postCompaction,
|
|
1221
|
+
...(heartbeat ? { intervalSeconds: interval } : {}),
|
|
1222
|
+
})
|
|
1223
|
+
// `replaced` is absent against a server that predates it, which is indistinguishable from "there was
|
|
1224
|
+
// nothing" — so the clause only ever appears when the row genuinely carried something.
|
|
1225
|
+
const replaced = written?.result?.replaced
|
|
1226
|
+
|
|
1227
|
+
const every = heartbeat ? cadenceLabel(interval) : null
|
|
1228
|
+
// One clause per armed trigger, joined — with three of them the old nested ternary could no longer say
|
|
1229
|
+
// what was actually armed, and a worker that misreads which trigger it holds waits for a delivery that
|
|
1230
|
+
// is never coming.
|
|
1231
|
+
const clauses = [
|
|
1232
|
+
stopHook ? "every time you come to rest" : null,
|
|
1233
|
+
every ? `every ${every} (the heartbeat reaches you mid-turn)` : null,
|
|
1234
|
+
postCompaction ? "every time your context is compacted, delivered into the emptied window" : null,
|
|
1235
|
+
].filter(Boolean)
|
|
1236
|
+
const when = clauses.length === 1
|
|
1237
|
+
? clauses[0]
|
|
1238
|
+
: `${clauses.slice(0, -1).join(", ")} AND ${clauses[clauses.length - 1]}`
|
|
1239
|
+
// Spelled out in full, not summarized: if this overwrote the human's own edit, the words themselves
|
|
1240
|
+
// are the only way the worker can put them back.
|
|
1241
|
+
const superseded = replaced
|
|
1242
|
+
? `\n\nIT REPLACED an existing goal — check that discarding it was intended, and restore ` +
|
|
1243
|
+
`it with another \`start\` if it was not:\n\n${goalReport(replaced)}\n`
|
|
1244
|
+
: ""
|
|
1245
|
+
// NO QUESTION HOLD ANY MORE (2026-08-16). Every trigger fires while you are waiting on the human, and
|
|
1246
|
+
// the at-rest one fires over your own unanswered registered question — the delivery says so, and expects
|
|
1247
|
+
// you to decide the question yourself rather than re-ask it. A ```done fence, and an ```awaiting on a
|
|
1248
|
+
// wait frizz itself will deliver, still stop the at-rest trigger.
|
|
1249
|
+
return (
|
|
1250
|
+
`Goal armed — frizz will send you this ${when}.${superseded}\n\n` +
|
|
1251
|
+
"Call this tool again with `action: \"stop\"` once the work it drives is finished — one left armed on " +
|
|
1252
|
+
"a finished thread wakes it forever. The human can also edit or switch it off in the thread footer. " +
|
|
1253
|
+
"Signing off with a ```done fence stops it too, but only when there is genuinely nothing left: it " +
|
|
1254
|
+
"files the thread away until the human sends more work."
|
|
1255
|
+
)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** How a timer reads back to the worker: its id, when it fires, and enough of its text to tell two apart.
|
|
1259
|
+
* @param {{ id: string, fireAt: string, prompt: string }} t */
|
|
1260
|
+
function timerLine(t) {
|
|
1261
|
+
const words = t.prompt.replace(/\s+/g, " ").trim()
|
|
1262
|
+
return ` ${t.id} — ${t.fireAt} — ${words.length > 72 ? `${words.slice(0, 72)}…` : words}`
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** @param {{ timers?: { id: string, fireAt: string, prompt: string }[] }|null} payload */
|
|
1266
|
+
function armedList(payload) {
|
|
1267
|
+
const timers = payload?.timers ?? []
|
|
1268
|
+
if (!timers.length) return "No timers are armed on this thread."
|
|
1269
|
+
return `Armed timers (${timers.length}):\n${timers.map(timerLine).join("\n")}`
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** The `timer` handler: set / cancel / list this thread's ONE-OFF timers.
|
|
1273
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1274
|
+
async function timer(args) {
|
|
1275
|
+
const slug = threadSlug()
|
|
1276
|
+
const action = typeof args.action === "string" ? args.action.trim() : ""
|
|
1277
|
+
if (action !== "set" && action !== "cancel" && action !== "list") {
|
|
1278
|
+
throw new Error("`action` must be one of \"set\", \"cancel\" or \"list\"")
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (action === "list") {
|
|
1282
|
+
return armedList((await callRpc("listOwnThreadTimers", { slug }))?.result)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (action === "cancel") {
|
|
1286
|
+
const id = typeof args.id === "string" ? args.id.trim() : ""
|
|
1287
|
+
if (!id) throw new Error("`id` is required to cancel a timer — take it from `set`'s reply or from `action: \"list\"`")
|
|
1288
|
+
const result = (await callRpc("cancelOwnThreadTimer", { slug, id }))?.result
|
|
1289
|
+
const head = result?.cancelled
|
|
1290
|
+
? `Timer ${id} cancelled — it will not fire.`
|
|
1291
|
+
: `No ARMED timer ${id} on this thread (it may have already fired, or already been cancelled).`
|
|
1292
|
+
return `${head}\n\n${armedList(result)}`
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""
|
|
1296
|
+
if (!prompt) throw new Error("`prompt` is required to set a timer — it is the text you will be sent when it fires")
|
|
1297
|
+
|
|
1298
|
+
// Exactly one of the two ways to name the instant. Accepting both would mean silently preferring one,
|
|
1299
|
+
// and a worker that gave two different times meant something by each of them.
|
|
1300
|
+
const hasIn = args.in_seconds !== undefined && args.in_seconds !== null
|
|
1301
|
+
const hasAt = typeof args.at === "string" && args.at.trim() !== ""
|
|
1302
|
+
if (hasIn && hasAt) throw new Error("give `in_seconds` OR `at`, not both")
|
|
1303
|
+
if (!hasIn && !hasAt) throw new Error("give either `in_seconds` (fire N seconds from now) or `at` (an ISO-8601 instant)")
|
|
1304
|
+
|
|
1305
|
+
const nowMs = Date.now()
|
|
1306
|
+
let fireMs
|
|
1307
|
+
if (hasIn) {
|
|
1308
|
+
const seconds = typeof args.in_seconds === "number" ? Math.round(args.in_seconds) : NaN
|
|
1309
|
+
if (!Number.isFinite(seconds)) throw new Error("`in_seconds` must be a number of seconds")
|
|
1310
|
+
if (seconds < TIMER_MIN_DELAY_SECONDS || seconds > TIMER_MAX_DELAY_SECONDS) {
|
|
1311
|
+
throw new Error(`\`in_seconds\` must be between ${TIMER_MIN_DELAY_SECONDS} and ${TIMER_MAX_DELAY_SECONDS} (thirty days)`)
|
|
1312
|
+
}
|
|
1313
|
+
fireMs = nowMs + seconds * 1000
|
|
1314
|
+
} else {
|
|
1315
|
+
fireMs = Date.parse(String(args.at))
|
|
1316
|
+
if (!Number.isFinite(fireMs)) throw new Error("`at` must be an ISO-8601 instant, e.g. `2026-08-04T15:00:00Z`")
|
|
1317
|
+
const delta = Math.round((fireMs - nowMs) / 1000)
|
|
1318
|
+
if (delta < TIMER_MIN_DELAY_SECONDS) {
|
|
1319
|
+
throw new Error(`\`at\` must be at least ${TIMER_MIN_DELAY_SECONDS}s in the future (it reads as ${delta}s from now)`)
|
|
1320
|
+
}
|
|
1321
|
+
if (delta > TIMER_MAX_DELAY_SECONDS) throw new Error("`at` must be within thirty days")
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// ONE representation crosses the wire — the exact UTC instant — so the stored row, the trailer on the
|
|
1325
|
+
// delivered message and this reply all name the same string.
|
|
1326
|
+
const fireAt = new Date(fireMs).toISOString()
|
|
1327
|
+
const result = (await callRpc("setOwnThreadTimer", { slug, prompt, fireAt }))?.result
|
|
1328
|
+
const id = result?.id ?? "(unknown)"
|
|
1329
|
+
return (
|
|
1330
|
+
`Timer ${id} set for ${fireAt} (${Math.round((fireMs - nowMs) / 1000)}s from now). It fires ONCE and ` +
|
|
1331
|
+
"then is gone — it may reach you mid-turn, so receiving it does not mean you had stopped. Cancel it " +
|
|
1332
|
+
`with \`action: "cancel", id: "${id}"\` if it stops being useful.\n\n${armedList(result)}`
|
|
1333
|
+
)
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
/** How the armed PR-watcher set reads back, on every action, so a worker never needs a second call.
|
|
1338
|
+
* @param {{ watches?: Array<{id: string, target: string, github?: {checks: string, running: number, passed: number, failed: number, failing: string[], merge: string, state: string}}> }|undefined} result */
|
|
1339
|
+
function armedPrWatchList(result) {
|
|
1340
|
+
const watches = Array.isArray(result?.watches) ? result.watches : []
|
|
1341
|
+
if (!watches.length) return "No pull requests are watched on this thread — nothing will wake you."
|
|
1342
|
+
const lines = watches.map((w) => {
|
|
1343
|
+
const g = w.github
|
|
1344
|
+
// The CHECK STATE rides the read-back because it is the reason a worker is listing at all: "where do
|
|
1345
|
+
// my PRs stand" is one call, not one per PR through `gh`.
|
|
1346
|
+
const state = !g
|
|
1347
|
+
? "not polled yet"
|
|
1348
|
+
: g.state !== "open"
|
|
1349
|
+
? g.state
|
|
1350
|
+
: g.checks === "passing" ? `checks green (${g.passed})`
|
|
1351
|
+
: g.checks === "failing" ? `checks FAILING${g.failing.length ? `: ${g.failing.join(", ")}` : ""}`
|
|
1352
|
+
: g.checks === "running" ? `checks running (${g.running} left)`
|
|
1353
|
+
: "no checks"
|
|
1354
|
+
return ` ${w.id} ${w.target} — ${state}${g && g.state === "open" && g.merge === "mergeable" ? ", mergeable" : ""}`
|
|
1355
|
+
})
|
|
1356
|
+
return `Watched on this thread now:\n${lines.join("\n")}`
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** The armed watches on this thread, as the read-back prints them. */
|
|
1360
|
+
function armedWatchList(result) {
|
|
1361
|
+
const watches = Array.isArray(result?.watches) ? result.watches : []
|
|
1362
|
+
if (!watches.length) return "No watches are armed on this thread — nothing here is holding it out of the queue."
|
|
1363
|
+
const lines = watches.map((w) => {
|
|
1364
|
+
const what = w.kind === "agent" ? "sub-agent" : "shell"
|
|
1365
|
+
// The LABEL is frizz's live reading, not a copy stored at registration — so it names the work as it
|
|
1366
|
+
// stands, and its ABSENCE means the target no longer resolves to anything running.
|
|
1367
|
+
const name = w.label ? `${w.label} (${w.target})` : w.target
|
|
1368
|
+
return ` ${w.id} ${what}: ${name} — expires ${w.expiresAt}`
|
|
1369
|
+
})
|
|
1370
|
+
return `Armed on this thread now:\n${lines.join("\n")}`
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** The `watch` handler: register a wait on this thread's own running work.
|
|
1374
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1375
|
+
async function watch(args) {
|
|
1376
|
+
const slug = threadSlug()
|
|
1377
|
+
const kind = typeof args.kind === "string" ? args.kind.trim() : ""
|
|
1378
|
+
if (kind !== "shell" && kind !== "agent") throw new Error("`kind` must be \"shell\" or \"agent\"")
|
|
1379
|
+
const target = typeof args.target === "string" ? args.target.trim() : ""
|
|
1380
|
+
if (!target) throw new Error("`target` is required — the handle you were shown; `activity` prints them all")
|
|
1381
|
+
const forValue = typeof args.for === "string" ? args.for.trim() : ""
|
|
1382
|
+
if (!forValue) throw new Error("`for` is required — a DURATION like `30m`, `2h` or `3d` (max 24h), never an instant")
|
|
1383
|
+
const result = (await callRpc("addOwnWatch", { slug, kind, target, for: forValue }))?.result
|
|
1384
|
+
const id = result?.id ?? "(unknown)"
|
|
1385
|
+
const head = result?.alreadyArmed
|
|
1386
|
+
? `Already watching \`${target}\` as ${id} — nothing new was registered, and its original expiry stands.`
|
|
1387
|
+
: `Watching \`${target}\` as ${id}. Your thread is held out of the queue until it finishes, and the ` +
|
|
1388
|
+
"registration survives your turn ending, a compaction and a frizz restart."
|
|
1389
|
+
// A clamp is news here for the same reason it is on `watch_pr` — see that handler.
|
|
1390
|
+
const clamped = result?.clampedFrom
|
|
1391
|
+
? `\n\nYOUR \`for: ${result.clampedFrom}\` WAS CAPPED at the 24h ceiling for a shell or a sub-agent — ` +
|
|
1392
|
+
"the expiry listed below is what you actually hold."
|
|
1393
|
+
: ""
|
|
1394
|
+
return (
|
|
1395
|
+
`${head}${clamped}\n\nWHEN \`for\` RUNS OUT the row is CANCELLED and you are woken to re-decide — register ` +
|
|
1396
|
+
`again if you still mean it.\n\nDROP IT the moment it stops mattering (\`unwatch\`, id \`${id}\`); ` +
|
|
1397
|
+
`you do NOT need to when the work simply finishes.\n\n${armedWatchList(result)}`
|
|
1398
|
+
)
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** The `unwatch` handler: withdraw one registered watch by id.
|
|
1402
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1403
|
+
async function unwatch(args) {
|
|
1404
|
+
const slug = threadSlug()
|
|
1405
|
+
const id = typeof args.id === "string" ? args.id.trim() : ""
|
|
1406
|
+
if (!id) throw new Error("`id` is required — take it from `watch` or from `activity`")
|
|
1407
|
+
const result = (await callRpc("dropOwnWatch", { slug, id }))?.result
|
|
1408
|
+
// A drop that matched nothing is reported rather than swallowed: the id was wrong, already settled, or
|
|
1409
|
+
// another thread's — and a worker that believes it withdrew a wait it still holds will rest on it.
|
|
1410
|
+
const head = result?.dropped
|
|
1411
|
+
? `Watch ${id} dropped. It is no longer holding your thread, and it will not wake you.`
|
|
1412
|
+
: `No ARMED watch ${id} on this thread — it was already settled, or the id is not one of yours.`
|
|
1413
|
+
return `${head}\n\n${armedWatchList(result)}`
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** Read back what the human still owes an answer on, so a worker never needs a second call to find out.
|
|
1417
|
+
* @param {Record<string, unknown> | undefined} result @returns {string} */
|
|
1418
|
+
function openQuestionList(result) {
|
|
1419
|
+
const open = Array.isArray(result?.open) ? result.open : []
|
|
1420
|
+
if (!open.length) return "Nothing else is open on this thread — the human owes you no answer."
|
|
1421
|
+
const lines = open.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
|
|
1422
|
+
return `Open on this thread now:\n${lines.join("\n")}`
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** The `ask` handler: register one or more questions the human owes an answer to.
|
|
1426
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1427
|
+
async function ask(args) {
|
|
1428
|
+
const slug = threadSlug()
|
|
1429
|
+
const questions = Array.isArray(args.questions) ? args.questions : []
|
|
1430
|
+
if (!questions.length) throw new Error("`questions` is required — at least one question to register")
|
|
1431
|
+
const result = (await callRpc("ask", { slug, questions }))?.result
|
|
1432
|
+
const registered = Array.isArray(result?.registered) ? result.registered : []
|
|
1433
|
+
const lines = registered.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
|
|
1434
|
+
const head = registered.length === 1
|
|
1435
|
+
? `Registered 1 question. It is on the human's board now and it will stay there until they answer it.`
|
|
1436
|
+
: `Registered ${registered.length} questions. They are on the human's board now and they will stay ` +
|
|
1437
|
+
"there until answered — the card sends every answer as one batch."
|
|
1438
|
+
return (
|
|
1439
|
+
`${head}\n${lines.join("\n")}\n\n` +
|
|
1440
|
+
"KEEP WORKING. A question waits on a person, carries no timeout and does not end your turn — do " +
|
|
1441
|
+
"everything that does not depend on the answer while it sits there. The answer arrives as its own " +
|
|
1442
|
+
"wake, restating what was asked.\n\n" +
|
|
1443
|
+
"WITHDRAW ONE THE MOMENT IT STOPS MATTERING (`unask`), above all if you work the answer out " +
|
|
1444
|
+
`yourself.\n\n${openQuestionList(result)}`
|
|
1445
|
+
)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** The `unask` handler: withdraw one registered question by id.
|
|
1449
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1450
|
+
async function unask(args) {
|
|
1451
|
+
const slug = threadSlug()
|
|
1452
|
+
const id = typeof args.id === "string" ? args.id.trim() : ""
|
|
1453
|
+
if (!id) throw new Error("`id` is required — take it from `ask`")
|
|
1454
|
+
const result = (await callRpc("unask", { slug, id }))?.result
|
|
1455
|
+
// A withdrawal that matched nothing is reported rather than swallowed: the id was wrong, the human
|
|
1456
|
+
// already answered it, or it is another thread's — and a worker that believes it withdrew a question
|
|
1457
|
+
// the human is still looking at will get an answer it has stopped expecting.
|
|
1458
|
+
const head = result?.withdrawn
|
|
1459
|
+
? `Question ${id} withdrawn. Its card is gone and the human will not be asked.`
|
|
1460
|
+
: `No OPEN question ${id} on this thread — it was already answered or dismissed, or the id is not one of yours.`
|
|
1461
|
+
return `${head}\n\n${openQuestionList(result)}`
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** The `done` handler: declare the effort finished, or report exactly what refuses to let it.
|
|
1465
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1466
|
+
async function done(args) {
|
|
1467
|
+
const slug = threadSlug()
|
|
1468
|
+
const body = typeof args.body === "string" ? args.body.trim() : ""
|
|
1469
|
+
if (!body) throw new Error("`body` is required — the write-up the human reads on the card")
|
|
1470
|
+
const result = (await callRpc("markOwnDone", { slug, body }))?.result
|
|
1471
|
+
if (result?.done) {
|
|
1472
|
+
return (
|
|
1473
|
+
"Marked done. Your thread cards as a checked success in the human's queue and stays there until " +
|
|
1474
|
+
"they archive it.\n\nNOTHING WAS CLOSED, HIDDEN OR ARCHIVED — if there is more to say, say it in " +
|
|
1475
|
+
"your final message; if more work appears, keep going and call this again."
|
|
1476
|
+
)
|
|
1477
|
+
}
|
|
1478
|
+
// REFUSED, with everything that refuses it named by id, so the next move is a tool call and not a
|
|
1479
|
+
// guess. Reported as an ordinary result rather than thrown: this is a gate doing its job, not a fault.
|
|
1480
|
+
const questions = (result?.blockingQuestions ?? []).map((q) => ` ${q.id} ${(q.question ?? "").split("\n")[0]}`)
|
|
1481
|
+
const watches = (result?.blockingWatches ?? []).map((w) => ` ${w.id} ${w.what}`)
|
|
1482
|
+
const parts = ["NOT marked done. This thread still holds work open."]
|
|
1483
|
+
if (questions.length) {
|
|
1484
|
+
parts.push(
|
|
1485
|
+
`${questions.length} question${questions.length === 1 ? "" : "s"} the human has not answered:\n${questions.join("\n")}\n` +
|
|
1486
|
+
"Each one dies unread with a done card. Decide it yourself and withdraw it (`unask`), or leave it " +
|
|
1487
|
+
"open and keep working until it is answered.",
|
|
1488
|
+
)
|
|
1489
|
+
}
|
|
1490
|
+
if (watches.length) {
|
|
1491
|
+
parts.push(
|
|
1492
|
+
`${watches.length} registration${watches.length === 1 ? "" : "s"} still armed:\n${watches.join("\n")}\n` +
|
|
1493
|
+
"A live wait means the thing you were waiting for has not happened. Wait for it, or drop the ones " +
|
|
1494
|
+
"that stopped mattering (`unwatch`, or `watch_pr` with `action: \"drop\"`, or `timer` cancel).",
|
|
1495
|
+
)
|
|
1496
|
+
}
|
|
1497
|
+
parts.push("There is no force parameter. Resolve them and call `done` again.")
|
|
1498
|
+
return parts.join("\n\n")
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** The `watch_pr` handler: register, withdraw, or read back this thread's PR watchers.
|
|
1502
|
+
* @param {Record<string, unknown>} args @returns {Promise<string>} */
|
|
1503
|
+
async function watchPr(args) {
|
|
1504
|
+
const slug = threadSlug()
|
|
1505
|
+
const action = typeof args.action === "string" ? args.action.trim() : ""
|
|
1506
|
+
if (action !== "add" && action !== "list" && action !== "drop") {
|
|
1507
|
+
throw new Error("`action` must be one of \"add\", \"list\" or \"drop\"")
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (action === "list") {
|
|
1511
|
+
return armedPrWatchList((await callRpc("listOwnPrWatches", { slug }))?.result)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (action === "drop") {
|
|
1515
|
+
const id = typeof args.id === "string" ? args.id.trim() : ""
|
|
1516
|
+
if (!id) throw new Error("`id` is required to drop a watcher — take it from `add` or from `list`")
|
|
1517
|
+
const result = (await callRpc("dropOwnPrWatch", { slug, id }))?.result
|
|
1518
|
+
// A drop that matched nothing is reported rather than swallowed: the id was wrong, already settled,
|
|
1519
|
+
// or another thread's — and a worker that believes it withdrew a wait it still holds will rest.
|
|
1520
|
+
const head = result?.dropped
|
|
1521
|
+
? `Watcher ${id} dropped. It will not wake you.`
|
|
1522
|
+
: `No ARMED watcher ${id} on this thread — it was already settled, or the id is not one of yours.`
|
|
1523
|
+
return `${head}\n\n${armedPrWatchList(result)}`
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const target = typeof args.target === "string" ? args.target.trim() : ""
|
|
1527
|
+
if (!target) throw new Error("`target` is required — the pull request, as `owner/repo#123` or a PR URL")
|
|
1528
|
+
const result = (await callRpc("addOwnPrWatch", { slug, target, for: typeof args.for === "string" ? args.for.trim() : "" }))?.result
|
|
1529
|
+
const id = result?.id ?? "(unknown)"
|
|
1530
|
+
const ref = result?.target ?? target
|
|
1531
|
+
const until = result?.expiresAt ? ` until ${result.expiresAt}` : ""
|
|
1532
|
+
const head = result?.alreadyArmed
|
|
1533
|
+
? `Already watching ${ref} as ${id}${until} — nothing new was registered, its original expiry stands, and you will be woken once per event.`
|
|
1534
|
+
: `Watching ${ref} as ${id}${until}. Frizz wakes you when CI passes or fails and on every later ` +
|
|
1535
|
+
"review or comment, and the registration survives your turn ending, a compaction and a frizz restart."
|
|
1536
|
+
// A CLAMP IS NEWS. Silently handing back less coverage than was asked for is how a worker comes to
|
|
1537
|
+
// rest believing it is watched for a year when it is watched for one day.
|
|
1538
|
+
const clamped = result?.clampedFrom
|
|
1539
|
+
? `\n\nYOUR \`for: ${result.clampedFrom}\` WAS CAPPED at the ceiling — the expiry above is what you ` +
|
|
1540
|
+
"actually hold. Nothing else about the watcher changed."
|
|
1541
|
+
: ""
|
|
1542
|
+
return (
|
|
1543
|
+
`${head}${clamped}\n\nNAME IT IN YOUR \`\`\`awaiting FENCE TOO (\`prs: [${ref}]\`) — the watcher does the ` +
|
|
1544
|
+
`waking, the fence is what lets you come to rest and shows the human what you are waiting for.\n\n` +
|
|
1545
|
+
`DROP IT when it stops mattering (\`action: "drop", id: "${id}"\`).\n\n${armedPrWatchList(result)}`
|
|
1546
|
+
)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** @param {any} msg */
|
|
1550
|
+
async function handle(msg) {
|
|
1551
|
+
const { id, method, params } = msg ?? {}
|
|
1552
|
+
const isNotification = id === undefined || id === null
|
|
1553
|
+
|
|
1554
|
+
switch (method) {
|
|
1555
|
+
case "initialize": {
|
|
1556
|
+
const requested = params?.protocolVersion
|
|
1557
|
+
reply(id, {
|
|
1558
|
+
protocolVersion: typeof requested === "string" ? requested : PROTOCOL_FALLBACK,
|
|
1559
|
+
capabilities: { tools: {} },
|
|
1560
|
+
serverInfo: { name: "frizz", version: "0.1.0" },
|
|
1561
|
+
})
|
|
1562
|
+
return
|
|
1563
|
+
}
|
|
1564
|
+
case "notifications/initialized":
|
|
1565
|
+
case "initialized":
|
|
1566
|
+
return // notification — no reply
|
|
1567
|
+
case "ping":
|
|
1568
|
+
if (!isNotification) reply(id, {})
|
|
1569
|
+
return
|
|
1570
|
+
case "tools/list":
|
|
1571
|
+
reply(id, { tools: TOOLS })
|
|
1572
|
+
return
|
|
1573
|
+
case "tools/call": {
|
|
1574
|
+
const name = typeof params?.name === "string" ? params.name : ""
|
|
1575
|
+
const handler = HANDLERS[name]
|
|
1576
|
+
if (!handler) {
|
|
1577
|
+
replyError(id, -32602, `unknown tool: ${params?.name}`)
|
|
1578
|
+
return
|
|
1579
|
+
}
|
|
1580
|
+
try {
|
|
1581
|
+
replyTool(id, await handler(params?.arguments ?? {}))
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
replyTool(id, `\`${name}\` failed: ${err instanceof Error ? err.message : String(err)}`, true)
|
|
1584
|
+
}
|
|
1585
|
+
return
|
|
1586
|
+
}
|
|
1587
|
+
default:
|
|
1588
|
+
if (!isNotification) replyError(id, -32601, `method not found: ${method}`)
|
|
1589
|
+
return
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// NDJSON reader: buffer stdin, dispatch each complete line. Messages never contain raw newlines.
|
|
1594
|
+
let buf = ""
|
|
1595
|
+
process.stdin.setEncoding("utf8")
|
|
1596
|
+
process.stdin.on("data", (chunk) => {
|
|
1597
|
+
buf += chunk
|
|
1598
|
+
let nl
|
|
1599
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1600
|
+
const line = buf.slice(0, nl).trim()
|
|
1601
|
+
buf = buf.slice(nl + 1)
|
|
1602
|
+
if (!line) continue
|
|
1603
|
+
let msg
|
|
1604
|
+
try {
|
|
1605
|
+
msg = JSON.parse(line)
|
|
1606
|
+
} catch {
|
|
1607
|
+
continue // ignore unparseable lines
|
|
1608
|
+
}
|
|
1609
|
+
void handle(msg)
|
|
1610
|
+
}
|
|
1611
|
+
})
|
|
1612
|
+
process.stdin.on("end", () => process.exit(0))
|