pan-wizard 3.33.0 → 3.34.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 (197) hide show
  1. package/README.md +23 -20
  2. package/agents/pan-conductor.md +17 -16
  3. package/agents/pan-counterfactual.md +2 -2
  4. package/agents/pan-debugger.md +27 -8
  5. package/agents/pan-design-checker.md +1 -1
  6. package/agents/pan-designer.md +2 -2
  7. package/agents/pan-distiller.md +3 -3
  8. package/agents/pan-document_code.md +14 -14
  9. package/agents/pan-executor.md +12 -8
  10. package/agents/pan-experiment-runner.md +8 -7
  11. package/agents/pan-integration-checker.md +2 -2
  12. package/agents/pan-knowledge.md +1 -1
  13. package/agents/pan-meta-reviewer.md +3 -3
  14. package/agents/pan-optimizer.md +39 -43
  15. package/agents/pan-phase-researcher.md +8 -8
  16. package/agents/pan-plan-checker.md +30 -28
  17. package/agents/pan-planner.md +54 -43
  18. package/agents/pan-previewer.md +1 -1
  19. package/agents/pan-project-researcher.md +11 -11
  20. package/agents/pan-release.md +1 -1
  21. package/agents/pan-research-synthesizer.md +2 -2
  22. package/agents/pan-reviewer.md +24 -2
  23. package/agents/pan-roadmapper.md +15 -14
  24. package/agents/pan-verifier.md +32 -24
  25. package/bin/install-lib.cjs +58 -23
  26. package/bin/install.js +47 -1
  27. package/commands/pan/army.md +15 -11
  28. package/commands/pan/assumptions.md +2 -2
  29. package/commands/pan/audit-deployment.md +11 -12
  30. package/commands/pan/cost.md +8 -4
  31. package/commands/pan/debug.md +10 -7
  32. package/commands/pan/design-phase.md +3 -3
  33. package/commands/pan/discuss-phase.md +2 -0
  34. package/commands/pan/exec-phase.md +21 -37
  35. package/commands/pan/experiment.md +9 -8
  36. package/commands/pan/focus-auto.md +22 -20
  37. package/commands/pan/focus-design.md +7 -6
  38. package/commands/pan/focus-doc-audit.md +4 -3
  39. package/commands/pan/focus-drift-walking.md +4 -3
  40. package/commands/pan/focus-exec.md +6 -6
  41. package/commands/pan/focus-plan.md +12 -11
  42. package/commands/pan/focus-scan.md +5 -3
  43. package/commands/pan/focus-sync.md +3 -2
  44. package/commands/pan/git.md +8 -8
  45. package/commands/pan/health.md +2 -2
  46. package/commands/pan/hud.md +3 -2
  47. package/commands/pan/hygiene.md +14 -7
  48. package/commands/pan/knowledge.md +9 -9
  49. package/commands/pan/learn.md +10 -7
  50. package/commands/pan/links.md +4 -1
  51. package/commands/pan/map-codebase.md +1 -1
  52. package/commands/pan/mcp-bridge.md +18 -18
  53. package/commands/pan/milestone-done.md +6 -3
  54. package/commands/pan/milestone-new.md +1 -1
  55. package/commands/pan/new-project.md +10 -10
  56. package/commands/pan/optimize.md +16 -14
  57. package/commands/pan/patches.md +2 -6
  58. package/commands/pan/pause.md +1 -1
  59. package/commands/pan/phase-tests.md +2 -1
  60. package/commands/pan/plan-phase.md +9 -19
  61. package/commands/pan/preview.md +1 -1
  62. package/commands/pan/profile.md +2 -2
  63. package/commands/pan/quick.md +1 -1
  64. package/commands/pan/remove-phase.md +1 -1
  65. package/commands/pan/report.md +2 -2
  66. package/commands/pan/research-phase.md +12 -11
  67. package/commands/pan/resume.md +9 -11
  68. package/commands/pan/retro.md +5 -3
  69. package/commands/pan/review-deep.md +7 -7
  70. package/commands/pan/settings.md +1 -1
  71. package/commands/pan/verify-phase.md +0 -11
  72. package/commands/pan/what-if.md +2 -2
  73. package/hooks/dist/pan-context-monitor.js +343 -44
  74. package/hooks/dist/pan-cost-logger.js +30 -0
  75. package/hooks/dist/pan-statusline.js +68 -22
  76. package/hooks/dist/pan-stop-guard.js +7 -2
  77. package/package.json +1 -1
  78. package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
  79. package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
  80. package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
  81. package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
  82. package/pan-wizard-core/bin/lib/commands.cjs +33 -4
  83. package/pan-wizard-core/bin/lib/config.cjs +2 -1
  84. package/pan-wizard-core/bin/lib/constants.cjs +18 -6
  85. package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
  86. package/pan-wizard-core/bin/lib/core.cjs +53 -19
  87. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
  88. package/pan-wizard-core/bin/lib/cost.cjs +142 -13
  89. package/pan-wizard-core/bin/lib/distill.cjs +6 -3
  90. package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
  91. package/pan-wizard-core/bin/lib/findings.cjs +9 -2
  92. package/pan-wizard-core/bin/lib/focus.cjs +13 -4
  93. package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
  94. package/pan-wizard-core/bin/lib/hygiene.cjs +178 -17
  95. package/pan-wizard-core/bin/lib/init.cjs +7 -4
  96. package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
  97. package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
  98. package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
  99. package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
  100. package/pan-wizard-core/bin/lib/memory.cjs +508 -54
  101. package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
  102. package/pan-wizard-core/bin/lib/optimize.cjs +84 -16
  103. package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
  104. package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
  105. package/pan-wizard-core/bin/lib/phase.cjs +15 -13
  106. package/pan-wizard-core/bin/lib/preview.cjs +3 -1
  107. package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
  108. package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
  109. package/pan-wizard-core/bin/lib/runner.cjs +2 -1
  110. package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
  111. package/pan-wizard-core/bin/lib/state.cjs +43 -28
  112. package/pan-wizard-core/bin/lib/template.cjs +11 -4
  113. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
  114. package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
  115. package/pan-wizard-core/bin/lib/verify-scope.cjs +5 -2
  116. package/pan-wizard-core/bin/lib/verify.cjs +30 -28
  117. package/pan-wizard-core/bin/lib/whatif.cjs +5 -3
  118. package/pan-wizard-core/bin/pan-tools.cjs +101 -15
  119. package/pan-wizard-core/learnings/index.json +5 -5
  120. package/pan-wizard-core/opencode/pan-wizard.js +111 -0
  121. package/pan-wizard-core/references/checkpoints.md +3 -3
  122. package/pan-wizard-core/references/continuation-format.md +3 -3
  123. package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
  124. package/pan-wizard-core/references/design-methodology.md +1 -1
  125. package/pan-wizard-core/references/git-integration.md +4 -6
  126. package/pan-wizard-core/references/git-planning-commit.md +2 -2
  127. package/pan-wizard-core/references/guardrails.md +2 -2
  128. package/pan-wizard-core/references/handoff-decisions.md +3 -3
  129. package/pan-wizard-core/references/model-profile-resolution.md +5 -5
  130. package/pan-wizard-core/references/model-profiles.md +14 -9
  131. package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
  132. package/pan-wizard-core/references/planning-config.md +4 -4
  133. package/pan-wizard-core/references/tdd.md +10 -1
  134. package/pan-wizard-core/references/verification-patterns.md +5 -5
  135. package/pan-wizard-core/templates/codebase/architecture.md +2 -2
  136. package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
  137. package/pan-wizard-core/templates/codebase/concerns.md +3 -3
  138. package/pan-wizard-core/templates/codebase/integrations.md +6 -6
  139. package/pan-wizard-core/templates/codebase/relationships.md +2 -2
  140. package/pan-wizard-core/templates/codebase/stack.md +2 -2
  141. package/pan-wizard-core/templates/codebase/structure.md +4 -4
  142. package/pan-wizard-core/templates/codebase/testing.md +4 -4
  143. package/pan-wizard-core/templates/continue-here.md +1 -1
  144. package/pan-wizard-core/templates/debug-subagent-prompt.md +3 -2
  145. package/pan-wizard-core/templates/debug.md +15 -1
  146. package/pan-wizard-core/templates/milestone-archive.md +6 -6
  147. package/pan-wizard-core/templates/milestone.md +1 -1
  148. package/pan-wizard-core/templates/phase-prompt.md +29 -17
  149. package/pan-wizard-core/templates/planner-subagent-prompt.md +3 -5
  150. package/pan-wizard-core/templates/research-project/architecture.md +3 -3
  151. package/pan-wizard-core/templates/research-project/features.md +1 -1
  152. package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
  153. package/pan-wizard-core/templates/research-project/stack.md +3 -3
  154. package/pan-wizard-core/templates/research.md +5 -5
  155. package/pan-wizard-core/templates/retrospective.md +1 -1
  156. package/pan-wizard-core/templates/roadmap.md +15 -15
  157. package/pan-wizard-core/templates/summary-complex.md +1 -0
  158. package/pan-wizard-core/templates/summary-minimal.md +1 -0
  159. package/pan-wizard-core/templates/summary-standard.md +2 -1
  160. package/pan-wizard-core/templates/summary.md +18 -2
  161. package/pan-wizard-core/templates/uat.md +3 -3
  162. package/pan-wizard-core/templates/user-setup.md +8 -8
  163. package/pan-wizard-core/templates/verification-report.md +53 -8
  164. package/pan-wizard-core/workflows/add-phase.md +1 -1
  165. package/pan-wizard-core/workflows/assumptions.md +1 -1
  166. package/pan-wizard-core/workflows/discuss-phase.md +9 -8
  167. package/pan-wizard-core/workflows/exec-phase.md +26 -57
  168. package/pan-wizard-core/workflows/execute-plan.md +8 -7
  169. package/pan-wizard-core/workflows/health.md +13 -12
  170. package/pan-wizard-core/workflows/help.md +8 -9
  171. package/pan-wizard-core/workflows/learn.md +13 -11
  172. package/pan-wizard-core/workflows/map-codebase.md +10 -10
  173. package/pan-wizard-core/workflows/milestone-audit.md +10 -10
  174. package/pan-wizard-core/workflows/milestone-gaps.md +17 -24
  175. package/pan-wizard-core/workflows/milestone-new.md +7 -6
  176. package/pan-wizard-core/workflows/new-project.md +9 -9
  177. package/pan-wizard-core/workflows/optimize.md +11 -9
  178. package/pan-wizard-core/workflows/pause.md +6 -1
  179. package/pan-wizard-core/workflows/phase-tests.md +4 -4
  180. package/pan-wizard-core/workflows/plan-phase.md +31 -34
  181. package/pan-wizard-core/workflows/profile.md +2 -2
  182. package/pan-wizard-core/workflows/progress.md +5 -7
  183. package/pan-wizard-core/workflows/quick.md +5 -3
  184. package/pan-wizard-core/workflows/remove-phase.md +1 -1
  185. package/pan-wizard-core/workflows/research-phase.md +9 -5
  186. package/pan-wizard-core/workflows/resume-project.md +4 -6
  187. package/pan-wizard-core/workflows/retro.md +3 -1
  188. package/pan-wizard-core/workflows/settings.md +16 -24
  189. package/pan-wizard-core/workflows/todo-check.md +1 -1
  190. package/pan-wizard-core/workflows/transition.md +12 -12
  191. package/pan-wizard-core/workflows/update.md +25 -17
  192. package/pan-wizard-core/workflows/verify-phase.md +17 -15
  193. package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
  194. package/pan-zcode/README.md +7 -7
  195. package/pan-zcode/bin/install-zcode.js +1 -1
  196. package/scripts/build-plugin.js +3 -1
  197. package/scripts/generate-skills-docs.py +13 -4
@@ -1,18 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
  // Context Monitor - PostToolUse hook
3
- // Reads context metrics from the statusline bridge file and injects
4
- // warnings when context usage is high. This makes the AGENT aware of
5
- // context limits (the statusline only shows the user).
3
+ // Works out how full the agent's context is and injects a note when the host
4
+ // will compact it soon. This makes the AGENT aware of context limits (the
5
+ // statusline only shows the user).
6
6
  //
7
7
  // How it works:
8
8
  // 1. The statusline hook writes metrics to <os tmpdir>/pan-hooks-<uid>/claude-ctx-{session_id}.json
9
9
  // 2. This hook reads those metrics after each tool use
10
- // 3. When remaining context drops below thresholds, it injects a warning
11
- // as additionalContext, which the agent sees in its conversation
10
+ // 3. When the room left before the host compacts drops below thresholds, it
11
+ // injects a note as additionalContext, which the agent sees in its conversation
12
12
  //
13
- // Thresholds:
14
- // WARNING (remaining <= 35%): Agent should wrap up current task
15
- // CRITICAL (remaining <= 25%): Agent should stop immediately and save state
13
+ // Without the status line. `claude -p` never runs the status line (checked
14
+ // 2026-10-04), and a user may run their own, so the bridge is often missing. Then
15
+ // the hook reads the session transcript the payload names: the newest assistant
16
+ // record carries the usage of the API call that issued this tool call, and its
17
+ // input + cache-read + cache-write tokens are what the status line reports as
18
+ // `total_input_tokens` (code.claude.com/docs/en/statusline). The model id on that
19
+ // record gives the window. A tool call inside a subagent is measured against the
20
+ // subagent's own transcript, never the main session's: the payload carries
21
+ // `agent_id` and still names the MAIN transcript (Claude Code 2.1.288, captured
22
+ // 2026-10-04). Only a bounded tail of the file is read.
23
+ //
24
+ // What it measures (O7). Claude Code's percentages are against the full model
25
+ // window, but the host compacts earlier: about 33K short of the auto-compact window
26
+ // (CLAUDE_CODE_AUTO_COMPACT_WINDOW, then `autoCompactWindow` in settings, per model
27
+ // or for all, else the model window) — about 967K on a 1M model by default — or at
28
+ // CLAUDE_AUTOCOMPACT_PCT_OVERRIDE's share of the window when that is lower. With
29
+ // token counts, the room left is measured against that point; without them,
30
+ // against the model window.
31
+ //
32
+ // What it says (O7). No countdown: vendor guidance is that a visible remaining-
33
+ // context figure makes a model wrap up early and cut corners, and a note after a
34
+ // tool result that orders it to STOP reads like an injection. The note asks for a
35
+ // checkpoint in state.md at the next natural stopping point, says compaction is
36
+ // safe because PAN restores the state, and asks for no shortcuts.
37
+ //
38
+ // Thresholds (of the room before compaction):
39
+ // WARNING (left <= 35%): checkpoint at the next natural stopping point
40
+ // CRITICAL (left <= 25%): checkpoint before the next step
16
41
  //
17
42
  // Debounce: 5 tool uses between warnings to avoid spam
18
43
  // Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
@@ -92,23 +117,267 @@ function bridgeDir() {
92
117
  } catch { return null; }
93
118
  }
94
119
 
95
- const WARNING_THRESHOLD = 35; // remaining_percentage <= 35%
96
- const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25%
120
+ const WARNING_THRESHOLD = 35; // room left before compaction <= 35%
121
+ const CRITICAL_THRESHOLD = 25; // room left before compaction <= 25%
97
122
  const STALE_SECONDS = 60; // ignore metrics older than 60s
98
123
  const DEBOUNCE_CALLS = 5; // min tool uses between warnings
124
+ const MIN_COMPACT_WINDOW = 100000;
125
+ const MAX_COMPACT_WINDOW = 1000000;
126
+ // The host compacts this far short of its window, whatever the window: about 967K on
127
+ // a native 1M window (code.claude.com/docs/en/model-config), and on 2026-10-04 a 100K
128
+ // autoCompactWindow compacted after a call that measured 67,032 tokens and not after
129
+ // one that measured 54,371 (harness context-note-headless). A margin proportional to
130
+ // the window would have put that compaction at ~96.7K.
131
+ const COMPACT_MARGIN = 33000;
132
+ const STANDARD_WINDOW = 200000;
133
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024; // the first read from the end of the transcript
134
+ const TRANSCRIPT_MAX_BYTES = 4 * 1024 * 1024; // never read more than this of it
135
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; // session and agent ids name files
136
+
137
+ /**
138
+ * The token count at which the host compacts this session, or null when it does
139
+ * not compact: the compaction window less COMPACT_MARGIN, or the percentage
140
+ * override's share of the window when that is lower. Pure over its inputs.
141
+ * modelWindow — context_window_size from the statusline
142
+ * modelId — the model id, for `modelSettings.<id>.autoCompactWindow`
143
+ * env — the environment (CLAUDE_CODE_AUTO_COMPACT_WINDOW,
144
+ * CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, DISABLE_AUTO_COMPACT)
145
+ * settings — settings objects, highest precedence first (local, project, user)
146
+ */
147
+ function compactTrigger({ modelWindow, modelId, env = {}, settings = [] }) {
148
+ if (!Number.isFinite(modelWindow) || modelWindow <= 0) return null;
149
+ if (env.DISABLE_AUTO_COMPACT === '1') return null;
150
+ for (const s of settings) if (s && s.autoCompactEnabled === false) return null;
151
+ const clamp = (n) => Math.min(modelWindow, Math.max(MIN_COMPACT_WINDOW, Math.min(MAX_COMPACT_WINDOW, n)));
152
+ let window = null;
153
+ if (/^\d+$/.test(String(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || ''))) {
154
+ window = clamp(Number(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW));
155
+ } else {
156
+ for (const s of settings) {
157
+ if (!s || typeof s !== 'object') continue;
158
+ const perModel = modelId && s.modelSettings && s.modelSettings[modelId] ? s.modelSettings[modelId].autoCompactWindow : undefined;
159
+ const v = perModel !== undefined ? perModel : s.autoCompactWindow;
160
+ if (v === 'auto') break; // the tuned default, below
161
+ if (Number.isFinite(v)) { window = clamp(v); break; }
162
+ }
163
+ }
164
+ if (window == null) window = Math.min(modelWindow, MAX_COMPACT_WINDOW);
165
+ let trigger = window - COMPACT_MARGIN;
166
+ const pct = Number(env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE);
167
+ if (Number.isInteger(pct) && pct >= 1 && pct < 100) trigger = Math.min(trigger, Math.floor(window * pct / 100));
168
+ return trigger;
169
+ }
170
+
171
+ /**
172
+ * The room left before compaction, as a percentage, and what it was measured
173
+ * against. Falls back to the model window's remaining_percentage when the bridge
174
+ * carries no token counts or the host does not compact.
175
+ */
176
+ function roomLeft(metrics, opts = {}) {
177
+ const used = Number(metrics.total_input_tokens);
178
+ const trigger = Number.isFinite(used)
179
+ ? compactTrigger({ modelWindow: Number(metrics.context_window_size), modelId: metrics.model_id, env: opts.env || {}, settings: opts.settings || [] })
180
+ : null;
181
+ if (trigger) return { remaining: Math.max(0, Math.min(100, ((trigger - used) / trigger) * 100)), basis: 'compact-window' };
182
+ const r = Number(metrics.remaining_percentage);
183
+ return { remaining: r, basis: 'model-window' };
184
+ }
185
+
186
+ // ─── Without the status line: the transcript ─────────────────────────────────
187
+
188
+ /**
189
+ * Pure: the context of the newest API call in a transcript's lines, scanning back
190
+ * from the end. Returns `{ found: true, used, model }`, `{ found: false, compacted:
191
+ * true }` when a compaction boundary comes first (the context was just rebuilt and
192
+ * no call has measured it yet), or `{ found: false }` when these lines hold no
193
+ * assistant record, so the caller can read further back.
194
+ *
195
+ * `used` is input + cache-read + cache-write tokens, the status line's
196
+ * `total_input_tokens`. Output tokens are left out, as its `used_percentage` leaves
197
+ * them out. A record with no usage (a synthetic or error record) is skipped. In the
198
+ * main transcript a sidechain record (an older host's subagent turn) is skipped; in
199
+ * a subagent's own transcript every record is a sidechain one.
200
+ */
201
+ function lastContextFromLines(lines, { skipSidechain = true } = {}) {
202
+ for (let i = lines.length - 1; i >= 0; i--) {
203
+ const line = lines[i];
204
+ if (!line || (!line.includes('"assistant"') && !line.includes('"compact_boundary"'))) continue;
205
+ let e;
206
+ try { e = JSON.parse(line); } catch { continue; }
207
+ if (!e || typeof e !== 'object') continue;
208
+ if (e.type === 'system' && e.subtype === 'compact_boundary') return { found: false, compacted: true };
209
+ if (e.type !== 'assistant' || (skipSidechain && e.isSidechain === true)) continue;
210
+ const u = e.message && e.message.usage;
211
+ if (!u || typeof u !== 'object') continue;
212
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
213
+ const used = n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens);
214
+ if (used <= 0) continue;
215
+ return { found: true, used, model: typeof e.message.model === 'string' ? e.message.model : null };
216
+ }
217
+ return { found: false };
218
+ }
219
+
220
+ /**
221
+ * The last `bytes` of a file as text, without the partial line the cut starts in.
222
+ * Defensive only: in well-formed JSONL a line's tail never parses as an object (a
223
+ * nested object is always followed by its parent's closing brace), so keeping the
224
+ * fragment would change nothing; dropping it keeps a fragment from ever being read.
225
+ */
226
+ function readTail(file, bytes) {
227
+ // Read-only; the owner-only mode never applies to an open for reading, but keeps the
228
+ // call in the form PAN's temp-dir rule requires (the path may be under the temp dir).
229
+ const fd = fs.openSync(file, 'r', 0o600);
230
+ try {
231
+ const size = fs.fstatSync(fd).size;
232
+ const start = Math.max(0, size - bytes);
233
+ const buf = Buffer.alloc(size - start);
234
+ fs.readSync(fd, buf, 0, buf.length, start);
235
+ let text = buf.toString('utf8');
236
+ if (start > 0) {
237
+ const nl = text.indexOf('\n');
238
+ text = nl === -1 ? '' : text.slice(nl + 1);
239
+ }
240
+ return { text, whole: start === 0 };
241
+ } finally {
242
+ fs.closeSync(fd);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * The context of the newest API call in a transcript file, or null. Reads the
248
+ * tail, and further back (up to TRANSCRIPT_MAX_BYTES) only while a large tool
249
+ * result hides the last assistant record. Fails open: any error is null.
250
+ */
251
+ function contextFromTranscript(file, { skipSidechain = true, tailBytes = TRANSCRIPT_TAIL_BYTES, maxBytes = TRANSCRIPT_MAX_BYTES } = {}) {
252
+ if (typeof file !== 'string' || !file) return null;
253
+ let bytes = tailBytes;
254
+ for (;;) {
255
+ let tail;
256
+ try { tail = readTail(file, bytes); } catch { return null; }
257
+ const r = lastContextFromLines(tail.text.split('\n'), { skipSidechain });
258
+ if (r.found) return { used: r.used, model: r.model };
259
+ if (r.compacted || tail.whole || bytes >= maxBytes) return null;
260
+ bytes = Math.min(bytes * 4, maxBytes);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Pure: a Claude model id's family and version, or null. Accepts the API ids
266
+ * (`claude-opus-5-5`, `claude-haiku-4-5-20251001`, `claude-3-5-sonnet-20241022`)
267
+ * and marks the forms a third-party provider gives them (`us.anthropic.claude-…-v1:0`
268
+ * on Bedrock, `claude-…@date` on Google Cloud).
269
+ */
270
+ function parseModelId(id) {
271
+ if (typeof id !== 'string' || !id) return null;
272
+ const s = id.toLowerCase().trim().replace(/\[1m\]$/, '');
273
+ const thirdParty = /(^|\.)anthropic\.|@|-v\d+(:\d+)?$|^arn:/.test(s);
274
+ let m = s.match(/claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?!\d)/);
275
+ if (m) return { family: m[1], major: Number(m[2]), minor: m[3] === undefined ? 0 : Number(m[3]), thirdParty };
276
+ m = s.match(/claude-(\d+)(?:-(\d{1,2}))?-(opus|sonnet|haiku)/);
277
+ if (m) return { family: m[3], major: Number(m[1]), minor: m[2] === undefined ? 0 : Number(m[2]), thirdParty };
278
+ return null;
279
+ }
280
+
281
+ /**
282
+ * Pure: the context window of the model that wrote a transcript record, or null
283
+ * when it cannot be known — then the hook says nothing rather than guess. From
284
+ * code.claude.com/docs/en/model-config (read 2026-10-04):
285
+ * - On the Anthropic API, Fable 5.x, Sonnet 5 and later and Opus 4.7 and later
286
+ * run a native 1M window. On Bedrock, Google Cloud and Foundry some of them run
287
+ * 200K, so there the window is unknown.
288
+ * - Opus 4.6 and Sonnet 4.6 (and older) run 200K unless their `[1m]` variant is
289
+ * configured; Haiku runs 200K.
290
+ * - CLAUDE_CODE_DISABLE_1M_CONTEXT=1 holds every model to 200K.
291
+ * - A context already past 200K can only be in a 1M window.
292
+ */
293
+ function modelWindowFor(modelId, { env = {}, settings = [], used = 0 } = {}) {
294
+ if (used > STANDARD_WINDOW) return MAX_COMPACT_WINDOW;
295
+ if (env.CLAUDE_CODE_DISABLE_1M_CONTEXT === '1') return STANDARD_WINDOW;
296
+ const m = parseModelId(modelId);
297
+ if (!m) return null;
298
+ const native1m = m.family === 'fable'
299
+ || (m.family === 'sonnet' && m.major >= 5)
300
+ || (m.family === 'opus' && (m.major > 4 || (m.major === 4 && m.minor >= 7)));
301
+ if (native1m) {
302
+ const thirdParty = m.thirdParty || ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY']
303
+ .some((k) => env[k] === '1' || env[k] === 'true');
304
+ return thirdParty ? null : MAX_COMPACT_WINDOW;
305
+ }
306
+ if (m.family === 'haiku') return STANDARD_WINDOW;
307
+ const extended = [env.ANTHROPIC_MODEL, ...settings.map((s) => (s && typeof s === 'object' ? s.model : undefined))]
308
+ .some((v) => typeof v === 'string' && /\[1m\]\s*$/i.test(v));
309
+ return extended ? MAX_COMPACT_WINDOW : STANDARD_WINDOW;
310
+ }
311
+
312
+ /**
313
+ * Metrics shaped like the bridge's, from a transcript: the tokens in context, the
314
+ * model window and the model id. Null when the transcript, its usage or the window
315
+ * cannot be known.
316
+ */
317
+ function metricsFromTranscript(file, { env = {}, settings = [], skipSidechain = true, tailBytes, maxBytes } = {}) {
318
+ const ctx = contextFromTranscript(file, { skipSidechain, tailBytes, maxBytes });
319
+ if (!ctx) return null;
320
+ const window = modelWindowFor(ctx.model, { env, settings, used: ctx.used });
321
+ if (!window) return null;
322
+ return {
323
+ total_input_tokens: ctx.used,
324
+ context_window_size: window,
325
+ ...(ctx.model ? { model_id: ctx.model } : {}),
326
+ remaining_percentage: Math.max(0, 100 - (ctx.used / window) * 100),
327
+ source: 'transcript',
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Which transcript measures this tool call. A call inside a subagent carries
333
+ * `agent_id`; the payload's `transcript_path` is still the main session's, and the
334
+ * subagent's own file is `<dir>/<session>/subagents/agent-<id>.jsonl` (an
335
+ * `agent_transcript_path`, where a host sends one, wins). A subagent the Workflow
336
+ * tool spawned (PAN's native `/pan-*` workflow scripts) is written one level down,
337
+ * under the run that spawned it: `subagents/workflows/<run>/agent-<id>.jsonl`, found
338
+ * the way pan-cost-logger.js resolveAgentTranscript finds it. The directory is read
339
+ * only for that search. Null when the ids are not safe to put in a path.
340
+ */
341
+ function transcriptForPayload(data) {
342
+ if (!data || typeof data !== 'object') return null;
343
+ const agentId = data.agent_id;
344
+ if (agentId === undefined || agentId === null || agentId === '') {
345
+ return typeof data.transcript_path === 'string' && data.transcript_path ? { file: data.transcript_path, subagent: false } : null;
346
+ }
347
+ if (typeof agentId !== 'string' || !SAFE_ID.test(agentId)) return null;
348
+ if (typeof data.agent_transcript_path === 'string' && data.agent_transcript_path) return { file: data.agent_transcript_path, subagent: true };
349
+ if (typeof data.transcript_path !== 'string' || !data.transcript_path) return null;
350
+ if (typeof data.session_id !== 'string' || !SAFE_ID.test(data.session_id)) return null;
351
+ const base = path.dirname(data.transcript_path);
352
+ const subagentsDir = path.join(base, data.session_id, 'subagents');
353
+ const direct = path.join(subagentsDir, `agent-${agentId}.jsonl`);
354
+ if (!fs.existsSync(direct)) {
355
+ const resolvedBase = path.resolve(base);
356
+ let runs = [];
357
+ try { runs = fs.readdirSync(path.join(subagentsDir, 'workflows')); } catch { /* no workflow runs */ }
358
+ for (const run of runs) {
359
+ const nested = path.join(subagentsDir, 'workflows', run, `agent-${agentId}.jsonl`);
360
+ if (path.resolve(nested).startsWith(resolvedBase + path.sep) && fs.existsSync(nested)) return { file: nested, subagent: true };
361
+ }
362
+ }
363
+ return { file: direct, subagent: true };
364
+ }
99
365
 
100
366
  // Pure decision function (exported for tests).
101
367
  //
102
- // metrics — { remaining_percentage, used_pct?, timestamp? } from the bridge
368
+ // metrics — { remaining_percentage, total_input_tokens?, context_window_size?,
369
+ // model_id?, timestamp? } from the bridge, or the same shape from
370
+ // metricsFromTranscript (no timestamp: measured now)
103
371
  // warnState — { callsSinceWarn, lastLevel } from the prior warn file, or null
104
372
  // on the first warning (no file yet / corrupt file)
105
373
  // nowSeconds — current unix time in seconds
374
+ // opts — { env, settings } for the compaction point (see compactTrigger)
106
375
  //
107
376
  // Returns one of:
108
377
  // { action: 'exit' } — nothing to do
109
378
  // { action: 'debounce', warnState } — persist counter, no warn
110
- // { action: 'emit', level, warnState, message } — persist + emit warning
111
- function buildContextWarning(metrics, warnState, nowSeconds) {
379
+ // { action: 'emit', level, warnState, message, basis } — persist + emit the note
380
+ function buildContextWarning(metrics, warnState, nowSeconds, opts = {}) {
112
381
  if (!metrics || typeof metrics !== 'object') return { action: 'exit' };
113
382
 
114
383
  // Ignore stale metrics (statusline stopped updating — e.g. session ended).
@@ -116,7 +385,7 @@ function buildContextWarning(metrics, warnState, nowSeconds) {
116
385
  return { action: 'exit' };
117
386
  }
118
387
 
119
- const remaining = Number(metrics.remaining_percentage);
388
+ const { remaining, basis } = roomLeft(metrics, opts);
120
389
  if (!Number.isFinite(remaining)) return { action: 'exit' };
121
390
 
122
391
  // No warning needed
@@ -140,33 +409,41 @@ function buildContextWarning(metrics, warnState, nowSeconds) {
140
409
  };
141
410
  }
142
411
 
143
- // L39: report Usage and Remaining on the SAME scale so they are internally
144
- // consistent (sum to 100). The bridge's used_pct is the statusline's
145
- // 80%-rescaled figure (80% real usage displays as 100%); pairing it with the
146
- // raw remaining produced self-contradictory numbers like
147
- // "Usage at 94%. Remaining: 25%". Derive used from remaining instead.
148
- const rem = Math.round(remaining);
149
- const used = 100 - rem;
150
-
151
- let message;
152
- if (isCritical) {
153
- message = `CONTEXT MONITOR CRITICAL: Usage at ${used}%. Remaining: ${rem}%. ` +
154
- 'STOP new work immediately. Save state NOW and inform the user that context is nearly exhausted. ' +
155
- 'If using PAN, run /pan:pause to save execution state.';
156
- } else {
157
- message = `CONTEXT MONITOR WARNING: Usage at ${used}%. Remaining: ${rem}%. ` +
158
- 'Begin wrapping up current task. Do not start new complex work. ' +
159
- 'If using PAN, consider /pan:pause to save state.';
160
- }
412
+ // No figures (O7): a countdown makes a model wrap up early, and a percentage is
413
+ // the thing it would count down. The note names the checkpoint, says compaction
414
+ // is safe, and asks for no shortcuts.
415
+ const message = isCritical
416
+ ? 'PAN context note (from the context-monitor hook, not the user): the host will compact this session soon. ' +
417
+ 'Before your next step, make sure .planning/state.md records where you are — the phase, the plan and task, and what comes next; /pan:pause writes it. ' +
418
+ 'Then carry on with the current task as normal. After compaction PAN restores the planning state, so finish the task properly rather than quickly.'
419
+ : 'PAN context note (from the context-monitor hook, not the user): this session\'s context is filling up. ' +
420
+ 'At your next natural stopping point, such as a finished task or a commit, make sure .planning/state.md records where you are; /pan:pause writes it. ' +
421
+ 'Nothing needs to stop: the host compacts on its own and PAN restores the planning state afterwards, so keep working at full quality, with no shortcuts and no skipped verification.';
161
422
 
162
423
  return {
163
424
  action: 'emit',
164
425
  level: currentLevel,
165
426
  warnState: { callsSinceWarn: 0, lastLevel: currentLevel },
166
427
  message,
428
+ basis,
167
429
  };
168
430
  }
169
431
 
432
+ /** Claude Code's settings for a project, highest precedence first: local, project, user. */
433
+ function readClaudeSettings(projectDir, homeDir = os.homedir()) {
434
+ // Assembled, not a literal: the installer rewrites quoted .claude literals in hook
435
+ // copies, and these are Claude Code's own settings (only it writes the bridge).
436
+ const dir = ['.', 'claude'].join('');
437
+ const files = [];
438
+ if (typeof projectDir === 'string' && projectDir) files.push(path.join(projectDir, dir, 'settings.local.json'), path.join(projectDir, dir, 'settings.json'));
439
+ if (homeDir) files.push(path.join(homeDir, dir, 'settings.json'));
440
+ const out = [];
441
+ for (const f of files) {
442
+ try { out.push(JSON.parse(fs.readFileSync(f, 'utf8'))); } catch { /* absent or unreadable */ }
443
+ }
444
+ return out;
445
+ }
446
+
170
447
  function main() {
171
448
  let input = '';
172
449
  process.stdin.setEncoding('utf8');
@@ -177,27 +454,37 @@ function main() {
177
454
  if (deferToClaudeRegistration(data.cwd || process.cwd())) process.exit(0);
178
455
  const sessionId = data.session_id;
179
456
 
180
- if (!sessionId) {
457
+ if (typeof sessionId !== 'string' || !SAFE_ID.test(sessionId)) {
181
458
  process.exit(0);
182
459
  }
183
460
 
184
461
  const tmpDir = bridgeDir();
185
462
  if (!tmpDir) process.exit(0); // insecure/unavailable bridge dir — fail closed (M60)
186
- const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`);
463
+ const now = Math.floor(Date.now() / 1000);
464
+ const settings = readClaudeSettings(data.cwd || process.cwd());
465
+ const source = transcriptForPayload(data);
466
+ const subagent = !!(source && source.subagent) || (data.agent_id !== undefined && data.agent_id !== null && data.agent_id !== '');
187
467
 
188
- // Read metrics directly; absence (subagent/fresh session) or a corrupt
189
- // file just means "nothing to warn about" — exit silently. No
468
+ // The bridge is the main session's context (the status line renders the main
469
+ // thread), so only a main-thread call reads it, and only while it is fresh.
470
+ // Absence or a corrupt file falls through to the transcript. No
190
471
  // existsSync-then-read gap.
191
- let metrics;
192
- try {
193
- metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8'));
194
- } catch {
195
- process.exit(0);
472
+ let metrics = null;
473
+ if (!subagent) {
474
+ try {
475
+ const bridge = JSON.parse(fs.readFileSync(path.join(tmpDir, `claude-ctx-${sessionId}.json`), 'utf8'));
476
+ if (bridge && typeof bridge === 'object' && !(bridge.timestamp != null && (now - bridge.timestamp) > STALE_SECONDS)) metrics = bridge;
477
+ } catch { /* no bridge: no status line, or not PAN's */ }
196
478
  }
197
- const now = Math.floor(Date.now() / 1000);
479
+ if (!metrics && source) {
480
+ metrics = metricsFromTranscript(source.file, { env: process.env, settings, skipSidechain: !source.subagent });
481
+ }
482
+ if (!metrics) process.exit(0);
198
483
 
199
484
  // Load prior warn state (null when no file / corrupt → treated as first warn).
200
- const warnPath = path.join(tmpDir, `claude-ctx-${sessionId}-warned.json`);
485
+ // A subagent debounces on its own: its context is not the main session's.
486
+ const warnKey = source && source.subagent ? `${sessionId}-${data.agent_id}` : sessionId;
487
+ const warnPath = path.join(tmpDir, `claude-ctx-${warnKey}-warned.json`);
201
488
  let warnData = null;
202
489
  try {
203
490
  warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8'));
@@ -205,7 +492,10 @@ function main() {
205
492
  warnData = null;
206
493
  }
207
494
 
208
- const decision = buildContextWarning(metrics, warnData, now);
495
+ const decision = buildContextWarning(metrics, warnData, now, {
496
+ env: process.env,
497
+ settings: Number.isFinite(Number(metrics.total_input_tokens)) ? settings : [],
498
+ });
209
499
 
210
500
  if (decision.action === 'exit') {
211
501
  process.exit(0);
@@ -241,7 +531,16 @@ if (require.main === module) {
241
531
  module.exports = {
242
532
  deferToClaudeRegistration,
243
533
  buildContextWarning,
534
+ compactTrigger,
535
+ roomLeft,
536
+ readClaudeSettings,
244
537
  bridgeDir,
538
+ lastContextFromLines,
539
+ contextFromTranscript,
540
+ parseModelId,
541
+ modelWindowFor,
542
+ metricsFromTranscript,
543
+ transcriptForPayload,
245
544
  WARNING_THRESHOLD,
246
545
  CRITICAL_THRESHOLD,
247
546
  STALE_SECONDS,
@@ -655,6 +655,7 @@ function buildCostRecord(data, cwd) {
655
655
  let cacheWrite = 0;
656
656
  // { h1, m5 } when the source carried the cache-write lifetime split (M13).
657
657
  let ttlSplit = null;
658
+ let longPrompt = null;
658
659
  let durationMs = null;
659
660
  // token_source records WHICH path produced the counts: `agent-transcript` (the
660
661
  // subagent's own conversation file — see resolveAgentTranscript), `transcript`
@@ -717,6 +718,9 @@ function buildCostRecord(data, cwd) {
717
718
  if (typeof fromTranscript.cache_write_1h_tokens === 'number' && rawCw <= SLICE_MAX.cache_write) {
718
719
  ttlSplit = { h1: fromTranscript.cache_write_1h_tokens, m5: fromTranscript.cache_write_5m_tokens || 0 };
719
720
  }
721
+ // The long-prompt part of the slice, kept only when nothing was clamped (a clamped
722
+ // total no longer contains it).
723
+ if (fromTranscript.long_prompt && !clamped) longPrompt = fromTranscript.long_prompt;
720
724
  durationMs = durationFromSpan(fromTranscript.first_ts, fromTranscript.last_ts); // as measured — see SLICE_MAX
721
725
  if (!model) model = fromTranscript.model;
722
726
  if (fromTranscript.lineCount > since) {
@@ -809,6 +813,8 @@ function buildCostRecord(data, cwd) {
809
813
  cache_write_tokens: cacheWrite,
810
814
  // The lifetime split of cache_write_tokens, present only when measured (M13).
811
815
  ...(ttlSplit ? { cache_write_1h_tokens: ttlSplit.h1, cache_write_5m_tokens: ttlSplit.m5 } : {}),
816
+ // Tokens of the requests whose prompt was over LONG_PROMPT_THRESHOLD, when any were.
817
+ ...(longPrompt ? { long_prompt: longPrompt } : {}),
812
818
  cost_usd: null,
813
819
  duration_ms: durationMs,
814
820
  phase,
@@ -938,6 +944,7 @@ function readUsageFromTranscript(transcriptPath, sessionId, sinceLine = 0) {
938
944
  totals.cache_read_input_tokens += extractNumber(usage, 'cache_read_input_tokens');
939
945
  totals.cache_creation_input_tokens += extractNumber(usage, 'cache_creation_input_tokens');
940
946
  addCacheTtlSplit(totals, usage);
947
+ addLongPrompt(totals, usage);
941
948
  }
942
949
  totals.lineCount = seen;
943
950
  return totals;
@@ -958,6 +965,29 @@ function addCacheTtlSplit(totals, usage) {
958
965
  totals.cache_write_5m_tokens = (totals.cache_write_5m_tokens || 0) + extractNumber(cc, 'ephemeral_5m_input_tokens');
959
966
  }
960
967
 
968
+ // Haiku 5.5 bills a request whose prompt (input plus cache reads and writes) is over
969
+ // this many tokens at higher rates, request by request. A row sums many requests, so
970
+ // the tokens of those over it are kept apart as `long_prompt`, and the read side
971
+ // (lib/cost.cjs computeCost) prices them at the model's long rate. A copy of
972
+ // LONG_PROMPT_THRESHOLD in lib/cost.cjs: hooks require nothing from lib, and
973
+ // tests/cost-long-prompt.test.cjs pins the two equal.
974
+ const LONG_PROMPT_THRESHOLD = 100000;
975
+
976
+ /** Add one request's usage to `totals.long_prompt` when its prompt is over the threshold. */
977
+ function addLongPrompt(totals, usage) {
978
+ const inp = extractNumber(usage, 'input_tokens');
979
+ const cr = extractNumber(usage, 'cache_read_input_tokens');
980
+ const cw = extractNumber(usage, 'cache_creation_input_tokens');
981
+ if (inp + cr + cw <= LONG_PROMPT_THRESHOLD) return;
982
+ const lp = totals.long_prompt || (totals.long_prompt = { above: LONG_PROMPT_THRESHOLD, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, cache_write_1h_tokens: 0 });
983
+ lp.input_tokens += inp;
984
+ lp.output_tokens += extractNumber(usage, 'output_tokens');
985
+ lp.cache_read_tokens += cr;
986
+ lp.cache_write_tokens += cw;
987
+ const cc = usage && usage.cache_creation;
988
+ if (cc && typeof cc === 'object') lp.cache_write_1h_tokens += extractNumber(cc, 'ephemeral_1h_input_tokens');
989
+ }
990
+
961
991
  /**
962
992
  * Append record to .planning/metrics/tokens.jsonl. Silently succeeds
963
993
  * even if the file or directory can't be written — hook must not block.
@@ -30,6 +30,54 @@ function buildStatuslineOutput(data, deps) {
30
30
  const session = data.session_id || '';
31
31
  const remaining = data.context_window?.remaining_percentage;
32
32
 
33
+ // The per-user 0700 bridge directory, or null when it is not safe to write into.
34
+ // Mirrors bridgeDir() in pan-context-monitor.js (the reader).
35
+ let bridgeSubdirMemo;
36
+ const secureBridgeDir = () => {
37
+ if (bridgeSubdirMemo !== undefined) return bridgeSubdirMemo;
38
+ bridgeSubdirMemo = null;
39
+ const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
40
+ const sub = pathMod.join(tmpDir, `pan-hooks-${uid}`);
41
+ // Fail CLOSED if the per-user dir is pre-planted/symlinked/owned by someone
42
+ // else: mkdirSync{recursive} silently no-ops on an existing dir, so verify
43
+ // ownership+mode before writing (M60).
44
+ try {
45
+ fsMod.mkdirSync(sub, { recursive: true, mode: 0o700 });
46
+ const st = fsMod.lstatSync(sub);
47
+ // Symlink check is cross-platform; POSIX ownership/mode checks apply only
48
+ // where getuid exists (Windows fakes mode bits; gating on them there
49
+ // disabled the bridge entirely, N15).
50
+ let secure = !st.isSymbolicLink();
51
+ if (secure && typeof process.getuid === 'function') {
52
+ secure = st.uid === process.getuid() && (st.mode & 0o077) === 0;
53
+ }
54
+ if (secure) bridgeSubdirMemo = sub;
55
+ } catch { /* not safe */ }
56
+ return bridgeSubdirMemo;
57
+ };
58
+
59
+ // Usage limits (Claude Code subscriptions: `rate_limits.five_hour` / `seven_day`).
60
+ // Account-wide, so one file for every session; `pan-tools cost limits` and the
61
+ // focus-auto weekly stop read it. Written only when the host sent the block.
62
+ const limits = data.rate_limits && typeof data.rate_limits === 'object' ? data.rate_limits : null;
63
+ const limitWin = (w) => (w && Number.isFinite(Number(w.used_percentage))
64
+ ? { used_percentage: Number(w.used_percentage), ...(Number.isFinite(Number(w.resets_at)) ? { resets_at: Number(w.resets_at) } : {}) }
65
+ : null);
66
+ const fiveHour = limits ? limitWin(limits.five_hour) : null;
67
+ const sevenDay = limits ? limitWin(limits.seven_day) : null;
68
+ if ((fiveHour || sevenDay) && d.skipBridge !== true) {
69
+ try {
70
+ const sub = secureBridgeDir();
71
+ if (sub) {
72
+ fsMod.writeFileSync(pathMod.join(sub, 'claude-limits.json'), JSON.stringify({
73
+ ...(fiveHour ? { five_hour: fiveHour } : {}),
74
+ ...(sevenDay ? { seven_day: sevenDay } : {}),
75
+ timestamp: Math.floor(Date.now() / 1000),
76
+ }), { mode: 0o600 });
77
+ }
78
+ } catch { /* best-effort */ }
79
+ }
80
+
33
81
  // Context window bar — shows USED percentage scaled so 80% real = 100% shown.
34
82
  let ctx = '';
35
83
  if (remaining != null) {
@@ -39,34 +87,23 @@ function buildStatuslineOutput(data, deps) {
39
87
 
40
88
  if (session && d.skipBridge !== true) {
41
89
  try {
42
- // Write the bridge file into a per-user 0700 subdir so another user on
90
+ // The session bridge goes into the per-user 0700 subdir so another user on
43
91
  // a shared host can't symlink-attack the predictable session path.
44
- // Mirrors bridgeDir() in pan-context-monitor.js (the reader).
45
- const uid = (typeof process.getuid === 'function' ? process.getuid() : process.env.USERNAME || 'win');
46
- const bridgeSubdir = pathMod.join(tmpDir, `pan-hooks-${uid}`);
47
- // Fail CLOSED if the per-user dir is pre-planted/symlinked/owned by
48
- // someone else — mkdirSync{recursive} silently no-ops on an existing dir,
49
- // so verify ownership+mode before writing the session bridge (M60).
50
- let secure = false;
51
- try {
52
- fsMod.mkdirSync(bridgeSubdir, { recursive: true, mode: 0o700 });
53
- const st = fsMod.lstatSync(bridgeSubdir);
54
- // Symlink check is cross-platform; POSIX ownership/mode checks apply
55
- // only where getuid exists (Windows fakes mode bits — gating on them
56
- // there disabled the bridge entirely, N15).
57
- secure = !st.isSymbolicLink();
58
- if (secure && typeof process.getuid === 'function') {
59
- secure = st.uid === process.getuid() && (st.mode & 0o077) === 0;
60
- }
61
- } catch { secure = false; }
62
- if (secure) {
92
+ const bridgeSubdir = secureBridgeDir();
93
+ if (bridgeSubdir) {
63
94
  const bridgePath = pathMod.join(bridgeSubdir, `claude-ctx-${session}.json`);
95
+ // Token counts and the model id let the context monitor measure against
96
+ // the window the host compacts at, not the model window (O7).
97
+ const cw = data.context_window || {};
64
98
  fsMod.writeFileSync(bridgePath, JSON.stringify({
65
99
  session_id: session,
66
100
  remaining_percentage: remaining,
67
101
  used_pct: used,
102
+ ...(Number.isFinite(cw.total_input_tokens) ? { total_input_tokens: cw.total_input_tokens } : {}),
103
+ ...(Number.isFinite(cw.context_window_size) ? { context_window_size: cw.context_window_size } : {}),
104
+ ...(data.model && data.model.id ? { model_id: data.model.id } : {}),
68
105
  timestamp: Math.floor(Date.now() / 1000),
69
- }));
106
+ }), { mode: 0o600 });
70
107
  }
71
108
  } catch { /* bridge is best-effort */ }
72
109
  }
@@ -154,7 +191,16 @@ function buildStatuslineOutput(data, deps) {
154
191
  const head = `${panUpdate}\x1b[2m${model}\x1b[0m`;
155
192
  const taskSegment = task ? ` │ \x1b[1m${task}\x1b[0m` : '';
156
193
  const dirSegment = ` │ \x1b[2m${dirname}\x1b[0m`;
157
- return `${head}${taskSegment}${dirSegment}${ctx}${cacheBadge}${thinkingBadge}`;
194
+ // Usage-limit badge: the window closer to its cap, green below 70%, yellow below
195
+ // 90%, red from 90% (focus-auto's default weekly stop).
196
+ let limitBadge = '';
197
+ const shown = [fiveHour && ['5h', fiveHour.used_percentage], sevenDay && ['7d', sevenDay.used_percentage]].filter(Boolean);
198
+ if (shown.length) {
199
+ const worst = Math.max(...shown.map(([, v]) => v));
200
+ const color = worst >= 90 ? '' : worst >= 70 ? '' : '';
201
+ limitBadge = ` ${color}${shown.map(([k, v]) => `${k} ${Math.round(v)}%`).join(' · ')}`;
202
+ }
203
+ return `${head}${taskSegment}${dirSegment}${ctx}${cacheBadge}${thinkingBadge}${limitBadge}`;
158
204
  }
159
205
 
160
206
  // ─── Stdin driver ───────────────────────────────────────────────────────────