@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/tmux.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Opt-in tmux integration for in-place session handoff (POSIX only).
3
+ *
4
+ * `run --tmux` wraps a provider session in an agent-switch-MANAGED tmux session
5
+ * (a recorded name). `takeover --in-place` then hands the account over *inside
6
+ * that same pane*: send a clean exit to the running CLI, wait for it to exit,
7
+ * and respawn the pane with the target profile's env — no new terminal.
8
+ *
9
+ * Only agent-switch-managed panes are ever touched (the recorded-name check);
10
+ * a session running in a user's own tmux/terminal is never send-keys'd.
11
+ *
12
+ * The argv builders here are pure (unit-tested); the thin exec + the recorded
13
+ * managed-session file are the only side effects.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { spawnSync } from "node:child_process";
18
+ import { ROOT } from "./profiles.js";
19
+ /** `<ROOT>/tmux-sessions.json`: tmux session name → the profile it manages. */
20
+ export const TMUX_STATE = path.join(ROOT, "tmux-sessions.json");
21
+ /** Deterministic, collision-resistant managed session name for a profile. */
22
+ export function tmuxSessionName(provider, profile) {
23
+ return `asw-${provider}-${profile}`;
24
+ }
25
+ export function readTmuxRegistry(file = TMUX_STATE) {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
28
+ return raw && typeof raw === "object" ? raw : {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ export function recordManagedSession(name, session, file = TMUX_STATE) {
35
+ const reg = readTmuxRegistry(file);
36
+ reg[name] = session;
37
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
38
+ fs.writeFileSync(file, JSON.stringify(reg, null, 2) + "\n", { mode: 0o600 });
39
+ }
40
+ export function forgetManagedSession(name, file = TMUX_STATE) {
41
+ const reg = readTmuxRegistry(file);
42
+ if (name in reg) {
43
+ delete reg[name];
44
+ fs.writeFileSync(file, JSON.stringify(reg, null, 2) + "\n", { mode: 0o600 });
45
+ }
46
+ }
47
+ // ---------- pure argv builders ----------------------------------------------
48
+ /** `tmux new-session -A -s <name> -e VAR=dir -- <cmd...>` — attach-or-create a
49
+ * named session with the profile env exported into it. */
50
+ export function newSessionArgs(name, envVar, dir, cmd) {
51
+ return ["new-session", "-A", "-s", name, "-e", `${envVar}=${dir}`, "--", ...cmd];
52
+ }
53
+ /** `tmux respawn-pane -k -t <target> -e VAR=dir -- <cmd...>` — replace the
54
+ * pane's process with the target profile's env + the resume command. `-k`
55
+ * reliably kills-and-replaces (the pane persists), which is why the in-place
56
+ * handoff uses this rather than a send-keys/close dance that would tear the
57
+ * pane down when the CLI is the pane's own command. */
58
+ export function respawnPaneArgs(target, envVar, dir, cmd) {
59
+ return ["respawn-pane", "-k", "-t", target, "-e", `${envVar}=${dir}`, "--", ...cmd];
60
+ }
61
+ /** `tmux send-keys -t <target> <literal> Enter` — type a command into a pane.
62
+ * Used by `compact` to type `/compact` into an agent-switch-MANAGED pane (the
63
+ * CLI is the pane's own process and stays running, so send-keys is right here,
64
+ * unlike takeover's respawn-pane). NEVER used on a non-managed pane. */
65
+ export function sendKeysArgs(target, literal) {
66
+ return ["send-keys", "-t", target, literal, "Enter"];
67
+ }
68
+ // ---------- environment detection --------------------------------------------
69
+ export function tmuxAvailable() {
70
+ if (process.platform === "win32")
71
+ return false;
72
+ return spawnSync("tmux", ["-V"], { stdio: "ignore" }).status === 0;
73
+ }
74
+ /** Inside a tmux pane? (`$TMUX` is set by tmux for every pane.) */
75
+ export function insideTmux(env = process.env) {
76
+ return typeof env.TMUX === "string" && env.TMUX.length > 0;
77
+ }
78
+ /**
79
+ * The managed session for the current pane, or null. Pure given the current
80
+ * session name + the registry — so `insideTmux` + a `tmux display-message`
81
+ * lookup feed it, and it is unit-testable without tmux.
82
+ */
83
+ export function currentManagedSession(currentName, registry) {
84
+ if (!currentName)
85
+ return null;
86
+ return registry[currentName] ?? null;
87
+ }
88
+ /** The current pane's tmux session name (via `tmux display-message -p '#S'`),
89
+ * or null when not inside tmux / tmux missing. */
90
+ export function currentTmuxSessionName() {
91
+ if (!insideTmux())
92
+ return null;
93
+ const r = spawnSync("tmux", ["display-message", "-p", "#S"], { encoding: "utf8" });
94
+ if (r.status !== 0)
95
+ return null;
96
+ const s = r.stdout.trim();
97
+ return s.length ? s : null;
98
+ }
package/dist/tokens.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Token + cost reporting (roadmap: road-to-agent-switch-session-telemetry,
3
+ * Phase 5), DELEGATED to ccusage (council D2 — verified viable in spikes/t7).
4
+ * agent-switch does NOT re-implement transcript aggregation, dedup, or a
5
+ * pricing engine. It contributes two things ccusage cannot: (1) pointing
6
+ * ccusage at a specific profile's config dir, and (2) the cost-honesty label —
7
+ * agent-switch knows a profile is a subscription/OAuth login, so its cost is
8
+ * "notional" (API-equivalent), not real spend.
9
+ *
10
+ * ccusage is an OPTIONAL external tool (the tmux/playwright pattern): detected
11
+ * on PATH, never bundled. Absent → the caller prints an install pointer.
12
+ */
13
+ import { spawnSync } from "node:child_process";
14
+ /** The command `tokens install` runs (in the GUI's embedded terminal or a
15
+ * user's shell) to install ccusage globally. Fixed literal — never bundled as
16
+ * a dependency (zero-dep invariant + council D2). */
17
+ export const CCUSAGE_INSTALL = ["npm", "install", "-g", "ccusage"];
18
+ /** Resolve the ccusage runner. A PATH binary is preferred; the
19
+ * `AGENT_SWITCH_CCUSAGE` env var overrides with an explicit argv prefix
20
+ * (e.g. "npx -y ccusage@latest") for a zero-install run or tests. Null when
21
+ * neither is available. */
22
+ export function resolveCcusageRunner(env = process.env) {
23
+ const override = env.AGENT_SWITCH_CCUSAGE?.trim();
24
+ if (override)
25
+ return override.split(/\s+/);
26
+ const probe = process.platform === "win32" ? "where" : "which";
27
+ const r = spawnSync(probe, ["ccusage"], { encoding: "utf8" });
28
+ if (r.status === 0 && (r.stdout || "").trim())
29
+ return ["ccusage"];
30
+ return null;
31
+ }
32
+ /** Parse ccusage `daily --json` output ({ daily: [...], totals: {...} }) into
33
+ * our shape. Pure + defensive: unknown/short shapes degrade to empty, never
34
+ * throw. `costBasis` is stamped by the caller (ccusage cannot know it). */
35
+ export function parseCcusageDaily(json, costBasis) {
36
+ const rows = Array.isArray(json?.daily) ? json.daily : Array.isArray(json) ? json : [];
37
+ const num = (v) => (Number.isFinite(v) ? v : 0);
38
+ const days = rows.map((r) => ({
39
+ date: typeof r?.period === "string" ? r.period : typeof r?.date === "string" ? r.date : "?",
40
+ inputTokens: num(r?.inputTokens),
41
+ outputTokens: num(r?.outputTokens),
42
+ cacheCreationTokens: num(r?.cacheCreationTokens),
43
+ cacheReadTokens: num(r?.cacheReadTokens),
44
+ totalTokens: num(r?.totalTokens),
45
+ cost: num(r?.totalCost),
46
+ models: Array.isArray(r?.modelsUsed) ? r.modelsUsed : [],
47
+ }));
48
+ const t = json?.totals ?? {};
49
+ const totals = {
50
+ inputTokens: num(t.inputTokens) || days.reduce((s, d) => s + d.inputTokens, 0),
51
+ outputTokens: num(t.outputTokens) || days.reduce((s, d) => s + d.outputTokens, 0),
52
+ cacheCreationTokens: num(t.cacheCreationTokens) || days.reduce((s, d) => s + d.cacheCreationTokens, 0),
53
+ cacheReadTokens: num(t.cacheReadTokens) || days.reduce((s, d) => s + d.cacheReadTokens, 0),
54
+ totalTokens: num(t.totalTokens) || days.reduce((s, d) => s + d.totalTokens, 0),
55
+ cost: num(t.totalCost) || days.reduce((s, d) => s + d.cost, 0),
56
+ };
57
+ return { days, totals, costBasis };
58
+ }
59
+ /** The cost basis for a profile. agent-switch profiles are OAuth/subscription
60
+ * logins → their token cost is NOTIONAL (API-equivalent, not real spend). A
61
+ * raw API key credential (sk-ant-…) would be `computed`. */
62
+ export function costBasisFor(rawCredential) {
63
+ // A raw API key → real, computed spend. Anything else (OAuth login blob, or
64
+ // unknown) → notional, the safe label that never overstates real spend.
65
+ if (rawCredential && /^sk-(ant|proj)-/.test(rawCredential.trim()))
66
+ return "computed";
67
+ return "notional";
68
+ }
69
+ /** Run ccusage for one profile's config dir and return the parsed report, or
70
+ * null when ccusage failed / produced no JSON (caller degrades gracefully). */
71
+ export function runCcusage(runner, provider, configDir, costBasis) {
72
+ const envKey = provider === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
73
+ const [cmd, ...prefix] = runner;
74
+ const r = spawnSync(cmd, [...prefix, "daily", "--json"], {
75
+ encoding: "utf8",
76
+ timeout: 180_000,
77
+ env: { ...process.env, [envKey]: configDir },
78
+ });
79
+ const out = (r.stdout || "").trim();
80
+ if (!out)
81
+ return null;
82
+ try {
83
+ return parseCcusageDaily(JSON.parse(out), costBasis);
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Usage engine — policy-scoped, own-profile only.
3
+ *
4
+ * Parses Claude's OAuth `/usage` response into a defensive snapshot, formats it
5
+ * for `status`, tracks 30-day history, and detects active-profile threshold
6
+ * crossings. It NEVER compares or ranks accounts — that is the anti-rotation
7
+ * boundary the whole roadmap is locked around. Codex/Antigravity have no usage
8
+ * readout (verified), so they surface "usage unavailable" and never reach here.
9
+ */
10
+ // The windows Claude's /usage exposes, richest first. seven_day_opus/sonnet are
11
+ // the per-model 7-day windows (extension-verified); absent shapes are skipped.
12
+ const WINDOW_DEFS = [
13
+ ["five_hour", "5h"],
14
+ ["seven_day", "7d"],
15
+ ["seven_day_opus", "7d Opus"],
16
+ ["seven_day_sonnet", "7d Sonnet"],
17
+ ];
18
+ /** One entry of the current `limits[]` shape → a window, or null to skip. This
19
+ * array is the ONLY place the per-model weekly limit (e.g. Fable) is exposed;
20
+ * `weekly_all` is the all-models weekly window, `session` the 5h window. */
21
+ function windowFromLimit(l) {
22
+ if (!l || typeof l !== "object")
23
+ return null;
24
+ const utilization = typeof l.percent === "number" ? Math.round(l.percent) : null;
25
+ const resetsAt = typeof l.resets_at === "string" ? l.resets_at : null;
26
+ if (utilization === null && resetsAt === null)
27
+ return null;
28
+ const kind = typeof l.kind === "string" ? l.kind : "";
29
+ if (kind === "session")
30
+ return { key: "five_hour", label: "5h", utilization, resetsAt };
31
+ if (kind === "weekly_all")
32
+ return { key: "seven_day", label: "All", utilization, resetsAt };
33
+ if (kind === "weekly_scoped") {
34
+ const model = l.scope?.model?.display_name;
35
+ const surface = l.scope?.surface;
36
+ const name = (typeof model === "string" && model) || (typeof surface === "string" && surface) || "scoped";
37
+ const key = `weekly_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
38
+ return { key, label: name, utilization, resetsAt };
39
+ }
40
+ return { key: kind || "unknown", label: kind ? kind.replace(/_/g, " ") : "?", utilization, resetsAt };
41
+ }
42
+ /** Defensive parse — unknown shapes degrade to fewer windows, never throw.
43
+ * Prefers the current `limits[]` array (carries the per-model Fable window);
44
+ * falls back to the legacy top-level `five_hour`/`seven_day` keys. */
45
+ export function parseUsage(raw, capturedAt = new Date().toISOString()) {
46
+ const windows = [];
47
+ if (Array.isArray(raw?.limits) && raw.limits.length > 0) {
48
+ const seen = new Set();
49
+ for (const l of raw.limits) {
50
+ const w = windowFromLimit(l);
51
+ if (w && !seen.has(w.key)) {
52
+ seen.add(w.key);
53
+ windows.push(w);
54
+ }
55
+ }
56
+ }
57
+ if (windows.length === 0) {
58
+ for (const [key, label] of WINDOW_DEFS) {
59
+ const w = raw?.[key];
60
+ if (!w || typeof w !== "object")
61
+ continue;
62
+ const utilization = typeof w.utilization === "number" ? Math.round(w.utilization) : null;
63
+ const resetsAt = typeof w.resets_at === "string" ? w.resets_at : null;
64
+ if (utilization === null && resetsAt === null)
65
+ continue;
66
+ windows.push({ key, label, utilization, resetsAt });
67
+ }
68
+ }
69
+ let routines = null;
70
+ const r = raw?.routines;
71
+ if (r && typeof r.used === "number" && typeof r.limit === "number") {
72
+ routines = { used: r.used, limit: r.limit };
73
+ }
74
+ return { windows, routines, capturedAt };
75
+ }
76
+ function resetHint(iso) {
77
+ const d = new Date(iso);
78
+ if (isNaN(d.getTime()))
79
+ return "";
80
+ return ` resets ${d.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" })}`;
81
+ }
82
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
83
+ const DAY_MS = 24 * 60 * 60 * 1000;
84
+ /** Default minimum gap (fraction of the window) before pace is called either
85
+ * way — guards against noise around the on-track line. */
86
+ export const PACE_MIN_GAP = 0.05;
87
+ /** Window length for pace, derived from the key. The 5h window is deliberately
88
+ * excluded (too short/bursty to pace meaningfully); only weekly windows pace. */
89
+ function paceWindowMs(key) {
90
+ if (key === "five_hour")
91
+ return null;
92
+ if (key.startsWith("seven_day") || key.startsWith("weekly"))
93
+ return WEEK_MS;
94
+ return null;
95
+ }
96
+ /**
97
+ * "Ahead of pace" = more of the window's quota consumed than of its cycle
98
+ * elapsed. PURE and stale-safe: time is measured against the snapshot's
99
+ * `capturedAt` (its real measurement instant), never the wall clock, so a
100
+ * re-served stale snapshot is judged against when it was actually taken. This
101
+ * is purely informational — it is NEVER an input to a switch decision (the
102
+ * anti-rotation boundary this file is locked around).
103
+ *
104
+ * Returns null when it cannot be judged: no utilization/reset, the 5h window,
105
+ * within 24h of a reset (suppressed — early-cycle noise), or a captured time
106
+ * outside the window's own cycle.
107
+ */
108
+ export function windowPace(w, capturedAtIso, minGap = PACE_MIN_GAP) {
109
+ if (w.utilization === null || w.resetsAt === null)
110
+ return null;
111
+ const durMs = paceWindowMs(w.key);
112
+ if (durMs === null)
113
+ return null;
114
+ const reset = Date.parse(w.resetsAt);
115
+ const captured = Date.parse(capturedAtIso);
116
+ if (Number.isNaN(reset) || Number.isNaN(captured))
117
+ return null;
118
+ const cycleStart = reset - durMs;
119
+ const elapsedMs = captured - cycleStart;
120
+ if (elapsedMs < DAY_MS)
121
+ return null; // 24h post-reset suppression
122
+ if (elapsedMs <= 0 || elapsedMs > durMs)
123
+ return null; // outside this cycle
124
+ const aheadBy = w.utilization / 100 - elapsedMs / durMs;
125
+ if (aheadBy > minGap)
126
+ return "ahead";
127
+ if (aheadBy < -minGap)
128
+ return "behind";
129
+ return "on-track";
130
+ }
131
+ /** Human-readable lines for `status`; empty when nothing is known. */
132
+ export function formatSnapshot(s) {
133
+ const lines = [];
134
+ for (const w of s.windows) {
135
+ if (w.utilization === null)
136
+ continue;
137
+ // Surface only "ahead of pace" — the one informative signal; on-track /
138
+ // behind are the quiet default and add no line noise.
139
+ const pace = windowPace(w, s.capturedAt) === "ahead" ? " · ahead of pace" : "";
140
+ lines.push(` ${w.label}: ${String(w.utilization).padStart(3)}%${w.resetsAt ? resetHint(w.resetsAt) : ""}${pace}`);
141
+ }
142
+ if (s.routines)
143
+ lines.push(` routines: ${s.routines.used}/${s.routines.limit}`);
144
+ return lines;
145
+ }
146
+ // ---------- headroom + auto-switch decision (pure) --------------------------
147
+ /** Highest utilization across a snapshot's windows (0-100), or null when no
148
+ * window reports a number. This is a profile's OWN headroom, never a ranking. */
149
+ export function maxUtilization(s) {
150
+ const vals = s.windows.map((w) => w.utilization).filter((u) => typeof u === "number");
151
+ return vals.length ? Math.max(...vals) : null;
152
+ }
153
+ /**
154
+ * Opt-in auto-switch decision (pure, no I/O). Given the active profile and the
155
+ * same-provider candidates with their usage, return the profile to switch to,
156
+ * or null. Switches only when the active profile has hit `threshold`% on some
157
+ * window AND another candidate has strictly more headroom (lower max
158
+ * utilization) while itself staying below `threshold`. Ties keep the first
159
+ * candidate. This is a single switch decision gated behind config — never a
160
+ * display ranking.
161
+ */
162
+ export function pickSwitchTarget(active, candidates, threshold, isEligible = () => true) {
163
+ const activeSnap = candidates.find((c) => c.name === active)?.snapshot;
164
+ const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
165
+ if (activeMax === null || activeMax < threshold)
166
+ return null; // active still has headroom
167
+ let best = null;
168
+ for (const c of candidates) {
169
+ if (c.name === active)
170
+ continue;
171
+ if (!isEligible(c.name))
172
+ continue; // tag filter: only eligible accounts are switch targets
173
+ const m = maxUtilization(c.snapshot);
174
+ if (m === null || m >= threshold)
175
+ continue; // unknown or also maxed → not a target
176
+ if (!best || m < best.max)
177
+ best = { name: c.name, max: m };
178
+ }
179
+ return best?.name ?? null;
180
+ }
181
+ export const DEFAULT_THRESHOLDS = [75, 90];
182
+ /**
183
+ * Edge-triggered threshold detection: a threshold fires once when utilization
184
+ * first reaches it, and the fired-set resets when the window rolls over
185
+ * (`resetsAt` changes). Returns the crossings for THIS poll plus the new state
186
+ * to persist. Pure — no I/O, no cross-account view.
187
+ */
188
+ export function detectCrossings(snapshot, prev, thresholds = DEFAULT_THRESHOLDS) {
189
+ const state = {};
190
+ const crossings = [];
191
+ for (const w of snapshot.windows) {
192
+ const prevW = prev[w.key];
193
+ if (w.utilization === null) {
194
+ state[w.key] = prevW ?? { resetsAt: w.resetsAt, fired: [] };
195
+ continue;
196
+ }
197
+ // Same cycle → keep the fired set; rolled over → reset it.
198
+ const fired = prevW && prevW.resetsAt === w.resetsAt ? [...prevW.fired] : [];
199
+ for (const t of thresholds) {
200
+ if (w.utilization >= t && !fired.includes(t)) {
201
+ crossings.push({ window: w.key, threshold: t, utilization: w.utilization });
202
+ fired.push(t);
203
+ }
204
+ }
205
+ state[w.key] = { resetsAt: w.resetsAt, fired };
206
+ }
207
+ return { crossings, state };
208
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@event4u/agent-switch",
3
+ "version": "1.0.0",
4
+ "description": "Switch between multiple Claude Code accounts on macOS, Linux, and Windows via isolated CLAUDE_CONFIG_DIR profiles",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/event4u-app/agent-switch.git"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "agent-switch": "dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "provenance": true
19
+ },
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "prepare": "tsc",
23
+ "test": "tsc -p tsconfig.test.json && cd dist-test && node --test"
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "license": "MIT",
29
+ "devDependencies": {
30
+ "@types/node": "^22.20.1",
31
+ "typescript": "^5.9.3"
32
+ },
33
+ "optionalDependencies": {
34
+ "playwright": "^1.49.0",
35
+ "@event4u/agent-switch-darwin": "1.0.0",
36
+ "@event4u/agent-switch-linux-x64": "1.0.0",
37
+ "@event4u/agent-switch-win32-x64": "1.0.0"
38
+ }
39
+ }