@event4u/agent-switch 2.1.0 → 2.2.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/README.md +139 -70
- package/dist/activity.js +97 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +149 -0
- package/dist/balance.js +52 -0
- package/dist/browsers.js +90 -0
- package/dist/daemon.js +89 -38
- package/dist/desktop-sessions.js +115 -0
- package/dist/dir-resolve.js +151 -0
- package/dist/doctor.js +205 -69
- package/dist/gui-settings.js +67 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +690 -61
- package/dist/mappings.js +48 -9
- package/dist/pools.js +144 -0
- package/dist/profiles.js +69 -6
- package/dist/protection.js +93 -0
- package/dist/rebind.js +108 -11
- package/dist/service.js +24 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +100 -21
- package/dist/single-load.js +5 -0
- package/package.json +1 -1
package/dist/mappings.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* canonicalized) paths; values are `{ provider, name }`. A path may map a
|
|
13
13
|
* different profile per provider, so the stored value is keyed by provider:
|
|
14
14
|
* `{ "<path>": { claude: "<name>", codex: "<name>", ... } }`.
|
|
15
|
+
*
|
|
16
|
+
* A rule can be paused without losing it: `paused` lists `{ "<path>": [provider] }`
|
|
17
|
+
* next to `mappings`, and resolution skips a paused rule as if it were absent.
|
|
15
18
|
*/
|
|
16
19
|
import * as fs from "node:fs";
|
|
17
20
|
import * as path from "node:path";
|
|
@@ -39,14 +42,30 @@ export function normalizePath(p) {
|
|
|
39
42
|
}
|
|
40
43
|
return canonicalizeWin32(resolved);
|
|
41
44
|
}
|
|
42
|
-
|
|
43
|
-
let data;
|
|
45
|
+
function readFile() {
|
|
44
46
|
try {
|
|
45
|
-
|
|
47
|
+
return JSON.parse(fs.readFileSync(MAPPINGS_FILE, "utf8"));
|
|
46
48
|
}
|
|
47
49
|
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function loadPaused() {
|
|
54
|
+
const raw = readFile()?.paused;
|
|
55
|
+
if (!raw || typeof raw !== "object")
|
|
48
56
|
return {};
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
59
|
+
if (Array.isArray(value))
|
|
60
|
+
out[key] = value.filter((v) => typeof v === "string");
|
|
49
61
|
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const isPaused = (paused, key, providerId) => !!paused[key]?.includes(providerId);
|
|
65
|
+
export function loadMappings() {
|
|
66
|
+
const data = readFile();
|
|
67
|
+
if (!data)
|
|
68
|
+
return {};
|
|
50
69
|
const raw = data && typeof data.mappings === "object" ? data.mappings : {};
|
|
51
70
|
const out = {};
|
|
52
71
|
for (const [key, value] of Object.entries(raw)) {
|
|
@@ -58,11 +77,29 @@ export function loadMappings() {
|
|
|
58
77
|
}
|
|
59
78
|
return out;
|
|
60
79
|
}
|
|
61
|
-
|
|
80
|
+
/** Write mappings; paused flags of rules that no longer exist are dropped. */
|
|
81
|
+
function save(mappings, paused = loadPaused()) {
|
|
62
82
|
ensureRoot();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
const kept = {};
|
|
84
|
+
for (const [key, providers] of Object.entries(paused)) {
|
|
85
|
+
const live = providers.filter((p) => mappings[key]?.[p]);
|
|
86
|
+
if (live.length > 0)
|
|
87
|
+
kept[key] = live;
|
|
88
|
+
}
|
|
89
|
+
const body = Object.keys(kept).length > 0 ? { schema: 2, mappings, paused: kept } : { schema: 2, mappings };
|
|
90
|
+
fs.writeFileSync(MAPPINGS_FILE, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 });
|
|
91
|
+
}
|
|
92
|
+
/** Pause or resume one folder rule. False when the folder has no rule for the provider. */
|
|
93
|
+
export function setPaused(dir, providerId, on) {
|
|
94
|
+
const key = normalizePath(dir);
|
|
95
|
+
const mappings = loadMappings();
|
|
96
|
+
if (!mappings[key]?.[providerId])
|
|
97
|
+
return false;
|
|
98
|
+
const paused = loadPaused();
|
|
99
|
+
const rest = (paused[key] ?? []).filter((p) => p !== providerId);
|
|
100
|
+
paused[key] = on ? [...rest, providerId] : rest;
|
|
101
|
+
save(mappings, paused);
|
|
102
|
+
return true;
|
|
66
103
|
}
|
|
67
104
|
export function setMapping(dir, providerId, name) {
|
|
68
105
|
const key = normalizePath(dir);
|
|
@@ -93,10 +130,11 @@ export function removeMapping(dir, providerId) {
|
|
|
93
130
|
/** Nearest mapped ancestor of `dir` (inclusive) for a provider; null if none. */
|
|
94
131
|
export function resolveMapping(dir, providerId) {
|
|
95
132
|
const mappings = loadMappings();
|
|
133
|
+
const paused = loadPaused();
|
|
96
134
|
let current = normalizePath(dir);
|
|
97
135
|
for (;;) {
|
|
98
136
|
const entry = mappings[current];
|
|
99
|
-
if (entry && entry[providerId])
|
|
137
|
+
if (entry && entry[providerId] && !isPaused(paused, current, providerId))
|
|
100
138
|
return { path: current, name: entry[providerId] };
|
|
101
139
|
const parent = path.dirname(current);
|
|
102
140
|
if (parent === current)
|
|
@@ -123,9 +161,10 @@ export function pruneMappings(providerId, name) {
|
|
|
123
161
|
/** All mappings as flat rows for display. */
|
|
124
162
|
export function mappingRows() {
|
|
125
163
|
const rows = [];
|
|
164
|
+
const paused = loadPaused();
|
|
126
165
|
for (const [key, entry] of Object.entries(loadMappings())) {
|
|
127
166
|
for (const [provider, name] of Object.entries(entry)) {
|
|
128
|
-
rows.push({ path: key, provider: provider, name: name });
|
|
167
|
+
rows.push({ path: key, provider: provider, name: name, paused: isPaused(paused, key, provider) });
|
|
129
168
|
}
|
|
130
169
|
}
|
|
131
170
|
return rows.sort((a, b) => a.path.localeCompare(b.path) || a.provider.localeCompare(b.provider));
|
package/dist/pools.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pools — explicit, user-created groups of profiles that a folder rule may point
|
|
3
|
+
* at (`map pool:<id> [dir]`). Additive and opt-in: the store starts empty, an
|
|
4
|
+
* upgrade creates nothing, and labels (Work/Personal/Other) and
|
|
5
|
+
* `autoSwitch.tag` are never read or changed here. Membership is many-to-many
|
|
6
|
+
* and keyed by `provider/name`; a pool may hold profiles of several providers,
|
|
7
|
+
* and a lookup only ever considers members of the provider being started.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { ROOT, ensureRoot, labelFor, listAllProfiles, PROFILE_LABELS } from "./profiles.js";
|
|
12
|
+
import { isProviderId } from "./providers.js";
|
|
13
|
+
export const POOLS_FILE = path.join(ROOT, "pools.json");
|
|
14
|
+
export const POOL_PREFIX = "pool:";
|
|
15
|
+
export function isPoolRef(v) {
|
|
16
|
+
return v.startsWith(POOL_PREFIX);
|
|
17
|
+
}
|
|
18
|
+
export function poolIdOf(ref) {
|
|
19
|
+
return ref.slice(POOL_PREFIX.length);
|
|
20
|
+
}
|
|
21
|
+
/** `claude/work` → `{ provider, name }`, or null when it is not a member key. */
|
|
22
|
+
export function parseMember(v) {
|
|
23
|
+
const m = /^([a-z]+)\/([A-Za-z0-9_-]+)$/.exec(v);
|
|
24
|
+
if (!m || !isProviderId(m[1]))
|
|
25
|
+
return null;
|
|
26
|
+
return { provider: m[1], name: m[2] };
|
|
27
|
+
}
|
|
28
|
+
export function slugify(name) {
|
|
29
|
+
return name
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.normalize("NFKD")
|
|
32
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "")
|
|
34
|
+
.slice(0, 40);
|
|
35
|
+
}
|
|
36
|
+
export function readPools(file = POOLS_FILE) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
39
|
+
if (!Array.isArray(raw?.pools))
|
|
40
|
+
return [];
|
|
41
|
+
return raw.pools
|
|
42
|
+
.filter((p) => p && typeof p.id === "string" && typeof p.name === "string")
|
|
43
|
+
.map((p) => ({
|
|
44
|
+
id: p.id,
|
|
45
|
+
name: p.name,
|
|
46
|
+
members: Array.isArray(p.members) ? p.members.filter((m) => typeof m === "string" && parseMember(m)) : [],
|
|
47
|
+
createdAt: typeof p.createdAt === "string" ? p.createdAt : "",
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function save(pools, file) {
|
|
55
|
+
if (file === POOLS_FILE)
|
|
56
|
+
ensureRoot();
|
|
57
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify({ schema: 1, pools }, null, 2) + "\n", { mode: 0o600 });
|
|
59
|
+
fs.renameSync(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
export function findPool(id, file = POOLS_FILE) {
|
|
62
|
+
return readPools(file).find((p) => p.id === id) ?? null;
|
|
63
|
+
}
|
|
64
|
+
function mustFind(pools, id) {
|
|
65
|
+
const p = pools.find((x) => x.id === id);
|
|
66
|
+
if (!p)
|
|
67
|
+
throw new Error(`no pool "${id}" (pools: ${pools.map((x) => x.id).join(", ") || "none"})`);
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
export function createPool(name, file = POOLS_FILE, now = new Date()) {
|
|
71
|
+
const display = name.trim();
|
|
72
|
+
const id = slugify(display);
|
|
73
|
+
if (!id)
|
|
74
|
+
throw new Error("pool name needs at least one letter or digit");
|
|
75
|
+
const pools = readPools(file);
|
|
76
|
+
if (pools.some((p) => p.id === id))
|
|
77
|
+
throw new Error(`a pool "${id}" already exists`);
|
|
78
|
+
const pool = { id, name: display, members: [], createdAt: now.toISOString() };
|
|
79
|
+
save([...pools, pool], file);
|
|
80
|
+
return pool;
|
|
81
|
+
}
|
|
82
|
+
/** Renames the display name only — the id stays, so folder rules keep working. */
|
|
83
|
+
export function renamePool(id, name, file = POOLS_FILE) {
|
|
84
|
+
const pools = readPools(file);
|
|
85
|
+
const p = mustFind(pools, id);
|
|
86
|
+
if (!name.trim())
|
|
87
|
+
throw new Error("pool name must not be empty");
|
|
88
|
+
p.name = name.trim();
|
|
89
|
+
save(pools, file);
|
|
90
|
+
return p;
|
|
91
|
+
}
|
|
92
|
+
export function deletePool(id, file = POOLS_FILE) {
|
|
93
|
+
const pools = readPools(file);
|
|
94
|
+
mustFind(pools, id);
|
|
95
|
+
save(pools.filter((p) => p.id !== id), file);
|
|
96
|
+
}
|
|
97
|
+
export function addPoolMember(id, member, file = POOLS_FILE) {
|
|
98
|
+
if (!parseMember(member))
|
|
99
|
+
throw new Error(`"${member}" is not a profile key (expected provider/name, e.g. claude/work)`);
|
|
100
|
+
const pools = readPools(file);
|
|
101
|
+
const p = mustFind(pools, id);
|
|
102
|
+
if (!p.members.includes(member))
|
|
103
|
+
p.members.push(member);
|
|
104
|
+
save(pools, file);
|
|
105
|
+
return p;
|
|
106
|
+
}
|
|
107
|
+
export function removePoolMember(id, member, file = POOLS_FILE) {
|
|
108
|
+
const pools = readPools(file);
|
|
109
|
+
const p = mustFind(pools, id);
|
|
110
|
+
p.members = p.members.filter((m) => m !== member);
|
|
111
|
+
save(pools, file);
|
|
112
|
+
return p;
|
|
113
|
+
}
|
|
114
|
+
/** Keep memberships pointing at a profile across a rename / drop them on remove. */
|
|
115
|
+
export function renamePoolMember(from, to, file = POOLS_FILE) {
|
|
116
|
+
const pools = readPools(file);
|
|
117
|
+
let changed = false;
|
|
118
|
+
for (const p of pools) {
|
|
119
|
+
if (!p.members.includes(from))
|
|
120
|
+
continue;
|
|
121
|
+
p.members = p.members.filter((m) => m !== from);
|
|
122
|
+
if (to && !p.members.includes(to))
|
|
123
|
+
p.members.push(to);
|
|
124
|
+
changed = true;
|
|
125
|
+
}
|
|
126
|
+
if (changed)
|
|
127
|
+
save(pools, file);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* "Suggest from tags": one proposed pool per label in use. A preview only —
|
|
131
|
+
* nothing is stored until the user confirms (`pool suggest --confirm`), and a
|
|
132
|
+
* proposal whose id already exists is marked so it is never overwritten.
|
|
133
|
+
*/
|
|
134
|
+
export function suggestPoolsFromLabels(profiles = listAllProfiles(), label = labelFor, existing = readPools()) {
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const l of PROFILE_LABELS) {
|
|
137
|
+
const members = profiles.filter((p) => label(p.provider, p.name) === l).map((p) => `${p.provider}/${p.name}`);
|
|
138
|
+
if (members.length === 0)
|
|
139
|
+
continue;
|
|
140
|
+
const id = slugify(l);
|
|
141
|
+
out.push({ id, name: l, members, exists: existing.some((p) => p.id === id) });
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
package/dist/profiles.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { PROVIDER_IDS, provider, isProviderId } from "./providers.js";
|
|
6
6
|
import { credentialStore } from "./credentials.js";
|
|
7
|
-
import { DEFAULT_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
|
|
7
|
+
import { DEFAULT_GUARD_POLICY, FRESH_INSTALL_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
|
|
8
8
|
export const HOME = os.homedir();
|
|
9
9
|
export const ROOT = process.env.AGENT_SWITCH_HOME ?? path.join(HOME, ".agent-switch");
|
|
10
10
|
export const STATE_FILE = path.join(ROOT, "state.json");
|
|
@@ -13,6 +13,11 @@ export const PROFILE_LABELS = ["Work", "Personal", "Other"];
|
|
|
13
13
|
export function isProfileLabel(v) {
|
|
14
14
|
return typeof v === "string" && PROFILE_LABELS.includes(v);
|
|
15
15
|
}
|
|
16
|
+
/** The account colours the GUI offers (its swatches use the same names). */
|
|
17
|
+
export const PROFILE_COLORS = ["orange", "red", "yellow", "green", "teal", "blue", "purple", "pink"];
|
|
18
|
+
export function isProfileColor(v) {
|
|
19
|
+
return typeof v === "string" && PROFILE_COLORS.includes(v);
|
|
20
|
+
}
|
|
16
21
|
export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
|
|
17
22
|
/**
|
|
18
23
|
* Providers enabled out of the box. Everything else is available but off by
|
|
@@ -33,7 +38,13 @@ export function die(msg) {
|
|
|
33
38
|
process.exit(1);
|
|
34
39
|
}
|
|
35
40
|
export function ensureRoot() {
|
|
41
|
+
// A fresh install pins its fresh-install defaults (guard: warn) on the first
|
|
42
|
+
// write, so the first profile it gets cannot flip it into looking like an
|
|
43
|
+
// upgraded 2.1.0 install (profiles, no state.json → block).
|
|
44
|
+
const fresh = !fs.existsSync(STATE_FILE) && listAllProfiles().length === 0;
|
|
36
45
|
fs.mkdirSync(ROOT, { recursive: true, mode: 0o700 });
|
|
46
|
+
if (fresh)
|
|
47
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(readState(), null, 2) + "\n", { mode: 0o600 });
|
|
37
48
|
}
|
|
38
49
|
function validateName(name) {
|
|
39
50
|
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
@@ -102,6 +113,16 @@ function normalizeLabels(raw) {
|
|
|
102
113
|
}
|
|
103
114
|
return out;
|
|
104
115
|
}
|
|
116
|
+
function normalizeColors(raw) {
|
|
117
|
+
const out = {};
|
|
118
|
+
if (raw && typeof raw === "object") {
|
|
119
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
120
|
+
if (isProfileColor(v))
|
|
121
|
+
out[k] = v;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
105
126
|
function normalizeAutoSwitch(raw) {
|
|
106
127
|
const r = (raw ?? {});
|
|
107
128
|
const threshold = typeof r.threshold === "number" && r.threshold >= 1 && r.threshold <= 100 ? Math.round(r.threshold) : DEFAULT_AUTOSWITCH.threshold;
|
|
@@ -168,6 +189,8 @@ export function readState() {
|
|
|
168
189
|
try {
|
|
169
190
|
const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
170
191
|
const labels = normalizeLabels(raw?.labels);
|
|
192
|
+
const colors = normalizeColors(raw?.colors);
|
|
193
|
+
const disabled = Array.isArray(raw?.disabled) ? raw.disabled.filter((k) => typeof k === "string") : [];
|
|
171
194
|
const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
|
|
172
195
|
const providers = normalizeProvidersMap(raw?.providers);
|
|
173
196
|
const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
|
|
@@ -176,17 +199,21 @@ export function readState() {
|
|
|
176
199
|
const guard = normalizeGuard(raw?.guard);
|
|
177
200
|
// v1: { active: "<name>" } — a single Claude profile.
|
|
178
201
|
if (typeof raw?.active === "string") {
|
|
179
|
-
return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
202
|
+
return { active: { ...emptyActive(), claude: raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
180
203
|
}
|
|
181
204
|
if (raw?.active && typeof raw.active === "object") {
|
|
182
|
-
return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
205
|
+
return { active: { ...emptyActive(), ...raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
183
206
|
}
|
|
184
|
-
return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
207
|
+
return { active: emptyActive(), labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
185
208
|
}
|
|
186
209
|
catch {
|
|
187
210
|
/* absent / unparsable → default */
|
|
188
211
|
}
|
|
189
|
-
|
|
212
|
+
// Fresh = nothing at all yet. `add` without `use` writes no state.json, so a
|
|
213
|
+
// 2.1.0 install can have profiles and no state file — that user must keep the
|
|
214
|
+
// pre-2.2 default (block), not be treated as new.
|
|
215
|
+
const fresh = !fs.existsSync(STATE_FILE) && listAllProfiles().length === 0;
|
|
216
|
+
return { active: emptyActive(), labels: {}, colors: {}, disabled: [], autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE }, guard: fresh ? FRESH_INSTALL_GUARD_POLICY : DEFAULT_GUARD_POLICY };
|
|
190
217
|
}
|
|
191
218
|
export function readOsNotifications() {
|
|
192
219
|
return readState().osNotifications;
|
|
@@ -247,9 +274,40 @@ export function setLabel(providerId, name, label) {
|
|
|
247
274
|
delete state.labels[key];
|
|
248
275
|
writeState(state);
|
|
249
276
|
}
|
|
250
|
-
/** Drop a removed profile's label so
|
|
277
|
+
/** Drop a removed profile's label, colour and disabled flag so they can't
|
|
278
|
+
* linger as orphans (a reused name starts clean). */
|
|
251
279
|
export function clearLabel(providerId, name) {
|
|
252
280
|
setLabel(providerId, name, null);
|
|
281
|
+
setColor(providerId, name, null);
|
|
282
|
+
setDisabled(providerId, name, false);
|
|
283
|
+
}
|
|
284
|
+
export function isDisabled(providerId, name) {
|
|
285
|
+
return readState().disabled.includes(labelKey(providerId, name));
|
|
286
|
+
}
|
|
287
|
+
/** Disable (or re-enable) a profile. Disabling the active one clears the slot. */
|
|
288
|
+
export function setDisabled(providerId, name, disabled) {
|
|
289
|
+
const state = readState();
|
|
290
|
+
const key = labelKey(providerId, name);
|
|
291
|
+
state.disabled = state.disabled.filter((k) => k !== key);
|
|
292
|
+
if (disabled) {
|
|
293
|
+
state.disabled.push(key);
|
|
294
|
+
if (state.active[providerId] === name)
|
|
295
|
+
state.active[providerId] = null;
|
|
296
|
+
}
|
|
297
|
+
writeState(state);
|
|
298
|
+
}
|
|
299
|
+
export function colorOf(providerId, name) {
|
|
300
|
+
return readState().colors[labelKey(providerId, name)] ?? null;
|
|
301
|
+
}
|
|
302
|
+
/** Set (or clear, with null) a profile's colour. */
|
|
303
|
+
export function setColor(providerId, name, color) {
|
|
304
|
+
const state = readState();
|
|
305
|
+
const key = labelKey(providerId, name);
|
|
306
|
+
if (color)
|
|
307
|
+
state.colors[key] = color;
|
|
308
|
+
else
|
|
309
|
+
delete state.colors[key];
|
|
310
|
+
writeState(state);
|
|
253
311
|
}
|
|
254
312
|
/**
|
|
255
313
|
* Rename a profile: move its config dir, carry its Claude credential across the
|
|
@@ -292,6 +350,11 @@ export function renameProfile(providerId, from, to, store = credentialStore()) {
|
|
|
292
350
|
state.labels[toKey] = state.labels[fromKey];
|
|
293
351
|
delete state.labels[fromKey];
|
|
294
352
|
}
|
|
353
|
+
if (state.colors[fromKey] !== undefined) {
|
|
354
|
+
state.colors[toKey] = state.colors[fromKey];
|
|
355
|
+
delete state.colors[fromKey];
|
|
356
|
+
}
|
|
357
|
+
state.disabled = state.disabled.map((k) => (k === fromKey ? toKey : k));
|
|
295
358
|
writeState(state);
|
|
296
359
|
}
|
|
297
360
|
export function readAutoSwitch(providerId) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session protection — a per-profile policy, separate from the near-limit
|
|
3
|
+
* notifications (`autoswitch`, 95 %) and the context alerts. Default ON at 98 %
|
|
4
|
+
* for every profile, including ones that existed before this file did: a
|
|
5
|
+
* profile with no stored entry simply reads the default.
|
|
6
|
+
*
|
|
7
|
+
* What it does today: the daemon WARNS when a polled profile reaches its
|
|
8
|
+
* threshold. It does not pause anything. A real pause needs the provider to
|
|
9
|
+
* hold the session (Claude Code's own auto-continue), which agent-switch cannot
|
|
10
|
+
* verify from here, so every notice says "warn" — never "paused".
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { ROOT, ensureRoot } from "./profiles.js";
|
|
15
|
+
export const PROTECTION_FILE = path.join(ROOT, "protection.json");
|
|
16
|
+
export const DEFAULT_PROTECTION = { enabled: true, thresholdPct: 98 };
|
|
17
|
+
function key(providerId, name) {
|
|
18
|
+
return `${providerId}/${name}`;
|
|
19
|
+
}
|
|
20
|
+
function validThreshold(v) {
|
|
21
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 100;
|
|
22
|
+
}
|
|
23
|
+
function load(file) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
26
|
+
return raw && typeof raw.profiles === "object" && raw.profiles ? raw.profiles : {};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function save(profiles, file) {
|
|
33
|
+
if (file === PROTECTION_FILE)
|
|
34
|
+
ensureRoot();
|
|
35
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
36
|
+
fs.writeFileSync(tmp, JSON.stringify({ schema: 1, profiles }, null, 2) + "\n", { mode: 0o600 });
|
|
37
|
+
fs.renameSync(tmp, file);
|
|
38
|
+
}
|
|
39
|
+
function normalize(raw) {
|
|
40
|
+
return {
|
|
41
|
+
enabled: typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULT_PROTECTION.enabled,
|
|
42
|
+
thresholdPct: validThreshold(raw?.thresholdPct) ? raw.thresholdPct : DEFAULT_PROTECTION.thresholdPct,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function readProtection(providerId, name, file = PROTECTION_FILE) {
|
|
46
|
+
return normalize(load(file)[key(providerId, name)]);
|
|
47
|
+
}
|
|
48
|
+
export function setProtection(providerId, name, patch, file = PROTECTION_FILE) {
|
|
49
|
+
if (patch.thresholdPct !== undefined && !validThreshold(patch.thresholdPct)) {
|
|
50
|
+
throw new Error("threshold must be a whole number between 1 and 100");
|
|
51
|
+
}
|
|
52
|
+
const all = load(file);
|
|
53
|
+
const next = normalize({ ...normalize(all[key(providerId, name)]), ...patch });
|
|
54
|
+
all[key(providerId, name)] = next;
|
|
55
|
+
save(all, file);
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
/** Carry a profile's policy across a rename; drop it on remove. */
|
|
59
|
+
export function renameProtection(providerId, from, to, file = PROTECTION_FILE) {
|
|
60
|
+
const all = load(file);
|
|
61
|
+
if (!(key(providerId, from) in all))
|
|
62
|
+
return;
|
|
63
|
+
all[key(providerId, to)] = all[key(providerId, from)];
|
|
64
|
+
delete all[key(providerId, from)];
|
|
65
|
+
save(all, file);
|
|
66
|
+
}
|
|
67
|
+
export function forgetProtection(providerId, name, file = PROTECTION_FILE) {
|
|
68
|
+
const all = load(file);
|
|
69
|
+
if (!(key(providerId, name) in all))
|
|
70
|
+
return;
|
|
71
|
+
delete all[key(providerId, name)];
|
|
72
|
+
save(all, file);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The threshold warning for one profile, or null. Pure. Fires once per reset
|
|
76
|
+
* cycle of the window that crossed (`lastCycle` = what was announced before).
|
|
77
|
+
*/
|
|
78
|
+
export function protectionNotice(target, snap, policy, lastCycle) {
|
|
79
|
+
if (!policy.enabled)
|
|
80
|
+
return null;
|
|
81
|
+
const hit = snap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= policy.thresholdPct);
|
|
82
|
+
if (!hit)
|
|
83
|
+
return null;
|
|
84
|
+
const cycle = `${hit.key}@${hit.resetsAt ?? ""}`;
|
|
85
|
+
if (cycle === lastCycle)
|
|
86
|
+
return null;
|
|
87
|
+
return {
|
|
88
|
+
cycle,
|
|
89
|
+
title: "Session protection: limit almost reached",
|
|
90
|
+
message: `${target} reached ${hit.utilization}% of its ${hit.label} limit (protection threshold ${policy.thresholdPct}%). ` +
|
|
91
|
+
`This is a warning only — agent-switch does not pause running sessions.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/rebind.js
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Move-semantics + safety: the target account's credential is moved INTO the
|
|
15
15
|
* running profile's store and the target's own store is emptied, so the family
|
|
16
|
-
* is live in exactly one store. Both original credentials are stashed in
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* is live in exactly one store. Both original credentials are stashed in the
|
|
17
|
+
* Keychain BEFORE any mutation and a per-profile binding-marker names those
|
|
18
|
+
* entries (never the tokens), so `--restore` fully reverses it and a crash
|
|
19
|
+
* leaves a recoverable marker. Nothing is deleted without a stash.
|
|
19
20
|
*
|
|
20
21
|
* Deferred to later Phase-2 slices (see road-to-live-rebind.md), not built here:
|
|
21
22
|
* live pid/process auto-detection, a global cross-profile binding registry +
|
|
@@ -365,15 +366,30 @@ export function readBinding(cfgDir, d = defaultDeps()) {
|
|
|
365
366
|
return null;
|
|
366
367
|
try {
|
|
367
368
|
const m = JSON.parse(raw);
|
|
368
|
-
if (m
|
|
369
|
+
if (!m || typeof m.boundToProfile !== "string")
|
|
370
|
+
return null;
|
|
371
|
+
const stashed = typeof m.runningOwnStash === "string" && typeof m.targetOrigStash === "string";
|
|
372
|
+
const legacy = typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string";
|
|
373
|
+
if (stashed || legacy)
|
|
369
374
|
return m;
|
|
370
|
-
}
|
|
371
375
|
}
|
|
372
376
|
catch {
|
|
373
377
|
/* corrupt marker → treat as none */
|
|
374
378
|
}
|
|
375
379
|
return null;
|
|
376
380
|
}
|
|
381
|
+
/** Keychain services that hold a binding's originals while the rebind is live. */
|
|
382
|
+
function stashServices(pConfig, d) {
|
|
383
|
+
const base = d.serviceNameFor(pConfig);
|
|
384
|
+
return { own: `${base}-agent-switch-rebind-own`, target: `${base}-agent-switch-rebind-target` };
|
|
385
|
+
}
|
|
386
|
+
/** The two original credentials a binding needs for restore — from the Keychain
|
|
387
|
+
* stash, or inline for a marker written before D16. Null when unreadable. */
|
|
388
|
+
export function bindingCredentials(m, d = defaultDeps()) {
|
|
389
|
+
const runningOwn = m.runningOwnStash ? d.kcGet(m.runningOwnStash) : (m.runningOwnCredential ?? null);
|
|
390
|
+
const targetOrig = m.targetOrigStash ? d.kcGet(m.targetOrigStash) : (m.targetOrigCredential ?? null);
|
|
391
|
+
return runningOwn && targetOrig ? { runningOwn, targetOrig } : null;
|
|
392
|
+
}
|
|
377
393
|
export class RebindError extends Error {
|
|
378
394
|
}
|
|
379
395
|
/**
|
|
@@ -615,12 +631,21 @@ async function performRebind(opts, d) {
|
|
|
615
631
|
// All guards passed — commit the global binding record.
|
|
616
632
|
reg[account] = { runningProfile: profile, boundAt: new Date(d.now()).toISOString() };
|
|
617
633
|
writeRegistry(reg, d);
|
|
618
|
-
// 1. Recovery point BEFORE any store mutation: stash both originals
|
|
634
|
+
// 1. Recovery point BEFORE any store mutation: stash both originals in the
|
|
635
|
+
// Keychain (never in the marker file), then point the marker at them.
|
|
636
|
+
const stash = stashServices(pConfig, d);
|
|
637
|
+
if (!d.kcAdd(stash.own, pCred) || !d.kcAdd(stash.target, credToMove)) {
|
|
638
|
+
d.kcDelete(stash.own);
|
|
639
|
+
d.kcDelete(stash.target);
|
|
640
|
+
delete reg[account];
|
|
641
|
+
writeRegistry(reg, d);
|
|
642
|
+
throw new RebindError(`could not stash the original credentials in the Keychain — no swap performed.`);
|
|
643
|
+
}
|
|
619
644
|
const marker = {
|
|
620
645
|
boundToProfile: account,
|
|
621
646
|
boundAt: new Date(d.now()).toISOString(),
|
|
622
|
-
|
|
623
|
-
|
|
647
|
+
runningOwnStash: stash.own,
|
|
648
|
+
targetOrigStash: stash.target,
|
|
624
649
|
targetFileMoved: false,
|
|
625
650
|
// Snapshot the running profile's OWN identity stamp before the swap: from
|
|
626
651
|
// here on Claude Code will overwrite it with the borrowed account's.
|
|
@@ -648,6 +673,57 @@ async function performRebind(opts, d) {
|
|
|
648
673
|
`Restore with: agent-switch rebind --restore --profile ${profile}`,
|
|
649
674
|
};
|
|
650
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Drop an active rebind on `profile` WITHOUT restoring it — the `remove --force`
|
|
678
|
+
* path, where the running profile is about to be deleted. Deletes both Keychain
|
|
679
|
+
* stash entries (they would otherwise outlive the profile), releases the
|
|
680
|
+
* registry entry and the marker, and hands the borrowed account back to its own
|
|
681
|
+
* profile: the stashed original goes back into that store only while it is
|
|
682
|
+
* empty (a store that holds a login again is never overwritten), and a lent
|
|
683
|
+
* plaintext file is returned. Returns false when there was no rebind.
|
|
684
|
+
*/
|
|
685
|
+
export function discardBinding(profile, d = defaultDeps()) {
|
|
686
|
+
const pConfig = configDir("claude", profile);
|
|
687
|
+
const marker = readBinding(pConfig, d);
|
|
688
|
+
if (!marker)
|
|
689
|
+
return false;
|
|
690
|
+
const originals = bindingCredentials(marker, d);
|
|
691
|
+
const aConfig = configDir("claude", marker.boundToProfile);
|
|
692
|
+
const aSvc = d.serviceNameFor(aConfig);
|
|
693
|
+
if (originals && d.kcGet(aSvc) === null)
|
|
694
|
+
d.kcAdd(aSvc, originals.targetOrig);
|
|
695
|
+
const aFile = credFile(aConfig);
|
|
696
|
+
if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX) && !d.exists(aFile))
|
|
697
|
+
d.renameFile(aFile + LENT_SUFFIX, aFile);
|
|
698
|
+
const stash = stashServices(pConfig, d);
|
|
699
|
+
d.kcDelete(marker.runningOwnStash ?? stash.own);
|
|
700
|
+
d.kcDelete(marker.targetOrigStash ?? stash.target);
|
|
701
|
+
const reg = readRegistry(d);
|
|
702
|
+
if (reg[marker.boundToProfile]?.runningProfile === profile) {
|
|
703
|
+
delete reg[marker.boundToProfile];
|
|
704
|
+
writeRegistry(reg, d);
|
|
705
|
+
}
|
|
706
|
+
d.removeFile(markerFile(pConfig));
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
/** The profile currently serving `account`'s login through a rebind, or null. */
|
|
710
|
+
export function borrowerOf(account, d = defaultDeps()) {
|
|
711
|
+
return readRegistry(d)[account]?.runningProfile ?? null;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Before `account`'s profile is deleted (`remove --force`): put the profile
|
|
715
|
+
* borrowing it back on its own login through the normal restore path, so both
|
|
716
|
+
* stores stay consistent and the stash is cleared. Restore's provenance checks
|
|
717
|
+
* still apply — a refusal propagates and nothing is released. Returns the
|
|
718
|
+
* borrower that was restored, or null when nothing borrowed the account.
|
|
719
|
+
*/
|
|
720
|
+
export async function releaseBorrowedAccount(account, d = defaultDeps()) {
|
|
721
|
+
const holder = borrowerOf(account, d);
|
|
722
|
+
if (!holder)
|
|
723
|
+
return null;
|
|
724
|
+
await restoreRebind({ profile: holder }, d);
|
|
725
|
+
return holder;
|
|
726
|
+
}
|
|
651
727
|
/** Reverse an active rebind on `profile`: restore both profiles' own credentials. */
|
|
652
728
|
export async function restoreRebind(opts, d = defaultDeps()) {
|
|
653
729
|
const { profile } = opts;
|
|
@@ -658,6 +734,11 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
658
734
|
const marker = readBinding(pConfig, d);
|
|
659
735
|
if (!marker)
|
|
660
736
|
throw new RebindRefused(`no active rebind on profile "${profile}".`);
|
|
737
|
+
const originals = bindingCredentials(marker, d);
|
|
738
|
+
if (!originals) {
|
|
739
|
+
throw new RebindRefused(`the stashed credentials for profile "${profile}"'s rebind are not readable — refusing restore (no mutation). ` +
|
|
740
|
+
`The binding marker is left in place for manual recovery.`);
|
|
741
|
+
}
|
|
661
742
|
const aConfig = configDir("claude", marker.boundToProfile);
|
|
662
743
|
const pSvc = d.serviceNameFor(pConfig);
|
|
663
744
|
const aSvc = d.serviceNameFor(aConfig);
|
|
@@ -672,7 +753,7 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
672
753
|
// account we bound INTO it. If someone re-logged the running profile, its
|
|
673
754
|
// store now holds a DIFFERENT account — never clobber that fresh login.
|
|
674
755
|
const liveRunning = readCred(pConfig, d);
|
|
675
|
-
const branch = await compareAccounts(
|
|
756
|
+
const branch = await compareAccounts(originals.targetOrig, liveRunning, d);
|
|
676
757
|
if (branch === "unparseable") {
|
|
677
758
|
throw new RebindRefused(`running profile "${profile}"'s live credential is missing or unparseable — refusing restore (no mutation). ` +
|
|
678
759
|
`The binding marker is left in place for manual recovery.`);
|
|
@@ -691,11 +772,23 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
691
772
|
}
|
|
692
773
|
// "same" / "rotated" → the bound account is still there → restore normally.
|
|
693
774
|
// Restore the running profile to its own account.
|
|
694
|
-
d.kcAdd(pSvc,
|
|
775
|
+
if (!d.kcAdd(pSvc, originals.runningOwn)) {
|
|
776
|
+
throw new RebindError(`could not write profile "${profile}"'s own credential back to the Keychain — restore aborted. ` +
|
|
777
|
+
`Nothing was changed; the marker and the stashed credentials are kept — retry the restore.`);
|
|
778
|
+
}
|
|
695
779
|
// Restore the target profile's own store. On "rotated" the live credential
|
|
696
780
|
// is the same account with a FRESHER (rotated) refresh token — hand that
|
|
697
781
|
// back instead of the stale stash, or the target profile logs out.
|
|
698
|
-
d.kcAdd(aSvc, branch === "rotated" ? liveRunning :
|
|
782
|
+
if (!d.kcAdd(aSvc, branch === "rotated" ? liveRunning : originals.targetOrig)) {
|
|
783
|
+
// Put the running store back on the bound account, so the provenance
|
|
784
|
+
// check of a retry still sees the account it was rebound to (otherwise
|
|
785
|
+
// the retry would read its own credential as a foreign re-login).
|
|
786
|
+
const rolledBack = d.kcAdd(pSvc, liveRunning);
|
|
787
|
+
throw new RebindError(`could not write "${marker.boundToProfile}"'s credential back to the Keychain — restore aborted. ` +
|
|
788
|
+
(rolledBack
|
|
789
|
+
? `Profile "${profile}" still serves "${marker.boundToProfile}"; the marker and the stashed credentials are kept — retry the restore.`
|
|
790
|
+
: `Profile "${profile}" could not be rolled back either; the marker and the stashed credentials are kept for manual recovery.`));
|
|
791
|
+
}
|
|
699
792
|
if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX)) {
|
|
700
793
|
d.renameFile(aFile + LENT_SUFFIX, aFile);
|
|
701
794
|
}
|
|
@@ -703,6 +796,10 @@ export async function restoreRebind(opts, d = defaultDeps()) {
|
|
|
703
796
|
// Claude Code stamped into its `.claude.json` while it served the other account.
|
|
704
797
|
await restoreStampedIdentity(pConfig, marker.runningOwnIdentity, d);
|
|
705
798
|
d.removeFile(markerFile(pConfig));
|
|
799
|
+
if (marker.runningOwnStash)
|
|
800
|
+
d.kcDelete(marker.runningOwnStash);
|
|
801
|
+
if (marker.targetOrigStash)
|
|
802
|
+
d.kcDelete(marker.targetOrigStash);
|
|
706
803
|
// Release the global binding-registry entry for the bound account.
|
|
707
804
|
const reg = readRegistry(d);
|
|
708
805
|
if (reg[marker.boundToProfile]) {
|