frayui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/dist/claude-agent-broker.js +21641 -0
  4. package/dist/codex-app-server-daemon.js +354 -0
  5. package/dist/dev-child.js +40319 -0
  6. package/dist/frayui.js +8738 -0
  7. package/package.json +46 -0
  8. package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
  9. package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
  10. package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
  11. package/runtime/cc/scripts/fray/config.mjs +982 -0
  12. package/runtime/cc/scripts/fray/decisions.mjs +97 -0
  13. package/runtime/cc/scripts/fray/index.mjs +699 -0
  14. package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
  15. package/runtime/cc/scripts/fray/notify.mjs +81 -0
  16. package/runtime/cc/scripts/fray/ownership.mjs +120 -0
  17. package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
  18. package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
  19. package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
  20. package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
  21. package/runtime/cc-worker/DECISIONS.md +923 -0
  22. package/runtime/cc-worker/LICENSE +21 -0
  23. package/runtime/cc-worker/agents/fable-high.md +8 -0
  24. package/runtime/cc-worker/agents/fable-low.md +8 -0
  25. package/runtime/cc-worker/agents/fable-max.md +8 -0
  26. package/runtime/cc-worker/agents/fable-medium.md +8 -0
  27. package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
  28. package/runtime/cc-worker/agents/haiku.md +7 -0
  29. package/runtime/cc-worker/agents/opus-high.md +8 -0
  30. package/runtime/cc-worker/agents/opus-low.md +8 -0
  31. package/runtime/cc-worker/agents/opus-max.md +8 -0
  32. package/runtime/cc-worker/agents/opus-medium.md +8 -0
  33. package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
  34. package/runtime/cc-worker/agents/sonnet-high.md +8 -0
  35. package/runtime/cc-worker/agents/sonnet-low.md +8 -0
  36. package/runtime/cc-worker/agents/sonnet-max.md +8 -0
  37. package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
  38. package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
  39. package/runtime/cc-worker/bin/fray +17 -0
  40. package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
  41. package/runtime/cc-worker/bin/fray-update +18 -0
  42. package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
  43. package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
  44. package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
  45. package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
  46. package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
  47. package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
  48. package/runtime/cc-worker/hooks/hooks.json +111 -0
  49. package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
  50. package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
  51. package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
  52. package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
  53. package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
  54. package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
  55. package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
  56. package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
  57. package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
  58. package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
  59. package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
  60. package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
  61. package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
  62. package/web-dist/apple-touch-icon.png +0 -0
  63. package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
  64. package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
  65. package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
  66. package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
  67. package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
  68. package/web-dist/assets/array-BifhSqXX.js +1 -0
  69. package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
  70. package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
  71. package/web-dist/assets/channel-C2eUWc74.js +1 -0
  72. package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  73. package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
  74. package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
  75. package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  76. package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  77. package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
  78. package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
  79. package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
  80. package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
  81. package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
  82. package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  83. package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  84. package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
  85. package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
  86. package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
  87. package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
  88. package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
  89. package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
  90. package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
  91. package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
  92. package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
  93. package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
  94. package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  95. package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
  96. package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  97. package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
  98. package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
  99. package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
  100. package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
  101. package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
  102. package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  103. package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
  104. package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
  105. package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
  106. package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
  107. package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
  108. package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
  109. package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
  110. package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
  111. package/web-dist/assets/dist-DoH_9pyS.js +1 -0
  112. package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
  113. package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
  114. package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
  115. package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
  116. package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
  117. package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
  118. package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
  119. package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
  120. package/web-dist/assets/index-BHzIN-tQ.js +357 -0
  121. package/web-dist/assets/index-Duiy4w7C.css +1 -0
  122. package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
  123. package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
  124. package/web-dist/assets/init-D6jRqBbL.js +1 -0
  125. package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
  126. package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
  127. package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
  128. package/web-dist/assets/katex-CddkPoXu.js +257 -0
  129. package/web-dist/assets/line-Ds4xvN3d.js +1 -0
  130. package/web-dist/assets/linear-z2V0wJk9.js +1 -0
  131. package/web-dist/assets/map-DsCK-0Cs.js +1 -0
  132. package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
  133. package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
  134. package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
  135. package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
  136. package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
  137. package/web-dist/assets/path-BWPyau1x.js +1 -0
  138. package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
  139. package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
  140. package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
  141. package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
  142. package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
  143. package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
  144. package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
  145. package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
  146. package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
  147. package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
  148. package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
  149. package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
  150. package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
  151. package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
  152. package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
  153. package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  154. package/web-dist/assets/src-C4XfhTaE.js +1 -0
  155. package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
  156. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
  157. package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
  158. package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
  159. package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
  160. package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
  161. package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
  162. package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
  163. package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
  164. package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
  165. package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
  166. package/web-dist/favicon-16.png +0 -0
  167. package/web-dist/favicon-32.png +0 -0
  168. package/web-dist/favicon.svg +78 -0
  169. package/web-dist/icon-192.png +0 -0
  170. package/web-dist/icon-512.png +0 -0
  171. package/web-dist/icon-maskable-512.png +0 -0
  172. package/web-dist/index.html +33 -0
  173. package/web-dist/manifest.webmanifest +16 -0
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreCompact hook (fray-worker) — steers WHAT SURVIVES a compaction. Run directly with node (zero
4
+ // deps, max Node compat), mirroring cc's hook idiom.
5
+ //
6
+ // WHY THIS EXISTS: compaction is the single largest source of context loss in a long worker session.
7
+ // A ~967k-token session collapses to a ~10-15k summary, and what gets dropped first is the HIGH-LEVEL
8
+ // approach — the plan, the alternatives rejected, the reasoning — leaving a worker that remembers
9
+ // mechanics but has lost the thread. `session-seed.mjs` already re-grounds AFTER the fact on
10
+ // SessionStart(compact); this hook is the other half, steering the summary itself BEFORE it is written.
11
+ //
12
+ // THE CHANNEL — and it is unusual, so do not "fix" it to JSON: unlike every other hook in this
13
+ // plugin, a PreCompact hook's PLAIN STDOUT becomes the compaction instructions. Claude Code collects
14
+ // the trimmed stdout of every succeeding, non-blocking PreCompact hook, joins them with a blank line,
15
+ // and splices the result into the summarization prompt as a literal `Additional Instructions:`
16
+ // section (verified live: cli 2.1.220, `FTe`/`Ysd`). Emitting `hookSpecificOutput` JSON here would
17
+ // send the summarizer a blob of JSON as its instructions. The matcher is the TRIGGER string
18
+ // (`auto` / `manual`), which is why hooks.json registers this twice.
19
+ //
20
+ // LENGTH IS ONLY STEERABLE BY AN EXPLICIT NUMBER — measured, so do not soften WORD_TARGET into prose.
21
+ // Single-variable differential, three `--fork-session` branches off one identical 345,898-token seed:
22
+ //
23
+ // no hook (control) postTokens 4,060
24
+ // qualitative "prefer a long summary" postTokens 4,429 (+9% — noise)
25
+ // explicit ">= 15,000 words" postTokens 20,339 (5.0x the control) <-- WORD_TARGET
26
+ // explicit ">= 35,000 words" postTokens 5,182 (WORSE, and slower + dearer)
27
+ //
28
+ // DO NOT RAISE WORD_TARGET without re-measuring: the response is NON-MONOTONIC. 35,000 words is past
29
+ // what the model will produce in one response and asking for it degraded compliance badly — a quarter
30
+ // the output of the 15,000 ask (2,174 words vs 9,829), at 11.2 min / $3.73 vs 8.6 min / $2.04. Three
31
+ // single samples, so treat the exact figures as indicative, but 15,000 was clearly the best of them.
32
+ //
33
+ // Adjectives do nothing; a hard number does the work. The COST is latency: the 15,000-word run
34
+ // produced no compaction result at all inside a 10-minute cap and only completed on a longer budget.
35
+ // That is acceptable here because fray workers run with `precomputeCompactionEnabled`, which arms
36
+ // summary generation in a background sidecar at ~80% of the window and swaps it in at the real
37
+ // threshold — so on an `auto` trigger the wait is largely hidden. A `manual` /compact pays it in
38
+ // front of the human.
39
+ //
40
+ // HARD CEILING: the summary is ONE model response, so it is bounded by max output tokens (64k for
41
+ // Opus 5 / Sonnet 5, ~48k words). A 200k-token summary is unreachable at any prompt.
42
+ //
43
+ // TONE MATTERS: an instruction that reads like injected prompt-hijacking gets REFUSED by the
44
+ // summarizer — measured: an early probe demanding a sentinel token produced a summary that explicitly
45
+ // declined to comply and called the instructions fake. Keep this legible as an ordinary editorial
46
+ // brief about what to retain.
47
+ //
48
+ // GATE: everything is gated on FRAY_UI_THREAD, so the plugin stays inert when loaded outside a
49
+ // fray-ui worker. Sub-agent contexts are skipped, matching session-seed.mjs — the scratchpad path
50
+ // below is only guaranteed correct for the top-level worker session.
51
+ import { readFileSync } from 'node:fs';
52
+ import { currentSessionId } from '../scripts/fray/config.mjs';
53
+
54
+ // See "LENGTH IS ONLY STEERABLE BY AN EXPLICIT NUMBER" above before changing this.
55
+ const WORD_TARGET = 15000;
56
+
57
+ /** @type {{ agent_id?: unknown, agentId?: unknown, trigger?: string, session_id?: string }} */
58
+ let input = {};
59
+ try {
60
+ input = JSON.parse(readFileSync(0, 'utf8'));
61
+ } catch {
62
+ /* no stdin / not JSON → input stays {} → proceed with the generic brief */
63
+ }
64
+ // Skip inside sub-agent contexts (they carry agent_id) — see GATE above.
65
+ if (input.agent_id ?? input.agentId) process.exit(0);
66
+
67
+ // WORKER GATE — inert unless this is a fray-ui worker session.
68
+ const thread = (process.env.FRAY_UI_THREAD ?? '').trim();
69
+ if (!thread) process.exit(0);
70
+
71
+ let sid = null;
72
+ try {
73
+ sid = currentSessionId(input.session_id);
74
+ } catch {
75
+ /* best-effort — fall back to the generic path shape below */
76
+ }
77
+ const scratch = sid
78
+ ? '.fray/threads/' + sid + '/scratch.md'
79
+ : '.fray/threads/<session-id>/scratch.md';
80
+
81
+ // Written as an editorial brief, not as a command block — see TONE MATTERS above.
82
+ const brief = [
83
+ 'This is a fray-ui worker session driving one engineering effort (`' + thread + '`). Three things ' +
84
+ 'matter more here than brevity does, because they are what actually gets lost in compaction:',
85
+ '',
86
+ '1. LENGTH. Write an EXHAUSTIVE summary, not a condensed one: target at least ' +
87
+ WORD_TARGET.toLocaleString('en-US') + ' words. There is ample room for it, so do not compress. ' +
88
+ 'A short summary is the failure mode here — omitting something load-bearing is far worse than ' +
89
+ 'including something redundant.',
90
+ '',
91
+ '2. THE HIGH-LEVEL APPROACH, AT HIGH FIDELITY. Preserve the shape of the work, not just its ' +
92
+ 'mechanics: what problem is being solved, the approach chosen, the approaches considered and ' +
93
+ 'REJECTED and why, and the reasoning that led there. Reproduce this in full rather than ' +
94
+ 'condensing it into a sentence — a summary that lists edits but loses the plan leaves the next ' +
95
+ 'turn unable to judge whether a step is still the right one. Include substantial verbatim ' +
96
+ 'excerpts of the code and output that matter rather than describing them from memory.',
97
+ '',
98
+ 'Carry forward, in as much detail as you can:',
99
+ '- The task list and its state — every item, marked done / in progress / not started / blocked.',
100
+ '- Decisions made and the rationale for each, especially any the human made, approved, or reversed.',
101
+ '- Constraints, conventions, and explicit human instructions or corrections — in the human\'s own ' +
102
+ 'wording where you can, since paraphrase is where intent gets lost.',
103
+ '- Paths of every file created, read, or modified, and what changed in each.',
104
+ '- Commands that matter (build / test / run / verification) and their OBSERVED results.',
105
+ '- What has been VERIFIED by actually running it versus what is merely believed to work. Keep that ' +
106
+ 'distinction explicit — it is routinely lost in compaction and its loss causes false claims of ' +
107
+ 'completion.',
108
+ '- Open questions, known failures, dead ends already ruled out, and anything left unfinished.',
109
+ '',
110
+ '3. RE-GROUNDING. End the summary with a final section headed exactly "Re-grounding before ' +
111
+ 'continuing:" that tells the next turn what to re-establish BEFORE it asserts anything or resumes ' +
112
+ 'editing. Make it concrete and specific to this session — not generic advice:',
113
+ '- Re-read the scratchpad at `' + scratch + '` first. It is the durable working state and it ' +
114
+ 'outlives this summary.',
115
+ '- Name the specific files to re-read before describing or changing them, rather than relying on ' +
116
+ 'remembered contents.',
117
+ '- Name any verification that was in flight and should be re-run rather than assumed.',
118
+ '- State the single next action to take.',
119
+ ].join('\n');
120
+
121
+ process.stdout.write(brief + '\n');
122
+ process.exit(0);
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // Optional rest-time reminder carried by the thread's canonical scratchpad. A worker registers one
4
+ // by writing `stop_hook:` in scratch.md's YAML front matter and deregisters it by removing that key.
5
+ // The hook passes the message back verbatim when the worker tries to stop. A persisted two-minute
6
+ // cooldown prevents a forgotten reminder from creating a tight stop/block loop.
7
+ import { readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { currentSessionId } from '../scripts/fray/config.mjs';
10
+
11
+ const COOLDOWN_MS = 2 * 60 * 1000;
12
+ const MAX_MESSAGE_CHARS = 8_000;
13
+
14
+ /** @param {string[]} argv @param {string} flag */
15
+ function flagValue(argv, flag) {
16
+ const hit = argv.find((arg) => arg.startsWith(flag + '='));
17
+ return hit ? hit.slice(flag.length + 1) : null;
18
+ }
19
+
20
+ /** @param {string} value */
21
+ function inlineYamlString(value) {
22
+ const trimmed = value.trim();
23
+ if (!trimmed) return '';
24
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
25
+ try {
26
+ const parsed = JSON.parse(trimmed);
27
+ return typeof parsed === 'string' ? parsed : '';
28
+ } catch {
29
+ return '';
30
+ }
31
+ }
32
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
33
+ return trimmed.slice(1, -1).replace(/''/g, "'");
34
+ }
35
+ return trimmed;
36
+ }
37
+
38
+ /**
39
+ * Reads only the reserved `stop_hook` key from top-of-file YAML front matter. The scratchpad body
40
+ * remains free-form Markdown; accepting the common literal/folded block forms keeps multi-line
41
+ * reminders readable without pulling a YAML runtime into a zero-dependency hook.
42
+ * @param {string} source
43
+ */
44
+ export function scratchpadStopMessage(source) {
45
+ const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
46
+ if (!frontmatter) return null;
47
+ const lines = frontmatter.split(/\r?\n/);
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const match = lines[i].match(/^stop_hook:\s*(.*?)\s*$/);
50
+ if (!match) continue;
51
+ const value = match[1];
52
+ if (value === '|' || value === '>-'
53
+ || value === '>' || value === '|-' || value === '|+' || value === '>+') {
54
+ const block = [];
55
+ for (let j = i + 1; j < lines.length; j++) {
56
+ const line = lines[j];
57
+ if (line.trim() && !/^\s/.test(line)) break;
58
+ block.push(line.replace(/^(?: {2}|\t)/, ''));
59
+ }
60
+ const text = value.startsWith('>')
61
+ ? block.map((line) => line.trim()).join(' ').trim()
62
+ : block.join('\n').trim();
63
+ return text ? text.slice(0, MAX_MESSAGE_CHARS) : null;
64
+ }
65
+ const text = inlineYamlString(value);
66
+ return text ? text.slice(0, MAX_MESSAGE_CHARS) : null;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * @param {unknown} input
73
+ * @param {{ projectDir: string, sessionId: string, now?: number }} context
74
+ */
75
+ export function evaluateScratchpadStop(input, context) {
76
+ if (!input || typeof input !== 'object' || !context.sessionId) return {};
77
+ const threadDir = join(context.projectDir, '.fray', 'threads', context.sessionId);
78
+ let message;
79
+ try {
80
+ message = scratchpadStopMessage(readFileSync(join(threadDir, 'scratch.md'), 'utf8'));
81
+ } catch {
82
+ return {};
83
+ }
84
+ if (!message) return {};
85
+
86
+ const now = context.now ?? Date.now();
87
+ const statePath = join(threadDir, '.stop-hook-state.json');
88
+ let lastFiredAt = 0;
89
+ try {
90
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
91
+ if (Number.isFinite(state?.lastFiredAt)) lastFiredAt = state.lastFiredAt;
92
+ } catch {
93
+ // A missing/corrupt state is an unfired registration.
94
+ }
95
+ if (now - lastFiredAt < COOLDOWN_MS) return {};
96
+
97
+ // Persist BEFORE blocking. If the state cannot be recorded, fail open rather than creating a stop
98
+ // loop whose cooldown can never advance.
99
+ const tempPath = `${statePath}.${process.pid}.tmp`;
100
+ try {
101
+ writeFileSync(tempPath, JSON.stringify({ lastFiredAt: now }) + '\n', { mode: 0o600 });
102
+ renameSync(tempPath, statePath);
103
+ } catch {
104
+ try {
105
+ rmSync(tempPath, { force: true });
106
+ } catch {
107
+ // best effort
108
+ }
109
+ return {};
110
+ }
111
+ return { decision: 'block', reason: message };
112
+ }
113
+
114
+ if (process.argv[1]?.endsWith('scratchpad-stop.mjs')) {
115
+ try {
116
+ const input = JSON.parse(readFileSync(0, 'utf8'));
117
+ const argv = process.argv.slice(2);
118
+ const explicitSession = flagValue(argv, '--session');
119
+ const sessionId = explicitSession || currentSessionId(input?.session_id);
120
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
121
+ process.stdout.write(JSON.stringify(evaluateScratchpadStop(input, { projectDir, sessionId })));
122
+ } catch {
123
+ process.stdout.write('{}');
124
+ }
125
+ }
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // SCRATCHPAD REINFORCEMENT hook (fray-worker) — keeps the ONE per-thread scratchpad
4
+ // (`.fray/threads/<sid>/scratch.md`) written and re-grounded across compaction. Run directly with
5
+ // node (zero deps, max Node compat), mirroring the other hooks in this plugin.
6
+ //
7
+ // WHY THIS EXISTS: compaction is the largest source of context loss in a long session, and the
8
+ // scratchpad is fray's answer to it — but the scratchpad only works if two things happen, and
9
+ // nothing was enforcing either. It has to be WRITTEN while the work is happening, and it has to be
10
+ // RE-READ after the context is gone. A worker that forgets the first has nothing to recover; one
11
+ // that forgets the second has a recovery it never opens.
12
+ //
13
+ // This hook closes both, and deliberately targets scratch.md rather than a second file of its own.
14
+ // An earlier revision shipped a separate `carryover.md` brief; it was redundant with the scratchpad
15
+ // and made the worker maintain two overlapping documents. ONE doc per thread is the rule — the
16
+ // scratchpad the dispatcher already provisions, already names in the system prompt, and shares with
17
+ // sub-agents under the merge-only contract (see agent-dispatch.mjs).
18
+ //
19
+ // CODEX CHILD EPILOGUE — native Codex sub-agents inherit the root conversation's system/user
20
+ // scratchpad instructions even with `fork_turns:"none"`. Sharing the thread pad is useful: a child
21
+ // can persist progress even if the root later compacts. But an undifferentiated "keep it current"
22
+ // mandate once made a child replace the whole document with its task notes, then DELETE that
23
+ // replacement as a misguided rollback after realizing it had clobbered the root's state. The
24
+ // `subagent-start` mode preserves collaborative writes while requiring merge-only, scoped edits.
25
+ //
26
+ // THE RE-READ SIDE — injection, not a reminder. On compaction/resume the head of scratch.md is
27
+ // spliced into the context window by the harness, before the model's first token, alongside a
28
+ // pointer to read the rest. A bare "remember to read your scratchpad" routes recovery through a
29
+ // decision the model can skip, which is exactly the failure being fixed; injecting the head
30
+ // guarantees a floor of orientation even if the pointer is ignored. The cap keeps the guarantee
31
+ // affordable — a scratchpad is unbounded working memory and the whole file is not owed to every
32
+ // session start.
33
+ //
34
+ // THE WRITE SIDE — a staleness nudge on two channels:
35
+ // UserPromptSubmit — the turn boundary.
36
+ // PostToolUse — MID-TURN, and this is the one that matters. A fray 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 and let
39
+ // it compact unpersisted. PostToolUse additionalContext was verified live
40
+ // against cli 2.1.220: a real session quoted a sentinel injected after a Bash
41
+ // call. Both channels share one state file, so the interval is global — moving
42
+ // to per-tool-call firing does NOT multiply the number of nudges.
43
+ //
44
+ // NO HOOK FIRES ON CONTEXT PRESSURE — measured, not assumed. Claude Code 2.1.220 exposes 31 hook
45
+ // events and not one of them signals an approaching context limit; no hook input carries a token
46
+ // count at all (the docs say plainly: poll the transcript yourself). So this computes the fill
47
+ // itself from the transcript's newest usage record — `input + cache_creation + cache_read` is the
48
+ // live context size — reading only the file's TAIL, since transcripts reach tens of megabytes.
49
+ //
50
+ // STALENESS IS GROWTH SINCE THE LAST WRITE, never an absolute threshold: the window size is not
51
+ // knowable from a hook (a real compaction in this project fired at preTokens 935,291 on a 1M-window
52
+ // session, while a 200k session compacts near 160k). Growth is window-independent and self-resetting.
53
+ //
54
+ // NOT A BLOCKING GATE. A Stop hook could refuse to let the worker rest until it writes, and that was
55
+ // tried and REMOVED on 2026-07-02 (maintainer's call): the block-until-file-edited nag forced even
56
+ // trivial workers into Read/Edit dances that render as noise in the chat UI. This nudges; it never
57
+ // blocks.
58
+ import { readFileSync, writeFileSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
59
+ import { join } from 'node:path';
60
+ import { currentSessionId } from '../scripts/fray/config.mjs';
61
+
62
+ const SCRATCH_FILE = 'scratch.md';
63
+
64
+ /** Hard cap on injected characters. The scratchpad is unbounded working memory, so this bounds what
65
+ * a session start is charged; past the cap we inject the HEAD (a scratchpad's orientation lives at
66
+ * the top) and say plainly that it was clipped, pointing at the file for the rest. */
67
+ const MAX_INJECT_CHARS = intFromEnv('FRAY_SCRATCHPAD_MAX_CHARS', 12000);
68
+
69
+ /** Context-token growth since the last scratchpad write that marks it stale. 60k is ~a third of a
70
+ * 200k window and ~6% of a 1M one: frequent enough that the pad is never many turns behind, rare
71
+ * enough not to be chatter. Also the first-write trigger — an untouched template counts as
72
+ * unwritten, so the baseline is zero and the first nudge lands once a session has accumulated 60k
73
+ * tokens actually worth persisting. */
74
+ const STALE_TOKENS = intFromEnv('FRAY_SCRATCHPAD_STALE_TOKENS', 60000);
75
+
76
+ /** @param {string} name @param {number} fallback */
77
+ function intFromEnv(name, fallback) {
78
+ const n = parseInt(String(process.env[name] ?? ''), 10);
79
+ return Number.isFinite(n) && n > 0 ? n : fallback;
80
+ }
81
+
82
+ const argv = process.argv.slice(2);
83
+ /** @param {string} flag */
84
+ const flagValue = (flag) => {
85
+ const hit = argv.find((a) => a.startsWith(flag + '='));
86
+ return hit ? hit.slice(flag.length + 1) : null;
87
+ };
88
+ const mode = flagValue('--mode') ?? 'session-start';
89
+ const via = flagValue('--via') ?? 'plugin';
90
+
91
+ // ALWAYS ON — deliberately not settings-gated. The scratchpad is the CANONICAL document for a thread,
92
+ // so re-grounding on it after a compaction is not an opinion a project opts into; it is what makes the
93
+ // pad worth writing at all. An earlier revision put this behind an opt-in setting that defaulted OFF,
94
+ // which meant the default worker got nothing back after a compaction — the exact failure the pad
95
+ // exists to prevent (maintainer's correction: the thing that should be opt-in is the FORK-based
96
+ // auto-updating, not the re-grounding).
97
+ //
98
+ // The escape hatch is an env var, not a setting, because it is for a one-off ("this session is doing
99
+ // something where the injection is in the way"), not a project posture. Anything affirmative-looking
100
+ // is ignored: only an explicit off value disables.
101
+ if (/^(off|0|false|no|disabled)$/i.test((process.env.FRAY_SCRATCHPAD_HOOK ?? '').trim())) process.exit(0);
102
+
103
+ // The repo-local registration defers to the plugin one for fray workers (see --via, and the
104
+ // registration note in DECISIONS.md) so a fray worker never injects twice.
105
+ if (via === 'project' && (process.env.FRAY_UI_THREAD ?? '').trim()) process.exit(0);
106
+
107
+ /** @type {{ agent_id?: unknown, agentId?: unknown, source?: string, trigger?: string, session_id?: string, transcript_path?: string }} */
108
+ let input = {};
109
+ try {
110
+ input = JSON.parse(readFileSync(0, 'utf8'));
111
+ } catch {
112
+ /* no stdin / not JSON → fall back to env for the session id */
113
+ }
114
+ const childId = input.agent_id ?? input.agentId;
115
+ // Sub-agent contexts are silent on every reinforcement mode. The child-only epilogue is the one
116
+ // exception: it constrains the undifferentiated scratchpad instruction the child otherwise inherits.
117
+ if (childId && mode !== 'subagent-start') process.exit(0);
118
+
119
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
120
+
121
+ // WHICH session keys the pad. On Claude the hook's `session_id` IS fray's thread session id, so the
122
+ // derived path is correct. On CODEX it is NOT: codex reports its own rollout session id (measured —
123
+ // e.g. `019fb427-93aa-…`, with transcript_path pointing into ~/.codex/sessions), which has nothing to
124
+ // do with `.fray/threads/<fray sessionId>/scratch.md`. Deriving the path there would silently address
125
+ // a pad that does not exist and the worker would look unreinforced for a reason nobody could see. So
126
+ // fray bakes `--session=<fray sessionId>` into the codex hook command, and an explicit value always
127
+ // wins over the reported one.
128
+ const explicitSession = flagValue('--session');
129
+ let sid = null;
130
+ try {
131
+ sid = explicitSession || currentSessionId(input.session_id);
132
+ } catch {
133
+ /* best-effort */
134
+ }
135
+ // Without a session id there is no key, and an unkeyed pad would bleed between sessions.
136
+ if (!sid) process.exit(0);
137
+
138
+ const threadDir = join(projectDir, '.fray', 'threads', sid);
139
+ const relPath = '.fray/threads/' + sid + '/' + SCRATCH_FILE;
140
+ const absPath = join(threadDir, SCRATCH_FILE);
141
+
142
+ // ── mode: subagent-start (Codex child-only merge epilogue) ───────────────────────────────────────
143
+ if (mode === 'subagent-start') {
144
+ process.stdout.write(JSON.stringify({
145
+ hookSpecificOutput: {
146
+ hookEventName: 'SubagentStart',
147
+ additionalContext:
148
+ '⟦shared scratchpad — merge only⟧ `' + relPath + '` is the canonical document shared with ' +
149
+ 'the top-level `/root` worker. Update your own task progress in it as you work rather than ' +
150
+ 'leaving the root as its sole writer. Before every edit, re-read the current file, then ' +
151
+ 'patch only your scoped task/progress entry while ' +
152
+ 'preserving every other agent’s content. This exact file is Fray coordination state, not a ' +
153
+ 'project deliverable or source edit: a scoped merge is an explicit exception to delegated ' +
154
+ 'phrases such as “write only <path>” or “do not modify the repo”; never classify that merge ' +
155
+ 'as unauthorized or roll it back. Never delete, truncate, reinitialize, move, or replace the ' +
156
+ 'whole file — not even to “clean up” or undo your own mistaken change. Other project files, ' +
157
+ 'including files at the repository root, remain governed by your delegated authority; their ' +
158
+ 'location alone neither permits nor forbids editing. If the file is absent or you cannot ' +
159
+ 'merge safely, return your state to the parent without writing.',
160
+ },
161
+ }));
162
+ process.exit(0);
163
+ }
164
+
165
+ // Ensure the directory exists so a first Write lands. fray's dispatcher already provisions this for
166
+ // a real thread; this only covers a session that started outside a dispatch.
167
+ try {
168
+ mkdirSync(threadDir, { recursive: true });
169
+ } catch {
170
+ /* a read-only or racing FS just means the agent's Write creates it instead */
171
+ }
172
+
173
+ /** Raw scratchpad text, or null when absent/empty/unreadable. */
174
+ function readPad() {
175
+ try {
176
+ const raw = readFileSync(absPath, 'utf8');
177
+ return raw.trim() ? raw : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ /** Characters of SUBSTANTIVE content — the pad minus its provisioned skeleton.
184
+ * fray writes scratch.md up front with an H1, a one-line orientation, section headings and an empty
185
+ * task box, so unlike a file that simply does not exist, "present" no longer means "written". This
186
+ * strips exactly those skeleton shapes and measures what is left. A heuristic on purpose: it only
187
+ * decides whether to NUDGE, so a wrong call costs one redundant reminder, never correctness.
188
+ * @param {string|null} text */
189
+ function substanceLength(text) {
190
+ if (!text) return 0;
191
+ return text
192
+ .split('\n')
193
+ .filter((line) => {
194
+ const t = line.trim();
195
+ if (!t) return false;
196
+ if (t.startsWith('#')) return false; // headings
197
+ if (/^[-*]\s*\[\s*\]\s*$/.test(t)) return false; // an empty task box
198
+ // The visible legend/collaboration guide provisioned in every new pad. They teach the shared
199
+ // editing contract but are not evidence that the worker has recorded any task state yet.
200
+ if (/^>\s*(?:Status legend:|Collaboration:)/.test(t)) return false;
201
+ // The provisioned orientation line. Matched on the CONCEPT rather than a leading word, because
202
+ // the wording has changed once already and pads written under the old shape are still on disk —
203
+ // anchoring on a prefix silently reclassified a template as "written", which made an empty pad
204
+ // skip its re-grounding and made the summarizer swallow a skeleton.
205
+ if (/compaction-survival mechanism|compaction-proof working memory/.test(t)) return false;
206
+ return true;
207
+ })
208
+ .join('')
209
+ .trim().length;
210
+ }
211
+
212
+ /** @param {string} text */
213
+ function capped(text) {
214
+ if (text.length <= MAX_INJECT_CHARS) return text;
215
+ return (
216
+ text.slice(0, MAX_INJECT_CHARS) +
217
+ '\n\n[…clipped at ' + MAX_INJECT_CHARS.toLocaleString('en-US') + ' characters — read `' + relPath +
218
+ '` for the rest.]'
219
+ );
220
+ }
221
+
222
+ /** @param {string} additionalContext @param {'SessionStart'|'UserPromptSubmit'|'PostToolUse'} hookEventName */
223
+ function emitJson(additionalContext, hookEventName) {
224
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } }));
225
+ process.exit(0);
226
+ }
227
+
228
+ // ── mode: session-start ──────────────────────────────────────────────────────────────────────────
229
+ if (mode === 'session-start') {
230
+ const pad = readPad();
231
+ const written = substanceLength(pad) > 0;
232
+ const parts = [];
233
+
234
+ // The sources where the deep model of the work is actually GONE. On these the worker is ALWAYS
235
+ // re-grounded on its scratchpad — whether or not there is anything in it yet, because "your pad is
236
+ // empty and you just lost your context" is itself the most urgent thing the next turn can be told.
237
+ const lostContext = input.source === 'compact' || input.source === 'resume' || input.source === 'clear'
238
+
239
+ if (lostContext && written) {
240
+ // Inject the head AND point at the file: the injection is the floor (it cannot be skipped), the
241
+ // pointer is the ceiling (the pad may be longer than the cap, and it is the canonical doc).
242
+ const lead =
243
+ input.source === 'compact'
244
+ ? '⟦scratchpad — reground here⟧ Context was just compacted. Your scratchpad `' + relPath +
245
+ '` is the CANONICAL record of this thread and the head of it follows. RE-GROUND ON IT BEFORE ' +
246
+ 'DOING ANYTHING ELSE: re-read the full file, treat it as authoritative over anything the ' +
247
+ 'summary implies, and re-read only the code you are about to describe or change.'
248
+ : '⟦scratchpad — reground here⟧ This session resumed and lost its working context. Your ' +
249
+ 'scratchpad `' + relPath + '` is the CANONICAL record of this thread and the head of it ' +
250
+ 'follows. Re-read the full file before acting.';
251
+ parts.push(lead + '\n\n' + capped(/** @type {string} */ (pad)) + '\n\n⟦end scratchpad⟧');
252
+ } else if (lostContext) {
253
+ // Context is gone and there is nothing to restore. Say plainly that the exact pad is empty and
254
+ // constrain reconstruction to the compact summary + named handoffs. Searching neighbouring
255
+ // thread pads is both expensive and unsafe: they belong to unrelated workers.
256
+ parts.push(
257
+ '⟦scratchpad — reground here⟧ Context was just compacted or resumed. Your scratchpad `' +
258
+ relPath + '` is the CANONICAL record of this thread, but it is absent or has nothing ' +
259
+ 'substantive in it. That exact path is authoritative: do not search other ' +
260
+ '`.fray/threads/*/scratch.md` files 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, then WRITE this exact pad: the problem, the approach ' +
263
+ 'and the approaches you rejected, the decisions the human made, what is verified versus merely ' +
264
+ 'believed, and the next action.',
265
+ );
266
+ } else {
267
+ // A fresh start has lost nothing — teach the contract so the pad gets written in the first place.
268
+ parts.push(
269
+ '⟦scratchpad⟧ `' + relPath + '` is the CANONICAL document for this thread and your ONE durable ' +
270
+ 'working doc: its head is injected back into your context automatically whenever the context ' +
271
+ 'is lost, so it is the only thing guaranteed to survive a compaction. Keep it current as you ' +
272
+ 'work — the problem, the approach and the approaches you REJECTED and why, decisions the human ' +
273
+ 'made or reversed, what is VERIFIED by running it versus merely believed, and the next action.',
274
+ );
275
+ }
276
+ emitJson(parts.join('\n\n'), 'SessionStart');
277
+ }
278
+
279
+ // ── mode: precompact ─────────────────────────────────────────────────────────────────────────────
280
+ // PLAIN STDOUT — joined with the other PreCompact hooks' stdout into the summarizer's
281
+ // `Additional Instructions:`. Worded as an ordinary editorial note: precompact-instructions.mjs
282
+ // records that a summarizer REFUSES instructions that read like prompt-hijacking.
283
+ if (mode === 'precompact') {
284
+ const pad = readPad();
285
+ if (substanceLength(pad) === 0) process.exit(0);
286
+ process.stdout.write(
287
+ 'The worker keeps a running scratchpad of this effort at `' + relPath + '`, written by hand as ' +
288
+ 'the work progressed. Its current head is reproduced below. Treat it as the authoritative ' +
289
+ 'account of the problem, the chosen approach, and the decisions behind them, and make sure the ' +
290
+ 'summary preserves its substance — where it disagrees with your reading of the transcript, ' +
291
+ 'prefer it.\n\n' +
292
+ capped(/** @type {string} */ (pad)) + '\n',
293
+ );
294
+ process.exit(0);
295
+ }
296
+
297
+ // ── nudge modes (UserPromptSubmit + PostToolUse) ─────────────────────────────────────────────────
298
+ // Everything below answers one question: has the context moved on since the pad was last written?
299
+
300
+ /** Live context fill in tokens from the transcript's newest usage record, or null.
301
+ * Reads only the TAIL — transcripts reach tens of megabytes, and on PostToolUse this runs after
302
+ * every single tool call. Scanning backwards means the one line the tail read may have cut in half
303
+ * is reached last, and its parse failure is simply skipped.
304
+ * @param {string} path */
305
+ function contextTokens(path) {
306
+ let fd = null;
307
+ try {
308
+ const size = statSync(path).size;
309
+ const want = Math.min(size, 128 * 1024);
310
+ const buf = Buffer.alloc(want);
311
+ fd = openSync(path, 'r');
312
+ readSync(fd, buf, 0, want, size - want);
313
+ const lines = buf.toString('utf8').split('\n');
314
+ for (let i = lines.length - 1; i >= 0; i--) {
315
+ const line = lines[i].trim();
316
+ if (!line) continue;
317
+ let rec;
318
+ try {
319
+ rec = JSON.parse(line);
320
+ } catch {
321
+ continue;
322
+ }
323
+ const u = rec?.message?.usage;
324
+ if (!u) continue;
325
+ const n =
326
+ (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
327
+ if (Number.isFinite(n) && n > 0) return n;
328
+ }
329
+ return null;
330
+ } catch {
331
+ return null;
332
+ } finally {
333
+ if (fd !== null) {
334
+ try {
335
+ closeSync(fd);
336
+ } catch {
337
+ /* ignore */
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ const statePath = join(threadDir, '.scratchpad-state.json');
344
+ /** @returns {{ mtimeMs?: number, tokensAtWrite?: number, tokensAtNudge?: number }} */
345
+ function readState() {
346
+ try {
347
+ const s = JSON.parse(readFileSync(statePath, 'utf8'));
348
+ return s && typeof s === 'object' ? s : {};
349
+ } catch {
350
+ return {};
351
+ }
352
+ }
353
+ /** @param {{ mtimeMs?: number, tokensAtWrite?: number, tokensAtNudge?: number }} s */
354
+ function writeState(s) {
355
+ try {
356
+ writeFileSync(statePath, JSON.stringify(s) + '\n');
357
+ } catch {
358
+ /* best-effort — a lost state file costs at most one extra nudge */
359
+ }
360
+ }
361
+
362
+ if (mode === 'nudge') {
363
+ const transcript = input.transcript_path;
364
+ const tokens = transcript ? contextTokens(transcript) : null;
365
+ // No readable usage yet, or an unparseable transcript → say nothing. The nudge is an optimization;
366
+ // silence is always safe.
367
+ if (!tokens) process.exit(0);
368
+
369
+ const pad = readPad();
370
+ const written = substanceLength(pad) > 0;
371
+
372
+ let mtimeMs = 0;
373
+ try {
374
+ mtimeMs = written ? statSync(absPath).mtimeMs : 0;
375
+ } catch {
376
+ mtimeMs = 0;
377
+ }
378
+
379
+ let state = readState();
380
+ // A changed mtime means the pad was just written — rebase the baseline to NOW and go quiet. This is
381
+ // also the first-ever observation, and it is why a fresh write buys a full interval of silence. It
382
+ // fires for a human's hand-edit exactly as for the agent's Write: both are just an mtime change.
383
+ if (state.mtimeMs !== mtimeMs) {
384
+ state = { mtimeMs, tokensAtWrite: tokens, tokensAtNudge: 0 };
385
+ writeState(state);
386
+ }
387
+
388
+ // An unwritten pad (absent, or still the provisioned skeleton) measures growth from ZERO: the whole
389
+ // session is unpersisted, so the clock starts at the beginning, not at whenever this first looked.
390
+ const baseline = mtimeMs ? (state.tokensAtWrite ?? tokens) : 0;
391
+ const grown = tokens - baseline;
392
+ if (grown < STALE_TOKENS) process.exit(0);
393
+ // Space repeat nudges by the same interval. Both channels share this state, so firing on every tool
394
+ // call does not multiply reminders — it only makes the existing budget land sooner and mid-turn.
395
+ if (state.tokensAtNudge && tokens - state.tokensAtNudge < STALE_TOKENS) process.exit(0);
396
+
397
+ writeState({ ...state, tokensAtNudge: tokens });
398
+
399
+ const k = Math.round(grown / 1000);
400
+ const event = /** @type {'UserPromptSubmit'|'PostToolUse'} */ (
401
+ input.transcript_path && flagValue('--event') === 'PostToolUse' ? 'PostToolUse' : 'UserPromptSubmit'
402
+ );
403
+ emitJson(
404
+ mtimeMs
405
+ ? '⟦scratchpad stale⟧ Your context has grown ~' + k + 'k tokens since you last wrote `' +
406
+ relPath + '`. Bring it up to date now, before a compaction forces the issue — the problem, ' +
407
+ 'the approach and what you rejected, the human\'s decisions, what is verified versus ' +
408
+ 'believed, and the next action. Its head is injected back automatically after a compaction, ' +
409
+ 'so it is the one thing you are guaranteed to still have.'
410
+ : '⟦scratchpad empty⟧ This session is ~' + k + 'k tokens deep and `' + relPath + '` still has ' +
411
+ 'nothing substantive in it. Write it now: the problem, the approach and the approaches you ' +
412
+ 'rejected, the human\'s decisions, what is verified versus merely believed, and the next ' +
413
+ 'action. Its head is injected back automatically after a compaction, so it is what survives ' +
414
+ 'when the rest of this context does not.',
415
+ event,
416
+ );
417
+ }
418
+
419
+ process.exit(0);