@event4u/agent-switch 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/share.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Share user-level settings across profiles via links.
3
+ *
4
+ * Two link kinds, because Claude Code's settings writer writes atomically
5
+ * (write-temp + rename), which breaks a symlinked FILE but not a symlinked
6
+ * DIRECTORY (issue #40857, verified in tests/integration.test.ts):
7
+ *
8
+ * - Directories (`skills/`, `commands/`, `agents/`) — genuinely shared. A
9
+ * write inside the linked dir lands in the source. Linked as a symlink on
10
+ * POSIX, a junction on win32 (junctions need no elevation).
11
+ * - Files (`settings.json`, `keybindings.json`, `CLAUDE.md`) — linked, but
12
+ * an in-profile `/config` write forks the file (the rename replaces the
13
+ * link with a regular file). `agent-switch share sync` detects a forked link
14
+ * from the manifest, pushes the profile's edit back into the source, and
15
+ * re-links (last-sync-wins across profiles). On win32, file symlinks need
16
+ * Developer Mode/admin, so file links degrade with a message there.
17
+ *
18
+ * Account-/instance-scoped items (`.claude.json`, `.credentials.json`,
19
+ * `plugins/`, `sessions/`, `ide/`, `statsig/`) are deliberately never shared.
20
+ * History (`projects/`, `history.jsonl`, `plans/`) is a POSIX-only opt-in.
21
+ * (`plans/` defaults to `<CLAUDE_CONFIG_DIR>/plans/` in Claude Code, so plan-mode
22
+ * files are otherwise isolated per profile — verified against claude-code 2.1.215.)
23
+ *
24
+ * A manifest (.agent-switch-shared.json) records what agent-switch created, so
25
+ * unshare/sync only ever touch agent-switch-managed links, never user data.
26
+ */
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ const BASE_ITEMS = [
30
+ { name: "settings.json", kind: "file" },
31
+ { name: "keybindings.json", kind: "file" },
32
+ { name: "CLAUDE.md", kind: "file" },
33
+ { name: "skills", kind: "dir" },
34
+ { name: "commands", kind: "dir" },
35
+ { name: "agents", kind: "dir" },
36
+ ];
37
+ /** Conversation history + plan-mode files; opt-in — shares `claude --resume`
38
+ * and the plan store across accounts. `plans/` is a `dir`, so writes inside it
39
+ * reach the source (no atomic-rename fork), exactly like `projects/`. */
40
+ const HISTORY_ITEMS = [
41
+ { name: "projects", kind: "dir" },
42
+ { name: "history.jsonl", kind: "file" },
43
+ { name: "plans", kind: "dir" },
44
+ ];
45
+ /** Names exported for messaging/tests. */
46
+ export const SHARED_ITEMS = BASE_ITEMS.map((i) => i.name);
47
+ export const HISTORY_ITEM_NAMES = HISTORY_ITEMS.map((i) => i.name);
48
+ const ITEM_KIND = new Map([...BASE_ITEMS, ...HISTORY_ITEMS].map((i) => [i.name, i.kind]));
49
+ const MANIFEST = ".agent-switch-shared.json";
50
+ function readManifest(dir) {
51
+ try {
52
+ const data = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), "utf8"));
53
+ return Array.isArray(data?.links) ? data.links : [];
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ function writeManifest(dir, links) {
60
+ fs.writeFileSync(path.join(dir, MANIFEST), JSON.stringify({ links, tool: "agent-switch" }, null, 2) + "\n", { mode: 0o600 });
61
+ }
62
+ /**
63
+ * Create a link of the right kind for the platform. Directories use a junction
64
+ * on win32 (no elevation); files use a native symlink everywhere. Throws on
65
+ * failure so the caller can degrade (win32 file symlink without Dev Mode).
66
+ */
67
+ function link(src, dst, kind) {
68
+ if (kind === "dir") {
69
+ fs.symlinkSync(src, dst, process.platform === "win32" ? "junction" : "dir");
70
+ }
71
+ else {
72
+ fs.symlinkSync(src, dst, process.platform === "win32" ? "file" : undefined);
73
+ }
74
+ }
75
+ /** True when the existing symlink already points at src (junction paths on
76
+ * win32 may carry a trailing separator / `\\?\` prefix, so compare loosely). */
77
+ function pointsAt(dst, src) {
78
+ try {
79
+ const target = fs.readlinkSync(dst).replace(/[\\/]+$/, "");
80
+ return path.resolve(target) === path.resolve(src);
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ // Keys that must never propagate across profiles via a shared settings.json:
87
+ // MCP server definitions (which can carry OAuth/device tokens) and any
88
+ // token-/secret-shaped key. `.claude.json` / `.credentials.json` are already
89
+ // excluded from sharing entirely; this closes the one shared surface that is a
90
+ // whole-file symlink with no per-key filtering.
91
+ const SENSITIVE_SETTINGS_KEY = /^(mcpServers$|.*(token|secret|api[_-]?key|credential|oauth).*)/i;
92
+ /** Reason string when a settings.json carries account-scoped keys that must not
93
+ * be shared, else null. Malformed JSON is not blocked — the guard targets the
94
+ * known sensitive keys, not file validity. Exported for the share guard test. */
95
+ export function settingsSharingRisk(settingsPath) {
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ if (!parsed || typeof parsed !== "object")
104
+ return null;
105
+ const hit = Object.keys(parsed).find((k) => SENSITIVE_SETTINGS_KEY.test(k));
106
+ return hit ? `contains "${hit}" — MCP/token state is account-scoped and never shared` : null;
107
+ }
108
+ /** Link items from sourceDir into targetDir. Returns human-readable actions. */
109
+ export function applySharing(sourceDir, targetDir, withHistory) {
110
+ const actions = [];
111
+ const items = withHistory ? [...BASE_ITEMS, ...HISTORY_ITEMS] : [...BASE_ITEMS];
112
+ const managed = new Set(readManifest(targetDir));
113
+ fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
114
+ for (const item of items) {
115
+ const src = path.join(sourceDir, item.name);
116
+ const dst = path.join(targetDir, item.name);
117
+ if (!fs.existsSync(src))
118
+ continue;
119
+ let st = null;
120
+ try {
121
+ st = fs.lstatSync(dst);
122
+ }
123
+ catch {
124
+ /* absent */
125
+ }
126
+ if (st?.isSymbolicLink()) {
127
+ if (pointsAt(dst, src)) {
128
+ managed.add(item.name);
129
+ continue; // already correct
130
+ }
131
+ if (!managed.has(item.name)) {
132
+ actions.push(`skipped ${item.name}: foreign symlink present`);
133
+ continue;
134
+ }
135
+ fs.unlinkSync(dst); // ours, retarget
136
+ }
137
+ else if (st) {
138
+ // A regular file/dir sits where a link would go.
139
+ if (item.kind === "file" && managed.has(item.name)) {
140
+ // We linked it before; it's now a regular file → an in-profile write
141
+ // forked it. Don't clobber — `share sync` reconciles it.
142
+ actions.push(`forked ${item.name}: run 'agent-switch share sync' to re-link`);
143
+ continue;
144
+ }
145
+ actions.push(`skipped ${item.name}: profile has its own copy (remove it to share)`);
146
+ continue;
147
+ }
148
+ if (item.name === "settings.json") {
149
+ const risk = settingsSharingRisk(src);
150
+ if (risk) {
151
+ actions.push(`skipped ${item.name}: ${risk}`);
152
+ continue;
153
+ }
154
+ }
155
+ try {
156
+ link(src, dst, item.kind);
157
+ managed.add(item.name);
158
+ actions.push(`linked ${item.name}`);
159
+ }
160
+ catch (err) {
161
+ if (process.platform === "win32" && item.kind === "file") {
162
+ actions.push(`skipped ${item.name}: Windows file symlinks need Developer Mode or admin ` +
163
+ `(directories are shared via junction regardless)`);
164
+ }
165
+ else {
166
+ throw err;
167
+ }
168
+ }
169
+ }
170
+ writeManifest(targetDir, [...managed]);
171
+ return actions;
172
+ }
173
+ /**
174
+ * Reconcile forked file links: a managed file that is now a regular file was
175
+ * forked by an in-profile `/config` write. Push its content back into the
176
+ * shared source (so the edit propagates, last-sync-wins), then re-link. Also
177
+ * re-creates a managed link that vanished. Directory links do not fork, so an
178
+ * unexpected directory in place of a link is reported, never clobbered.
179
+ */
180
+ export function syncSharing(sourceDir, targetDir) {
181
+ const actions = [];
182
+ for (const name of readManifest(targetDir)) {
183
+ const src = path.join(sourceDir, name);
184
+ const dst = path.join(targetDir, name);
185
+ const kind = ITEM_KIND.get(name) ?? "file";
186
+ let st = null;
187
+ try {
188
+ st = fs.lstatSync(dst);
189
+ }
190
+ catch {
191
+ /* missing */
192
+ }
193
+ if (!st) {
194
+ if (fs.existsSync(src)) {
195
+ link(src, dst, kind);
196
+ actions.push(`relinked ${name} (was missing)`);
197
+ }
198
+ continue;
199
+ }
200
+ if (st.isSymbolicLink())
201
+ continue; // intact
202
+ if (st.isFile()) {
203
+ // Forked file: propagate the profile's edit to the source, then re-link.
204
+ if (name === "settings.json") {
205
+ const risk = settingsSharingRisk(dst);
206
+ if (risk) {
207
+ actions.push(`skipped ${name}: ${risk} — profile edit not pushed to the shared source`);
208
+ continue;
209
+ }
210
+ }
211
+ fs.mkdirSync(path.dirname(src), { recursive: true });
212
+ fs.copyFileSync(dst, src);
213
+ fs.unlinkSync(dst);
214
+ link(src, dst, "file");
215
+ actions.push(`synced ${name} (profile edit pushed to source, re-linked)`);
216
+ }
217
+ else {
218
+ actions.push(`skipped ${name}: unexpected directory where a link was — left untouched`);
219
+ }
220
+ }
221
+ return actions;
222
+ }
223
+ /** Health of each manifest-managed link in a profile, for `doctor`.
224
+ * `linked` = intact symlink/junction; `forked` = a /config write replaced it
225
+ * with a regular file (run `share sync`); `missing` = the link is gone. */
226
+ export function sharedLinkHealth(targetDir) {
227
+ return readManifest(targetDir).map((name) => {
228
+ const dst = path.join(targetDir, name);
229
+ try {
230
+ return { name, state: fs.lstatSync(dst).isSymbolicLink() ? "linked" : "forked" };
231
+ }
232
+ catch {
233
+ return { name, state: "missing" };
234
+ }
235
+ });
236
+ }
237
+ /** Remove agent-switch-created links only (manifest-guarded). */
238
+ export function removeSharing(targetDir) {
239
+ const actions = [];
240
+ for (const name of readManifest(targetDir)) {
241
+ const dst = path.join(targetDir, name);
242
+ try {
243
+ const st = fs.lstatSync(dst);
244
+ // A symlink (POSIX) or a junction (win32, reported as a symlink by
245
+ // lstat) is ours to remove; a forked regular file is the user's, leave it.
246
+ if (st.isSymbolicLink()) {
247
+ fs.unlinkSync(dst);
248
+ actions.push(`unlinked ${name}`);
249
+ }
250
+ }
251
+ catch {
252
+ /* already gone */
253
+ }
254
+ }
255
+ writeManifest(targetDir, []);
256
+ return actions;
257
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shell integration snippets, per shell — a wrapper for each provider binary
3
+ * (`claude`, `codex`, `agy`) plus `asw`.
4
+ *
5
+ * Each binary wrapper injects that provider's isolation env var from the
6
+ * resolved profile (`agent-switch dir --provider <id>`: mapping >
7
+ * active-for-provider > default). The wrapper must call the REAL binary, not
8
+ * recurse — each shell has its own escape (`command` on POSIX/fish,
9
+ * `Get-Command -CommandType Application` on PowerShell). cmd.exe has no clean
10
+ * function-wrapper story, so there is no cmd snippet — `agent-switch run <name>
11
+ * [--provider p]` is the cmd.exe path.
12
+ *
13
+ * `asw` convenience: bare `asw` lists all providers' profiles; `asw <name>`
14
+ * switches the active Claude profile; `asw <provider> <name>` switches that
15
+ * provider's.
16
+ */
17
+ import { allProviders } from "./providers.js";
18
+ export const SHELLS = ["zsh", "bash", "fish", "powershell"];
19
+ /** Resolve the target shell: explicit request wins, else detect from the
20
+ * environment / platform. Windows defaults to PowerShell; POSIX reads $SHELL. */
21
+ export function detectShell(requested, env = process.env, platform = process.platform) {
22
+ if (requested) {
23
+ const r = requested.toLowerCase();
24
+ if (r === "pwsh" || r === "powershell")
25
+ return "powershell";
26
+ if (SHELLS.includes(r))
27
+ return r;
28
+ throw new Error(`unknown shell "${requested}" (choose: ${SHELLS.join(", ")})`);
29
+ }
30
+ if (platform === "win32")
31
+ return "powershell";
32
+ const sh = env.SHELL ?? "";
33
+ if (sh.includes("fish"))
34
+ return "fish";
35
+ if (sh.includes("bash"))
36
+ return "bash";
37
+ if (sh.includes("zsh"))
38
+ return "zsh";
39
+ return "zsh"; // sensible POSIX default when $SHELL is unset
40
+ }
41
+ function posixWrapper(binary, envVar, id) {
42
+ return `${binary}() {
43
+ local dir
44
+ dir="$(command agent-switch dir --provider ${id} 2>/dev/null)"
45
+ if [ -n "$dir" ]; then
46
+ ${envVar}="$dir" command ${binary} "$@"
47
+ else
48
+ command ${binary} "$@"
49
+ fi
50
+ }`;
51
+ }
52
+ function fishWrapper(binary, envVar, id) {
53
+ return `function ${binary}
54
+ set -l dir (command agent-switch dir --provider ${id} 2>/dev/null)
55
+ if test -n "$dir"
56
+ ${envVar}=$dir command ${binary} $argv
57
+ else
58
+ command ${binary} $argv
59
+ end
60
+ end`;
61
+ }
62
+ function powershellWrapper(binary, envVar, id) {
63
+ return `function ${binary} {
64
+ $exe = Get-Command ${binary} -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
65
+ if (-not $exe) { Write-Error '${binary} not found on PATH.'; return }
66
+ $dir = (& agent-switch dir --provider ${id} 2>$null)
67
+ if ($dir) {
68
+ $prev = Get-Item -Path Env:\\${envVar} -ErrorAction SilentlyContinue
69
+ $env:${envVar} = $dir
70
+ try { & $exe.Source @args } finally { if ($prev) { $env:${envVar} = $prev.Value } else { Remove-Item Env:\\${envVar} -ErrorAction SilentlyContinue } }
71
+ } else {
72
+ & $exe.Source @args
73
+ }
74
+ }`;
75
+ }
76
+ function build(header, wrappers, asw) {
77
+ return [header, ...wrappers, asw].join("\n");
78
+ }
79
+ export function shellenvScript(shell) {
80
+ const providers = allProviders();
81
+ if (shell === "fish") {
82
+ const header = `# agent-switch shell integration — add to ~/.config/fish/config.fish: agent-switch shellenv --shell fish | source`;
83
+ const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
84
+ function asw
85
+ if test (count $argv) -eq 0
86
+ command agent-switch list
87
+ else if contains -- $argv[1] claude codex antigravity
88
+ command agent-switch use $argv[2] --provider $argv[1]
89
+ else
90
+ command agent-switch use $argv
91
+ end
92
+ end`;
93
+ return build(header, providers.map((p) => fishWrapper(p.binary, p.envVar, p.id)), asw);
94
+ }
95
+ if (shell === "powershell") {
96
+ const header = `# agent-switch shell integration — add to $PROFILE: agent-switch shellenv --shell powershell | Out-String | Invoke-Expression`;
97
+ const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
98
+ function asw {
99
+ if ($args.Count -eq 0) { command agent-switch list; return }
100
+ if (@('claude','codex','antigravity') -contains $args[0]) { & agent-switch use $args[1] --provider $args[0] }
101
+ else { & agent-switch use @args }
102
+ }`;
103
+ return build(header, providers.map((p) => powershellWrapper(p.binary, p.envVar, p.id)), asw);
104
+ }
105
+ // zsh + bash share the POSIX snippet.
106
+ const header = `# agent-switch shell integration — add to your rc file: eval "$(agent-switch shellenv)"`;
107
+ const asw = `# Convenience: "asw" lists all; "asw work" switches claude; "asw codex work" switches a provider
108
+ asw() {
109
+ if [ $# -eq 0 ]; then command agent-switch list; return; fi
110
+ case "$1" in
111
+ claude|codex|antigravity) command agent-switch use "$2" --provider "$1" ;;
112
+ *) command agent-switch use "$@" ;;
113
+ esac
114
+ }`;
115
+ return build(header, providers.map((p) => posixWrapper(p.binary, p.envVar, p.id)), asw);
116
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Session telemetry — the SINGLE sanctioned transcript reader (roadmap:
3
+ * road-to-agent-switch-session-telemetry, Phase 1, decision gate D0).
4
+ *
5
+ * Iron rule amendment (D0, verified by scripts/spikes/t1+t2 on claude 2.1.210):
6
+ * transcripts remain OPAQUE, version-unstable blobs *for transfer* (see
7
+ * sessions.ts — takeover never parses beyond line 1). READ-ONLY TELEMETRY is
8
+ * one of the two sanctioned body readers (the other is read-only PREVIEW,
9
+ * `src/session-preview.ts`, ADR-002); telemetry parsing is permitted here and
10
+ * ONLY here, under four gates:
11
+ * 1. version matrix — SUPPORTED_CLAUDE below; unknown → low confidence.
12
+ * 2. pre-flight canary — `preflightClaude`, run by the daemon on start.
13
+ * 3. degraded mode — every reader returns null / low confidence rather
14
+ * than throwing; the caller shows staleness, never
15
+ * a crash or a silently-wrong number.
16
+ * 4. confidence scoring — malformed-line ratio → high | low.
17
+ *
18
+ * Zero deps (node built-ins only). Pure + fixture-tested. Own-session only —
19
+ * this reads a session's OWN context/token state, never a cross-account view
20
+ * (the anti-rotation lock).
21
+ *
22
+ * Ground truth (scripts/spikes/t1, t3):
23
+ * Claude assistant line: { type:"assistant", isSidechain?, sessionId,
24
+ * message:{ id, model, stop_reason, usage:{ input_tokens,
25
+ * cache_read_input_tokens, cache_creation_input_tokens, output_tokens }}}.
26
+ * Context size = last FINALIZED (stop_reason set) MAIN-CHAIN
27
+ * (isSidechain !== true) non-synthetic entry's input-side sum; output
28
+ * excluded (matches the official statusline used_percentage).
29
+ * Codex rollout line: { type:"event_msg", payload:{ type:"token_count",
30
+ * info:{ total_token_usage:{ total_tokens, ... }, model_context_window }}}.
31
+ * `info` is nullable per event → walk backward to the last non-null one.
32
+ */
33
+ import * as fs from "node:fs";
34
+ /** Bytes tailed from the end of a transcript. A live context reading only
35
+ * needs the newest entries; capping the read keeps the daemon cycle cheap. */
36
+ export const TAIL_CAP = 256 * 1024;
37
+ /** Claude-Code major.minor prefixes the fixtures + spikes were verified on.
38
+ * A running version outside this set still works but reads at low confidence
39
+ * (the daemon logs a version-drift warning). */
40
+ export const SUPPORTED_CLAUDE = ["2.1"];
41
+ /** Effective context windows by model (transcript JSONL carries no window
42
+ * size — only the statusline does, in-band). Unknown model → null (show raw
43
+ * tokens, never guess a percentage). User overrides via state.json merge on
44
+ * top. Codex needs no table — its rollout carries model_context_window. */
45
+ export const CONTEXT_WINDOWS = {
46
+ "claude-fable-5": 1_000_000,
47
+ "claude-opus-4-8": 1_000_000,
48
+ "claude-opus-4-7": 1_000_000,
49
+ "claude-opus-4-6": 1_000_000,
50
+ "claude-sonnet-4-6": 1_000_000,
51
+ "claude-sonnet-5": 1_000_000,
52
+ "claude-sonnet-4-5": 200_000,
53
+ "claude-opus-4-5": 200_000,
54
+ "claude-opus-4-1": 200_000,
55
+ "claude-haiku-4-5": 200_000,
56
+ };
57
+ export function contextWindowFor(model, overrides = {}) {
58
+ if (!model)
59
+ return null;
60
+ return overrides[model] ?? CONTEXT_WINDOWS[model] ?? null;
61
+ }
62
+ /** Read the last `cap` bytes of a file as complete lines. When the file is
63
+ * larger than the cap, the first (partial) line is dropped. Returns [] on any
64
+ * error — degraded mode, never throws. */
65
+ export function tailLines(file, cap = TAIL_CAP) {
66
+ let fd;
67
+ try {
68
+ fd = fs.openSync(file, "r");
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ try {
74
+ const size = fs.fstatSync(fd).size;
75
+ const start = size > cap ? size - cap : 0;
76
+ const len = size - start;
77
+ const buf = Buffer.alloc(len);
78
+ fs.readSync(fd, buf, 0, len, start);
79
+ const text = buf.toString("utf8");
80
+ const lines = text.split("\n");
81
+ if (start > 0 && lines.length > 0)
82
+ lines.shift(); // drop the partial first line
83
+ return lines.filter((l) => l.length > 0);
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ finally {
89
+ fs.closeSync(fd);
90
+ }
91
+ }
92
+ function isVersionSupported(version) {
93
+ if (!version)
94
+ return false;
95
+ return SUPPORTED_CLAUDE.some((p) => version.startsWith(p));
96
+ }
97
+ /**
98
+ * Claude live context from a transcript. Walks the tail backward to the last
99
+ * FINALIZED (stop_reason set) MAIN-CHAIN (isSidechain !== true) non-synthetic
100
+ * assistant entry; sums the input side (input + cache_read + cache_creation),
101
+ * excludes output — matching the official statusline used_percentage. Subagent
102
+ * (sidechain) tokens never inflate the parent context reading.
103
+ *
104
+ * @param claudeVersion the running `claude --version` string, for the version
105
+ * gate; unknown/unsupported → confidence "low" (still returns the number).
106
+ */
107
+ export function readClaudeContext(file, opts = {}) {
108
+ const lines = tailLines(file, opts.cap ?? TAIL_CAP);
109
+ if (lines.length === 0)
110
+ return null;
111
+ let parsed = 0;
112
+ let malformed = 0;
113
+ let hit = null;
114
+ for (let i = lines.length - 1; i >= 0; i--) {
115
+ let o;
116
+ try {
117
+ o = JSON.parse(lines[i]);
118
+ parsed++;
119
+ }
120
+ catch {
121
+ malformed++;
122
+ continue;
123
+ }
124
+ if (hit)
125
+ continue; // keep counting malformed for confidence, but we have our entry
126
+ if (o?.type !== "assistant")
127
+ continue;
128
+ if (o.isSidechain === true)
129
+ continue;
130
+ const m = o.message;
131
+ if (!m || typeof m !== "object" || !m.usage)
132
+ continue;
133
+ if (m.model === "<synthetic>")
134
+ continue;
135
+ const sr = m.stop_reason;
136
+ if (sr === null || sr === undefined)
137
+ continue; // streaming intermediate
138
+ const u = m.usage;
139
+ const inputSide = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
140
+ hit = { inputSide, model: typeof m.model === "string" ? m.model : null, ts: o.timestamp ?? null };
141
+ }
142
+ if (!hit)
143
+ return null;
144
+ const total = parsed + malformed;
145
+ const malformedRatio = total > 0 ? malformed / total : 0;
146
+ const confidence = malformedRatio > 0.1 || !isVersionSupported(opts.claudeVersion) ? "low" : "high";
147
+ const windowTokens = contextWindowFor(hit.model, opts.overrides);
148
+ const pct = windowTokens ? Math.round((hit.inputSide / windowTokens) * 1000) / 10 : null;
149
+ return {
150
+ provider: "claude",
151
+ contextTokens: hit.inputSide,
152
+ windowTokens,
153
+ pct,
154
+ model: hit.model,
155
+ timestamp: hit.ts,
156
+ confidence,
157
+ };
158
+ }
159
+ /**
160
+ * Codex live context from a rollout file. Walks the tail backward to the last
161
+ * `event_msg` whose `payload.type === "token_count"` carries a non-null `info`
162
+ * (short/aborted sessions emit info:null — verified in spikes/t3). Context =
163
+ * `total_token_usage.total_tokens`; window = in-band `model_context_window`.
164
+ */
165
+ export function readCodexContext(file, opts = {}) {
166
+ const lines = tailLines(file, opts.cap ?? TAIL_CAP);
167
+ if (lines.length === 0)
168
+ return null;
169
+ let parsed = 0;
170
+ let malformed = 0;
171
+ for (let i = lines.length - 1; i >= 0; i--) {
172
+ let o;
173
+ try {
174
+ o = JSON.parse(lines[i]);
175
+ parsed++;
176
+ }
177
+ catch {
178
+ malformed++;
179
+ continue;
180
+ }
181
+ const p = o?.payload ?? o;
182
+ if (p?.type !== "token_count")
183
+ continue;
184
+ const info = p.info;
185
+ if (!info || typeof info !== "object")
186
+ continue; // info:null — keep walking back
187
+ const usage = info.total_token_usage || info.last_token_usage;
188
+ if (!usage || !Number.isFinite(usage.total_tokens))
189
+ continue;
190
+ const windowTokens = Number.isFinite(info.model_context_window) ? info.model_context_window : null;
191
+ const pct = windowTokens ? Math.round((usage.total_tokens / windowTokens) * 1000) / 10 : null;
192
+ const total = parsed + malformed;
193
+ const confidence = total > 0 && malformed / total > 0.1 ? "low" : "high";
194
+ return {
195
+ provider: "codex",
196
+ contextTokens: usage.total_tokens,
197
+ windowTokens,
198
+ pct,
199
+ model: null, // codex rollout token_count carries no model id; window is in-band
200
+ timestamp: typeof o.timestamp === "string" ? o.timestamp : null,
201
+ confidence,
202
+ };
203
+ }
204
+ return null;
205
+ }
206
+ /** Provider-dispatching reader. */
207
+ export function readContext(provider, file, opts = {}) {
208
+ return provider === "codex" ? readCodexContext(file, opts) : readClaudeContext(file, opts);
209
+ }
210
+ /** Idle guard for `compact` (Phase 4). A turn is "in flight" when the LAST
211
+ * transcript line is a non-finalized assistant entry (`stop_reason` null) — we
212
+ * must not type `/compact` into a running turn. Pure; degraded → false (don't
213
+ * block on an unreadable file). `nowMs`/`maxAgeMs` bound how recent the
214
+ * in-flight entry must be to still count (a stale null entry from a crashed
215
+ * turn should not block forever). */
216
+ export function turnInFlight(file, nowMs, maxAgeMs) {
217
+ const lines = tailLines(file, 64 * 1024);
218
+ for (let i = lines.length - 1; i >= 0; i--) {
219
+ let o;
220
+ try {
221
+ o = JSON.parse(lines[i]);
222
+ }
223
+ catch {
224
+ continue;
225
+ }
226
+ if (o?.type !== "assistant")
227
+ return false; // newest entry isn't an assistant turn
228
+ const sr = o.message?.stop_reason;
229
+ if (sr !== null && sr !== undefined)
230
+ return false; // finalized → not in flight
231
+ const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
232
+ if (Number.isNaN(ts))
233
+ return true; // no timestamp but non-finalized → assume in flight
234
+ return nowMs - ts <= maxAgeMs; // recent + non-finalized → in flight
235
+ }
236
+ return false;
237
+ }
238
+ /** A session's context reading joined from its on-disk transcript. `file` is
239
+ * the resolved transcript/rollout path; `provider` selects the reader. Thin
240
+ * join so the CLI/daemon can go SessionRow → path → reading in one call. */
241
+ export function sessionContext(sess, opts = {}) {
242
+ return readContext(sess.provider, sess.file, opts);
243
+ }
244
+ /** Resolve a Claude session's transcript path from its config dir + on-disk
245
+ * encoded project dir + session id (the sessions.ts SessionRow shape). Pure
246
+ * path join — does not touch the filesystem. */
247
+ export function claudeTranscriptPath(configDir, projectDir, sessionId) {
248
+ return `${configDir}/projects/${projectDir}/${sessionId}.jsonl`;
249
+ }
250
+ /**
251
+ * Pre-flight canary (D0 gate 2). Validates that a transcript still parses into
252
+ * a usable context reading on the running Claude version. The daemon runs this
253
+ * on start; a failure flips the feature to degraded mode with a loud log line
254
+ * rather than emitting silently-wrong numbers.
255
+ */
256
+ export function preflightClaude(file, claudeVersion) {
257
+ const reading = readClaudeContext(file, { claudeVersion });
258
+ if (!reading) {
259
+ return { ok: false, confidence: "low", reason: "no finalized main-chain assistant entry found — format may have drifted" };
260
+ }
261
+ if (!isVersionSupported(claudeVersion)) {
262
+ return {
263
+ ok: true,
264
+ confidence: "low",
265
+ reason: `claude version ${claudeVersion ?? "unknown"} outside tested set [${SUPPORTED_CLAUDE.join(", ")}] — reading at low confidence`,
266
+ };
267
+ }
268
+ return { ok: true, confidence: reading.confidence, reason: "ok" };
269
+ }