@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/README.md +166 -73
- package/dist/activity.js +99 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +159 -0
- package/dist/balance.js +55 -0
- package/dist/browsers.js +90 -0
- package/dist/daemon.js +95 -42
- package/dist/desktop-sessions.js +115 -0
- package/dist/desktop-settings.js +90 -0
- package/dist/dir-resolve.js +178 -0
- package/dist/doctor.js +239 -69
- package/dist/gui-settings.js +67 -0
- package/dist/history.js +20 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +879 -68
- package/dist/launchers.js +228 -0
- package/dist/locks.js +61 -0
- package/dist/mappings.js +48 -9
- package/dist/pools.js +157 -0
- package/dist/profiles.js +181 -67
- package/dist/protection.js +100 -0
- package/dist/rebind.js +115 -12
- package/dist/service.js +40 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +122 -21
- package/dist/single-load.js +5 -0
- package/dist/token-freshen.js +11 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
state.active[providerId]
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
state.labels[
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
}
|