@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/LICENSE +21 -0
- package/README.md +383 -0
- package/dist/agy-keychain.js +70 -0
- package/dist/api.js +154 -0
- package/dist/apps.js +112 -0
- package/dist/args.js +85 -0
- package/dist/codex-reset.js +93 -0
- package/dist/codex-usage.js +105 -0
- package/dist/credentials.js +66 -0
- package/dist/daemon.js +393 -0
- package/dist/doctor.js +119 -0
- package/dist/gui-launch.js +91 -0
- package/dist/handoff.js +167 -0
- package/dist/history.js +35 -0
- package/dist/hooks.js +142 -0
- package/dist/index.js +1693 -0
- package/dist/keychain.js +54 -0
- package/dist/locks.js +89 -0
- package/dist/mappings.js +132 -0
- package/dist/notifications.js +70 -0
- package/dist/notify.js +84 -0
- package/dist/os-notify.js +66 -0
- package/dist/profiles.js +376 -0
- package/dist/providers.js +170 -0
- package/dist/service.js +223 -0
- package/dist/session-preview.js +163 -0
- package/dist/sessions.js +543 -0
- package/dist/settings-inherit.js +81 -0
- package/dist/share.js +257 -0
- package/dist/shellenv.js +116 -0
- package/dist/telemetry.js +269 -0
- package/dist/tmux.js +98 -0
- package/dist/tokens.js +88 -0
- package/dist/usage.js +208 -0
- package/package.json +39 -0
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
|
+
}
|
package/dist/shellenv.js
ADDED
|
@@ -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
|
+
}
|