@event4u/agent-switch 2.2.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/README.md +28 -4
- package/dist/activity.js +2 -0
- package/dist/auth-status.js +10 -0
- package/dist/balance.js +4 -1
- package/dist/daemon.js +7 -5
- package/dist/desktop-settings.js +90 -0
- package/dist/dir-resolve.js +55 -28
- package/dist/doctor.js +35 -1
- package/dist/history.js +20 -0
- package/dist/index.js +193 -11
- package/dist/launchers.js +228 -0
- package/dist/locks.js +61 -0
- package/dist/pools.js +58 -45
- package/dist/profiles.js +132 -81
- package/dist/protection.js +26 -19
- package/dist/rebind.js +7 -1
- package/dist/service.js +16 -0
- package/dist/shellenv.js +25 -3
- package/dist/token-freshen.js +11 -0
- package/package.json +1 -1
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/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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
p.members.
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
p.members.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
state.active[providerId]
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
state.active[providerId]
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
state.labels[
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
state.colors[
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
/**
|
package/dist/protection.js
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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();
|