peaks-loop 4.0.26 → 4.0.27
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/CHANGELOG.md +10 -0
- package/dist/cli/commands/code-runtime-commands.js +7 -1
- package/dist/cli/commands/dispatch-commands.js +3 -0
- package/dist/services/code/auto-compact-orchestrator.js +3 -0
- package/dist/services/context/auto-compact-reader.d.ts +45 -49
- package/dist/services/context/auto-compact-reader.js +21 -148
- package/dist/services/context/auto-compact-types.d.ts +17 -2
- package/dist/services/ide/adapters/claude-code-adapter.d.ts +12 -12
- package/dist/services/ide/adapters/claude-code-adapter.js +290 -1
- package/dist/services/ide/ide-types.d.ts +39 -0
- package/dist/services/session/binding-status-service.d.ts +11 -0
- package/dist/services/session/binding-status-service.js +25 -4
- package/dist/services/skill/skill-search-service.d.ts +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.27 — 2026-09-01 (auto-compact context probe fix)
|
|
4
|
+
|
|
5
|
+
**1 atomic commit from session 2026-09-01-session-fdd7aa**:
|
|
6
|
+
|
|
7
|
+
- `7f0f4ca2` perf(auto-compact): vendor-neutral context probe + token-based transcript calibration
|
|
8
|
+
|
|
9
|
+
**Highlights**:
|
|
10
|
+
|
|
11
|
+
1. **auto-compact 根因修复(三级链)** — context 比例探针之前永远返回 `conservative-fallback`(ratio 0),auto-compact 从不触发。三处修复:(a) vendor-neutral:Claude transcript/statusline fallback 收进 claude-code-adapter 的 `IdeCompactProfile.readContextPercentFallback`,通用 reader 不再硬编码 `~/.claude`;(b) session-id:transcript 查找改用 `outerSessionId`(Claude Code 真 UUID)而非 peaks `sessionId`;(c) 校准:`ratio = contextTokens / contextWindowTokens`,contextTokens 从最新 `message.usage`(input + cache_read + cache_creation)读取,contextWindowTokens 模型感知(1M vs 200K 默认),替换错误的 `bytes/256KB`(永远 100%)。
|
|
12
|
+
|
|
3
13
|
## 4.0.26 — 2026-09-01 (headroom removal + prompt-cache prefix alignment)
|
|
4
14
|
|
|
5
15
|
**2 atomic commits from session 2026-09-01-session-fdd7aa**:
|
|
@@ -15,6 +15,7 @@ import { evaluateEmitHandoff, JOB_NOT_INITIALIZED, JOB_REMAINING_BLOCKED } from
|
|
|
15
15
|
import { readJobShapeDecision, JobShapeDecisionError } from '../../services/code/job-shape-decision.js';
|
|
16
16
|
import { getSkillPresence } from '../../services/skills/skill-presence-service.js';
|
|
17
17
|
import { probeInFlightBatch } from '../../services/workflow/workflow-inflight-probe.js';
|
|
18
|
+
import { resolveOuterSessionId } from '../../services/session/binding-status-service.js';
|
|
18
19
|
export function registerCodeRuntimeCommands(code, io) {
|
|
19
20
|
addJsonOption(code
|
|
20
21
|
.command('post-compact-detect')
|
|
@@ -175,9 +176,12 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
175
176
|
// missing/malformed decision file is fine — fall back to advisory.
|
|
176
177
|
}
|
|
177
178
|
}
|
|
179
|
+
const sessionId = opts.sessionId ?? readActiveSid(opts.project) ?? 'unknown';
|
|
180
|
+
const outerSessionId = resolveOuterSessionId(opts.project, sessionId);
|
|
178
181
|
const probe = readContextPercent({
|
|
179
182
|
projectRoot: opts.project,
|
|
180
|
-
sessionId
|
|
183
|
+
sessionId,
|
|
184
|
+
outerSessionId,
|
|
181
185
|
env: process.env,
|
|
182
186
|
promptSizeBytes
|
|
183
187
|
});
|
|
@@ -218,6 +222,8 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
218
222
|
ide: probe.ide,
|
|
219
223
|
capacityBytes: probe.capacityBytes,
|
|
220
224
|
rawBytes: probe.rawBytes ?? null,
|
|
225
|
+
rawTokens: probe.rawTokens ?? null,
|
|
226
|
+
capacityTokens: probe.capacityTokens ?? null,
|
|
221
227
|
bytesPrompt: promptSizeBytes ?? null,
|
|
222
228
|
capturedAt: probe.capturedAt
|
|
223
229
|
}, [], [
|
|
@@ -27,6 +27,7 @@ import { noteDispatched, BATCH_LIMIT } from '../../services/dispatch/batch-count
|
|
|
27
27
|
import { writeInitialDispatchRecord } from '../../services/dispatch/dispatch-record-writer.js';
|
|
28
28
|
import { evaluatePromptSize } from '../../services/context/context-guard.js';
|
|
29
29
|
import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
|
|
30
|
+
import { resolveOuterSessionId } from '../../services/session/binding-status-service.js';
|
|
30
31
|
import { buildArtifactMeta, buildContextImpact } from '../../services/context/artifact-meta.js';
|
|
31
32
|
import { assertSafeArtifactPath } from 'peaks-loop-shared-channel';
|
|
32
33
|
import { playwrightProfilePaths } from '../../services/worktree/playwright-profile.js';
|
|
@@ -382,9 +383,11 @@ export function registerDispatchCommand(parent, io) {
|
|
|
382
383
|
let contextProbe = null;
|
|
383
384
|
try {
|
|
384
385
|
const { readContextPercent } = await import('../../services/context/auto-compact-reader.js');
|
|
386
|
+
const outerSessionId = resolveOuterSessionId(projectRoot, sid);
|
|
385
387
|
contextProbe = readContextPercent({
|
|
386
388
|
projectRoot,
|
|
387
389
|
sessionId: sid,
|
|
390
|
+
outerSessionId,
|
|
388
391
|
env: process.env
|
|
389
392
|
});
|
|
390
393
|
}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
32
32
|
import { dirname, join } from 'node:path';
|
|
33
33
|
import { getSessionIdCanonical } from '../session/session-manager.js';
|
|
34
|
+
import { resolveOuterSessionId } from '../session/binding-status-service.js';
|
|
34
35
|
import { AUTO_COMPACT_PRE_COMPACT_RATIO, AUTO_COMPACT_THRESHOLD_RATIO } from '../context/auto-compact-types.js';
|
|
35
36
|
import { describeMode, thresholdFor } from './auto-compact-modes.js';
|
|
36
37
|
import { read24hState } from '../24h-mode/store.js';
|
|
@@ -525,9 +526,11 @@ export async function runAutoCompact(input) {
|
|
|
525
526
|
const mode = input.mode ?? resolveAutoCompactMode(input.projectRoot, sessionId);
|
|
526
527
|
// Lazy import to avoid the AC-1 module depending on the orchestrator.
|
|
527
528
|
const { readContextPercent } = await import('../context/auto-compact-reader.js');
|
|
529
|
+
const outerSessionId = resolveOuterSessionId(input.projectRoot, sessionId, input.env ?? process.env);
|
|
528
530
|
const probe = readContextPercent({
|
|
529
531
|
projectRoot: input.projectRoot,
|
|
530
532
|
sessionId,
|
|
533
|
+
outerSessionId,
|
|
531
534
|
env: input.env
|
|
532
535
|
});
|
|
533
536
|
const decision = evaluateAutoCompactDecision({
|
|
@@ -1,14 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AC-1 — auto context-percent probe.
|
|
3
|
+
*
|
|
4
|
+
* Reads the current AI CLI context-fill ratio without requiring the
|
|
5
|
+
* LLM to pass `--prompt-size <bytes>` manually. Strategy: ask the
|
|
6
|
+
* registered `IdeAdapter.compact` profile which env-var to read and,
|
|
7
|
+
* when that misses, ask the adapter for a vendor-specific fallback —
|
|
8
|
+
* no hard-coded IDE names. Per-adapter:
|
|
9
|
+
*
|
|
10
|
+
* - claude-code: its adapter-declared env-var (MVP) + a
|
|
11
|
+
* `readContextPercentFallback` that polls the statusline /
|
|
12
|
+
* transcript (see claude-code-adapter.ts).
|
|
13
|
+
* - trae / codex / cursor / qoder / tongyi-lingma / hermes /
|
|
14
|
+
* openclaw / zcode: each adapter fills its own env-var; until
|
|
15
|
+
* L2-dogfood verifies each surface, adapters may omit `compact`
|
|
16
|
+
* and the probe returns `source: 'conservative-fallback'`.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order (user-overridden → env-var → adapter fallback →
|
|
19
|
+
* conservative-fallback):
|
|
20
|
+
* 1. `promptSizeBytes` (P0 `--prompt-size <bytes>` escape hatch) →
|
|
21
|
+
* `source: 'user-overridden'`.
|
|
22
|
+
* 2. `adapter.compact.envVarForContextPercent` env-var →
|
|
23
|
+
* `source: '<ideId>-env'`.
|
|
24
|
+
* 3. `adapter.compact.readContextPercentFallback?.(input)` — the
|
|
25
|
+
* adapter owns any vendor-specific statusline / transcript probe.
|
|
26
|
+
* 4. `ratio: 0` with `source: 'conservative-fallback'` — the
|
|
27
|
+
* orchestrator MUST NOT auto-fire compact on this signal.
|
|
28
|
+
*/
|
|
1
29
|
import type { ContextPercentProbe } from './auto-compact-types.js';
|
|
2
30
|
export interface ReadContextPercentInput {
|
|
3
31
|
readonly projectRoot: string;
|
|
4
32
|
readonly sessionId: string;
|
|
33
|
+
/**
|
|
34
|
+
* Outer (harness / IDE) session id — the id the IDE uses to name its
|
|
35
|
+
* transcript / session files. Resolved by the caller (env signal → bound
|
|
36
|
+
* session meta) and passed through to the adapter's
|
|
37
|
+
* `readContextPercentFallback`. Optional: when unresolved, the adapter
|
|
38
|
+
* fallback returns null → conservative-fallback.
|
|
39
|
+
*/
|
|
40
|
+
readonly outerSessionId?: string | undefined;
|
|
5
41
|
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
6
42
|
/**
|
|
7
43
|
* Slice 2026-07-31-rid-002: explicit byte count from `--prompt-size <bytes>`.
|
|
8
44
|
* When set to a finite non-negative number, short-circuits the entire
|
|
9
45
|
* env / statusline / transcript chain with `source: 'user-overridden'`.
|
|
10
|
-
* Mac escape hatch — Claude Code
|
|
11
|
-
*
|
|
46
|
+
* Mac escape hatch — some IDEs (e.g. Claude Code on macOS) do NOT
|
|
47
|
+
* inject their context-percent env-var into PreToolUse sub-shells, so the
|
|
12
48
|
* user (or a hook wrapper) can inject the bytes they observed themselves.
|
|
13
49
|
* Priority P0 — above everything else.
|
|
14
50
|
*/
|
|
@@ -21,60 +57,20 @@ export interface ReadContextPercentInput {
|
|
|
21
57
|
* so the function itself has no hard-coded IDE names.
|
|
22
58
|
*/
|
|
23
59
|
declare function readEnvPercent(env: NodeJS.ProcessEnv, varName: string): number | null;
|
|
24
|
-
/**
|
|
25
|
-
* Read the IDE-specific statusline state. MVP path is Claude Code's
|
|
26
|
-
* `~/.claude/statusline-state.json`; other IDEs are intentionally
|
|
27
|
-
* left for future slices (each IDE will expose its own
|
|
28
|
-
* `compact.postCompactDetectCommand` to drive this).
|
|
29
|
-
*/
|
|
30
|
-
declare function readClaudeStatuslinePercent(): number | null;
|
|
31
|
-
/**
|
|
32
|
-
* Recursive search for `<sessionId>.jsonl` under `projectsDir`. Used by
|
|
33
|
-
* `readClaudeTranscriptFallback` and exported via `_internal` so unit
|
|
34
|
-
* tests can drive it without monkey-patching `os.homedir` (which is
|
|
35
|
-
* non-configurable in ESM module namespaces).
|
|
36
|
-
*
|
|
37
|
-
* The Mac layout encodes the cwd as a single hash directory; on Mac
|
|
38
|
-
* Claude Code nests the transcript under that hash with an extra level
|
|
39
|
-
* of subdirectory we cannot predict ahead of time. A flat readdir misses
|
|
40
|
-
* that branch and returns null — the silent-failure mode that this fix
|
|
41
|
-
* closes.
|
|
42
|
-
*/
|
|
43
|
-
declare function findTranscriptJsonl(projectsDir: string, sessionId: string): {
|
|
44
|
-
path: string;
|
|
45
|
-
bytes: number;
|
|
46
|
-
} | null;
|
|
47
|
-
/**
|
|
48
|
-
* Conservative transcript-size fallback. Recursively searches
|
|
49
|
-
* `~/.claude/projects/<hash>/<sid-or-nested>.jsonl` (Mac may nest
|
|
50
|
-
* the jsonl under an
|
|
51
|
-
* extra directory we cannot predict ahead of time) and estimates
|
|
52
|
-
* `ratio = bytesUsed / 256K`. Returns the bytes seen so the
|
|
53
|
-
* orchestrator can show "estimated from 124KB of 256KB transcript"
|
|
54
|
-
* in the envelope. Tagged `'transcript-estimate'` (v2.14.0) so callers
|
|
55
|
-
* know it is a real signal, NOT a hard gate.
|
|
56
|
-
*/
|
|
57
|
-
declare function readClaudeTranscriptFallback(sessionId: string): {
|
|
58
|
-
ratio: number;
|
|
59
|
-
bytes: number;
|
|
60
|
-
} | null;
|
|
61
60
|
/**
|
|
62
61
|
* Probe the current AI CLI's context-fill ratio. Adapter-driven:
|
|
63
62
|
* looks up the registered `IdeAdapter.compact` profile via
|
|
64
|
-
* `
|
|
65
|
-
* adapter-declared env-var.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* with `ratio: 0` so the
|
|
70
|
-
* missing signal.
|
|
63
|
+
* `getAdapter(detectIdeFromEnv(env))` and reads the
|
|
64
|
+
* adapter-declared env-var. When that misses, delegates to the
|
|
65
|
+
* adapter's optional `readContextPercentFallback` (which owns any
|
|
66
|
+
* vendor-specific statusline / transcript probe). Adapters without a
|
|
67
|
+
* fallback (or a fallback that returns null) yield
|
|
68
|
+
* `source: 'conservative-fallback'` with `ratio: 0` so the
|
|
69
|
+
* orchestrator never auto-fires on a missing signal.
|
|
71
70
|
*/
|
|
72
71
|
export declare function readContextPercent(input: ReadContextPercentInput): ContextPercentProbe;
|
|
73
72
|
/** Re-export the env-var probe for unit tests. */
|
|
74
73
|
export declare const _internal: {
|
|
75
74
|
readEnvPercent: typeof readEnvPercent;
|
|
76
|
-
readClaudeStatuslinePercent: typeof readClaudeStatuslinePercent;
|
|
77
|
-
readClaudeTranscriptFallback: typeof readClaudeTranscriptFallback;
|
|
78
|
-
findTranscriptJsonl: typeof findTranscriptJsonl;
|
|
79
75
|
};
|
|
80
76
|
export {};
|
|
@@ -1,30 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AC-1 — auto context-percent probe.
|
|
3
|
-
*
|
|
4
|
-
* Reads the current AI CLI context-fill ratio without requiring the
|
|
5
|
-
* LLM to pass `--prompt-size <bytes>` manually. Strategy: ask the
|
|
6
|
-
* registered `IdeAdapter.compact` profile which env-var to read;
|
|
7
|
-
* no hard-coded IDE names. Per-adapter:
|
|
8
|
-
*
|
|
9
|
-
* - claude-code: `CLAUDE_CONTEXT_USAGE_PERCENT` (MVP)
|
|
10
|
-
* - trae / codex / cursor / qoder / tongyi-lingma / hermes /
|
|
11
|
-
* openclaw: each adapter fills its own env-var; until L2-dogfood
|
|
12
|
-
* verifies each surface, adapters may omit `compact` and the
|
|
13
|
-
* probe returns `source: 'conservative-fallback'`.
|
|
14
|
-
*
|
|
15
|
-
* Fallback chain (when adapter.compact is undefined OR the env-var
|
|
16
|
-
* is missing):
|
|
17
|
-
* 1. statusline poll (`~/.claude/statusline-state.json` for
|
|
18
|
-
* Claude Code MVP; other IDEs register their own poll path
|
|
19
|
-
* by exposing `compact.postCompactDetectCommand`).
|
|
20
|
-
* 2. Conservative transcript-size estimate
|
|
21
|
-
* (`~/.claude/projects/<hash>/<sid>.jsonl` for Claude Code).
|
|
22
|
-
* 3. `ratio: 0` with `source: 'conservative-fallback'` — the
|
|
23
|
-
* orchestrator MUST NOT auto-fire compact on this signal.
|
|
24
|
-
*/
|
|
25
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
26
|
-
import { homedir } from 'node:os';
|
|
27
|
-
import { join } from 'node:path';
|
|
28
1
|
import { detectIdeFromEnv } from './ide-detect.js';
|
|
29
2
|
import { getAdapter } from '../ide/ide-registry.js';
|
|
30
3
|
/**
|
|
@@ -44,106 +17,16 @@ function readEnvPercent(env, varName) {
|
|
|
44
17
|
return null;
|
|
45
18
|
return Math.max(0, Math.min(1, parsed));
|
|
46
19
|
}
|
|
47
|
-
/**
|
|
48
|
-
* Read the IDE-specific statusline state. MVP path is Claude Code's
|
|
49
|
-
* `~/.claude/statusline-state.json`; other IDEs are intentionally
|
|
50
|
-
* left for future slices (each IDE will expose its own
|
|
51
|
-
* `compact.postCompactDetectCommand` to drive this).
|
|
52
|
-
*/
|
|
53
|
-
function readClaudeStatuslinePercent() {
|
|
54
|
-
const path = join(homedir(), '.claude', 'statusline-state.json');
|
|
55
|
-
if (!existsSync(path))
|
|
56
|
-
return null;
|
|
57
|
-
try {
|
|
58
|
-
const json = JSON.parse(readFileSync(path, 'utf8'));
|
|
59
|
-
const candidates = ['contextPercent', 'context_usage_percent', 'contextPercentUsed'];
|
|
60
|
-
for (const key of candidates) {
|
|
61
|
-
const raw = json[key];
|
|
62
|
-
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
63
|
-
return raw > 1.5 ? raw / 100 : Math.max(0, Math.min(1, raw));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
|
|
68
|
-
if (err instanceof ReferenceError)
|
|
69
|
-
throw err; // surface module-load bugs
|
|
70
|
-
if (err instanceof SyntaxError)
|
|
71
|
-
throw err; // surface parse bugs (e.g. broken statusline JSON)
|
|
72
|
-
return null; // only swallow IO errors
|
|
73
|
-
}
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Recursive search for `<sessionId>.jsonl` under `projectsDir`. Used by
|
|
78
|
-
* `readClaudeTranscriptFallback` and exported via `_internal` so unit
|
|
79
|
-
* tests can drive it without monkey-patching `os.homedir` (which is
|
|
80
|
-
* non-configurable in ESM module namespaces).
|
|
81
|
-
*
|
|
82
|
-
* The Mac layout encodes the cwd as a single hash directory; on Mac
|
|
83
|
-
* Claude Code nests the transcript under that hash with an extra level
|
|
84
|
-
* of subdirectory we cannot predict ahead of time. A flat readdir misses
|
|
85
|
-
* that branch and returns null — the silent-failure mode that this fix
|
|
86
|
-
* closes.
|
|
87
|
-
*/
|
|
88
|
-
function findTranscriptJsonl(projectsDir, sessionId) {
|
|
89
|
-
if (!existsSync(projectsDir))
|
|
90
|
-
return null;
|
|
91
|
-
try {
|
|
92
|
-
const stack = [projectsDir];
|
|
93
|
-
while (stack.length > 0) {
|
|
94
|
-
const dir = stack.pop();
|
|
95
|
-
if (dir === undefined)
|
|
96
|
-
break;
|
|
97
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
98
|
-
for (const entry of entries) {
|
|
99
|
-
const full = join(dir, entry.name);
|
|
100
|
-
if (entry.isDirectory()) {
|
|
101
|
-
stack.push(full);
|
|
102
|
-
}
|
|
103
|
-
else if (entry.isFile() && entry.name === `${sessionId}.jsonl`) {
|
|
104
|
-
const bytes = statSync(full).size;
|
|
105
|
-
return { path: full, bytes };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
|
|
111
|
-
if (err instanceof ReferenceError)
|
|
112
|
-
throw err; // surface module-load bugs
|
|
113
|
-
if (err instanceof SyntaxError)
|
|
114
|
-
throw err; // surface parse bugs
|
|
115
|
-
return null; // only swallow IO errors
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Conservative transcript-size fallback. Recursively searches
|
|
121
|
-
* `~/.claude/projects/<hash>/<sid-or-nested>.jsonl` (Mac may nest
|
|
122
|
-
* the jsonl under an
|
|
123
|
-
* extra directory we cannot predict ahead of time) and estimates
|
|
124
|
-
* `ratio = bytesUsed / 256K`. Returns the bytes seen so the
|
|
125
|
-
* orchestrator can show "estimated from 124KB of 256KB transcript"
|
|
126
|
-
* in the envelope. Tagged `'transcript-estimate'` (v2.14.0) so callers
|
|
127
|
-
* know it is a real signal, NOT a hard gate.
|
|
128
|
-
*/
|
|
129
|
-
function readClaudeTranscriptFallback(sessionId) {
|
|
130
|
-
const projectsDir = join(homedir(), '.claude', 'projects');
|
|
131
|
-
const hit = findTranscriptJsonl(projectsDir, sessionId);
|
|
132
|
-
if (hit === null)
|
|
133
|
-
return null;
|
|
134
|
-
const ratio = Math.min(1, hit.bytes / (256 * 1024));
|
|
135
|
-
return { ratio, bytes: hit.bytes };
|
|
136
|
-
}
|
|
137
20
|
/**
|
|
138
21
|
* Probe the current AI CLI's context-fill ratio. Adapter-driven:
|
|
139
22
|
* looks up the registered `IdeAdapter.compact` profile via
|
|
140
|
-
* `
|
|
141
|
-
* adapter-declared env-var.
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* with `ratio: 0` so the
|
|
146
|
-
* missing signal.
|
|
23
|
+
* `getAdapter(detectIdeFromEnv(env))` and reads the
|
|
24
|
+
* adapter-declared env-var. When that misses, delegates to the
|
|
25
|
+
* adapter's optional `readContextPercentFallback` (which owns any
|
|
26
|
+
* vendor-specific statusline / transcript probe). Adapters without a
|
|
27
|
+
* fallback (or a fallback that returns null) yield
|
|
28
|
+
* `source: 'conservative-fallback'` with `ratio: 0` so the
|
|
29
|
+
* orchestrator never auto-fires on a missing signal.
|
|
147
30
|
*/
|
|
148
31
|
export function readContextPercent(input) {
|
|
149
32
|
const env = input.env ?? process.env;
|
|
@@ -158,7 +41,7 @@ export function readContextPercent(input) {
|
|
|
158
41
|
// through `unknown` to IdeId's wider 8-element set.
|
|
159
42
|
const ideId = (detected === 'unknown' ? 'claude-code' : detected);
|
|
160
43
|
const adapter = getAdapter(ideId);
|
|
161
|
-
// P0 user-overridden takes priority over env /
|
|
44
|
+
// P0 user-overridden takes priority over env / fallback / transcript.
|
|
162
45
|
// Mac escape hatch: when the CLI/helper passes `--prompt-size <bytes>`,
|
|
163
46
|
// honor that number directly. Do NOT read env, statusline, or transcript
|
|
164
47
|
// — user intent always wins. Negative / non-finite values are ignored
|
|
@@ -176,8 +59,8 @@ export function readContextPercent(input) {
|
|
|
176
59
|
capturedAt
|
|
177
60
|
};
|
|
178
61
|
}
|
|
179
|
-
// Primary: read the adapter-declared env-var (no hard-coded IDE names).
|
|
180
62
|
if (adapter.compact) {
|
|
63
|
+
// Primary: read the adapter-declared env-var (no hard-coded IDE names).
|
|
181
64
|
const primary = readEnvPercent(env, adapter.compact.envVarForContextPercent);
|
|
182
65
|
if (primary !== null) {
|
|
183
66
|
return {
|
|
@@ -188,27 +71,17 @@ export function readContextPercent(input) {
|
|
|
188
71
|
capturedAt
|
|
189
72
|
};
|
|
190
73
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (fallback !== null) {
|
|
203
|
-
return {
|
|
204
|
-
ratio: fallback.ratio,
|
|
205
|
-
source: 'transcript-estimate',
|
|
206
|
-
rawBytes: fallback.bytes,
|
|
207
|
-
capacityBytes,
|
|
208
|
-
ide: ideId,
|
|
209
|
-
capturedAt
|
|
210
|
-
};
|
|
211
|
-
}
|
|
74
|
+
// Fallback: the adapter owns any vendor-specific statusline /
|
|
75
|
+
// transcript probe. When it returns a probe, honor it; otherwise
|
|
76
|
+
// fall through to conservative-fallback.
|
|
77
|
+
const fallback = adapter.compact.readContextPercentFallback?.({
|
|
78
|
+
projectRoot: input.projectRoot,
|
|
79
|
+
sessionId: input.sessionId,
|
|
80
|
+
outerSessionId: input.outerSessionId,
|
|
81
|
+
env
|
|
82
|
+
});
|
|
83
|
+
if (fallback)
|
|
84
|
+
return fallback;
|
|
212
85
|
}
|
|
213
86
|
// No signal available — return `ratio: 0` so the orchestrator
|
|
214
87
|
// stays in `none` zone and the LLM can still pass `--prompt-size`
|
|
@@ -216,4 +89,4 @@ export function readContextPercent(input) {
|
|
|
216
89
|
return { ratio: 0, source: 'conservative-fallback', capacityBytes, ide: ideId, capturedAt };
|
|
217
90
|
}
|
|
218
91
|
/** Re-export the env-var probe for unit tests. */
|
|
219
|
-
export const _internal = { readEnvPercent
|
|
92
|
+
export const _internal = { readEnvPercent };
|
|
@@ -161,8 +161,10 @@ export interface ContextPercentProbe {
|
|
|
161
161
|
* - `transcript-estimate` (v2.14.0 Mac-aware: recursive
|
|
162
162
|
* readdir under ~/.claude/projects/
|
|
163
163
|
* finds <sid>.jsonl and reports
|
|
164
|
-
*
|
|
165
|
-
*
|
|
164
|
+
* contextTokens / contextWindowTokens
|
|
165
|
+
* from the LATEST message.usage entry —
|
|
166
|
+
* token-based + model-aware; a real
|
|
167
|
+
* signal, NOT a hard gate)
|
|
166
168
|
* - `conservative-fallback` (no signal available — caller
|
|
167
169
|
* MUST NOT treat as hard gate)
|
|
168
170
|
* Future IDEs may add per-ide sources; the type accepts any
|
|
@@ -171,6 +173,19 @@ export interface ContextPercentProbe {
|
|
|
171
173
|
readonly source: string;
|
|
172
174
|
readonly rawBytes?: number;
|
|
173
175
|
readonly capacityBytes?: number;
|
|
176
|
+
/**
|
|
177
|
+
* Token-based numerator for `transcript-estimate`: the observed
|
|
178
|
+
* `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`
|
|
179
|
+
* from the LATEST transcript `message.usage` entry. Undefined for byte /
|
|
180
|
+
* percent sources (`user-overridden`, `${ideId}-env`, `statusline-poll`).
|
|
181
|
+
*/
|
|
182
|
+
readonly rawTokens?: number;
|
|
183
|
+
/**
|
|
184
|
+
* Token-based denominator for `transcript-estimate`: the model's context
|
|
185
|
+
* window in tokens (`modelContextWindowTokens`). Undefined for byte /
|
|
186
|
+
* percent sources.
|
|
187
|
+
*/
|
|
188
|
+
readonly capacityTokens?: number;
|
|
174
189
|
readonly ide: string;
|
|
175
190
|
readonly capturedAt: string;
|
|
176
191
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import type { IdeAdapter } from '../ide-types.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Model-aware context-window size in tokens.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Detection rule (documented):
|
|
6
|
+
* 1. Empty / unknown model → DEFAULT_CONTEXT_WINDOW_TOKENS (200_000).
|
|
7
|
+
* 2. Suffix heuristic — a model id containing `1m` (case-insensitive) is
|
|
8
|
+
* treated as 1M-context.
|
|
9
|
+
* 3. Explicit allowlist — known 1M Claude model ids
|
|
10
|
+
* (ONE_MILLION_CONTEXT_MODELS).
|
|
11
|
+
* 4. Everything else → 200_000 (the safe default).
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - envVar = 'CLAUDE_PROJECT_DIR' : Claude Code 注入的 env 变量,用于 ${...} 占位
|
|
13
|
-
* - hookEvent = 'PreToolUse' : Claude Code hook 数组 key
|
|
14
|
-
* - toolMatcher = 'Bash' | 'Task' : PreToolUse 数组元素的 matcher 字段
|
|
15
|
-
*
|
|
16
|
-
* 不可消除的 per-IDE 字段(见 tech-doc.md §1.3)。
|
|
13
|
+
* Callers MAY additionally infer ≥1M from the observed token count: if
|
|
14
|
+
* `contextTokens > DEFAULT_CONTEXT_WINDOW_TOKENS`, the model cannot be a
|
|
15
|
+
* 200K model and must be ≥1M (see `readClaudeTranscriptEstimate`).
|
|
17
16
|
*/
|
|
17
|
+
export declare function modelContextWindowTokens(model: string): number;
|
|
18
18
|
export declare const CLAUDE_CODE_ADAPTER: IdeAdapter;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
3
|
import { join, resolve } from 'node:path';
|
|
3
4
|
import { claudeCodeSubAgentDispatcher } from '../../dispatch/sub-agent-dispatcher.js';
|
|
@@ -17,6 +18,293 @@ import { claudeCodeSubAgentDispatcher } from '../../dispatch/sub-agent-dispatche
|
|
|
17
18
|
*
|
|
18
19
|
* 不可消除的 per-IDE 字段(见 tech-doc.md §1.3)。
|
|
19
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* Read Claude Code's statusline state file
|
|
23
|
+
* (`~/.claude/statusline-state.json`) and parse a context-percent key.
|
|
24
|
+
* Moved from the generic reader in slice
|
|
25
|
+
* 2026-09-02-vendor-neutral-context-probe — Claude-specific paths now live
|
|
26
|
+
* only in the Claude Code adapter.
|
|
27
|
+
*/
|
|
28
|
+
function readClaudeStatuslinePercent() {
|
|
29
|
+
const path = join(homedir(), '.claude', 'statusline-state.json');
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const json = JSON.parse(readFileSync(path, 'utf8'));
|
|
34
|
+
const candidates = ['contextPercent', 'context_usage_percent', 'contextPercentUsed'];
|
|
35
|
+
for (const key of candidates) {
|
|
36
|
+
const raw = json[key];
|
|
37
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
38
|
+
return raw > 1.5 ? raw / 100 : Math.max(0, Math.min(1, raw));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
|
|
43
|
+
if (err instanceof ReferenceError)
|
|
44
|
+
throw err; // surface module-load bugs
|
|
45
|
+
if (err instanceof SyntaxError)
|
|
46
|
+
throw err; // surface parse bugs (e.g. broken statusline JSON)
|
|
47
|
+
return null; // only swallow IO errors
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Recursive search for `<outerSessionId>.jsonl` under `projectsDir`. The
|
|
53
|
+
* Mac layout encodes the cwd as a single hash directory; on Mac Claude Code
|
|
54
|
+
* nests the transcript under that hash with an extra level of subdirectory we
|
|
55
|
+
* cannot predict ahead of time. A flat readdir misses that branch and returns
|
|
56
|
+
* null — the silent-failure mode this recursion closes.
|
|
57
|
+
*
|
|
58
|
+
* Moved from the generic reader in slice
|
|
59
|
+
* 2026-09-02-vendor-neutral-context-probe. The lookup key is the OUTER
|
|
60
|
+
* session id (Claude Code names its transcript by the outer session UUID),
|
|
61
|
+
* NOT the peaks session id.
|
|
62
|
+
*/
|
|
63
|
+
function findTranscriptJsonl(projectsDir, outerSessionId) {
|
|
64
|
+
if (!existsSync(projectsDir))
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
const stack = [projectsDir];
|
|
68
|
+
while (stack.length > 0) {
|
|
69
|
+
const dir = stack.pop();
|
|
70
|
+
if (dir === undefined)
|
|
71
|
+
break;
|
|
72
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const full = join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
stack.push(full);
|
|
77
|
+
}
|
|
78
|
+
else if (entry.isFile() && entry.name === `${outerSessionId}.jsonl`) {
|
|
79
|
+
return full;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
|
|
85
|
+
if (err instanceof ReferenceError)
|
|
86
|
+
throw err; // surface module-load bugs
|
|
87
|
+
if (err instanceof SyntaxError)
|
|
88
|
+
throw err; // surface parse bugs
|
|
89
|
+
return null; // only swallow IO errors
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
/** 1M-context window size in tokens (documented single choice: 1,000,000). */
|
|
94
|
+
const ONE_MILLION_CONTEXT_TOKENS = 1_000_000;
|
|
95
|
+
/** Safe-default (non-1M) context window size in tokens. */
|
|
96
|
+
const DEFAULT_CONTEXT_WINDOW_TOKENS = 200_000;
|
|
97
|
+
/** Reverse-scan chunk size in bytes (keeps memory bounded on multi-MB transcripts). */
|
|
98
|
+
const TRANSCRIPT_SCAN_CHUNK_BYTES = 64 * 1024;
|
|
99
|
+
/**
|
|
100
|
+
* Known 1M-context Claude model id prefixes whose ids do NOT carry a `1m`
|
|
101
|
+
* suffix (e.g. `claude-sonnet-4-5-20250929`). The substring match is
|
|
102
|
+
* intentionally generous — every `claude-sonnet-4*` / `claude-opus-4*`
|
|
103
|
+
* variant is 1M-context.
|
|
104
|
+
*/
|
|
105
|
+
const ONE_MILLION_CONTEXT_MODELS = ['claude-opus-4', 'claude-sonnet-4'];
|
|
106
|
+
/**
|
|
107
|
+
* Model-aware context-window size in tokens.
|
|
108
|
+
*
|
|
109
|
+
* Detection rule (documented):
|
|
110
|
+
* 1. Empty / unknown model → DEFAULT_CONTEXT_WINDOW_TOKENS (200_000).
|
|
111
|
+
* 2. Suffix heuristic — a model id containing `1m` (case-insensitive) is
|
|
112
|
+
* treated as 1M-context.
|
|
113
|
+
* 3. Explicit allowlist — known 1M Claude model ids
|
|
114
|
+
* (ONE_MILLION_CONTEXT_MODELS).
|
|
115
|
+
* 4. Everything else → 200_000 (the safe default).
|
|
116
|
+
*
|
|
117
|
+
* Callers MAY additionally infer ≥1M from the observed token count: if
|
|
118
|
+
* `contextTokens > DEFAULT_CONTEXT_WINDOW_TOKENS`, the model cannot be a
|
|
119
|
+
* 200K model and must be ≥1M (see `readClaudeTranscriptEstimate`).
|
|
120
|
+
*/
|
|
121
|
+
export function modelContextWindowTokens(model) {
|
|
122
|
+
const m = model.trim().toLowerCase();
|
|
123
|
+
if (m.length === 0)
|
|
124
|
+
return DEFAULT_CONTEXT_WINDOW_TOKENS;
|
|
125
|
+
if (m.includes('1m'))
|
|
126
|
+
return ONE_MILLION_CONTEXT_TOKENS;
|
|
127
|
+
for (const known of ONE_MILLION_CONTEXT_MODELS) {
|
|
128
|
+
if (m.includes(known))
|
|
129
|
+
return ONE_MILLION_CONTEXT_TOKENS;
|
|
130
|
+
}
|
|
131
|
+
return DEFAULT_CONTEXT_WINDOW_TOKENS;
|
|
132
|
+
}
|
|
133
|
+
/** A non-negative finite number, or null when the value is not numeric. */
|
|
134
|
+
function numericTokenCount(value) {
|
|
135
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
|
|
136
|
+
return value;
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Parse a single jsonl line into its token count + model id. Returns null
|
|
141
|
+
* when the line has no `message.usage` object with numeric token fields.
|
|
142
|
+
*/
|
|
143
|
+
function parseTranscriptUsageLine(line) {
|
|
144
|
+
if (line.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
let json;
|
|
147
|
+
try {
|
|
148
|
+
json = JSON.parse(line);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return null; // non-JSON line (blank / corrupt) — skip
|
|
152
|
+
}
|
|
153
|
+
if (typeof json !== 'object' || json === null)
|
|
154
|
+
return null;
|
|
155
|
+
const record = json;
|
|
156
|
+
const message = record.message;
|
|
157
|
+
if (typeof message !== 'object' || message === null)
|
|
158
|
+
return null;
|
|
159
|
+
const msg = message;
|
|
160
|
+
const usage = msg.usage;
|
|
161
|
+
if (typeof usage !== 'object' || usage === null)
|
|
162
|
+
return null;
|
|
163
|
+
const u = usage;
|
|
164
|
+
const inputTokens = numericTokenCount(u.input_tokens);
|
|
165
|
+
const cacheRead = numericTokenCount(u.cache_read_input_tokens);
|
|
166
|
+
const cacheCreation = numericTokenCount(u.cache_creation_input_tokens);
|
|
167
|
+
if (inputTokens === null && cacheRead === null && cacheCreation === null)
|
|
168
|
+
return null;
|
|
169
|
+
const contextTokens = (inputTokens ?? 0) + (cacheRead ?? 0) + (cacheCreation ?? 0);
|
|
170
|
+
// Model id lives at `message.model`, falling back to a top-level `model`.
|
|
171
|
+
const model = typeof msg.model === 'string'
|
|
172
|
+
? msg.model
|
|
173
|
+
: typeof record.model === 'string' ? record.model : '';
|
|
174
|
+
return { contextTokens, model };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Reverse-scan the transcript jsonl (from the END) for the LATEST entry that
|
|
178
|
+
* carries a numeric `message.usage`. The file can be many MB; it is read in
|
|
179
|
+
* backward chunks of TRANSCRIPT_SCAN_CHUNK_BYTES — never fully into memory —
|
|
180
|
+
* and stops at the first (newest) usable entry.
|
|
181
|
+
*/
|
|
182
|
+
function findLatestTranscriptUsage(filePath) {
|
|
183
|
+
let fd = null;
|
|
184
|
+
try {
|
|
185
|
+
const size = statSync(filePath).size;
|
|
186
|
+
if (size === 0)
|
|
187
|
+
return null;
|
|
188
|
+
fd = openSync(filePath, 'r');
|
|
189
|
+
let position = size;
|
|
190
|
+
let carry = ''; // partial line head carried into the next (older) chunk
|
|
191
|
+
while (position > 0) {
|
|
192
|
+
const readLen = Math.min(TRANSCRIPT_SCAN_CHUNK_BYTES, position);
|
|
193
|
+
position -= readLen;
|
|
194
|
+
const buf = Buffer.alloc(readLen);
|
|
195
|
+
const bytesRead = readSync(fd, buf, 0, readLen, position);
|
|
196
|
+
if (bytesRead <= 0)
|
|
197
|
+
break;
|
|
198
|
+
const lines = (buf.toString('utf8', 0, bytesRead) + carry).split('\n');
|
|
199
|
+
carry = lines[0] ?? '';
|
|
200
|
+
for (let i = lines.length - 1; i >= 1; i--) {
|
|
201
|
+
const line = lines[i];
|
|
202
|
+
if (line === undefined)
|
|
203
|
+
continue;
|
|
204
|
+
const parsed = parseTranscriptUsageLine(line);
|
|
205
|
+
if (parsed !== null)
|
|
206
|
+
return parsed;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// The final carry is the first line of the file (complete, since it starts at byte 0).
|
|
210
|
+
if (carry.length > 0) {
|
|
211
|
+
const parsed = parseTranscriptUsageLine(carry);
|
|
212
|
+
if (parsed !== null)
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
// Narrow: surface module-load / parse bugs, swallow IO errors only
|
|
219
|
+
// (mirrors the other adapter read helpers' catch discipline).
|
|
220
|
+
if (err instanceof ReferenceError)
|
|
221
|
+
throw err;
|
|
222
|
+
if (err instanceof SyntaxError)
|
|
223
|
+
throw err;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
if (fd !== null) {
|
|
228
|
+
try {
|
|
229
|
+
closeSync(fd);
|
|
230
|
+
}
|
|
231
|
+
catch { /* best-effort */ }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Resolve the context window for a transcript usage entry. Prefers the
|
|
237
|
+
* explicit `modelContextWindowTokens(model)` mapping; when the observed token
|
|
238
|
+
* count contradicts it (tokens exceed the mapped window), the model must be
|
|
239
|
+
* ≥1M, so bump to the 1M window.
|
|
240
|
+
*/
|
|
241
|
+
function resolveContextWindowTokens(model, contextTokens) {
|
|
242
|
+
const window = modelContextWindowTokens(model);
|
|
243
|
+
return contextTokens > window ? ONE_MILLION_CONTEXT_TOKENS : window;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Conservative transcript-estimate fallback. Recursively searches
|
|
247
|
+
* `~/.claude/projects/<hash>/<outerSessionId-or-nested>.jsonl` (Mac may nest
|
|
248
|
+
* the jsonl under an extra directory we cannot predict ahead of time) and
|
|
249
|
+
* estimates `ratio = contextTokens / contextWindowTokens` from the LATEST
|
|
250
|
+
* `message.usage` entry — token-based + model-aware, NOT the old
|
|
251
|
+
* `bytes / 256KB` (which over-fired because the transcript grows unboundedly).
|
|
252
|
+
* Tagged `'transcript-estimate'` (v2.14.0) so callers know it is a real
|
|
253
|
+
* signal, NOT a hard gate.
|
|
254
|
+
*/
|
|
255
|
+
function readClaudeTranscriptEstimate(outerSessionId) {
|
|
256
|
+
const projectsDir = join(homedir(), '.claude', 'projects');
|
|
257
|
+
const path = findTranscriptJsonl(projectsDir, outerSessionId);
|
|
258
|
+
if (path === null)
|
|
259
|
+
return null;
|
|
260
|
+
const latest = findLatestTranscriptUsage(path);
|
|
261
|
+
if (latest === null)
|
|
262
|
+
return null;
|
|
263
|
+
const contextWindowTokens = resolveContextWindowTokens(latest.model, latest.contextTokens);
|
|
264
|
+
const ratio = Math.min(1, latest.contextTokens / contextWindowTokens);
|
|
265
|
+
return { ratio, contextTokens: latest.contextTokens, contextWindowTokens };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Claude Code's vendor-specific context-percent fallback, exposed as
|
|
269
|
+
* `IdeCompactProfile.readContextPercentFallback`. The generic reader calls
|
|
270
|
+
* this only when the primary env-var probe misses; only the adapter knows the
|
|
271
|
+
* Claude-specific statusline + transcript paths. Resolution order:
|
|
272
|
+
* 1. statusline poll (`~/.claude/statusline-state.json`)
|
|
273
|
+
* 2. transcript estimate — looks up `<outerSessionId>.jsonl` under
|
|
274
|
+
* `~/.claude/projects/<hash>/...` using the OUTER session id (Claude
|
|
275
|
+
* names its transcript by the outer session UUID, not the peaks sid),
|
|
276
|
+
* and estimates `contextTokens / contextWindowTokens` from the LATEST
|
|
277
|
+
* `message.usage` entry (token-based + model-aware).
|
|
278
|
+
* Returns `null` when neither yields a signal → the reader emits
|
|
279
|
+
* `conservative-fallback`.
|
|
280
|
+
*/
|
|
281
|
+
function readContextPercentFallback(input) {
|
|
282
|
+
const capturedAt = new Date().toISOString();
|
|
283
|
+
// Byte-based capacity is carried only for the percent path (statusline-poll
|
|
284
|
+
// returns a 0..1 ratio; capacityBytes is metadata there). The
|
|
285
|
+
// transcript-estimate path is token-based and surfaces `capacityTokens`
|
|
286
|
+
// (the model window) instead — see readClaudeTranscriptEstimate.
|
|
287
|
+
const capacityBytes = 256 * 1024;
|
|
288
|
+
const ide = 'claude-code';
|
|
289
|
+
const statusline = readClaudeStatuslinePercent();
|
|
290
|
+
if (statusline !== null) {
|
|
291
|
+
return { ratio: statusline, source: 'statusline-poll', capacityBytes, ide, capturedAt };
|
|
292
|
+
}
|
|
293
|
+
if (typeof input.outerSessionId === 'string' && input.outerSessionId.length > 0) {
|
|
294
|
+
const estimate = readClaudeTranscriptEstimate(input.outerSessionId);
|
|
295
|
+
if (estimate !== null) {
|
|
296
|
+
return {
|
|
297
|
+
ratio: estimate.ratio,
|
|
298
|
+
source: 'transcript-estimate',
|
|
299
|
+
rawTokens: estimate.contextTokens,
|
|
300
|
+
capacityTokens: estimate.contextWindowTokens,
|
|
301
|
+
ide,
|
|
302
|
+
capturedAt
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
20
308
|
export const CLAUDE_CODE_ADAPTER = {
|
|
21
309
|
id: 'claude-code',
|
|
22
310
|
displayName: 'Claude Code',
|
|
@@ -71,7 +359,8 @@ export const CLAUDE_CODE_ADAPTER = {
|
|
|
71
359
|
envVarForContextPercent: 'CLAUDE_CONTEXT_USAGE_PERCENT',
|
|
72
360
|
compactCommand: 'claude --compact',
|
|
73
361
|
compactPathway: 'ide-native',
|
|
74
|
-
postCompactDetectCommand: 'peaks compact auto --json'
|
|
362
|
+
postCompactDetectCommand: 'peaks compact auto --json',
|
|
363
|
+
readContextPercentFallback
|
|
75
364
|
},
|
|
76
365
|
// Slice #011: standards profile. Claude Code reads its constitution at
|
|
77
366
|
// CLAUDE.md + module-level rules under .claude/rules/**. The values mirror
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* 其他全部归一化到 peaks 内部模型(见 hook-protocol.ts)。
|
|
13
13
|
*/
|
|
14
14
|
import type { SubAgentDispatcher } from '../dispatch/sub-agent-dispatcher.js';
|
|
15
|
+
import type { ContextPercentProbe } from '../context/auto-compact-types.js';
|
|
15
16
|
export type IdeId = 'claude-code' | 'trae' | 'codex' | 'cursor' | 'qoder' | 'tongyi-lingma' | 'hermes' | 'openclaw' | 'zcode';
|
|
16
17
|
export interface IdeCapabilities {
|
|
17
18
|
/** peaks gate enforce 是否适用该 IDE(必备) */
|
|
@@ -200,6 +201,44 @@ export interface IdeCompactProfile {
|
|
|
200
201
|
* the orchestrator polls `envVarForContextPercent` directly.
|
|
201
202
|
*/
|
|
202
203
|
readonly postCompactDetectCommand?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Optional vendor-specific fallback the generic `readContextPercent`
|
|
206
|
+
* reader calls when the primary env-var probe misses. Returns a
|
|
207
|
+
* completed `ContextPercentProbe` (e.g. `statusline-poll` /
|
|
208
|
+
* `transcript-estimate`) or `null` when the adapter has no signal —
|
|
209
|
+
* the reader then falls through to `conservative-fallback`.
|
|
210
|
+
*
|
|
211
|
+
* Keeps IDE-specific filesystem / env knowledge (Claude Code's
|
|
212
|
+
* `~/.claude/statusline-state.json` + `~/.claude/projects` transcript
|
|
213
|
+
* layout, etc.) inside the adapter, not the generic reader. Adapters
|
|
214
|
+
* that do not opt in simply omit the field; new IDEs are addable
|
|
215
|
+
* without touching the generic reader.
|
|
216
|
+
*
|
|
217
|
+
* Added in slice 2026-09-02-vendor-neutral-context-probe.
|
|
218
|
+
*/
|
|
219
|
+
readonly readContextPercentFallback?: (input: ContextPercentFallbackInput) => ContextPercentProbe | null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Input the generic `readContextPercent` reader passes to an adapter's
|
|
223
|
+
* optional `IdeCompactProfile.readContextPercentFallback` hook. The adapter
|
|
224
|
+
* owns the vendor-specific fallback logic (statusline poll, transcript
|
|
225
|
+
* lookup, etc.); the generic reader stays IDE-agnostic.
|
|
226
|
+
*
|
|
227
|
+
* Added in slice 2026-09-02-vendor-neutral-context-probe.
|
|
228
|
+
*/
|
|
229
|
+
export interface ContextPercentFallbackInput {
|
|
230
|
+
/** Project root (the probe's `--project` anchor). */
|
|
231
|
+
readonly projectRoot: string;
|
|
232
|
+
/** Peaks session id (NOT the harness / IDE transcript id). */
|
|
233
|
+
readonly sessionId: string;
|
|
234
|
+
/**
|
|
235
|
+
* Outer (harness / IDE) session id — the id the IDE uses to name its
|
|
236
|
+
* transcript / session files (e.g. a UUID). Optional: when unresolved,
|
|
237
|
+
* adapters whose fallback depends on it should return `null`.
|
|
238
|
+
*/
|
|
239
|
+
readonly outerSessionId?: string | undefined;
|
|
240
|
+
/** Injectable env (defaults to process.env in the reader). */
|
|
241
|
+
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
203
242
|
}
|
|
204
243
|
/**
|
|
205
244
|
* Per-IDE standards-file location + format profile. Used by the
|
|
@@ -38,6 +38,17 @@ export type BindingStatusView = {
|
|
|
38
38
|
* helper that does no filesystem writes; safe to call from any test.
|
|
39
39
|
*/
|
|
40
40
|
export declare function loadBindingStatus(projectRoot: string): BindingStatusView;
|
|
41
|
+
export declare function readOuterSessionId(): string;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the outer (harness / IDE) session id for the auto-compact
|
|
44
|
+
* context-percent probe. Order: env signal first (PEAKS_OUTER_SESSION_ID /
|
|
45
|
+
* CLAUDE_CODE_SESSION_ID), then the bound peaks session's recorded
|
|
46
|
+
* `outerSessionId` (`session.json` meta). Returns `undefined` when neither is
|
|
47
|
+
* available — the caller passes `undefined` through and the adapter's fallback
|
|
48
|
+
* returns null → conservative-fallback. Vendor-neutral: this helper has no
|
|
49
|
+
* IDE-specific naming; the env-var read lives alongside the binding layer.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveOuterSessionId(projectRoot: string, sessionId: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
41
52
|
/**
|
|
42
53
|
* Render the binding as a pipeable ASCII table. The columns are fixed
|
|
43
54
|
* (no truncation, no wrapping) so a downstream `awk` / `cut` pipeline
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { existsSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { readBinding } from './binding-store.js';
|
|
25
|
+
import { getSessionMeta } from './session-manager.js';
|
|
25
26
|
/**
|
|
26
27
|
* Read the binding from disk and assemble the read-only view. Pure
|
|
27
28
|
* helper that does no filesystem writes; safe to call from any test.
|
|
@@ -37,14 +38,34 @@ export function loadBindingStatus(projectRoot) {
|
|
|
37
38
|
: !Object.values(binding.instances).some((inst) => inst.callerId.startsWith(outerSessionId));
|
|
38
39
|
return { binding, source, projectRoot, stale, outerSessionId };
|
|
39
40
|
}
|
|
40
|
-
function readOuterSessionId() {
|
|
41
|
-
|
|
41
|
+
export function readOuterSessionId() {
|
|
42
|
+
return readOuterSessionIdFromEnv(process.env) ?? 'unknown';
|
|
43
|
+
}
|
|
44
|
+
/** Pure env read (no process.env dependency) so callers can inject an env. */
|
|
45
|
+
function readOuterSessionIdFromEnv(env) {
|
|
46
|
+
const peaks = env.PEAKS_OUTER_SESSION_ID;
|
|
42
47
|
if (typeof peaks === 'string' && peaks.length > 0)
|
|
43
48
|
return peaks;
|
|
44
|
-
const claude =
|
|
49
|
+
const claude = env.CLAUDE_CODE_SESSION_ID;
|
|
45
50
|
if (typeof claude === 'string' && claude.length > 0)
|
|
46
51
|
return claude;
|
|
47
|
-
return
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the outer (harness / IDE) session id for the auto-compact
|
|
56
|
+
* context-percent probe. Order: env signal first (PEAKS_OUTER_SESSION_ID /
|
|
57
|
+
* CLAUDE_CODE_SESSION_ID), then the bound peaks session's recorded
|
|
58
|
+
* `outerSessionId` (`session.json` meta). Returns `undefined` when neither is
|
|
59
|
+
* available — the caller passes `undefined` through and the adapter's fallback
|
|
60
|
+
* returns null → conservative-fallback. Vendor-neutral: this helper has no
|
|
61
|
+
* IDE-specific naming; the env-var read lives alongside the binding layer.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveOuterSessionId(projectRoot, sessionId, env = process.env) {
|
|
64
|
+
const envOuter = readOuterSessionIdFromEnv(env);
|
|
65
|
+
if (envOuter !== undefined)
|
|
66
|
+
return envOuter;
|
|
67
|
+
const recorded = getSessionMeta(projectRoot, sessionId)?.outerSessionId;
|
|
68
|
+
return typeof recorded === 'string' && recorded.length > 0 ? recorded : undefined;
|
|
48
69
|
}
|
|
49
70
|
/**
|
|
50
71
|
* Render the binding as a pipeable ASCII table. The columns are fixed
|
|
@@ -32,9 +32,9 @@ export declare const SkillSearchInputSchema: z.ZodObject<{
|
|
|
32
32
|
status: "status";
|
|
33
33
|
resume: "resume";
|
|
34
34
|
audit: "audit";
|
|
35
|
+
ide: "ide";
|
|
35
36
|
content: "content";
|
|
36
37
|
test: "test";
|
|
37
|
-
ide: "ide";
|
|
38
38
|
research: "research";
|
|
39
39
|
doctor: "doctor";
|
|
40
40
|
triage: "triage";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-loop",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.27",
|
|
4
4
|
"description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"keywords": [
|
|
@@ -101,10 +101,10 @@
|
|
|
101
101
|
"fzf": "^0.5.2",
|
|
102
102
|
"yaml": "^2.9.0",
|
|
103
103
|
"zod": "^4.4.3",
|
|
104
|
-
"peaks-loop-
|
|
105
|
-
"peaks-loop-
|
|
106
|
-
"peaks-loop-shared
|
|
107
|
-
"peaks-loop-
|
|
104
|
+
"peaks-loop-mut": "0.1.25",
|
|
105
|
+
"peaks-loop-internal-runtime": "0.0.12",
|
|
106
|
+
"peaks-loop-shared": "0.0.61",
|
|
107
|
+
"peaks-loop-shared-channel": "0.0.29"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
110
|
"@changesets/cli": "2.31.1",
|