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.
- package/README.md +23 -20
- package/agents/pan-conductor.md +17 -16
- package/agents/pan-counterfactual.md +2 -2
- package/agents/pan-debugger.md +27 -8
- package/agents/pan-design-checker.md +1 -1
- package/agents/pan-designer.md +2 -2
- package/agents/pan-distiller.md +3 -3
- package/agents/pan-document_code.md +14 -14
- package/agents/pan-executor.md +12 -8
- package/agents/pan-experiment-runner.md +8 -7
- package/agents/pan-integration-checker.md +2 -2
- package/agents/pan-knowledge.md +1 -1
- package/agents/pan-meta-reviewer.md +3 -3
- package/agents/pan-optimizer.md +39 -43
- package/agents/pan-phase-researcher.md +8 -8
- package/agents/pan-plan-checker.md +30 -28
- package/agents/pan-planner.md +54 -43
- package/agents/pan-previewer.md +1 -1
- package/agents/pan-project-researcher.md +11 -11
- package/agents/pan-release.md +1 -1
- package/agents/pan-research-synthesizer.md +2 -2
- package/agents/pan-reviewer.md +24 -2
- package/agents/pan-roadmapper.md +15 -14
- package/agents/pan-verifier.md +32 -24
- package/bin/install-lib.cjs +58 -23
- package/bin/install.js +47 -1
- package/commands/pan/army.md +15 -11
- package/commands/pan/assumptions.md +2 -2
- package/commands/pan/audit-deployment.md +11 -12
- package/commands/pan/cost.md +8 -4
- package/commands/pan/debug.md +10 -7
- package/commands/pan/design-phase.md +3 -3
- package/commands/pan/discuss-phase.md +2 -0
- package/commands/pan/exec-phase.md +21 -37
- package/commands/pan/experiment.md +9 -8
- package/commands/pan/focus-auto.md +22 -20
- package/commands/pan/focus-design.md +7 -6
- package/commands/pan/focus-doc-audit.md +4 -3
- package/commands/pan/focus-drift-walking.md +4 -3
- package/commands/pan/focus-exec.md +6 -6
- package/commands/pan/focus-plan.md +12 -11
- package/commands/pan/focus-scan.md +5 -3
- package/commands/pan/focus-sync.md +3 -2
- package/commands/pan/git.md +8 -8
- package/commands/pan/health.md +2 -2
- package/commands/pan/hud.md +3 -2
- package/commands/pan/hygiene.md +14 -7
- package/commands/pan/knowledge.md +9 -9
- package/commands/pan/learn.md +10 -7
- package/commands/pan/links.md +4 -1
- package/commands/pan/map-codebase.md +1 -1
- package/commands/pan/mcp-bridge.md +18 -18
- package/commands/pan/milestone-done.md +6 -3
- package/commands/pan/milestone-new.md +1 -1
- package/commands/pan/new-project.md +10 -10
- package/commands/pan/optimize.md +16 -14
- package/commands/pan/patches.md +2 -6
- package/commands/pan/pause.md +1 -1
- package/commands/pan/phase-tests.md +2 -1
- package/commands/pan/plan-phase.md +9 -19
- package/commands/pan/preview.md +1 -1
- package/commands/pan/profile.md +2 -2
- package/commands/pan/quick.md +1 -1
- package/commands/pan/remove-phase.md +1 -1
- package/commands/pan/report.md +2 -2
- package/commands/pan/research-phase.md +12 -11
- package/commands/pan/resume.md +9 -11
- package/commands/pan/retro.md +5 -3
- package/commands/pan/review-deep.md +7 -7
- package/commands/pan/settings.md +1 -1
- package/commands/pan/verify-phase.md +0 -11
- package/commands/pan/what-if.md +2 -2
- package/hooks/dist/pan-context-monitor.js +343 -44
- package/hooks/dist/pan-cost-logger.js +30 -0
- package/hooks/dist/pan-statusline.js +68 -22
- package/hooks/dist/pan-stop-guard.js +7 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
- package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
- package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
- package/pan-wizard-core/bin/lib/commands.cjs +33 -4
- package/pan-wizard-core/bin/lib/config.cjs +2 -1
- package/pan-wizard-core/bin/lib/constants.cjs +18 -6
- package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
- package/pan-wizard-core/bin/lib/core.cjs +53 -19
- package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
- package/pan-wizard-core/bin/lib/cost.cjs +142 -13
- package/pan-wizard-core/bin/lib/distill.cjs +6 -3
- package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
- package/pan-wizard-core/bin/lib/findings.cjs +9 -2
- package/pan-wizard-core/bin/lib/focus.cjs +13 -4
- package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
- package/pan-wizard-core/bin/lib/hygiene.cjs +178 -17
- package/pan-wizard-core/bin/lib/init.cjs +7 -4
- package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
- package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
- package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
- package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
- package/pan-wizard-core/bin/lib/memory.cjs +508 -54
- package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
- package/pan-wizard-core/bin/lib/optimize.cjs +84 -16
- package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
- package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
- package/pan-wizard-core/bin/lib/phase.cjs +15 -13
- package/pan-wizard-core/bin/lib/preview.cjs +3 -1
- package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
- package/pan-wizard-core/bin/lib/runner.cjs +2 -1
- package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
- package/pan-wizard-core/bin/lib/state.cjs +43 -28
- package/pan-wizard-core/bin/lib/template.cjs +11 -4
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
- package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
- package/pan-wizard-core/bin/lib/verify-scope.cjs +5 -2
- package/pan-wizard-core/bin/lib/verify.cjs +30 -28
- package/pan-wizard-core/bin/lib/whatif.cjs +5 -3
- package/pan-wizard-core/bin/pan-tools.cjs +101 -15
- package/pan-wizard-core/learnings/index.json +5 -5
- package/pan-wizard-core/opencode/pan-wizard.js +111 -0
- package/pan-wizard-core/references/checkpoints.md +3 -3
- package/pan-wizard-core/references/continuation-format.md +3 -3
- package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/git-integration.md +4 -6
- package/pan-wizard-core/references/git-planning-commit.md +2 -2
- package/pan-wizard-core/references/guardrails.md +2 -2
- package/pan-wizard-core/references/handoff-decisions.md +3 -3
- package/pan-wizard-core/references/model-profile-resolution.md +5 -5
- package/pan-wizard-core/references/model-profiles.md +14 -9
- package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
- package/pan-wizard-core/references/planning-config.md +4 -4
- package/pan-wizard-core/references/tdd.md +10 -1
- package/pan-wizard-core/references/verification-patterns.md +5 -5
- package/pan-wizard-core/templates/codebase/architecture.md +2 -2
- package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
- package/pan-wizard-core/templates/codebase/concerns.md +3 -3
- package/pan-wizard-core/templates/codebase/integrations.md +6 -6
- package/pan-wizard-core/templates/codebase/relationships.md +2 -2
- package/pan-wizard-core/templates/codebase/stack.md +2 -2
- package/pan-wizard-core/templates/codebase/structure.md +4 -4
- package/pan-wizard-core/templates/codebase/testing.md +4 -4
- package/pan-wizard-core/templates/continue-here.md +1 -1
- package/pan-wizard-core/templates/debug-subagent-prompt.md +3 -2
- package/pan-wizard-core/templates/debug.md +15 -1
- package/pan-wizard-core/templates/milestone-archive.md +6 -6
- package/pan-wizard-core/templates/milestone.md +1 -1
- package/pan-wizard-core/templates/phase-prompt.md +29 -17
- package/pan-wizard-core/templates/planner-subagent-prompt.md +3 -5
- package/pan-wizard-core/templates/research-project/architecture.md +3 -3
- package/pan-wizard-core/templates/research-project/features.md +1 -1
- package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
- package/pan-wizard-core/templates/research-project/stack.md +3 -3
- package/pan-wizard-core/templates/research.md +5 -5
- package/pan-wizard-core/templates/retrospective.md +1 -1
- package/pan-wizard-core/templates/roadmap.md +15 -15
- package/pan-wizard-core/templates/summary-complex.md +1 -0
- package/pan-wizard-core/templates/summary-minimal.md +1 -0
- package/pan-wizard-core/templates/summary-standard.md +2 -1
- package/pan-wizard-core/templates/summary.md +18 -2
- package/pan-wizard-core/templates/uat.md +3 -3
- package/pan-wizard-core/templates/user-setup.md +8 -8
- package/pan-wizard-core/templates/verification-report.md +53 -8
- package/pan-wizard-core/workflows/add-phase.md +1 -1
- package/pan-wizard-core/workflows/assumptions.md +1 -1
- package/pan-wizard-core/workflows/discuss-phase.md +9 -8
- package/pan-wizard-core/workflows/exec-phase.md +26 -57
- package/pan-wizard-core/workflows/execute-plan.md +8 -7
- package/pan-wizard-core/workflows/health.md +13 -12
- package/pan-wizard-core/workflows/help.md +8 -9
- package/pan-wizard-core/workflows/learn.md +13 -11
- package/pan-wizard-core/workflows/map-codebase.md +10 -10
- package/pan-wizard-core/workflows/milestone-audit.md +10 -10
- package/pan-wizard-core/workflows/milestone-gaps.md +17 -24
- package/pan-wizard-core/workflows/milestone-new.md +7 -6
- package/pan-wizard-core/workflows/new-project.md +9 -9
- package/pan-wizard-core/workflows/optimize.md +11 -9
- package/pan-wizard-core/workflows/pause.md +6 -1
- package/pan-wizard-core/workflows/phase-tests.md +4 -4
- package/pan-wizard-core/workflows/plan-phase.md +31 -34
- package/pan-wizard-core/workflows/profile.md +2 -2
- package/pan-wizard-core/workflows/progress.md +5 -7
- package/pan-wizard-core/workflows/quick.md +5 -3
- package/pan-wizard-core/workflows/remove-phase.md +1 -1
- package/pan-wizard-core/workflows/research-phase.md +9 -5
- package/pan-wizard-core/workflows/resume-project.md +4 -6
- package/pan-wizard-core/workflows/retro.md +3 -1
- package/pan-wizard-core/workflows/settings.md +16 -24
- package/pan-wizard-core/workflows/todo-check.md +1 -1
- package/pan-wizard-core/workflows/transition.md +12 -12
- package/pan-wizard-core/workflows/update.md +25 -17
- package/pan-wizard-core/workflows/verify-phase.md +17 -15
- package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
- package/pan-zcode/README.md +7 -7
- package/pan-zcode/bin/install-zcode.js +1 -1
- package/scripts/build-plugin.js +3 -1
- 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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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; //
|
|
96
|
-
const CRITICAL_THRESHOLD = 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,
|
|
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 }
|
|
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 =
|
|
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
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
-
//
|
|
189
|
-
//
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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 ? '[31m' : worst >= 70 ? '[33m' : '[32m';
|
|
201
|
+
limitBadge = ` ${color}${shown.map(([k, v]) => `${k} ${Math.round(v)}%`).join(' · ')}[0m`;
|
|
202
|
+
}
|
|
203
|
+
return `${head}${taskSegment}${dirSegment}${ctx}${cacheBadge}${thinkingBadge}${limitBadge}`;
|
|
158
204
|
}
|
|
159
205
|
|
|
160
206
|
// ─── Stdin driver ───────────────────────────────────────────────────────────
|