@event4u/agent-switch 2.1.0 → 2.3.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/profiles.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /** Profile roots, per-provider state, layout migration, and shared helpers. */
2
+ import { withFileLockSync } from "./locks.js";
2
3
  import * as fs from "node:fs";
3
4
  import * as os from "node:os";
4
5
  import * as path from "node:path";
5
6
  import { PROVIDER_IDS, provider, isProviderId } from "./providers.js";
6
7
  import { credentialStore } from "./credentials.js";
7
- import { DEFAULT_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
8
+ import { DEFAULT_GUARD_POLICY, FRESH_INSTALL_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
8
9
  export const HOME = os.homedir();
9
10
  export const ROOT = process.env.AGENT_SWITCH_HOME ?? path.join(HOME, ".agent-switch");
10
11
  export const STATE_FILE = path.join(ROOT, "state.json");
@@ -13,6 +14,11 @@ export const PROFILE_LABELS = ["Work", "Personal", "Other"];
13
14
  export function isProfileLabel(v) {
14
15
  return typeof v === "string" && PROFILE_LABELS.includes(v);
15
16
  }
17
+ /** The account colours the GUI offers (its swatches use the same names). */
18
+ export const PROFILE_COLORS = ["orange", "red", "yellow", "green", "teal", "blue", "purple", "pink"];
19
+ export function isProfileColor(v) {
20
+ return typeof v === "string" && PROFILE_COLORS.includes(v);
21
+ }
16
22
  export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
17
23
  /**
18
24
  * Providers enabled out of the box. Everything else is available but off by
@@ -33,7 +39,13 @@ export function die(msg) {
33
39
  process.exit(1);
34
40
  }
35
41
  export function ensureRoot() {
42
+ // A fresh install pins its fresh-install defaults (guard: warn) on the first
43
+ // write, so the first profile it gets cannot flip it into looking like an
44
+ // upgraded 2.1.0 install (profiles, no state.json → block).
45
+ const fresh = !fs.existsSync(STATE_FILE) && listAllProfiles().length === 0;
36
46
  fs.mkdirSync(ROOT, { recursive: true, mode: 0o700 });
47
+ if (fresh)
48
+ fs.writeFileSync(STATE_FILE, JSON.stringify(readState(), null, 2) + "\n", { mode: 0o600 });
37
49
  }
38
50
  function validateName(name) {
39
51
  if (!/^[A-Za-z0-9_-]+$/.test(name))
@@ -59,6 +71,20 @@ export function configDir(providerId, name) {
59
71
  export function browserDir(providerId, name) {
60
72
  return path.join(profileDir(providerId, name), "browser");
61
73
  }
74
+ /** Present in a profile dir when `import` seeded it with the default install's
75
+ * credential: both now share one OAuth lineage, and whichever side refreshes
76
+ * first signs the other out. Holds a date only, never a credential. */
77
+ export const SHARED_LOGIN_MARKER = "shares-default-login";
78
+ export function sharesDefaultLogin(providerId, name) {
79
+ return fs.existsSync(path.join(profileDir(providerId, name), SHARED_LOGIN_MARKER));
80
+ }
81
+ export function setSharesDefaultLogin(providerId, name, on) {
82
+ const file = path.join(profileDir(providerId, name), SHARED_LOGIN_MARKER);
83
+ if (!on)
84
+ return void fs.rmSync(file, { force: true });
85
+ fs.mkdirSync(path.dirname(file), { recursive: true });
86
+ fs.writeFileSync(file, `imported ${new Date().toISOString()}\n`, { mode: 0o600 });
87
+ }
62
88
  export function profileExists(providerId, name) {
63
89
  return fs.existsSync(configDir(providerId, name));
64
90
  }
@@ -102,6 +128,16 @@ function normalizeLabels(raw) {
102
128
  }
103
129
  return out;
104
130
  }
131
+ function normalizeColors(raw) {
132
+ const out = {};
133
+ if (raw && typeof raw === "object") {
134
+ for (const [k, v] of Object.entries(raw)) {
135
+ if (isProfileColor(v))
136
+ out[k] = v;
137
+ }
138
+ }
139
+ return out;
140
+ }
105
141
  function normalizeAutoSwitch(raw) {
106
142
  const r = (raw ?? {});
107
143
  const threshold = typeof r.threshold === "number" && r.threshold >= 1 && r.threshold <= 100 ? Math.round(r.threshold) : DEFAULT_AUTOSWITCH.threshold;
@@ -168,6 +204,8 @@ export function readState() {
168
204
  try {
169
205
  const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
170
206
  const labels = normalizeLabels(raw?.labels);
207
+ const colors = normalizeColors(raw?.colors);
208
+ const disabled = Array.isArray(raw?.disabled) ? raw.disabled.filter((k) => typeof k === "string") : [];
171
209
  const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
172
210
  const providers = normalizeProvidersMap(raw?.providers);
173
211
  const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
@@ -176,25 +214,31 @@ export function readState() {
176
214
  const guard = normalizeGuard(raw?.guard);
177
215
  // v1: { active: "<name>" } — a single Claude profile.
178
216
  if (typeof raw?.active === "string") {
179
- return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
217
+ return { active: { ...emptyActive(), claude: raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
180
218
  }
181
219
  if (raw?.active && typeof raw.active === "object") {
182
- return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
220
+ return { active: { ...emptyActive(), ...raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
183
221
  }
184
- return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
222
+ return { active: emptyActive(), labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
185
223
  }
186
224
  catch {
187
225
  /* absent / unparsable → default */
188
226
  }
189
- return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE }, guard: DEFAULT_GUARD_POLICY };
227
+ // Fresh = nothing at all yet. `add` without `use` writes no state.json, so a
228
+ // 2.1.0 install can have profiles and no state file — that user must keep the
229
+ // pre-2.2 default (block), not be treated as new.
230
+ const fresh = !fs.existsSync(STATE_FILE) && listAllProfiles().length === 0;
231
+ 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
232
  }
191
233
  export function readOsNotifications() {
192
234
  return readState().osNotifications;
193
235
  }
194
236
  export function setOsNotifications(on) {
195
- const state = readState();
196
- state.osNotifications = on;
197
- writeState(state);
237
+ return withFileLockSync(STATE_FILE, () => {
238
+ const state = readState();
239
+ state.osNotifications = on;
240
+ writeState(state);
241
+ });
198
242
  }
199
243
  // ---------- single-load guard policy ----------
200
244
  /** How the single-load guard reacts to a second load of one profile. */
@@ -203,9 +247,11 @@ export function readGuard() {
203
247
  }
204
248
  /** Persist the guard policy. */
205
249
  export function setGuard(policy) {
206
- const state = readState();
207
- state.guard = policy;
208
- writeState(state);
250
+ return withFileLockSync(STATE_FILE, () => {
251
+ const state = readState();
252
+ state.guard = policy;
253
+ writeState(state);
254
+ });
209
255
  }
210
256
  // ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
211
257
  /** Current rollback/circuit-breaker state for the `rebind` write path. */
@@ -214,42 +260,97 @@ export function readRebindKillSwitch() {
214
260
  }
215
261
  /** Persist the rebind kill-switch state (breaker counter + disabled flag). */
216
262
  export function writeRebindKillSwitch(next) {
217
- const state = readState();
218
- state.rebind = normalizeRebindState(next);
219
- writeState(state);
263
+ return withFileLockSync(STATE_FILE, () => {
264
+ const state = readState();
265
+ state.rebind = normalizeRebindState(next);
266
+ writeState(state);
267
+ });
220
268
  }
221
269
  /** Re-enable `rebind`: clear `disabled` + reset the failure counter (`--reset`). */
222
270
  export function resetRebindKillSwitch() {
223
271
  writeRebindKillSwitch({ ...DEFAULT_REBIND_STATE });
224
272
  }
273
+ /** Keys this version does not know are carried over: an older CLI still on
274
+ * PATH must not drop a newer one's settings (2.1.0 dropped `disabled`, which
275
+ * silently re-enabled disabled accounts). */
225
276
  export function writeState(state) {
226
277
  ensureRoot();
227
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
278
+ let previous = {};
279
+ try {
280
+ const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
281
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
282
+ previous = raw;
283
+ }
284
+ catch {
285
+ /* absent / unparsable — nothing to keep */
286
+ }
287
+ const tmp = `${STATE_FILE}.${process.pid}.tmp`;
288
+ fs.writeFileSync(tmp, JSON.stringify({ ...previous, ...state }, null, 2) + "\n", { mode: 0o600 });
289
+ fs.renameSync(tmp, STATE_FILE);
228
290
  }
229
291
  export function activeFor(providerId) {
230
292
  return readState().active[providerId];
231
293
  }
232
294
  export function setActive(providerId, name) {
233
- const state = readState();
234
- state.active[providerId] = name;
235
- writeState(state);
295
+ return withFileLockSync(STATE_FILE, () => {
296
+ const state = readState();
297
+ state.active[providerId] = name;
298
+ writeState(state);
299
+ });
236
300
  }
237
301
  export function labelFor(providerId, name) {
238
302
  return readState().labels[labelKey(providerId, name)] ?? null;
239
303
  }
240
304
  /** Set (or clear, with null) a profile's label. */
241
305
  export function setLabel(providerId, name, label) {
242
- const state = readState();
243
- const key = labelKey(providerId, name);
244
- if (label)
245
- state.labels[key] = label;
246
- else
247
- delete state.labels[key];
248
- writeState(state);
249
- }
250
- /** Drop a removed profile's label so it can't linger as an orphan. */
306
+ return withFileLockSync(STATE_FILE, () => {
307
+ const state = readState();
308
+ const key = labelKey(providerId, name);
309
+ if (label)
310
+ state.labels[key] = label;
311
+ else
312
+ delete state.labels[key];
313
+ writeState(state);
314
+ });
315
+ }
316
+ /** Drop a removed profile's label, colour and disabled flag so they can't
317
+ * linger as orphans (a reused name starts clean). */
251
318
  export function clearLabel(providerId, name) {
252
319
  setLabel(providerId, name, null);
320
+ setColor(providerId, name, null);
321
+ setDisabled(providerId, name, false);
322
+ }
323
+ export function isDisabled(providerId, name) {
324
+ return readState().disabled.includes(labelKey(providerId, name));
325
+ }
326
+ /** Disable (or re-enable) a profile. Disabling the active one clears the slot. */
327
+ export function setDisabled(providerId, name, disabled) {
328
+ return withFileLockSync(STATE_FILE, () => {
329
+ const state = readState();
330
+ const key = labelKey(providerId, name);
331
+ state.disabled = state.disabled.filter((k) => k !== key);
332
+ if (disabled) {
333
+ state.disabled.push(key);
334
+ if (state.active[providerId] === name)
335
+ state.active[providerId] = null;
336
+ }
337
+ writeState(state);
338
+ });
339
+ }
340
+ export function colorOf(providerId, name) {
341
+ return readState().colors[labelKey(providerId, name)] ?? null;
342
+ }
343
+ /** Set (or clear, with null) a profile's colour. */
344
+ export function setColor(providerId, name, color) {
345
+ return withFileLockSync(STATE_FILE, () => {
346
+ const state = readState();
347
+ const key = labelKey(providerId, name);
348
+ if (color)
349
+ state.colors[key] = color;
350
+ else
351
+ delete state.colors[key];
352
+ writeState(state);
353
+ });
253
354
  }
254
355
  /**
255
356
  * Rename a profile: move its config dir, carry its Claude credential across the
@@ -267,32 +368,39 @@ export function clearLabel(providerId, name) {
267
368
  * keychain ops are no-ops, so the re-seed is a harmless same-content rewrite.
268
369
  */
269
370
  export function renameProfile(providerId, from, to, store = credentialStore()) {
270
- if (!profileExists(providerId, from))
271
- throw new Error(`profile "${from}" does not exist`);
272
- if (profileExists(providerId, to))
273
- throw new Error(`profile "${to}" already exists`);
274
- const fromConfig = configDir(providerId, from);
275
- const toConfig = configDir(providerId, to);
276
- // Capture the credential while the old path (and thus the darwin keychain hash)
277
- // still resolves. Only Claude uses this store; codex/antigravity keep auth inside
278
- // the config dir, which simply moves with the rename.
279
- const cred = providerId === "claude" ? store.read(fromConfig) : null;
280
- fs.renameSync(profileDir(providerId, from), profileDir(providerId, to));
281
- if (cred) {
282
- store.clearStale(toConfig); // a stale hashed entry at the new path would shadow the seed (darwin)
283
- fs.writeFileSync(path.join(toConfig, ".credentials.json"), cred, { mode: 0o600 });
284
- store.removeEntry(fromConfig); // drop the orphaned old-path keychain entry (darwin)
285
- }
286
- const state = readState();
287
- if (state.active[providerId] === from)
288
- state.active[providerId] = to;
289
- const fromKey = labelKey(providerId, from);
290
- const toKey = labelKey(providerId, to);
291
- if (state.labels[fromKey] !== undefined) {
292
- state.labels[toKey] = state.labels[fromKey];
293
- delete state.labels[fromKey];
294
- }
295
- writeState(state);
371
+ return withFileLockSync(STATE_FILE, () => {
372
+ if (!profileExists(providerId, from))
373
+ throw new Error(`profile "${from}" does not exist`);
374
+ if (profileExists(providerId, to))
375
+ throw new Error(`profile "${to}" already exists`);
376
+ const fromConfig = configDir(providerId, from);
377
+ const toConfig = configDir(providerId, to);
378
+ // Capture the credential while the old path (and thus the darwin keychain hash)
379
+ // still resolves. Only Claude uses this store; codex/antigravity keep auth inside
380
+ // the config dir, which simply moves with the rename.
381
+ const cred = providerId === "claude" ? store.read(fromConfig) : null;
382
+ fs.renameSync(profileDir(providerId, from), profileDir(providerId, to));
383
+ if (cred) {
384
+ store.clearStale(toConfig); // a stale hashed entry at the new path would shadow the seed (darwin)
385
+ fs.writeFileSync(path.join(toConfig, ".credentials.json"), cred, { mode: 0o600 });
386
+ store.removeEntry(fromConfig); // drop the orphaned old-path keychain entry (darwin)
387
+ }
388
+ const state = readState();
389
+ if (state.active[providerId] === from)
390
+ state.active[providerId] = to;
391
+ const fromKey = labelKey(providerId, from);
392
+ const toKey = labelKey(providerId, to);
393
+ if (state.labels[fromKey] !== undefined) {
394
+ state.labels[toKey] = state.labels[fromKey];
395
+ delete state.labels[fromKey];
396
+ }
397
+ if (state.colors[fromKey] !== undefined) {
398
+ state.colors[toKey] = state.colors[fromKey];
399
+ delete state.colors[fromKey];
400
+ }
401
+ state.disabled = state.disabled.map((k) => (k === fromKey ? toKey : k));
402
+ writeState(state);
403
+ });
296
404
  }
297
405
  export function readAutoSwitch(providerId) {
298
406
  return readState().autoSwitch[providerId];
@@ -302,10 +410,12 @@ export function readAutoSwitchAll() {
302
410
  return readState().autoSwitch;
303
411
  }
304
412
  export function setAutoSwitch(providerId, cfg) {
305
- const state = readState();
306
- state.autoSwitch[providerId] = normalizeAutoSwitch({ ...state.autoSwitch[providerId], ...cfg });
307
- writeState(state);
308
- return state.autoSwitch[providerId];
413
+ return withFileLockSync(STATE_FILE, () => {
414
+ const state = readState();
415
+ state.autoSwitch[providerId] = normalizeAutoSwitch({ ...state.autoSwitch[providerId], ...cfg });
416
+ writeState(state);
417
+ return state.autoSwitch[providerId];
418
+ });
309
419
  }
310
420
  // ---------- provider enable/disable (per surface) ----------
311
421
  /** Every provider's enabled surfaces (for `providers status` / the GUI tab). */
@@ -324,10 +434,12 @@ export function enabledProviders(surface = "cli") {
324
434
  }
325
435
  /** Enable/disable one surface of a provider; returns its new surface state. */
326
436
  export function setProviderSurface(providerId, surface, enabled) {
327
- const state = readState();
328
- state.providers[providerId] = { ...state.providers[providerId], [surface]: enabled };
329
- writeState(state);
330
- return state.providers[providerId];
437
+ return withFileLockSync(STATE_FILE, () => {
438
+ const state = readState();
439
+ state.providers[providerId] = { ...state.providers[providerId], [surface]: enabled };
440
+ writeState(state);
441
+ return state.providers[providerId];
442
+ });
331
443
  }
332
444
  // ---------- linked binary paths (provider CLI not on PATH) ----------
333
445
  /** The user-linked absolute path to a provider's CLI binary, or null. */
@@ -340,12 +452,14 @@ export function readBinaryPaths() {
340
452
  }
341
453
  /** Link (set) or unlink (null) a provider's CLI binary path. */
342
454
  export function setBinaryPath(providerId, binPath) {
343
- const state = readState();
344
- if (binPath)
345
- state.binaryPaths[providerId] = binPath;
346
- else
347
- delete state.binaryPaths[providerId];
348
- writeState(state);
455
+ return withFileLockSync(STATE_FILE, () => {
456
+ const state = readState();
457
+ if (binPath)
458
+ state.binaryPaths[providerId] = binPath;
459
+ else
460
+ delete state.binaryPaths[providerId];
461
+ writeState(state);
462
+ });
349
463
  }
350
464
  // ---------- layout migration: v1 <ROOT>/<name> → <ROOT>/claude/<name> ----------
351
465
  /**
@@ -0,0 +1,100 @@
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 { withFileLockSync } from "./locks.js";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { ROOT, ensureRoot } from "./profiles.js";
16
+ export const PROTECTION_FILE = path.join(ROOT, "protection.json");
17
+ export const DEFAULT_PROTECTION = { enabled: true, thresholdPct: 98 };
18
+ function key(providerId, name) {
19
+ return `${providerId}/${name}`;
20
+ }
21
+ function validThreshold(v) {
22
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 100;
23
+ }
24
+ function load(file) {
25
+ try {
26
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
27
+ return raw && typeof raw.profiles === "object" && raw.profiles ? raw.profiles : {};
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ function save(profiles, file) {
34
+ if (file === PROTECTION_FILE)
35
+ ensureRoot();
36
+ const tmp = `${file}.${process.pid}.tmp`;
37
+ fs.writeFileSync(tmp, JSON.stringify({ schema: 1, profiles }, null, 2) + "\n", { mode: 0o600 });
38
+ fs.renameSync(tmp, file);
39
+ }
40
+ function normalize(raw) {
41
+ return {
42
+ enabled: typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULT_PROTECTION.enabled,
43
+ thresholdPct: validThreshold(raw?.thresholdPct) ? raw.thresholdPct : DEFAULT_PROTECTION.thresholdPct,
44
+ };
45
+ }
46
+ export function readProtection(providerId, name, file = PROTECTION_FILE) {
47
+ return normalize(load(file)[key(providerId, name)]);
48
+ }
49
+ export function setProtection(providerId, name, patch, file = PROTECTION_FILE) {
50
+ return withFileLockSync(file, () => {
51
+ if (patch.thresholdPct !== undefined && !validThreshold(patch.thresholdPct)) {
52
+ throw new Error("threshold must be a whole number between 1 and 100");
53
+ }
54
+ const all = load(file);
55
+ const next = normalize({ ...normalize(all[key(providerId, name)]), ...patch });
56
+ all[key(providerId, name)] = next;
57
+ save(all, file);
58
+ return next;
59
+ });
60
+ }
61
+ /** Carry a profile's policy across a rename; drop it on remove. */
62
+ export function renameProtection(providerId, from, to, file = PROTECTION_FILE) {
63
+ return withFileLockSync(file, () => {
64
+ const all = load(file);
65
+ if (!(key(providerId, from) in all))
66
+ return;
67
+ all[key(providerId, to)] = all[key(providerId, from)];
68
+ delete all[key(providerId, from)];
69
+ save(all, file);
70
+ });
71
+ }
72
+ export function forgetProtection(providerId, name, file = PROTECTION_FILE) {
73
+ return withFileLockSync(file, () => {
74
+ const all = load(file);
75
+ if (!(key(providerId, name) in all))
76
+ return;
77
+ delete all[key(providerId, name)];
78
+ save(all, file);
79
+ });
80
+ }
81
+ /**
82
+ * The threshold warning for one profile, or null. Pure. Fires once per reset
83
+ * cycle of the window that crossed (`lastCycle` = what was announced before).
84
+ */
85
+ export function protectionNotice(target, snap, policy, lastCycle) {
86
+ if (!policy.enabled)
87
+ return null;
88
+ const hit = snap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= policy.thresholdPct);
89
+ if (!hit)
90
+ return null;
91
+ const cycle = `${hit.key}@${hit.resetsAt ?? ""}`;
92
+ if (cycle === lastCycle)
93
+ return null;
94
+ return {
95
+ cycle,
96
+ title: "Session protection: limit almost reached",
97
+ message: `${target} reached ${hit.utilization}% of its ${hit.label} limit (protection threshold ${policy.thresholdPct}%). ` +
98
+ `This is a warning only — agent-switch does not pause running sessions.`,
99
+ };
100
+ }