@event4u/agent-switch 2.2.0 → 2.3.1

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/locks.js CHANGED
@@ -87,3 +87,64 @@ export async function withProperLock(target, fn, timeoutMs = DEFAULT_TIMEOUT_MS)
87
87
  }
88
88
  }
89
89
  }
90
+ /**
91
+ * Synchronous twin of {@link withProperLock} for agent-switch's own small
92
+ * stores (pools, protection): one read-modify-write at a time across every
93
+ * CLI and GUI process, so two writers cannot drop each other's change. Same
94
+ * directory-lock protocol and staleness rule; short waits block the thread
95
+ * (the critical section is a JSON read and write).
96
+ */
97
+ export function withFileLockSync(target, fn, timeoutMs = DEFAULT_TIMEOUT_MS) {
98
+ const lockDir = lockDirFor(target);
99
+ const deadline = Date.now() + timeoutMs;
100
+ const nap = new Int32Array(new SharedArrayBuffer(4));
101
+ fs.mkdirSync(path.dirname(target), { recursive: true });
102
+ for (;;) {
103
+ try {
104
+ fs.mkdirSync(lockDir);
105
+ break;
106
+ }
107
+ catch (err) {
108
+ // Windows answers EPERM while another process is removing the lock dir.
109
+ const busy = err?.code === "EEXIST" || (process.platform === "win32" && err?.code === "EPERM");
110
+ if (!busy)
111
+ throw err;
112
+ if (err.code === "EPERM") {
113
+ if (Date.now() > deadline)
114
+ throw err;
115
+ Atomics.wait(nap, 0, 0, 10);
116
+ continue;
117
+ }
118
+ let stale = false;
119
+ try {
120
+ stale = Date.now() - fs.statSync(lockDir).mtimeMs > STALENESS_MS;
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ if (stale) {
126
+ try {
127
+ fs.rmdirSync(lockDir);
128
+ }
129
+ catch {
130
+ /* raced with another taker — loop */
131
+ }
132
+ continue;
133
+ }
134
+ if (Date.now() > deadline)
135
+ throw new Error(`timed out waiting for the lock at ${lockDir}`);
136
+ Atomics.wait(nap, 0, 0, 10 + Math.floor(Math.random() * 15));
137
+ }
138
+ }
139
+ try {
140
+ return fn();
141
+ }
142
+ finally {
143
+ try {
144
+ fs.rmdirSync(lockDir);
145
+ }
146
+ catch {
147
+ /* already gone */
148
+ }
149
+ }
150
+ }
package/dist/os-notify.js CHANGED
@@ -2,7 +2,9 @@
2
2
  * Best-effort OS desktop notifications for the background daemon, so an
3
3
  * auto-switch is visible even when the GUI is closed. Per-platform native
4
4
  * command, no external dependency:
5
- * - macOS → `osascript -e 'display notification …'`
5
+ * - macOS → nothing: an `osascript` notification is attributed to Script
6
+ * Editor. The Agent Switch app shows the notice itself when it
7
+ * runs (records not marked OS-notified), otherwise no one does.
6
8
  * - Linux → `notify-send <title> <body>`
7
9
  * - Windows → PowerShell balloon tip via System.Windows.Forms.NotifyIcon
8
10
  *
@@ -10,10 +12,6 @@
10
12
  * testable; `osNotify` wraps it with a fire-and-forget spawn that never throws.
11
13
  */
12
14
  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
15
  /** PowerShell single-quoted string literal escaping: double the single quote. */
18
16
  function escPowerShellSingle(s) {
19
17
  return s.replace(/'/g, "''");
@@ -23,10 +21,6 @@ function escPowerShellSingle(s) {
23
21
  * `platform` matches `process.platform` values (`darwin` / `linux` / `win32`).
24
22
  */
25
23
  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
24
  if (platform === "linux") {
31
25
  // notify-send treats the first arg as summary, the second as body.
32
26
  return { program: "notify-send", args: [title, body] };
package/dist/pools.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * and keyed by `provider/name`; a pool may hold profiles of several providers,
7
7
  * and a lookup only ever considers members of the provider being started.
8
8
  */
9
+ import { withFileLockSync } from "./locks.js";
9
10
  import * as fs from "node:fs";
10
11
  import * as path from "node:path";
11
12
  import { ROOT, ensureRoot, labelFor, listAllProfiles, PROFILE_LABELS } from "./profiles.js";
@@ -68,63 +69,75 @@ function mustFind(pools, id) {
68
69
  return p;
69
70
  }
70
71
  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;
72
+ return withFileLockSync(file, () => {
73
+ const display = name.trim();
74
+ const id = slugify(display);
75
+ if (!id)
76
+ throw new Error("pool name needs at least one letter or digit");
77
+ const pools = readPools(file);
78
+ if (pools.some((p) => p.id === id))
79
+ throw new Error(`a pool "${id}" already exists`);
80
+ const pool = { id, name: display, members: [], createdAt: now.toISOString() };
81
+ save([...pools, pool], file);
82
+ return pool;
83
+ });
81
84
  }
82
85
  /** Renames the display name only — the id stays, so folder rules keep working. */
83
86
  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;
87
+ return withFileLockSync(file, () => {
88
+ const pools = readPools(file);
89
+ const p = mustFind(pools, id);
90
+ if (!name.trim())
91
+ throw new Error("pool name must not be empty");
92
+ p.name = name.trim();
93
+ save(pools, file);
94
+ return p;
95
+ });
91
96
  }
92
97
  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);
98
+ return withFileLockSync(file, () => {
99
+ const pools = readPools(file);
100
+ mustFind(pools, id);
101
+ save(pools.filter((p) => p.id !== id), file);
102
+ });
96
103
  }
97
104
  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;
105
+ return withFileLockSync(file, () => {
106
+ if (!parseMember(member))
107
+ throw new Error(`"${member}" is not a profile key (expected provider/name, e.g. claude/work)`);
108
+ const pools = readPools(file);
109
+ const p = mustFind(pools, id);
110
+ if (!p.members.includes(member))
111
+ p.members.push(member);
112
+ save(pools, file);
113
+ return p;
114
+ });
106
115
  }
107
116
  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;
117
+ return withFileLockSync(file, () => {
118
+ const pools = readPools(file);
119
+ const p = mustFind(pools, id);
120
+ p.members = p.members.filter((m) => m !== member);
121
+ save(pools, file);
122
+ return p;
123
+ });
113
124
  }
114
125
  /** Keep memberships pointing at a profile across a rename / drop them on remove. */
115
126
  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);
127
+ return withFileLockSync(file, () => {
128
+ const pools = readPools(file);
129
+ let changed = false;
130
+ for (const p of pools) {
131
+ if (!p.members.includes(from))
132
+ continue;
133
+ p.members = p.members.filter((m) => m !== from);
134
+ if (to && !p.members.includes(to))
135
+ p.members.push(to);
136
+ changed = true;
137
+ }
138
+ if (changed)
139
+ save(pools, file);
140
+ });
128
141
  }
129
142
  /**
130
143
  * "Suggest from tags": one proposed pool per label in use. A preview only —
package/dist/profiles.js CHANGED
@@ -1,4 +1,5 @@
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";
@@ -70,6 +71,20 @@ export function configDir(providerId, name) {
70
71
  export function browserDir(providerId, name) {
71
72
  return path.join(profileDir(providerId, name), "browser");
72
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
+ }
73
88
  export function profileExists(providerId, name) {
74
89
  return fs.existsSync(configDir(providerId, name));
75
90
  }
@@ -219,9 +234,11 @@ export function readOsNotifications() {
219
234
  return readState().osNotifications;
220
235
  }
221
236
  export function setOsNotifications(on) {
222
- const state = readState();
223
- state.osNotifications = on;
224
- writeState(state);
237
+ return withFileLockSync(STATE_FILE, () => {
238
+ const state = readState();
239
+ state.osNotifications = on;
240
+ writeState(state);
241
+ });
225
242
  }
226
243
  // ---------- single-load guard policy ----------
227
244
  /** How the single-load guard reacts to a second load of one profile. */
@@ -230,9 +247,11 @@ export function readGuard() {
230
247
  }
231
248
  /** Persist the guard policy. */
232
249
  export function setGuard(policy) {
233
- const state = readState();
234
- state.guard = policy;
235
- writeState(state);
250
+ return withFileLockSync(STATE_FILE, () => {
251
+ const state = readState();
252
+ state.guard = policy;
253
+ writeState(state);
254
+ });
236
255
  }
237
256
  // ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
238
257
  /** Current rollback/circuit-breaker state for the `rebind` write path. */
@@ -241,38 +260,58 @@ export function readRebindKillSwitch() {
241
260
  }
242
261
  /** Persist the rebind kill-switch state (breaker counter + disabled flag). */
243
262
  export function writeRebindKillSwitch(next) {
244
- const state = readState();
245
- state.rebind = normalizeRebindState(next);
246
- writeState(state);
263
+ return withFileLockSync(STATE_FILE, () => {
264
+ const state = readState();
265
+ state.rebind = normalizeRebindState(next);
266
+ writeState(state);
267
+ });
247
268
  }
248
269
  /** Re-enable `rebind`: clear `disabled` + reset the failure counter (`--reset`). */
249
270
  export function resetRebindKillSwitch() {
250
271
  writeRebindKillSwitch({ ...DEFAULT_REBIND_STATE });
251
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). */
252
276
  export function writeState(state) {
253
277
  ensureRoot();
254
- 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);
255
290
  }
256
291
  export function activeFor(providerId) {
257
292
  return readState().active[providerId];
258
293
  }
259
294
  export function setActive(providerId, name) {
260
- const state = readState();
261
- state.active[providerId] = name;
262
- writeState(state);
295
+ return withFileLockSync(STATE_FILE, () => {
296
+ const state = readState();
297
+ state.active[providerId] = name;
298
+ writeState(state);
299
+ });
263
300
  }
264
301
  export function labelFor(providerId, name) {
265
302
  return readState().labels[labelKey(providerId, name)] ?? null;
266
303
  }
267
304
  /** Set (or clear, with null) a profile's label. */
268
305
  export function setLabel(providerId, name, label) {
269
- const state = readState();
270
- const key = labelKey(providerId, name);
271
- if (label)
272
- state.labels[key] = label;
273
- else
274
- delete state.labels[key];
275
- writeState(state);
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
+ });
276
315
  }
277
316
  /** Drop a removed profile's label, colour and disabled flag so they can't
278
317
  * linger as orphans (a reused name starts clean). */
@@ -286,28 +325,32 @@ export function isDisabled(providerId, name) {
286
325
  }
287
326
  /** Disable (or re-enable) a profile. Disabling the active one clears the slot. */
288
327
  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);
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
+ });
298
339
  }
299
340
  export function colorOf(providerId, name) {
300
341
  return readState().colors[labelKey(providerId, name)] ?? null;
301
342
  }
302
343
  /** Set (or clear, with null) a profile's colour. */
303
344
  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);
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
+ });
311
354
  }
312
355
  /**
313
356
  * Rename a profile: move its config dir, carry its Claude credential across the
@@ -325,37 +368,39 @@ export function setColor(providerId, name, color) {
325
368
  * keychain ops are no-ops, so the re-seed is a harmless same-content rewrite.
326
369
  */
327
370
  export function renameProfile(providerId, from, to, store = credentialStore()) {
328
- if (!profileExists(providerId, from))
329
- throw new Error(`profile "${from}" does not exist`);
330
- if (profileExists(providerId, to))
331
- throw new Error(`profile "${to}" already exists`);
332
- const fromConfig = configDir(providerId, from);
333
- const toConfig = configDir(providerId, to);
334
- // Capture the credential while the old path (and thus the darwin keychain hash)
335
- // still resolves. Only Claude uses this store; codex/antigravity keep auth inside
336
- // the config dir, which simply moves with the rename.
337
- const cred = providerId === "claude" ? store.read(fromConfig) : null;
338
- fs.renameSync(profileDir(providerId, from), profileDir(providerId, to));
339
- if (cred) {
340
- store.clearStale(toConfig); // a stale hashed entry at the new path would shadow the seed (darwin)
341
- fs.writeFileSync(path.join(toConfig, ".credentials.json"), cred, { mode: 0o600 });
342
- store.removeEntry(fromConfig); // drop the orphaned old-path keychain entry (darwin)
343
- }
344
- const state = readState();
345
- if (state.active[providerId] === from)
346
- state.active[providerId] = to;
347
- const fromKey = labelKey(providerId, from);
348
- const toKey = labelKey(providerId, to);
349
- if (state.labels[fromKey] !== undefined) {
350
- state.labels[toKey] = state.labels[fromKey];
351
- delete state.labels[fromKey];
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));
358
- 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
+ });
359
404
  }
360
405
  export function readAutoSwitch(providerId) {
361
406
  return readState().autoSwitch[providerId];
@@ -365,10 +410,12 @@ export function readAutoSwitchAll() {
365
410
  return readState().autoSwitch;
366
411
  }
367
412
  export function setAutoSwitch(providerId, cfg) {
368
- const state = readState();
369
- state.autoSwitch[providerId] = normalizeAutoSwitch({ ...state.autoSwitch[providerId], ...cfg });
370
- writeState(state);
371
- 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
+ });
372
419
  }
373
420
  // ---------- provider enable/disable (per surface) ----------
374
421
  /** Every provider's enabled surfaces (for `providers status` / the GUI tab). */
@@ -387,10 +434,12 @@ export function enabledProviders(surface = "cli") {
387
434
  }
388
435
  /** Enable/disable one surface of a provider; returns its new surface state. */
389
436
  export function setProviderSurface(providerId, surface, enabled) {
390
- const state = readState();
391
- state.providers[providerId] = { ...state.providers[providerId], [surface]: enabled };
392
- writeState(state);
393
- 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
+ });
394
443
  }
395
444
  // ---------- linked binary paths (provider CLI not on PATH) ----------
396
445
  /** The user-linked absolute path to a provider's CLI binary, or null. */
@@ -403,12 +452,14 @@ export function readBinaryPaths() {
403
452
  }
404
453
  /** Link (set) or unlink (null) a provider's CLI binary path. */
405
454
  export function setBinaryPath(providerId, binPath) {
406
- const state = readState();
407
- if (binPath)
408
- state.binaryPaths[providerId] = binPath;
409
- else
410
- delete state.binaryPaths[providerId];
411
- 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
+ });
412
463
  }
413
464
  // ---------- layout migration: v1 <ROOT>/<name> → <ROOT>/claude/<name> ----------
414
465
  /**
@@ -9,6 +9,7 @@
9
9
  * hold the session (Claude Code's own auto-continue), which agent-switch cannot
10
10
  * verify from here, so every notice says "warn" — never "paused".
11
11
  */
12
+ import { withFileLockSync } from "./locks.js";
12
13
  import * as fs from "node:fs";
13
14
  import * as path from "node:path";
14
15
  import { ROOT, ensureRoot } from "./profiles.js";
@@ -46,30 +47,36 @@ export function readProtection(providerId, name, file = PROTECTION_FILE) {
46
47
  return normalize(load(file)[key(providerId, name)]);
47
48
  }
48
49
  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;
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
+ });
57
60
  }
58
61
  /** Carry a profile's policy across a rename; drop it on remove. */
59
62
  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);
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
+ });
66
71
  }
67
72
  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
+ 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
+ });
73
80
  }
74
81
  /**
75
82
  * The threshold warning for one profile, or null. Pure. Fires once per reset
package/dist/rebind.js CHANGED
@@ -30,7 +30,7 @@ import * as nodePath from "node:path";
30
30
  import { fetchProfile } from "./api.js";
31
31
  import * as keychain from "./keychain.js";
32
32
  import { withProperLock } from "./locks.js";
33
- import { configDir, ROOT, readRebindKillSwitch, writeRebindKillSwitch } from "./profiles.js";
33
+ import { configDir, ROOT, readRebindKillSwitch, sharesDefaultLogin, writeRebindKillSwitch } from "./profiles.js";
34
34
  import { freshenToken } from "./token-freshen.js";
35
35
  /** <10 min to expiry → refuse (2× Claude Code's own 5-min refresh buffer). */
36
36
  export const FRESHEN_FLOOR_MS = 10 * 60 * 1000;
@@ -127,6 +127,7 @@ export function defaultDeps() {
127
127
  }
128
128
  },
129
129
  resolveAccount: resolveAccountViaApi,
130
+ sharesDefaultLogin: (name) => sharesDefaultLogin("claude", name),
130
131
  };
131
132
  }
132
133
  function credFile(cfgDir) {
@@ -549,6 +550,11 @@ async function performRebind(opts, d) {
549
550
  if (account === profile) {
550
551
  throw new RebindRefused(`cannot rebind "${profile}" to itself`);
551
552
  }
553
+ for (const name of [profile, account]) {
554
+ if (d.sharesDefaultLogin?.(name)) {
555
+ throw new RebindRefused(`"${name}" shares its login with the default install — rebinding would refresh or move it and sign the default install out. Give it its own login first: agent-switch login ${name}`);
556
+ }
557
+ }
552
558
  const pConfig = configDir("claude", profile);
553
559
  const aConfig = configDir("claude", account);
554
560
  // Already rebound → RE-SWITCH: put both profiles back on their own accounts
package/dist/service.js CHANGED
@@ -96,6 +96,22 @@ export function serviceTarget() {
96
96
  const me = runInvocation();
97
97
  return m.invocation && m.invocation.exec === me.exec && m.invocation.args.join("\0") === me.args.join("\0") ? "this-cli" : "other-cli";
98
98
  }
99
+ /** What `update` does with the service once the new version is on disk. Our
100
+ * own service is re-installed so it restarts on the new code; one that runs
101
+ * another CLI (a dev checkout) is not ours to re-point. */
102
+ export function serviceAfterUpdate(target, interactive) {
103
+ if (target === "this-cli")
104
+ return "reinstall";
105
+ if (target === "other-cli")
106
+ return "leave";
107
+ return interactive ? "ask" : "hint";
108
+ }
109
+ /** Runs `service install` through the CLI file on disk — after a self-update
110
+ * that is the new version, while this process is still the old one. */
111
+ export function serviceInstallFromDisk() {
112
+ const { exec, args } = runInvocation();
113
+ return spawnSync(exec, [args[0], "service", "install"], { stdio: "inherit" }).status ?? 1;
114
+ }
99
115
  // ---------- install / uninstall ----------
100
116
  export function serviceInstall(log = console.log) {
101
117
  const { exec, args } = runInvocation();