@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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * macOS Keychain access for Claude Code credentials.
3
+ *
4
+ * Contract knowledge adopted from claude-swap (session.py, macos_keychain.py),
5
+ * which references claude-code's own envUtils.ts / macOsKeychainHelpers.ts:
6
+ *
7
+ * - Default install: service "Claude Code-credentials", account = username.
8
+ * - With CLAUDE_CONFIG_DIR set, Claude Code hashes the *raw, unresolved* env
9
+ * var value, NFC-normalized: service = "Claude Code-credentials-" +
10
+ * sha256(NFC(dir)).hex[:8]. Hash exactly the string that gets exported.
11
+ * - Claude Code reads the keychain BEFORE the plaintext .credentials.json
12
+ * fallback — a stale hashed entry shadows a fresh file seed, so delete the
13
+ * hashed entry before seeding a profile.
14
+ * - This is an internal contract of Claude Code, not a public API. Everything
15
+ * here is best-effort and must degrade gracefully if the naming changes.
16
+ */
17
+ import { execFileSync } from "node:child_process";
18
+ import * as crypto from "node:crypto";
19
+ import * as os from "node:os";
20
+ export const DEFAULT_SERVICE = "Claude Code-credentials";
21
+ export function isMac() {
22
+ return process.platform === "darwin";
23
+ }
24
+ /** Keychain service name Claude Code derives for a CLAUDE_CONFIG_DIR. */
25
+ export function serviceNameFor(configDir) {
26
+ const normalized = configDir.normalize("NFC");
27
+ const digest = crypto.createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 8);
28
+ return `${DEFAULT_SERVICE}-${digest}`;
29
+ }
30
+ function security(args) {
31
+ try {
32
+ return execFileSync("security", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Read a generic password; null if missing/off-macOS/denied. */
39
+ export function getPassword(service) {
40
+ if (!isMac())
41
+ return null;
42
+ const out = security(["find-generic-password", "-a", os.userInfo().username, "-w", "-s", service]);
43
+ return out === null ? null : out.replace(/\n$/, "");
44
+ }
45
+ /** Best-effort delete of a generic password entry. */
46
+ export function deletePassword(service) {
47
+ if (!isMac())
48
+ return false;
49
+ return (security(["delete-generic-password", "-a", os.userInfo().username, "-s", service]) !== null);
50
+ }
51
+ /** The live credential of the default ~/.claude install (keychain, macOS). */
52
+ export function readDefaultCredential() {
53
+ return getPassword(DEFAULT_SERVICE);
54
+ }
package/dist/locks.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Cooperate with Claude Code's own advisory locks while reading its files.
3
+ *
4
+ * Protocol adopted from claude-swap's claude_locks.py (which documents
5
+ * claude-code's utils/auth.ts, utils/config.ts, utils/lockfile.ts):
6
+ *
7
+ * - Claude Code guards its OAuth token refresh with npm `proper-lockfile` on
8
+ * the config home dir, and its ~/.claude.json writes on the config file.
9
+ * - The lock artifact is a DIRECTORY at "<target>.lock"; mkdir atomicity is
10
+ * the mutex. A lock is stale when its mtime is older than 10s; live holders
11
+ * touch every 5s. Claude Code retries a held credentials lock 5x with 1-2s
12
+ * jittered sleeps, so briefly holding it is fully cooperative.
13
+ *
14
+ * Why we need it: `agent-switch import` snapshots the live default credential. A
15
+ * read landing inside Claude Code's refresh window (read → network → save,
16
+ * all under ~/.claude.lock) could capture a pre-rotation refresh token that
17
+ * is dead the moment the refresh saves. Holding the lock during the read
18
+ * closes that race.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ const STALENESS_MS = 10_000;
23
+ const TOUCH_INTERVAL_MS = 3_000; // faster than claude-code's 5s, for margin
24
+ const DEFAULT_TIMEOUT_MS = 9_000;
25
+ const RETRY_SLEEP_MS = 250;
26
+ function lockDirFor(target) {
27
+ return path.join(path.dirname(target), path.basename(target) + ".lock");
28
+ }
29
+ function sleep(ms) {
30
+ return new Promise((r) => setTimeout(r, ms));
31
+ }
32
+ /** Acquire a proper-lockfile-compatible directory lock; run fn; release. */
33
+ export async function withProperLock(target, fn, timeoutMs = DEFAULT_TIMEOUT_MS) {
34
+ const lockDir = lockDirFor(target);
35
+ const deadline = Date.now() + timeoutMs;
36
+ for (;;) {
37
+ try {
38
+ fs.mkdirSync(lockDir); // atomic acquire
39
+ break;
40
+ }
41
+ catch (err) {
42
+ if (err?.code !== "EEXIST")
43
+ throw err;
44
+ // Held — take over if stale, else wait.
45
+ let stale = false;
46
+ try {
47
+ stale = Date.now() - fs.statSync(lockDir).mtimeMs > STALENESS_MS;
48
+ }
49
+ catch {
50
+ continue; // vanished between mkdir and stat — retry immediately
51
+ }
52
+ if (stale) {
53
+ try {
54
+ fs.rmdirSync(lockDir);
55
+ }
56
+ catch {
57
+ /* raced with another taker — loop */
58
+ }
59
+ continue;
60
+ }
61
+ if (Date.now() > deadline) {
62
+ throw new Error(`timed out waiting for Claude Code's lock at ${lockDir} — ` +
63
+ `a running session may be mid-refresh; retry in a moment`);
64
+ }
65
+ await sleep(RETRY_SLEEP_MS);
66
+ }
67
+ }
68
+ const toucher = setInterval(() => {
69
+ try {
70
+ const now = new Date();
71
+ fs.utimesSync(lockDir, now, now);
72
+ }
73
+ catch {
74
+ /* released early / removed — nothing to do */
75
+ }
76
+ }, TOUCH_INTERVAL_MS);
77
+ try {
78
+ return await fn();
79
+ }
80
+ finally {
81
+ clearInterval(toucher);
82
+ try {
83
+ fs.rmdirSync(lockDir);
84
+ }
85
+ catch {
86
+ /* best-effort */
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Directory → (provider, profile) mappings for automatic per-repo account
3
+ * selection across all three CLI assistants.
4
+ *
5
+ * `agent-switch dir --provider <id>` resolves the current working directory to
6
+ * the nearest mapped ancestor for that provider, so each shell wrapper
7
+ * (`claude`/`codex`/`agy`) picks the right account per repository without
8
+ * switching. Precedence in the wrapper: directory mapping > active-for-provider
9
+ * > default.
10
+ *
11
+ * Keys are normalized (expanded, absolute, symlink-resolved; win32 drive-letter
12
+ * canonicalized) paths; values are `{ provider, name }`. A path may map a
13
+ * different profile per provider, so the stored value is keyed by provider:
14
+ * `{ "<path>": { claude: "<name>", codex: "<name>", ... } }`.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { ROOT, ensureRoot } from "./profiles.js";
19
+ const MAPPINGS_FILE = path.join(ROOT, "mappings.json");
20
+ /**
21
+ * Windows path canonicalization for mapping keys. NTFS is case-insensitive and
22
+ * drive letters appear in both cases (`C:\` vs `c:\`), so a mapping keyed under
23
+ * one case must match a lookup in the other. Uppercase the drive letter (the
24
+ * Windows convention). Pure — no FS access — so it is unit-tested cross-platform.
25
+ * No-op off win32.
26
+ */
27
+ export function canonicalizeWin32(resolved, platform = process.platform) {
28
+ if (platform !== "win32")
29
+ return resolved;
30
+ return resolved.replace(/^([a-z]):/, (_m, d) => d.toUpperCase() + ":");
31
+ }
32
+ export function normalizePath(p) {
33
+ let resolved = path.resolve(p);
34
+ try {
35
+ resolved = fs.realpathSync(resolved);
36
+ }
37
+ catch {
38
+ /* keep unresolved if the path doesn't exist */
39
+ }
40
+ return canonicalizeWin32(resolved);
41
+ }
42
+ export function loadMappings() {
43
+ let data;
44
+ try {
45
+ data = JSON.parse(fs.readFileSync(MAPPINGS_FILE, "utf8"));
46
+ }
47
+ catch {
48
+ return {};
49
+ }
50
+ const raw = data && typeof data.mappings === "object" ? data.mappings : {};
51
+ const out = {};
52
+ for (const [key, value] of Object.entries(raw)) {
53
+ // v1: value was a bare profile name (Claude). v2: value is a provider map.
54
+ if (typeof value === "string")
55
+ out[key] = { claude: value };
56
+ else if (value && typeof value === "object")
57
+ out[key] = value;
58
+ }
59
+ return out;
60
+ }
61
+ function save(mappings) {
62
+ ensureRoot();
63
+ fs.writeFileSync(MAPPINGS_FILE, JSON.stringify({ schema: 2, mappings }, null, 2) + "\n", {
64
+ mode: 0o600,
65
+ });
66
+ }
67
+ export function setMapping(dir, providerId, name) {
68
+ const key = normalizePath(dir);
69
+ const mappings = loadMappings();
70
+ mappings[key] = { ...mappings[key], [providerId]: name };
71
+ save(mappings);
72
+ return key;
73
+ }
74
+ /** Remove a directory's mapping — one provider, or the whole entry. */
75
+ export function removeMapping(dir, providerId) {
76
+ const key = normalizePath(dir);
77
+ const mappings = loadMappings();
78
+ if (!(key in mappings))
79
+ return false;
80
+ if (providerId) {
81
+ if (!(providerId in mappings[key]))
82
+ return false;
83
+ delete mappings[key][providerId];
84
+ if (Object.keys(mappings[key]).length === 0)
85
+ delete mappings[key];
86
+ }
87
+ else {
88
+ delete mappings[key];
89
+ }
90
+ save(mappings);
91
+ return true;
92
+ }
93
+ /** Nearest mapped ancestor of `dir` (inclusive) for a provider; null if none. */
94
+ export function resolveMapping(dir, providerId) {
95
+ const mappings = loadMappings();
96
+ let current = normalizePath(dir);
97
+ for (;;) {
98
+ const entry = mappings[current];
99
+ if (entry && entry[providerId])
100
+ return { path: current, name: entry[providerId] };
101
+ const parent = path.dirname(current);
102
+ if (parent === current)
103
+ return null;
104
+ current = parent;
105
+ }
106
+ }
107
+ /** Drop mappings pointing at a removed (provider, name). Returns removed keys. */
108
+ export function pruneMappings(providerId, name) {
109
+ const mappings = loadMappings();
110
+ const removed = [];
111
+ for (const [key, entry] of Object.entries(mappings)) {
112
+ if (entry[providerId] === name) {
113
+ delete entry[providerId];
114
+ if (Object.keys(entry).length === 0)
115
+ delete mappings[key];
116
+ removed.push(key);
117
+ }
118
+ }
119
+ if (removed.length > 0)
120
+ save(mappings);
121
+ return removed;
122
+ }
123
+ /** All mappings as flat rows for display. */
124
+ export function mappingRows() {
125
+ const rows = [];
126
+ for (const [key, entry] of Object.entries(loadMappings())) {
127
+ for (const [provider, name] of Object.entries(entry)) {
128
+ rows.push({ path: key, provider: provider, name: name });
129
+ }
130
+ }
131
+ return rows.sort((a, b) => a.path.localeCompare(b.path) || a.provider.localeCompare(b.provider));
132
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Notification event log — a small persistent ring buffer shared by the daemon
3
+ * (auto-switch results, poll failures) and the GUI (its own limit-fetch
4
+ * failures). It is the single source of truth for the app's bell/flyout and the
5
+ * "internal system" fallback when desktop notifications are unavailable.
6
+ *
7
+ * Stored at `~/.agent-switch/notifications.json` as a plain JSON array, oldest
8
+ * first, capped at MAX_NOTIFICATIONS. Appends deduplicate an identical event
9
+ * within DEDUP_WINDOW_MS so a persistent failure polled every few minutes does
10
+ * not spam the list (and does not re-fire a desktop notification each cycle).
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { ROOT } from "./profiles.js";
15
+ export const NOTIFICATIONS_FILE = path.join(ROOT, "notifications.json");
16
+ export const MAX_NOTIFICATIONS = 25;
17
+ /** An identical event within this window is dropped instead of re-appended. */
18
+ export const DEDUP_WINDOW_MS = 30 * 60_000;
19
+ export function readNotifications(file = NOTIFICATIONS_FILE) {
20
+ try {
21
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
22
+ return Array.isArray(raw) ? raw : [];
23
+ }
24
+ catch {
25
+ return []; // missing / unreadable / malformed → empty, never throws
26
+ }
27
+ }
28
+ function writeNotifications(list, file) {
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ fs.writeFileSync(file, JSON.stringify(list.slice(-MAX_NOTIFICATIONS)));
31
+ }
32
+ /**
33
+ * Append an event (kept newest-last). Deduplicates a byte-identical
34
+ * kind+title+message against the most recent entry within DEDUP_WINDOW_MS.
35
+ * Returns the created notification, or `null` when it was deduplicated.
36
+ */
37
+ export function appendNotification(event, now = Date.now(), file = NOTIFICATIONS_FILE) {
38
+ const list = readNotifications(file);
39
+ const last = list[list.length - 1];
40
+ if (last &&
41
+ last.kind === event.kind &&
42
+ last.title === event.title &&
43
+ last.message === event.message &&
44
+ now - last.ts < DEDUP_WINDOW_MS) {
45
+ return null;
46
+ }
47
+ const created = {
48
+ id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
49
+ ts: now,
50
+ kind: event.kind,
51
+ title: event.title,
52
+ message: event.message,
53
+ };
54
+ writeNotifications([...list, created], file);
55
+ return created;
56
+ }
57
+ export function clearNotifications(file = NOTIFICATIONS_FILE) {
58
+ writeNotifications([], file);
59
+ }
60
+ /** Mark a stored notification as already-OS-notified (set by the daemon after it
61
+ * fires a desktop notification, so the GUI does not re-notify). No-op if the id
62
+ * is gone (trimmed out of the ring). */
63
+ export function markOsNotified(id, file = NOTIFICATIONS_FILE) {
64
+ const list = readNotifications(file);
65
+ const n = list.find((x) => x.id === id);
66
+ if (!n)
67
+ return;
68
+ n.osNotified = true;
69
+ writeNotifications(list, file);
70
+ }
package/dist/notify.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Per-session context-threshold detection + alert config (roadmap:
3
+ * road-to-agent-switch-session-telemetry, Phase 3). Own-session only: a
4
+ * crossing names a project dir + percentage + suggested action, NEVER another
5
+ * profile (council #5 — furthest from the rotation line).
6
+ *
7
+ * Delivery is via the shared notification log (`src/notifications.ts` →
8
+ * `appendNotification`): the daemon records the coalesced crossing there and
9
+ * the GUI bell/flyout reads it + fires a best-effort desktop notification (the
10
+ * app's single notifier). This module only DETECTS + COALESCES; it does not
11
+ * fire an OS toast itself.
12
+ *
13
+ * Config lives in `<ROOT>/telemetry-config.json` (its own file rather than
14
+ * state.json, whose readState rebuilds from known fields and would drop new
15
+ * ones): { notify: boolean (default OFF), contextThresholds: number[] }.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ export const DEFAULT_CONTEXT_THRESHOLDS = [80, 95];
20
+ export function configFile(root) {
21
+ return path.join(root, "telemetry-config.json");
22
+ }
23
+ export function readTelemetryConfig(root) {
24
+ try {
25
+ const raw = JSON.parse(fs.readFileSync(configFile(root), "utf8"));
26
+ const thresholds = Array.isArray(raw?.contextThresholds) && raw.contextThresholds.every((n) => typeof n === "number")
27
+ ? [...raw.contextThresholds].sort((a, b) => a - b)
28
+ : DEFAULT_CONTEXT_THRESHOLDS;
29
+ return { notify: raw?.notify === true, contextThresholds: thresholds };
30
+ }
31
+ catch {
32
+ return { notify: false, contextThresholds: DEFAULT_CONTEXT_THRESHOLDS };
33
+ }
34
+ }
35
+ export function writeTelemetryConfig(root, cfg) {
36
+ fs.mkdirSync(root, { recursive: true });
37
+ fs.writeFileSync(configFile(root), JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
38
+ }
39
+ /**
40
+ * Edge-triggered per-session context crossings. A threshold fires once when a
41
+ * session first reaches it. Re-arm (clear the fired set) happens when:
42
+ * - the session id is in `compacted` (a real PreCompact/PostCompact/
43
+ * SessionStart(compact) event was seen — the ground-truth signal), OR
44
+ * - pct fell below the lowest threshold (conservative fallback when hooks
45
+ * are not installed).
46
+ * Pure — no I/O, own-session only (caller passes one profile's live sessions).
47
+ */
48
+ export function detectContextCrossings(samples, prev, thresholds = DEFAULT_CONTEXT_THRESHOLDS, compacted = new Set()) {
49
+ const sorted = [...thresholds].sort((a, b) => a - b);
50
+ const minT = sorted[0] ?? Infinity;
51
+ const state = {};
52
+ const crossings = [];
53
+ for (const s of samples) {
54
+ if (s.pct === null)
55
+ continue; // window unknown → cannot threshold
56
+ const prevS = prev[s.sessionId];
57
+ let fired = prevS ? [...prevS.fired] : [];
58
+ // re-arm on real compaction or a drop below the lowest threshold
59
+ if (compacted.has(s.sessionId) || s.pct < minT)
60
+ fired = [];
61
+ for (const t of sorted) {
62
+ if (s.pct >= t && !fired.includes(t)) {
63
+ crossings.push({ sessionId: s.sessionId, where: s.where, threshold: t, pct: s.pct });
64
+ fired.push(t);
65
+ }
66
+ }
67
+ state[s.sessionId] = { fired, lastPct: s.pct };
68
+ }
69
+ return { crossings, state };
70
+ }
71
+ /** Coalesce same-cycle crossings into ONE notification body (council #11 — no
72
+ * toast storm). Reports the count + the single worst session. Project +
73
+ * percentage only; never a profile name. */
74
+ export function coalesce(crossings) {
75
+ if (crossings.length === 0)
76
+ return null;
77
+ const worst = crossings.reduce((a, b) => (b.pct > a.pct ? b : a));
78
+ const n = new Set(crossings.map((c) => c.sessionId)).size;
79
+ const title = n === 1 ? `Context ${worst.threshold}%+` : `${n} sessions high on context`;
80
+ const body = n === 1
81
+ ? `${worst.where} at ${worst.pct}% context — consider /compact`
82
+ : `worst: ${worst.where} at ${worst.pct}% — consider /compact`;
83
+ return { title, body };
84
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Best-effort OS desktop notifications for the background daemon, so an
3
+ * auto-switch is visible even when the GUI is closed. Per-platform native
4
+ * command, no external dependency:
5
+ * - macOS → `osascript -e 'display notification …'`
6
+ * - Linux → `notify-send <title> <body>`
7
+ * - Windows → PowerShell balloon tip via System.Windows.Forms.NotifyIcon
8
+ *
9
+ * `buildOsNotifyCommand` is pure (command construction only) so it is unit
10
+ * testable; `osNotify` wraps it with a fire-and-forget spawn that never throws.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ /** AppleScript string literal escaping: backslash and double-quote only. */
14
+ function escAppleScript(s) {
15
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
16
+ }
17
+ /** PowerShell single-quoted string literal escaping: double the single quote. */
18
+ function escPowerShellSingle(s) {
19
+ return s.replace(/'/g, "''");
20
+ }
21
+ /**
22
+ * Build the native notify command for a platform, or null when unsupported.
23
+ * `platform` matches `process.platform` values (`darwin` / `linux` / `win32`).
24
+ */
25
+ export function buildOsNotifyCommand(platform, title, body) {
26
+ if (platform === "darwin") {
27
+ const script = `display notification "${escAppleScript(body)}" with title "${escAppleScript(title)}"`;
28
+ return { program: "osascript", args: ["-e", script] };
29
+ }
30
+ if (platform === "linux") {
31
+ // notify-send treats the first arg as summary, the second as body.
32
+ return { program: "notify-send", args: [title, body] };
33
+ }
34
+ if (platform === "win32") {
35
+ const t = escPowerShellSingle(title);
36
+ const b = escPowerShellSingle(body);
37
+ const script = [
38
+ "Add-Type -AssemblyName System.Windows.Forms;",
39
+ "$n = New-Object System.Windows.Forms.NotifyIcon;",
40
+ "$n.Icon = [System.Drawing.SystemIcons]::Information;",
41
+ "$n.Visible = $true;",
42
+ `$n.ShowBalloonTip(5000, '${t}', '${b}', [System.Windows.Forms.ToolTipIcon]::Info);`,
43
+ "Start-Sleep -Milliseconds 6000; $n.Dispose();",
44
+ ].join(" ");
45
+ return { program: "powershell", args: ["-NoProfile", "-Command", script] };
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * Fire a desktop notification via the OS. Returns true when the command was
51
+ * dispatched successfully, false on an unsupported platform or any failure
52
+ * (the caller treats a false as "not shown" and leaves the in-app log as the
53
+ * record). Never throws.
54
+ */
55
+ export function osNotify(title, body, platform = process.platform) {
56
+ const cmd = buildOsNotifyCommand(platform, title, body);
57
+ if (!cmd)
58
+ return false;
59
+ try {
60
+ const r = spawnSync(cmd.program, cmd.args, { stdio: "ignore", timeout: 10_000 });
61
+ return r.status === 0;
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }