@event4u/agent-switch 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mappings.js CHANGED
@@ -12,6 +12,9 @@
12
12
  * canonicalized) paths; values are `{ provider, name }`. A path may map a
13
13
  * different profile per provider, so the stored value is keyed by provider:
14
14
  * `{ "<path>": { claude: "<name>", codex: "<name>", ... } }`.
15
+ *
16
+ * A rule can be paused without losing it: `paused` lists `{ "<path>": [provider] }`
17
+ * next to `mappings`, and resolution skips a paused rule as if it were absent.
15
18
  */
16
19
  import * as fs from "node:fs";
17
20
  import * as path from "node:path";
@@ -39,14 +42,30 @@ export function normalizePath(p) {
39
42
  }
40
43
  return canonicalizeWin32(resolved);
41
44
  }
42
- export function loadMappings() {
43
- let data;
45
+ function readFile() {
44
46
  try {
45
- data = JSON.parse(fs.readFileSync(MAPPINGS_FILE, "utf8"));
47
+ return JSON.parse(fs.readFileSync(MAPPINGS_FILE, "utf8"));
46
48
  }
47
49
  catch {
50
+ return null;
51
+ }
52
+ }
53
+ function loadPaused() {
54
+ const raw = readFile()?.paused;
55
+ if (!raw || typeof raw !== "object")
48
56
  return {};
57
+ const out = {};
58
+ for (const [key, value] of Object.entries(raw)) {
59
+ if (Array.isArray(value))
60
+ out[key] = value.filter((v) => typeof v === "string");
49
61
  }
62
+ return out;
63
+ }
64
+ const isPaused = (paused, key, providerId) => !!paused[key]?.includes(providerId);
65
+ export function loadMappings() {
66
+ const data = readFile();
67
+ if (!data)
68
+ return {};
50
69
  const raw = data && typeof data.mappings === "object" ? data.mappings : {};
51
70
  const out = {};
52
71
  for (const [key, value] of Object.entries(raw)) {
@@ -58,11 +77,29 @@ export function loadMappings() {
58
77
  }
59
78
  return out;
60
79
  }
61
- function save(mappings) {
80
+ /** Write mappings; paused flags of rules that no longer exist are dropped. */
81
+ function save(mappings, paused = loadPaused()) {
62
82
  ensureRoot();
63
- fs.writeFileSync(MAPPINGS_FILE, JSON.stringify({ schema: 2, mappings }, null, 2) + "\n", {
64
- mode: 0o600,
65
- });
83
+ const kept = {};
84
+ for (const [key, providers] of Object.entries(paused)) {
85
+ const live = providers.filter((p) => mappings[key]?.[p]);
86
+ if (live.length > 0)
87
+ kept[key] = live;
88
+ }
89
+ const body = Object.keys(kept).length > 0 ? { schema: 2, mappings, paused: kept } : { schema: 2, mappings };
90
+ fs.writeFileSync(MAPPINGS_FILE, JSON.stringify(body, null, 2) + "\n", { mode: 0o600 });
91
+ }
92
+ /** Pause or resume one folder rule. False when the folder has no rule for the provider. */
93
+ export function setPaused(dir, providerId, on) {
94
+ const key = normalizePath(dir);
95
+ const mappings = loadMappings();
96
+ if (!mappings[key]?.[providerId])
97
+ return false;
98
+ const paused = loadPaused();
99
+ const rest = (paused[key] ?? []).filter((p) => p !== providerId);
100
+ paused[key] = on ? [...rest, providerId] : rest;
101
+ save(mappings, paused);
102
+ return true;
66
103
  }
67
104
  export function setMapping(dir, providerId, name) {
68
105
  const key = normalizePath(dir);
@@ -93,10 +130,11 @@ export function removeMapping(dir, providerId) {
93
130
  /** Nearest mapped ancestor of `dir` (inclusive) for a provider; null if none. */
94
131
  export function resolveMapping(dir, providerId) {
95
132
  const mappings = loadMappings();
133
+ const paused = loadPaused();
96
134
  let current = normalizePath(dir);
97
135
  for (;;) {
98
136
  const entry = mappings[current];
99
- if (entry && entry[providerId])
137
+ if (entry && entry[providerId] && !isPaused(paused, current, providerId))
100
138
  return { path: current, name: entry[providerId] };
101
139
  const parent = path.dirname(current);
102
140
  if (parent === current)
@@ -123,9 +161,10 @@ export function pruneMappings(providerId, name) {
123
161
  /** All mappings as flat rows for display. */
124
162
  export function mappingRows() {
125
163
  const rows = [];
164
+ const paused = loadPaused();
126
165
  for (const [key, entry] of Object.entries(loadMappings())) {
127
166
  for (const [provider, name] of Object.entries(entry)) {
128
- rows.push({ path: key, provider: provider, name: name });
167
+ rows.push({ path: key, provider: provider, name: name, paused: isPaused(paused, key, provider) });
129
168
  }
130
169
  }
131
170
  return rows.sort((a, b) => a.path.localeCompare(b.path) || a.provider.localeCompare(b.provider));
package/dist/pools.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Pools — explicit, user-created groups of profiles that a folder rule may point
3
+ * at (`map pool:<id> [dir]`). Additive and opt-in: the store starts empty, an
4
+ * upgrade creates nothing, and labels (Work/Personal/Other) and
5
+ * `autoSwitch.tag` are never read or changed here. Membership is many-to-many
6
+ * and keyed by `provider/name`; a pool may hold profiles of several providers,
7
+ * and a lookup only ever considers members of the provider being started.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { ROOT, ensureRoot, labelFor, listAllProfiles, PROFILE_LABELS } from "./profiles.js";
12
+ import { isProviderId } from "./providers.js";
13
+ export const POOLS_FILE = path.join(ROOT, "pools.json");
14
+ export const POOL_PREFIX = "pool:";
15
+ export function isPoolRef(v) {
16
+ return v.startsWith(POOL_PREFIX);
17
+ }
18
+ export function poolIdOf(ref) {
19
+ return ref.slice(POOL_PREFIX.length);
20
+ }
21
+ /** `claude/work` → `{ provider, name }`, or null when it is not a member key. */
22
+ export function parseMember(v) {
23
+ const m = /^([a-z]+)\/([A-Za-z0-9_-]+)$/.exec(v);
24
+ if (!m || !isProviderId(m[1]))
25
+ return null;
26
+ return { provider: m[1], name: m[2] };
27
+ }
28
+ export function slugify(name) {
29
+ return name
30
+ .toLowerCase()
31
+ .normalize("NFKD")
32
+ .replace(/[^a-z0-9]+/g, "-")
33
+ .replace(/^-+|-+$/g, "")
34
+ .slice(0, 40);
35
+ }
36
+ export function readPools(file = POOLS_FILE) {
37
+ try {
38
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
39
+ if (!Array.isArray(raw?.pools))
40
+ return [];
41
+ return raw.pools
42
+ .filter((p) => p && typeof p.id === "string" && typeof p.name === "string")
43
+ .map((p) => ({
44
+ id: p.id,
45
+ name: p.name,
46
+ members: Array.isArray(p.members) ? p.members.filter((m) => typeof m === "string" && parseMember(m)) : [],
47
+ createdAt: typeof p.createdAt === "string" ? p.createdAt : "",
48
+ }));
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ }
54
+ function save(pools, file) {
55
+ if (file === POOLS_FILE)
56
+ ensureRoot();
57
+ const tmp = `${file}.${process.pid}.tmp`;
58
+ fs.writeFileSync(tmp, JSON.stringify({ schema: 1, pools }, null, 2) + "\n", { mode: 0o600 });
59
+ fs.renameSync(tmp, file);
60
+ }
61
+ export function findPool(id, file = POOLS_FILE) {
62
+ return readPools(file).find((p) => p.id === id) ?? null;
63
+ }
64
+ function mustFind(pools, id) {
65
+ const p = pools.find((x) => x.id === id);
66
+ if (!p)
67
+ throw new Error(`no pool "${id}" (pools: ${pools.map((x) => x.id).join(", ") || "none"})`);
68
+ return p;
69
+ }
70
+ export function createPool(name, file = POOLS_FILE, now = new Date()) {
71
+ const display = name.trim();
72
+ const id = slugify(display);
73
+ if (!id)
74
+ throw new Error("pool name needs at least one letter or digit");
75
+ const pools = readPools(file);
76
+ if (pools.some((p) => p.id === id))
77
+ throw new Error(`a pool "${id}" already exists`);
78
+ const pool = { id, name: display, members: [], createdAt: now.toISOString() };
79
+ save([...pools, pool], file);
80
+ return pool;
81
+ }
82
+ /** Renames the display name only — the id stays, so folder rules keep working. */
83
+ export function renamePool(id, name, file = POOLS_FILE) {
84
+ const pools = readPools(file);
85
+ const p = mustFind(pools, id);
86
+ if (!name.trim())
87
+ throw new Error("pool name must not be empty");
88
+ p.name = name.trim();
89
+ save(pools, file);
90
+ return p;
91
+ }
92
+ export function deletePool(id, file = POOLS_FILE) {
93
+ const pools = readPools(file);
94
+ mustFind(pools, id);
95
+ save(pools.filter((p) => p.id !== id), file);
96
+ }
97
+ export function addPoolMember(id, member, file = POOLS_FILE) {
98
+ if (!parseMember(member))
99
+ throw new Error(`"${member}" is not a profile key (expected provider/name, e.g. claude/work)`);
100
+ const pools = readPools(file);
101
+ const p = mustFind(pools, id);
102
+ if (!p.members.includes(member))
103
+ p.members.push(member);
104
+ save(pools, file);
105
+ return p;
106
+ }
107
+ export function removePoolMember(id, member, file = POOLS_FILE) {
108
+ const pools = readPools(file);
109
+ const p = mustFind(pools, id);
110
+ p.members = p.members.filter((m) => m !== member);
111
+ save(pools, file);
112
+ return p;
113
+ }
114
+ /** Keep memberships pointing at a profile across a rename / drop them on remove. */
115
+ export function renamePoolMember(from, to, file = POOLS_FILE) {
116
+ const pools = readPools(file);
117
+ let changed = false;
118
+ for (const p of pools) {
119
+ if (!p.members.includes(from))
120
+ continue;
121
+ p.members = p.members.filter((m) => m !== from);
122
+ if (to && !p.members.includes(to))
123
+ p.members.push(to);
124
+ changed = true;
125
+ }
126
+ if (changed)
127
+ save(pools, file);
128
+ }
129
+ /**
130
+ * "Suggest from tags": one proposed pool per label in use. A preview only —
131
+ * nothing is stored until the user confirms (`pool suggest --confirm`), and a
132
+ * proposal whose id already exists is marked so it is never overwritten.
133
+ */
134
+ export function suggestPoolsFromLabels(profiles = listAllProfiles(), label = labelFor, existing = readPools()) {
135
+ const out = [];
136
+ for (const l of PROFILE_LABELS) {
137
+ const members = profiles.filter((p) => label(p.provider, p.name) === l).map((p) => `${p.provider}/${p.name}`);
138
+ if (members.length === 0)
139
+ continue;
140
+ const id = slugify(l);
141
+ out.push({ id, name: l, members, exists: existing.some((p) => p.id === id) });
142
+ }
143
+ return out;
144
+ }
package/dist/profiles.js CHANGED
@@ -4,6 +4,7 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { PROVIDER_IDS, provider, isProviderId } from "./providers.js";
6
6
  import { credentialStore } from "./credentials.js";
7
+ import { DEFAULT_GUARD_POLICY, FRESH_INSTALL_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
7
8
  export const HOME = os.homedir();
8
9
  export const ROOT = process.env.AGENT_SWITCH_HOME ?? path.join(HOME, ".agent-switch");
9
10
  export const STATE_FILE = path.join(ROOT, "state.json");
@@ -12,6 +13,11 @@ export const PROFILE_LABELS = ["Work", "Personal", "Other"];
12
13
  export function isProfileLabel(v) {
13
14
  return typeof v === "string" && PROFILE_LABELS.includes(v);
14
15
  }
16
+ /** The account colours the GUI offers (its swatches use the same names). */
17
+ export const PROFILE_COLORS = ["orange", "red", "yellow", "green", "teal", "blue", "purple", "pink"];
18
+ export function isProfileColor(v) {
19
+ return typeof v === "string" && PROFILE_COLORS.includes(v);
20
+ }
15
21
  export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
16
22
  /**
17
23
  * Providers enabled out of the box. Everything else is available but off by
@@ -32,7 +38,13 @@ export function die(msg) {
32
38
  process.exit(1);
33
39
  }
34
40
  export function ensureRoot() {
41
+ // A fresh install pins its fresh-install defaults (guard: warn) on the first
42
+ // write, so the first profile it gets cannot flip it into looking like an
43
+ // upgraded 2.1.0 install (profiles, no state.json → block).
44
+ const fresh = !fs.existsSync(STATE_FILE) && listAllProfiles().length === 0;
35
45
  fs.mkdirSync(ROOT, { recursive: true, mode: 0o700 });
46
+ if (fresh)
47
+ fs.writeFileSync(STATE_FILE, JSON.stringify(readState(), null, 2) + "\n", { mode: 0o600 });
36
48
  }
37
49
  function validateName(name) {
38
50
  if (!/^[A-Za-z0-9_-]+$/.test(name))
@@ -101,6 +113,16 @@ function normalizeLabels(raw) {
101
113
  }
102
114
  return out;
103
115
  }
116
+ function normalizeColors(raw) {
117
+ const out = {};
118
+ if (raw && typeof raw === "object") {
119
+ for (const [k, v] of Object.entries(raw)) {
120
+ if (isProfileColor(v))
121
+ out[k] = v;
122
+ }
123
+ }
124
+ return out;
125
+ }
104
126
  function normalizeAutoSwitch(raw) {
105
127
  const r = (raw ?? {});
106
128
  const threshold = typeof r.threshold === "number" && r.threshold >= 1 && r.threshold <= 100 ? Math.round(r.threshold) : DEFAULT_AUTOSWITCH.threshold;
@@ -160,28 +182,38 @@ function normalizeRebindState(raw) {
160
182
  const consecutiveFailures = typeof r.consecutiveFailures === "number" && r.consecutiveFailures >= 0 ? Math.floor(r.consecutiveFailures) : 0;
161
183
  return { disabled: r.disabled === true, consecutiveFailures };
162
184
  }
185
+ function normalizeGuard(raw) {
186
+ return isGuardPolicy(raw) ? raw : DEFAULT_GUARD_POLICY;
187
+ }
163
188
  export function readState() {
164
189
  try {
165
190
  const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
166
191
  const labels = normalizeLabels(raw?.labels);
192
+ const colors = normalizeColors(raw?.colors);
193
+ const disabled = Array.isArray(raw?.disabled) ? raw.disabled.filter((k) => typeof k === "string") : [];
167
194
  const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
168
195
  const providers = normalizeProvidersMap(raw?.providers);
169
196
  const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
170
197
  const osNotifications = raw?.osNotifications === true;
171
198
  const rebind = normalizeRebindState(raw?.rebind);
199
+ const guard = normalizeGuard(raw?.guard);
172
200
  // v1: { active: "<name>" } — a single Claude profile.
173
201
  if (typeof raw?.active === "string") {
174
- return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
202
+ return { active: { ...emptyActive(), claude: raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
175
203
  }
176
204
  if (raw?.active && typeof raw.active === "object") {
177
- return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
205
+ return { active: { ...emptyActive(), ...raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
178
206
  }
179
- return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
207
+ return { active: emptyActive(), labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
180
208
  }
181
209
  catch {
182
210
  /* absent / unparsable → default */
183
211
  }
184
- return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE } };
212
+ // Fresh = nothing at all yet. `add` without `use` writes no state.json, so a
213
+ // 2.1.0 install can have profiles and no state file — that user must keep the
214
+ // pre-2.2 default (block), not be treated as new.
215
+ const fresh = !fs.existsSync(STATE_FILE) && listAllProfiles().length === 0;
216
+ return { active: emptyActive(), labels: {}, colors: {}, disabled: [], autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE }, guard: fresh ? FRESH_INSTALL_GUARD_POLICY : DEFAULT_GUARD_POLICY };
185
217
  }
186
218
  export function readOsNotifications() {
187
219
  return readState().osNotifications;
@@ -191,6 +223,17 @@ export function setOsNotifications(on) {
191
223
  state.osNotifications = on;
192
224
  writeState(state);
193
225
  }
226
+ // ---------- single-load guard policy ----------
227
+ /** How the single-load guard reacts to a second load of one profile. */
228
+ export function readGuard() {
229
+ return readState().guard;
230
+ }
231
+ /** Persist the guard policy. */
232
+ export function setGuard(policy) {
233
+ const state = readState();
234
+ state.guard = policy;
235
+ writeState(state);
236
+ }
194
237
  // ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
195
238
  /** Current rollback/circuit-breaker state for the `rebind` write path. */
196
239
  export function readRebindKillSwitch() {
@@ -231,9 +274,40 @@ export function setLabel(providerId, name, label) {
231
274
  delete state.labels[key];
232
275
  writeState(state);
233
276
  }
234
- /** Drop a removed profile's label so it can't linger as an orphan. */
277
+ /** Drop a removed profile's label, colour and disabled flag so they can't
278
+ * linger as orphans (a reused name starts clean). */
235
279
  export function clearLabel(providerId, name) {
236
280
  setLabel(providerId, name, null);
281
+ setColor(providerId, name, null);
282
+ setDisabled(providerId, name, false);
283
+ }
284
+ export function isDisabled(providerId, name) {
285
+ return readState().disabled.includes(labelKey(providerId, name));
286
+ }
287
+ /** Disable (or re-enable) a profile. Disabling the active one clears the slot. */
288
+ export function setDisabled(providerId, name, disabled) {
289
+ const state = readState();
290
+ const key = labelKey(providerId, name);
291
+ state.disabled = state.disabled.filter((k) => k !== key);
292
+ if (disabled) {
293
+ state.disabled.push(key);
294
+ if (state.active[providerId] === name)
295
+ state.active[providerId] = null;
296
+ }
297
+ writeState(state);
298
+ }
299
+ export function colorOf(providerId, name) {
300
+ return readState().colors[labelKey(providerId, name)] ?? null;
301
+ }
302
+ /** Set (or clear, with null) a profile's colour. */
303
+ export function setColor(providerId, name, color) {
304
+ const state = readState();
305
+ const key = labelKey(providerId, name);
306
+ if (color)
307
+ state.colors[key] = color;
308
+ else
309
+ delete state.colors[key];
310
+ writeState(state);
237
311
  }
238
312
  /**
239
313
  * Rename a profile: move its config dir, carry its Claude credential across the
@@ -276,6 +350,11 @@ export function renameProfile(providerId, from, to, store = credentialStore()) {
276
350
  state.labels[toKey] = state.labels[fromKey];
277
351
  delete state.labels[fromKey];
278
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));
279
358
  writeState(state);
280
359
  }
281
360
  export function readAutoSwitch(providerId) {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Session protection — a per-profile policy, separate from the near-limit
3
+ * notifications (`autoswitch`, 95 %) and the context alerts. Default ON at 98 %
4
+ * for every profile, including ones that existed before this file did: a
5
+ * profile with no stored entry simply reads the default.
6
+ *
7
+ * What it does today: the daemon WARNS when a polled profile reaches its
8
+ * threshold. It does not pause anything. A real pause needs the provider to
9
+ * hold the session (Claude Code's own auto-continue), which agent-switch cannot
10
+ * verify from here, so every notice says "warn" — never "paused".
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { ROOT, ensureRoot } from "./profiles.js";
15
+ export const PROTECTION_FILE = path.join(ROOT, "protection.json");
16
+ export const DEFAULT_PROTECTION = { enabled: true, thresholdPct: 98 };
17
+ function key(providerId, name) {
18
+ return `${providerId}/${name}`;
19
+ }
20
+ function validThreshold(v) {
21
+ return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 100;
22
+ }
23
+ function load(file) {
24
+ try {
25
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
26
+ return raw && typeof raw.profiles === "object" && raw.profiles ? raw.profiles : {};
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ function save(profiles, file) {
33
+ if (file === PROTECTION_FILE)
34
+ ensureRoot();
35
+ const tmp = `${file}.${process.pid}.tmp`;
36
+ fs.writeFileSync(tmp, JSON.stringify({ schema: 1, profiles }, null, 2) + "\n", { mode: 0o600 });
37
+ fs.renameSync(tmp, file);
38
+ }
39
+ function normalize(raw) {
40
+ return {
41
+ enabled: typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULT_PROTECTION.enabled,
42
+ thresholdPct: validThreshold(raw?.thresholdPct) ? raw.thresholdPct : DEFAULT_PROTECTION.thresholdPct,
43
+ };
44
+ }
45
+ export function readProtection(providerId, name, file = PROTECTION_FILE) {
46
+ return normalize(load(file)[key(providerId, name)]);
47
+ }
48
+ export function setProtection(providerId, name, patch, file = PROTECTION_FILE) {
49
+ if (patch.thresholdPct !== undefined && !validThreshold(patch.thresholdPct)) {
50
+ throw new Error("threshold must be a whole number between 1 and 100");
51
+ }
52
+ const all = load(file);
53
+ const next = normalize({ ...normalize(all[key(providerId, name)]), ...patch });
54
+ all[key(providerId, name)] = next;
55
+ save(all, file);
56
+ return next;
57
+ }
58
+ /** Carry a profile's policy across a rename; drop it on remove. */
59
+ export function renameProtection(providerId, from, to, file = PROTECTION_FILE) {
60
+ const all = load(file);
61
+ if (!(key(providerId, from) in all))
62
+ return;
63
+ all[key(providerId, to)] = all[key(providerId, from)];
64
+ delete all[key(providerId, from)];
65
+ save(all, file);
66
+ }
67
+ export function forgetProtection(providerId, name, file = PROTECTION_FILE) {
68
+ const all = load(file);
69
+ if (!(key(providerId, name) in all))
70
+ return;
71
+ delete all[key(providerId, name)];
72
+ save(all, file);
73
+ }
74
+ /**
75
+ * The threshold warning for one profile, or null. Pure. Fires once per reset
76
+ * cycle of the window that crossed (`lastCycle` = what was announced before).
77
+ */
78
+ export function protectionNotice(target, snap, policy, lastCycle) {
79
+ if (!policy.enabled)
80
+ return null;
81
+ const hit = snap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= policy.thresholdPct);
82
+ if (!hit)
83
+ return null;
84
+ const cycle = `${hit.key}@${hit.resetsAt ?? ""}`;
85
+ if (cycle === lastCycle)
86
+ return null;
87
+ return {
88
+ cycle,
89
+ title: "Session protection: limit almost reached",
90
+ message: `${target} reached ${hit.utilization}% of its ${hit.label} limit (protection threshold ${policy.thresholdPct}%). ` +
91
+ `This is a warning only — agent-switch does not pause running sessions.`,
92
+ };
93
+ }
package/dist/providers.js CHANGED
@@ -77,6 +77,7 @@ const claude = {
77
77
  oneShotArgs: (prompt) => ["-p", prompt],
78
78
  importFiles: [],
79
79
  hasUsageReadout: true,
80
+ hasLiveSessionSignal: true,
80
81
  };
81
82
  const codex = {
82
83
  id: "codex",
@@ -95,6 +96,7 @@ const codex = {
95
96
  oneShotArgs: (prompt) => ["exec", prompt],
96
97
  importFiles: ["auth.json"],
97
98
  hasUsageReadout: true, // live via wham/usage (ChatGPT backend)
99
+ hasLiveSessionSignal: false, // rollouts are date-partitioned; no live-pid marker
98
100
  };
99
101
  const antigravity = {
100
102
  id: "antigravity",
@@ -128,6 +130,7 @@ const antigravity = {
128
130
  oneShotArgs: (prompt) => ["--print", prompt],
129
131
  importFiles: [],
130
132
  hasUsageReadout: false,
133
+ hasLiveSessionSignal: false,
131
134
  };
132
135
  const PROVIDERS = { claude, codex, antigravity };
133
136
  export function provider(id) {