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,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// PermissionRequest hook (frizz worker), matcher "*" — the worker's permission POLICY, and the durable
|
|
4
|
+
// structured signal the tailer reads instead of scraping the worker's terminal output.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS DECIDES (it used to only observe): a frizz worker runs under a dashboard with nobody at the
|
|
7
|
+
// keyboard, so a tool-approval prompt parks the thread invisibly until a human happens to look. frizz
|
|
8
|
+
// dispatches Claude workers at `--permission-mode auto` (dispatch.ts WORKER_DISPATCH_PERMISSION), and
|
|
9
|
+
// `auto` is NOT non-interactive — its classifier still raises a prompt for anything it deems risky
|
|
10
|
+
// (a `git push`, a publish), which is exactly how a worker silently wedges for hours.
|
|
11
|
+
//
|
|
12
|
+
// The blunt fix would be to dispatch at `bypassPermissions`. This is deliberately NOT that: bypass
|
|
13
|
+
// removes the decision POINT, so nothing can ever inspect a request again. Keeping `auto` + deciding
|
|
14
|
+
// here preserves the seam — the same request that is auto-approved today can be routed to a policy,
|
|
15
|
+
// or to a human, without changing how workers launch. Claude Code labels the outcome in the
|
|
16
|
+
// transcript ("Allowed by PermissionRequest hook"), so an auto-approval stays visible rather than
|
|
17
|
+
// being indistinguishable from bypass.
|
|
18
|
+
//
|
|
19
|
+
// THREE OUTCOMES:
|
|
20
|
+
// allow — auto-approve; the worker proceeds with no prompt.
|
|
21
|
+
// deny — auto-refuse with a reason the model reads (rides top-level `additionalContext`).
|
|
22
|
+
// defer — emit NOTHING; the normal prompt is raised and a human answers it. This is the ONLY
|
|
23
|
+
// outcome the tailer treats as a human block (see permMarkerBlocks in tailer.ts).
|
|
24
|
+
//
|
|
25
|
+
// SCOPE: this plugin loads for EVERY project frizz drives, so the built-in table carries only
|
|
26
|
+
// UNIVERSAL rules. Nothing repo-specific belongs here — a rule that is right for one repo (e.g. "never
|
|
27
|
+
// open a PR") is wrong for the next.
|
|
28
|
+
//
|
|
29
|
+
// KNOWN LIMIT — an explicit `ask` RULE outranks this hook (verified 2026-07-25). A project or user
|
|
30
|
+
// settings entry like `"permissions": {"ask": ["Bash(git push:*)"]}` raises a prompt that an `allow`
|
|
31
|
+
// from here does NOT override: Claude Code says so on the prompt itself ("Ask rule … overrides auto
|
|
32
|
+
// mode for this command"). This was isolated against a hook that allows unconditionally — it prompted
|
|
33
|
+
// too — so it is Claude Code precedence, not a defect here, and it is arguably the right precedence
|
|
34
|
+
// (an explicit human rule should beat a blanket policy). The practical consequence: a repo whose
|
|
35
|
+
// settings carry `ask` rules can still park a worker, and the fix for that repo is to relax its own
|
|
36
|
+
// rule, not to change this file. Mode-driven asks (the `default`-mode prompt) ARE overridden.
|
|
37
|
+
//
|
|
38
|
+
// GATE: inert unless FRIZZ_THREAD is set, so a foreign/non-frizz session is never affected.
|
|
39
|
+
// FAIL-SAFE: any error at all → emit nothing → the prompt is raised and the human decides. Note this
|
|
40
|
+
// inverts the old observer's "fail open": for a hook that can APPROVE, the safe failure is to fall
|
|
41
|
+
// back to asking, never to allow.
|
|
42
|
+
import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
|
|
45
|
+
const slug = process.env.FRIZZ_THREAD;
|
|
46
|
+
if (!slug) process.exit(0);
|
|
47
|
+
|
|
48
|
+
// Top-level targets whose recursive deletion is unrecoverable. `/tmp/x` and `./build` are NOT here —
|
|
49
|
+
// only paths that take the machine or the home directory with them.
|
|
50
|
+
const ROOTISH =
|
|
51
|
+
/^(\/|\/\*|~|~\/|~\/\*|\$\{?HOME\}?|\$\{?HOME\}?\/\*?|\/(usr|etc|bin|sbin|var|lib|opt|System|Library|Applications|Users|home|boot|dev|proc)\/?\*?)$/;
|
|
52
|
+
|
|
53
|
+
// True when `cmd` contains an `rm` that is BOTH recursive and forced AND aimed at a root-ish target.
|
|
54
|
+
// Scans each pipeline/list segment so `cd /tmp && rm -rf /` is caught as readily as a bare `rm -rf /`.
|
|
55
|
+
function isCatastrophicRm(cmd) {
|
|
56
|
+
for (const seg of cmd.split(/[|;&\n]+/)) {
|
|
57
|
+
const m = /(?:^|\s)rm(\s.*)$/.exec(seg);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
const rest = m[1];
|
|
60
|
+
const flags = (rest.match(/(?:^|\s)-[a-zA-Z]+/g) || []).join('');
|
|
61
|
+
if (!/[rR]/.test(flags) || !/f/.test(flags)) continue;
|
|
62
|
+
const targets = rest.split(/\s+/).filter((t) => t && !t.startsWith('-'));
|
|
63
|
+
if (targets.some((t) => ROOTISH.test(t.replace(/["']/g, '')))) return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Writes straight to a raw device / formats a filesystem — unrecoverable, and never something a
|
|
69
|
+
// worker needs to do unattended.
|
|
70
|
+
function isDiskWrite(cmd) {
|
|
71
|
+
return /\bmkfs(\.\w+)?\b/.test(cmd) || /\bdd\b[^|;&]*\bof=\/dev\/(disk|r?disk|sd|nvme|hd)/.test(cmd);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The ordered policy table: FIRST MATCH WINS. Each rule returns a decision plus the `rule` id and
|
|
75
|
+
// `reason` that get recorded on the marker, so frizz can always say WHICH rule decided and WHY.
|
|
76
|
+
// `deny` reasons are written to be read by the MODEL (they become additionalContext).
|
|
77
|
+
const RULES = [
|
|
78
|
+
{
|
|
79
|
+
id: 'catastrophic-delete',
|
|
80
|
+
test: (i) => i.tool_name === 'Bash' && isCatastrophicRm(String(i.tool_input?.command ?? '')),
|
|
81
|
+
decision: 'deny',
|
|
82
|
+
reason:
|
|
83
|
+
'Refused: this recursively force-deletes a root-level or home directory, which is unrecoverable. If you genuinely need to remove a large tree, target an explicit project-relative path instead, and never `/`, `~`, or a top-level system directory.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'raw-disk-write',
|
|
87
|
+
test: (i) => i.tool_name === 'Bash' && isDiskWrite(String(i.tool_input?.command ?? '')),
|
|
88
|
+
decision: 'deny',
|
|
89
|
+
reason:
|
|
90
|
+
'Refused: this formats a filesystem or writes directly to a raw block device, which destroys data irrecoverably and is never required of an unattended worker.',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
// Respect a DELIBERATELY restrictive mode. frizz dispatches workers at `auto`; a thread sitting at
|
|
94
|
+
// `default`/`plan` got there because a human moved it there (the live per-thread permission
|
|
95
|
+
// control), and auto-approving would silently overrule that intent. This is what makes a genuine
|
|
96
|
+
// lower-permission mode usable today: switch a thread to `default` and its prompts come back.
|
|
97
|
+
id: 'restrictive-mode',
|
|
98
|
+
test: (i) => typeof i.permission_mode === 'string' && i.permission_mode !== 'auto',
|
|
99
|
+
decision: 'defer',
|
|
100
|
+
reason: 'The thread is in a restrictive permission mode, so this request is left for a human to answer.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
// Escape hatch for review-style operation without changing how workers launch.
|
|
104
|
+
id: 'review-policy',
|
|
105
|
+
test: () => (process.env.FRIZZ_PERM_POLICY ?? 'auto').toLowerCase() === 'review',
|
|
106
|
+
decision: 'defer',
|
|
107
|
+
reason: 'FRIZZ_PERM_POLICY=review — every request is left for a human to answer.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'worker-autonomy',
|
|
111
|
+
test: () => true,
|
|
112
|
+
decision: 'allow',
|
|
113
|
+
reason: 'Unattended frizz worker: approved automatically because no human is watching the terminal to answer a prompt.',
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
function evaluate(input) {
|
|
118
|
+
for (const rule of RULES) {
|
|
119
|
+
let hit = false;
|
|
120
|
+
try {
|
|
121
|
+
hit = rule.test(input);
|
|
122
|
+
} catch {
|
|
123
|
+
continue; // a throwing rule is skipped, never fatal
|
|
124
|
+
}
|
|
125
|
+
if (hit) return { decision: rule.decision, rule: rule.id, reason: rule.reason };
|
|
126
|
+
}
|
|
127
|
+
return { decision: 'defer', rule: 'no-rule-matched', reason: 'No policy rule matched.' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let input;
|
|
131
|
+
try {
|
|
132
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
133
|
+
} catch {
|
|
134
|
+
process.exit(0); // unparseable payload → defer to the human
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ExitPlanMode is always auto-denied by the sibling deny-plan.mjs (a frizz worker is never in plan
|
|
138
|
+
// mode), so it never becomes a real human block — leave it entirely alone, marker included.
|
|
139
|
+
if (input.tool_name === 'ExitPlanMode') process.exit(0);
|
|
140
|
+
|
|
141
|
+
// AskUserQuestion is not an authorization request — it is the agent ASKING, and the permission
|
|
142
|
+
// decision is where the ANSWER travels. `worker-autonomy` would allow it with `updatedInput` set to
|
|
143
|
+
// the untouched tool input, i.e. the questions and NO answers, and claude's own result mapper then
|
|
144
|
+
// tells the model "The user did not answer the questions." So the auto-approval that keeps a worker
|
|
145
|
+
// moving for every other tool is, for this one, a guaranteed wasted turn.
|
|
146
|
+
//
|
|
147
|
+
// Frizz's broker intercepts this call at canUseTool and renders it as a real question card the
|
|
148
|
+
// operator answers (claude-permission-interactions.ts), so the right move here is to say NOTHING —
|
|
149
|
+
// no decision AND no marker. A `defer` verdict would write a marker the tailer reads as a human
|
|
150
|
+
// permission block, stacking a second "needs you" surface on top of the card already asking.
|
|
151
|
+
// (Verified 2026-07-27 on a promoted artifact: workers dispatch at --permission-mode auto, so
|
|
152
|
+
// `restrictive-mode` does not catch this and `worker-autonomy` did allow it. A dev-stack harness at
|
|
153
|
+
// the default permission mode deferred and looked fine, which is exactly why this needed an
|
|
154
|
+
// artifact run to find.)
|
|
155
|
+
if (input.tool_name === 'AskUserQuestion') process.exit(0);
|
|
156
|
+
|
|
157
|
+
const verdict = evaluate(input);
|
|
158
|
+
|
|
159
|
+
// Record the decision BEFORE acting on it, best-effort. The marker is frizz's only structured view of
|
|
160
|
+
// what happened here: `decision` tells the tailer whether a human is actually blocked, and
|
|
161
|
+
// rule/reason/command are what the dashboard shows the human afterwards. A failed write must not
|
|
162
|
+
// hold up the worker, so this swallows its own errors — telemetry loss, not a stall.
|
|
163
|
+
const dir = process.env.FRIZZ_PERM_DIR;
|
|
164
|
+
if (dir) {
|
|
165
|
+
try {
|
|
166
|
+
const command = input.tool_name === 'Bash' ? String(input.tool_input?.command ?? '') : '';
|
|
167
|
+
const marker = {
|
|
168
|
+
slug,
|
|
169
|
+
tool: typeof input.tool_name === 'string' ? input.tool_name : null,
|
|
170
|
+
promptId: typeof input.prompt_id === 'string' ? input.prompt_id : null,
|
|
171
|
+
permissionMode: typeof input.permission_mode === 'string' ? input.permission_mode : null,
|
|
172
|
+
at: new Date().toISOString(),
|
|
173
|
+
decision: verdict.decision,
|
|
174
|
+
rule: verdict.rule,
|
|
175
|
+
reason: verdict.reason,
|
|
176
|
+
// Truncated: this is display text for the dashboard, not a re-executable command.
|
|
177
|
+
...(command ? { command: command.length > 300 ? `${command.slice(0, 300)}…` : command } : {}),
|
|
178
|
+
};
|
|
179
|
+
mkdirSync(dir, { recursive: true });
|
|
180
|
+
// Write to a temp sibling then rename, so the tailer never reads a half-written marker.
|
|
181
|
+
const dest = join(dir, `${slug}.json`);
|
|
182
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
183
|
+
writeFileSync(tmp, JSON.stringify(marker));
|
|
184
|
+
renameSync(tmp, dest);
|
|
185
|
+
} catch {
|
|
186
|
+
// telemetry only — never block the worker on a marker write
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (verdict.decision === 'allow') {
|
|
191
|
+
process.stdout.write(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
hookSpecificOutput: {
|
|
194
|
+
hookEventName: 'PermissionRequest',
|
|
195
|
+
decision: { behavior: 'allow', updatedInput: input.tool_input ?? {} },
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
} else if (verdict.decision === 'deny') {
|
|
200
|
+
// On a PermissionRequest DENY the `decision` object carries ONLY `{behavior:"deny"}`; the reason the
|
|
201
|
+
// model reads rides top-level `additionalContext` (same contract as deny-plan.mjs). Exit 0 with this
|
|
202
|
+
// JSON on stdout — exit 2 would make Claude Code ignore the JSON, so never mix the two.
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
|
|
206
|
+
additionalContext: verdict.reason,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
// defer → emit nothing: the normal prompt is raised and the human answers it.
|
|
211
|
+
process.exit(0);
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// SCRATCH-DIRECTORY hook (frizz-worker) — keeps a worker aware of the per-thread scratch directory
|
|
4
|
+
// (`.frizz/threads/<sid>/`) it may use, and re-orients it on that directory when its context is lost.
|
|
5
|
+
// Run directly with node (zero deps, max Node compat), mirroring the other hooks in this plugin.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS USED TO BE, AND WHY IT IS NOT ANY MORE. Until 2026-08-06 frizz provisioned ONE canonical
|
|
8
|
+
// `scratch.md` per thread and this hook spliced its HEAD into the context window after every compaction,
|
|
9
|
+
// on the argument that a bare "remember to read your scratchpad" routes recovery through a decision the
|
|
10
|
+
// model can skip. That argument was sound and the mechanism still lost: it made a maintained file the
|
|
11
|
+
// price of admission for every worker, it needed a merge-only contract per backend to keep sub-agents
|
|
12
|
+
// from clobbering it, and the injection was invisible to the operator, who could neither see nor change
|
|
13
|
+
// what their worker would be told.
|
|
14
|
+
//
|
|
15
|
+
// The maintainer's replacement (chosen deliberately over keeping a canonical doc): the thread gets a
|
|
16
|
+
// free-form scratch DIRECTORY, and compaction recovery moves to `mcp__frizz__goal`'s
|
|
17
|
+
// post_compaction trigger — the worker writes whatever doc it likes and LINKS it in a prompt frizz
|
|
18
|
+
// re-sends when the context is summarized away. Durable in SQLite, visible in the thread footer,
|
|
19
|
+
// editable by the human. This hook's job is therefore reduced to two honest things:
|
|
20
|
+
//
|
|
21
|
+
// 1. TELL the worker the directory exists, and that the arming is what makes anything in it come back.
|
|
22
|
+
// 2. On compact/resume, say what is IN the directory — a listing, not the content. That is the
|
|
23
|
+
// degradation the maintainer accepted when choosing this over a canonical doc, and it is stated
|
|
24
|
+
// here rather than quietly re-implemented as an injection: a worker that never armed the trigger
|
|
25
|
+
// gets a pointer it may skip. Naming the files it already wrote is the most a pointer can do.
|
|
26
|
+
//
|
|
27
|
+
// CODEX CHILD EPILOGUE — native Codex sub-agents inherit the root conversation's system/user
|
|
28
|
+
// instructions even with `fork_turns:"none"`. The `subagent-start` mode is what tells such a child to
|
|
29
|
+
// write its OWN file rather than treating a document it did not create as its own. It also carries the
|
|
30
|
+
// codex half of the default-off nesting rule (2026-08-04): a native child does the work itself and does
|
|
31
|
+
// not `spawn_agent` a layer of its own unless its task said to. SubagentStart is the only structural
|
|
32
|
+
// seam that reaches a native child, the way agent-dispatch.mjs's epilogue is for Claude.
|
|
33
|
+
//
|
|
34
|
+
// THE WRITE-SIDE NUDGE — two channels:
|
|
35
|
+
// UserPromptSubmit — the turn boundary.
|
|
36
|
+
// PostToolUse — MID-TURN, and this is the one that matters. A frizz worker runs enormous
|
|
37
|
+
// autonomous turns (dozens of tool calls between human prompts), so a
|
|
38
|
+
// turn-boundary-only nudge can miss an entire session's worth of work. PostToolUse
|
|
39
|
+
// additionalContext was verified live against cli 2.1.220: a real session quoted a
|
|
40
|
+
// sentinel injected after a Bash call. Both channels share one state file, so the
|
|
41
|
+
// interval is global — firing per tool call does NOT multiply the nudges.
|
|
42
|
+
//
|
|
43
|
+
// NO HOOK FIRES ON CONTEXT PRESSURE — measured, not assumed. Claude Code 2.1.220 exposes 31 hook
|
|
44
|
+
// events and not one of them signals an approaching context limit; no hook input carries a token
|
|
45
|
+
// count at all (the docs say plainly: poll the transcript yourself). So this computes the fill
|
|
46
|
+
// itself from the transcript's newest usage record — `input + cache_creation + cache_read` is the
|
|
47
|
+
// live context size — reading only the file's TAIL, since transcripts reach tens of megabytes.
|
|
48
|
+
//
|
|
49
|
+
// STALENESS IS GROWTH SINCE THE LAST WRITE, never an absolute threshold: the window size is not
|
|
50
|
+
// knowable from a hook (a real compaction in this project fired at preTokens 935,291 on a 1M-window
|
|
51
|
+
// session, while a 200k session compacts near 160k). Growth is window-independent and self-resetting.
|
|
52
|
+
//
|
|
53
|
+
// NOT A BLOCKING GATE. A Stop hook could refuse to let the worker rest until it writes, and that was
|
|
54
|
+
// tried and REMOVED on 2026-07-02 (maintainer's call): the block-until-file-edited nag forced even
|
|
55
|
+
// trivial workers into Read/Edit dances that render as noise in the chat UI. This nudges; it never
|
|
56
|
+
// blocks.
|
|
57
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync, readdirSync, openSync, readSync, closeSync } from 'node:fs';
|
|
58
|
+
import { join } from 'node:path';
|
|
59
|
+
import { currentSessionId } from '../scripts/frizz/config.mjs';
|
|
60
|
+
|
|
61
|
+
/** How many filenames a listing names before it summarizes the rest. A worker with 200 scratch files
|
|
62
|
+
* needs to know that, not to be handed 200 lines of them. */
|
|
63
|
+
const MAX_LISTED_FILES = intFromEnv('FRIZZ_SCRATCH_MAX_LISTED', 40);
|
|
64
|
+
|
|
65
|
+
/** Context-token growth since the last scratch write that marks the directory stale. 60k is ~a third of
|
|
66
|
+
* a 200k window and ~6% of a 1M one: frequent enough that a long effort is reminded while there is
|
|
67
|
+
* still something to record, rare enough not to be chatter. Also the first-write trigger — an EMPTY
|
|
68
|
+
* directory has no clock of its own, so the baseline is zero and the first nudge lands once a session
|
|
69
|
+
* has accumulated 60k tokens actually worth persisting. */
|
|
70
|
+
const STALE_TOKENS = intFromEnv('FRIZZ_SCRATCHPAD_STALE_TOKENS', 60000);
|
|
71
|
+
|
|
72
|
+
/** @param {string} name @param {number} fallback */
|
|
73
|
+
function intFromEnv(name, fallback) {
|
|
74
|
+
const n = parseInt(String(process.env[name] ?? ''), 10);
|
|
75
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const argv = process.argv.slice(2);
|
|
79
|
+
/** @param {string} flag */
|
|
80
|
+
const flagValue = (flag) => {
|
|
81
|
+
const hit = argv.find((a) => a.startsWith(flag + '='));
|
|
82
|
+
return hit ? hit.slice(flag.length + 1) : null;
|
|
83
|
+
};
|
|
84
|
+
const mode = flagValue('--mode') ?? 'session-start';
|
|
85
|
+
const via = flagValue('--via') ?? 'plugin';
|
|
86
|
+
|
|
87
|
+
// ALWAYS ON — deliberately not settings-gated. The scratchpad is the CANONICAL document for a thread,
|
|
88
|
+
// so re-grounding on it after a compaction is not an opinion a project opts into; it is what makes the
|
|
89
|
+
// pad worth writing at all. An earlier revision put this behind an opt-in setting that defaulted OFF,
|
|
90
|
+
// which meant the default worker got nothing back after a compaction — the exact failure the pad
|
|
91
|
+
// exists to prevent (maintainer's correction: the thing that should be opt-in is the FORK-based
|
|
92
|
+
// auto-updating, not the re-grounding).
|
|
93
|
+
//
|
|
94
|
+
// The escape hatch is an env var, not a setting, because it is for a one-off ("this session is doing
|
|
95
|
+
// something where the injection is in the way"), not a project posture. Anything affirmative-looking
|
|
96
|
+
// is ignored: only an explicit off value disables.
|
|
97
|
+
if (/^(off|0|false|no|disabled)$/i.test((process.env.FRIZZ_SCRATCHPAD_HOOK ?? '').trim())) process.exit(0);
|
|
98
|
+
|
|
99
|
+
// The repo-local registration defers to the plugin one for frizz workers (see --via, and the
|
|
100
|
+
// registration note in DECISIONS.md) so a frizz worker never injects twice.
|
|
101
|
+
if (via === 'project' && (process.env.FRIZZ_THREAD ?? '').trim()) process.exit(0);
|
|
102
|
+
|
|
103
|
+
/** @type {{ agent_id?: unknown, agentId?: unknown, source?: string, trigger?: string, session_id?: string, transcript_path?: string }} */
|
|
104
|
+
let input = {};
|
|
105
|
+
try {
|
|
106
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
107
|
+
} catch {
|
|
108
|
+
/* no stdin / not JSON → fall back to env for the session id */
|
|
109
|
+
}
|
|
110
|
+
const childId = input.agent_id ?? input.agentId;
|
|
111
|
+
// Sub-agent contexts are silent on every reinforcement mode. The child-only epilogue is the one
|
|
112
|
+
// exception: it constrains the undifferentiated scratchpad instruction the child otherwise inherits.
|
|
113
|
+
if (childId && mode !== 'subagent-start') process.exit(0);
|
|
114
|
+
|
|
115
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
116
|
+
|
|
117
|
+
// WHICH session keys the pad. On Claude the hook's `session_id` IS frizz's thread session id, so the
|
|
118
|
+
// derived path is correct. On CODEX it is NOT: codex reports its own rollout session id (measured —
|
|
119
|
+
// e.g. `019fb427-93aa-…`, with transcript_path pointing into ~/.codex/sessions), which has nothing to
|
|
120
|
+
// do with `.frizz/threads/<frizz sessionId>/scratch.md`. Deriving the path there would silently address
|
|
121
|
+
// a pad that does not exist and the worker would look unreinforced for a reason nobody could see. So
|
|
122
|
+
// frizz bakes `--session=<frizz sessionId>` into the codex hook command, and an explicit value always
|
|
123
|
+
// wins over the reported one.
|
|
124
|
+
const explicitSession = flagValue('--session');
|
|
125
|
+
let sid = null;
|
|
126
|
+
try {
|
|
127
|
+
sid = explicitSession || currentSessionId(input.session_id);
|
|
128
|
+
} catch {
|
|
129
|
+
/* best-effort */
|
|
130
|
+
}
|
|
131
|
+
// Without a session id there is no key, and an unkeyed pad would bleed between sessions.
|
|
132
|
+
if (!sid) process.exit(0);
|
|
133
|
+
|
|
134
|
+
const threadDir = join(projectDir, '.frizz', 'threads', sid);
|
|
135
|
+
const relPath = '.frizz/threads/' + sid + '/';
|
|
136
|
+
|
|
137
|
+
// ── mode: subagent-start (Codex child-only merge epilogue) ───────────────────────────────────────
|
|
138
|
+
if (mode === 'subagent-start') {
|
|
139
|
+
process.stdout.write(JSON.stringify({
|
|
140
|
+
hookSpecificOutput: {
|
|
141
|
+
hookEventName: 'SubagentStart',
|
|
142
|
+
additionalContext:
|
|
143
|
+
'⟦scratch directory — write your OWN file⟧ `' + relPath + '` is a scratch directory shared with ' +
|
|
144
|
+
'the top-level `/root` worker and any sibling agents. If you want notes on disk, create your ' +
|
|
145
|
+
'own file in it — never edit, replace or delete a file another agent wrote, and never treat a ' +
|
|
146
|
+
'document you did not create as yours to rewrite. One file per writer means there is nothing ' +
|
|
147
|
+
'to merge and nothing to clobber. Writing your own file here is Frizz coordination state, not ' +
|
|
148
|
+
'a project deliverable or source edit: it remains allowed when a delegated task limits your ' +
|
|
149
|
+
'deliverable paths with phrases such as “write only <path>” or “do not modify the repo”, and ' +
|
|
150
|
+
'must never be classified as unauthorized or rolled back. Other project files, including files ' +
|
|
151
|
+
'at the repository root, remain governed by your delegated authority; their location alone ' +
|
|
152
|
+
'neither permits nor forbids editing.\n' +
|
|
153
|
+
'⟦no fan-out of your own⟧ Do the work yourself: do not spawn agents of your own ' +
|
|
154
|
+
'(`spawn_agent`) unless the task you were given explicitly tells you to. You are already one ' +
|
|
155
|
+
'prong of the root worker’s fan-out, and another layer below you splits the context you were ' +
|
|
156
|
+
'handed and buries the real work further from whoever reads the tree. A slice that feels ' +
|
|
157
|
+
'large is still yours to work through.',
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Ensure the directory exists so a first Write lands. frizz's dispatcher already provisions this for
|
|
164
|
+
// a real thread; this only covers a session that started outside a dispatch.
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(threadDir, { recursive: true });
|
|
167
|
+
} catch {
|
|
168
|
+
/* a read-only or racing FS just means the agent's Write creates it instead */
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The worker's own files in the scratch directory, newest first — name, size, and how long ago it was
|
|
172
|
+
* touched. Dotfiles are excluded: frizz keeps its own per-thread bookkeeping in here
|
|
173
|
+
* (`.scratchpad-state.json`), and reporting that back to the worker as its own notes would be a lie.
|
|
174
|
+
* @returns {{ name: string, size: number, mtimeMs: number }[]} */
|
|
175
|
+
function listScratch() {
|
|
176
|
+
let names;
|
|
177
|
+
try {
|
|
178
|
+
names = readdirSync(threadDir);
|
|
179
|
+
} catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
if (name.startsWith('.')) continue;
|
|
185
|
+
try {
|
|
186
|
+
const st = statSync(join(threadDir, name));
|
|
187
|
+
if (!st.isFile()) continue;
|
|
188
|
+
out.push({ name, size: st.size, mtimeMs: st.mtimeMs });
|
|
189
|
+
} catch {
|
|
190
|
+
// vanished between the listing and the stat — simply not listed
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The listing as text: what the worker actually has to go back to. NAMES ONLY, never content — see
|
|
197
|
+
* the header for why this hook points rather than injects.
|
|
198
|
+
* @param {{ name: string, size: number }[]} files */
|
|
199
|
+
function describe(files) {
|
|
200
|
+
const shown = files.slice(0, MAX_LISTED_FILES).map((f) => ' - `' + relPath + f.name + '` (' + f.size + ' bytes)');
|
|
201
|
+
if (files.length > shown.length) shown.push(' - …and ' + (files.length - shown.length) + ' more');
|
|
202
|
+
return shown.join('\n');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
/** @param {string} additionalContext @param {'SessionStart'|'UserPromptSubmit'|'PostToolUse'} hookEventName */
|
|
208
|
+
function emitJson(additionalContext, hookEventName) {
|
|
209
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } }));
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── mode: session-start ──────────────────────────────────────────────────────────────────────────
|
|
214
|
+
if (mode === 'session-start') {
|
|
215
|
+
const files = listScratch();
|
|
216
|
+
const written = files.length > 0;
|
|
217
|
+
|
|
218
|
+
// The sources where the deep model of the work is actually GONE.
|
|
219
|
+
const lostContext = input.source === 'compact' || input.source === 'resume' || input.source === 'clear'
|
|
220
|
+
|
|
221
|
+
// Claude Code opens every compaction summary with the fixed preamble "This session is being
|
|
222
|
+
// continued from a previous conversation that ran out of context." That sentence is about the
|
|
223
|
+
// conversation just SUMMARIZED, but it lands at the moment the window is emptiest, and workers read
|
|
224
|
+
// it as a report on their own state and start winding down. Measured: nub session 5258ebe4 took the
|
|
225
|
+
// auto-compaction at line 20239 and then declared "I'm out of context" / "I'm at the end of this
|
|
226
|
+
// context window" on 13 consecutive turns at fills of 176k-244k, before self-diagnosing at line
|
|
227
|
+
// 20628 — "I've been treating 'low context' as a stopping condition ... and winding down instead of
|
|
228
|
+
// working." Kept to two sentences: the re-grounding instruction is the payload.
|
|
229
|
+
const compactedNote =
|
|
230
|
+
input.source === 'compact'
|
|
231
|
+
? ' The summary opens "a previous conversation that ran out of context" — that describes the ' +
|
|
232
|
+
'conversation just summarized, not your situation now: this window is close to EMPTY again, ' +
|
|
233
|
+
'and the harness will compact and continue as many times as the effort needs. Context is not ' +
|
|
234
|
+
'a reason to wind down, hand off, or leave the next step to a fresh session.'
|
|
235
|
+
: ''
|
|
236
|
+
|
|
237
|
+
const parts = [];
|
|
238
|
+
if (lostContext && written) {
|
|
239
|
+
// NAMES, NOT CONTENT. This is the pointer the maintainer accepted in place of an injection when
|
|
240
|
+
// the canonical pad was dropped; the guaranteed channel is now the recurring prompt's
|
|
241
|
+
// post_compaction trigger, which the worker arms for itself. Saying which files exist is the most
|
|
242
|
+
// a pointer can do, and it is worth doing: a worker that wrote three docs and lost its context
|
|
243
|
+
// otherwise has no idea they are there.
|
|
244
|
+
parts.push(
|
|
245
|
+
'⟦scratch directory⟧ Context was just ' + (input.source === 'compact' ? 'compacted' : 'lost') +
|
|
246
|
+
'. You have files in your scratch directory `' + relPath + '`:\n' + describe(files) +
|
|
247
|
+
'\n\nRead whichever of them bears on what you were doing BEFORE acting, and treat what you ' +
|
|
248
|
+
'wrote there as authoritative over anything the summary implies. (A goal armed via ' +
|
|
249
|
+
'mcp__frizz__goal with post_compaction: true can hand a link back at the next ' +
|
|
250
|
+
'compaction without relying on this note.)' +
|
|
251
|
+
compactedNote,
|
|
252
|
+
);
|
|
253
|
+
} else if (lostContext) {
|
|
254
|
+
// Context is gone and the worker left itself nothing. Say so plainly and constrain reconstruction:
|
|
255
|
+
// searching neighbouring threads' directories is both expensive and unsafe — they belong to
|
|
256
|
+
// unrelated workers.
|
|
257
|
+
parts.push(
|
|
258
|
+
'⟦scratch directory⟧ Context was just compacted or resumed, and your scratch directory `' +
|
|
259
|
+
relPath + '` is EMPTY — you left yourself nothing to recover from. Do not search other ' +
|
|
260
|
+
'`.frizz/threads/*/` directories for a substitute, and do not broadly reload repo docs or ' +
|
|
261
|
+
'skills merely to reconstruct context. Recover from the retained compaction summary and any ' +
|
|
262
|
+
'task-specific handoff it directly names. The directory is still available if you want notes ' +
|
|
263
|
+
'this time, and a goal armed via mcp__frizz__goal with post_compaction: true can ' +
|
|
264
|
+
're-send a prompt linking them at the next compaction.' +
|
|
265
|
+
compactedNote,
|
|
266
|
+
);
|
|
267
|
+
} else {
|
|
268
|
+
// A fresh start has lost nothing — say what is available and move on. The directory is offered,
|
|
269
|
+
// never prescribed (maintainer 2026-08-28: stop pushing the notes-plus-arming arrangement).
|
|
270
|
+
parts.push(
|
|
271
|
+
'⟦scratch directory⟧ `' + relPath + '` is yours: any files you like, no format expected, and ' +
|
|
272
|
+
'nothing in it is read automatically. Use it if you want it. If you ever want a note to come ' +
|
|
273
|
+
'back after a compaction, a goal armed via mcp__frizz__goal with ' +
|
|
274
|
+
'post_compaction: true re-sends a prompt of your choosing — one that can link a file here.',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
emitJson(parts.join('\n\n'), 'SessionStart');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── mode: precompact ─────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
// PLAIN STDOUT — handed to the summarizer as its `Additional Instructions:`. cc's usual
|
|
282
|
+
// `hookSpecificOutput` JSON would be read as literal instructions instead. Worded as an ordinary
|
|
283
|
+
// editorial note: a summarizer REFUSES instructions that read like prompt-hijacking (DECISIONS.md).
|
|
284
|
+
// This is the ONLY PreCompact hook the plugin registers — `precompact-instructions.mjs`, which also
|
|
285
|
+
// steered the summarizer, was deleted on 2026-08-26 as too opinionated.
|
|
286
|
+
if (mode === 'precompact') {
|
|
287
|
+
const files = listScratch();
|
|
288
|
+
if (files.length === 0) process.exit(0);
|
|
289
|
+
process.stdout.write(
|
|
290
|
+
'The worker kept working notes for this effort in `' + relPath + '`:\n' + describe(files) +
|
|
291
|
+
'\nThose files are the hand-written account of the problem, the chosen approach and the ' +
|
|
292
|
+
'decisions behind them. Make sure the summary preserves the substance of the work they describe, ' +
|
|
293
|
+
'and name their paths in it so the continuing session can open them.\n',
|
|
294
|
+
);
|
|
295
|
+
process.exit(0);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── nudge modes (UserPromptSubmit + PostToolUse) ─────────────────────────────────────────────────
|
|
299
|
+
// Everything below answers one question: has the context moved on since the pad was last written?
|
|
300
|
+
|
|
301
|
+
/** Live context fill in tokens from the transcript's newest usage record, or null.
|
|
302
|
+
* Reads only the TAIL — transcripts reach tens of megabytes, and on PostToolUse this runs after
|
|
303
|
+
* every single tool call. Scanning backwards means the one line the tail read may have cut in half
|
|
304
|
+
* is reached last, and its parse failure is simply skipped.
|
|
305
|
+
* @param {string} path */
|
|
306
|
+
function contextTokens(path) {
|
|
307
|
+
let fd = null;
|
|
308
|
+
try {
|
|
309
|
+
const size = statSync(path).size;
|
|
310
|
+
const want = Math.min(size, 128 * 1024);
|
|
311
|
+
const buf = Buffer.alloc(want);
|
|
312
|
+
fd = openSync(path, 'r');
|
|
313
|
+
readSync(fd, buf, 0, want, size - want);
|
|
314
|
+
const lines = buf.toString('utf8').split('\n');
|
|
315
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
316
|
+
const line = lines[i].trim();
|
|
317
|
+
if (!line) continue;
|
|
318
|
+
let rec;
|
|
319
|
+
try {
|
|
320
|
+
rec = JSON.parse(line);
|
|
321
|
+
} catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const u = rec?.message?.usage;
|
|
325
|
+
if (!u) continue;
|
|
326
|
+
const n =
|
|
327
|
+
(u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
328
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
} finally {
|
|
334
|
+
if (fd !== null) {
|
|
335
|
+
try {
|
|
336
|
+
closeSync(fd);
|
|
337
|
+
} catch {
|
|
338
|
+
/* ignore */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const statePath = join(threadDir, '.scratchpad-state.json');
|
|
345
|
+
/** @returns {{ mtimeMs?: number, tokensAtWrite?: number, tokensAtNudge?: number }} */
|
|
346
|
+
function readState() {
|
|
347
|
+
try {
|
|
348
|
+
const s = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
349
|
+
return s && typeof s === 'object' ? s : {};
|
|
350
|
+
} catch {
|
|
351
|
+
return {};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** @param {{ mtimeMs?: number, tokensAtWrite?: number, tokensAtNudge?: number }} s */
|
|
355
|
+
function writeState(s) {
|
|
356
|
+
try {
|
|
357
|
+
writeFileSync(statePath, JSON.stringify(s) + '\n');
|
|
358
|
+
} catch {
|
|
359
|
+
/* best-effort — a lost state file costs at most one extra nudge */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (mode === 'nudge') {
|
|
364
|
+
const transcript = input.transcript_path;
|
|
365
|
+
const tokens = transcript ? contextTokens(transcript) : null;
|
|
366
|
+
// No readable usage yet, or an unparseable transcript → say nothing. The nudge is an optimization;
|
|
367
|
+
// silence is always safe.
|
|
368
|
+
if (!tokens) process.exit(0);
|
|
369
|
+
|
|
370
|
+
const files = listScratch();
|
|
371
|
+
|
|
372
|
+
// The NEWEST write across the whole directory is the clock. A worker with several docs has "written
|
|
373
|
+
// recently" if it touched ANY of them — the nudge asks whether the effort is being recorded at all,
|
|
374
|
+
// not whether one particular file moved. Zero when the directory is empty.
|
|
375
|
+
const mtimeMs = files.length ? Math.max(...files.map((f) => f.mtimeMs)) : 0;
|
|
376
|
+
|
|
377
|
+
let state = readState();
|
|
378
|
+
// A changed mtime means something was just written — rebase the baseline to NOW and go quiet. This is
|
|
379
|
+
// also the first-ever observation, and it is why a fresh write buys a full interval of silence. It
|
|
380
|
+
// fires for a human's hand-edit exactly as for the agent's Write: both are just an mtime change.
|
|
381
|
+
if (state.mtimeMs !== mtimeMs) {
|
|
382
|
+
state = { mtimeMs, tokensAtWrite: tokens, tokensAtNudge: 0 };
|
|
383
|
+
writeState(state);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// An EMPTY directory measures growth from ZERO: the whole session is unrecorded, so the clock starts
|
|
387
|
+
// at the beginning, not at whenever this first looked.
|
|
388
|
+
const baseline = mtimeMs ? (state.tokensAtWrite ?? tokens) : 0;
|
|
389
|
+
const grown = tokens - baseline;
|
|
390
|
+
if (grown < STALE_TOKENS) process.exit(0);
|
|
391
|
+
// Space repeat nudges by the same interval. Both channels share this state, so firing on every tool
|
|
392
|
+
// call does not multiply reminders — it only makes the existing budget land sooner and mid-turn.
|
|
393
|
+
if (state.tokensAtNudge && tokens - state.tokensAtNudge < STALE_TOKENS) process.exit(0);
|
|
394
|
+
|
|
395
|
+
writeState({ ...state, tokensAtNudge: tokens });
|
|
396
|
+
|
|
397
|
+
const k = Math.round(grown / 1000);
|
|
398
|
+
const event = /** @type {'UserPromptSubmit'|'PostToolUse'} */ (
|
|
399
|
+
input.transcript_path && flagValue('--event') === 'PostToolUse' ? 'PostToolUse' : 'UserPromptSubmit'
|
|
400
|
+
);
|
|
401
|
+
emitJson(
|
|
402
|
+
mtimeMs
|
|
403
|
+
? '⟦scratch notes stale⟧ Your context has grown ~' + k + 'k tokens since you last wrote anything ' +
|
|
404
|
+
'in `' + relPath + '`. Top the notes up in passing if you still want them current (a goal ' +
|
|
405
|
+
'armed with post_compaction: true can link them). This is a background note, NOT a task and ' +
|
|
406
|
+
'NOT a reason to pause: do not stop working to service it, and never end a turn on it while ' +
|
|
407
|
+
'the human\'s instruction still has parts left.'
|
|
408
|
+
: '⟦scratch directory empty⟧ This session is ~' + k + 'k tokens deep and `' + relPath + '` is ' +
|
|
409
|
+
'empty. That is fine — notes are optional and writing them is not doing the work. The ' +
|
|
410
|
+
'directory is available if you want notes, and mcp__frizz__goal with ' +
|
|
411
|
+
'post_compaction: true can re-send a prompt linking them after a compaction. This is a ' +
|
|
412
|
+
'background note, NOT a task and NOT a reason to pause: keep going with what you were asked to do.',
|
|
413
|
+
event,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
process.exit(0);
|