@event4u/agent-switch 1.7.0 → 2.1.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/keychain.js CHANGED
@@ -48,6 +48,20 @@ export function deletePassword(service) {
48
48
  return false;
49
49
  return (security(["delete-generic-password", "-a", os.userInfo().username, "-s", service]) !== null);
50
50
  }
51
+ /**
52
+ * Upsert a generic password entry (`-U` overwrites an existing value). Returns
53
+ * true on success. This is the ONE credential-store WRITE in agent-switch — used
54
+ * ONLY by `src/rebind.ts` under Claude Code's lock (ADR-003). Elsewhere the
55
+ * credential store stays read-only.
56
+ *
57
+ * Note: the value is passed on argv (as `security` requires with `-w`), so it is
58
+ * briefly visible to `ps` — the same trade-off Claude Code's own tooling makes.
59
+ */
60
+ export function addPassword(service, value) {
61
+ if (!isMac())
62
+ return false;
63
+ return security(["add-generic-password", "-U", "-a", os.userInfo().username, "-s", service, "-w", value]) !== null;
64
+ }
51
65
  /** The live credential of the default ~/.claude install (keychain, macOS). */
52
66
  export function readDefaultCredential() {
53
67
  return getPassword(DEFAULT_SERVICE);
@@ -33,11 +33,16 @@ function writeNotifications(list, file) {
33
33
  * Append an event (kept newest-last). Deduplicates a byte-identical
34
34
  * kind+title+message against the most recent entry within DEDUP_WINDOW_MS.
35
35
  * Returns the created notification, or `null` when it was deduplicated.
36
+ *
37
+ * `dedupe: false` skips that collapse — for session-awareness events (Claude
38
+ * finished / is waiting) where each occurrence is a distinct moment the user
39
+ * wants surfaced, so a solo session's repeated turns are never swallowed.
36
40
  */
37
- export function appendNotification(event, now = Date.now(), file = NOTIFICATIONS_FILE) {
41
+ export function appendNotification(event, now = Date.now(), file = NOTIFICATIONS_FILE, dedupe = true) {
38
42
  const list = readNotifications(file);
39
43
  const last = list[list.length - 1];
40
- if (last &&
44
+ if (dedupe &&
45
+ last &&
41
46
  last.kind === event.kind &&
42
47
  last.title === event.title &&
43
48
  last.message === event.message &&
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, 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");
@@ -13,7 +14,6 @@ export function isProfileLabel(v) {
13
14
  return typeof v === "string" && PROFILE_LABELS.includes(v);
14
15
  }
15
16
  export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
16
- export const DEFAULT_SWITCH_STRATEGY = "reset-first";
17
17
  /**
18
18
  * Providers enabled out of the box. Everything else is available but off by
19
19
  * default — the user opts it in from the Providers settings tab. A provider that
@@ -21,6 +21,7 @@ export const DEFAULT_SWITCH_STRATEGY = "reset-first";
21
21
  * upgrading never hides an account the user already set up.
22
22
  */
23
23
  export const PROVIDERS_ON_BY_DEFAULT = ["claude", "codex"];
24
+ export const DEFAULT_REBIND_STATE = { disabled: false, consecutiveFailures: 0 };
24
25
  function emptyActive() {
25
26
  return { claude: null, codex: null, antigravity: null };
26
27
  }
@@ -155,6 +156,14 @@ function normalizeBinaryPaths(raw) {
155
156
  out[p] = r[p];
156
157
  return out;
157
158
  }
159
+ function normalizeRebindState(raw) {
160
+ const r = (raw ?? {});
161
+ const consecutiveFailures = typeof r.consecutiveFailures === "number" && r.consecutiveFailures >= 0 ? Math.floor(r.consecutiveFailures) : 0;
162
+ return { disabled: r.disabled === true, consecutiveFailures };
163
+ }
164
+ function normalizeGuard(raw) {
165
+ return isGuardPolicy(raw) ? raw : DEFAULT_GUARD_POLICY;
166
+ }
158
167
  export function readState() {
159
168
  try {
160
169
  const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
@@ -162,29 +171,22 @@ export function readState() {
162
171
  const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
163
172
  const providers = normalizeProvidersMap(raw?.providers);
164
173
  const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
165
- const switchStrategy = raw?.switchStrategy === "rotation-first" ? "rotation-first" : DEFAULT_SWITCH_STRATEGY;
166
174
  const osNotifications = raw?.osNotifications === true;
175
+ const rebind = normalizeRebindState(raw?.rebind);
176
+ const guard = normalizeGuard(raw?.guard);
167
177
  // v1: { active: "<name>" } — a single Claude profile.
168
178
  if (typeof raw?.active === "string") {
169
- return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
179
+ return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
170
180
  }
171
181
  if (raw?.active && typeof raw.active === "object") {
172
- return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
182
+ return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
173
183
  }
174
- return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, switchStrategy, osNotifications };
184
+ return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
175
185
  }
176
186
  catch {
177
187
  /* absent / unparsable → default */
178
188
  }
179
- return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, switchStrategy: DEFAULT_SWITCH_STRATEGY, osNotifications: false };
180
- }
181
- export function readSwitchStrategy() {
182
- return readState().switchStrategy;
183
- }
184
- export function setSwitchStrategy(strategy) {
185
- const state = readState();
186
- state.switchStrategy = strategy;
187
- writeState(state);
189
+ return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE }, guard: DEFAULT_GUARD_POLICY };
188
190
  }
189
191
  export function readOsNotifications() {
190
192
  return readState().osNotifications;
@@ -194,6 +196,32 @@ export function setOsNotifications(on) {
194
196
  state.osNotifications = on;
195
197
  writeState(state);
196
198
  }
199
+ // ---------- single-load guard policy ----------
200
+ /** How the single-load guard reacts to a second load of one profile. */
201
+ export function readGuard() {
202
+ return readState().guard;
203
+ }
204
+ /** Persist the guard policy. */
205
+ export function setGuard(policy) {
206
+ const state = readState();
207
+ state.guard = policy;
208
+ writeState(state);
209
+ }
210
+ // ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
211
+ /** Current rollback/circuit-breaker state for the `rebind` write path. */
212
+ export function readRebindKillSwitch() {
213
+ return readState().rebind;
214
+ }
215
+ /** Persist the rebind kill-switch state (breaker counter + disabled flag). */
216
+ export function writeRebindKillSwitch(next) {
217
+ const state = readState();
218
+ state.rebind = normalizeRebindState(next);
219
+ writeState(state);
220
+ }
221
+ /** Re-enable `rebind`: clear `disabled` + reset the failure counter (`--reset`). */
222
+ export function resetRebindKillSwitch() {
223
+ writeRebindKillSwitch({ ...DEFAULT_REBIND_STATE });
224
+ }
197
225
  export function writeState(state) {
198
226
  ensureRoot();
199
227
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
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) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The limit-dialog model (Phase 4) — the PURE, testable core behind the CLI's
3
+ * interactive `agent-switch rebind` picker. It computes the switch-target rows
4
+ * and the pre-selected best-headroom suggestion; it performs NO switch and does
5
+ * NO I/O. The switch itself is always a user interaction (§ Out of scope,
6
+ * road-to-live-rebind.md): this module only shapes what the user is shown and
7
+ * maps their keystroke to a target. The credential write lives in rebind.ts.
8
+ *
9
+ * The suggestion reuses the usage helper `maxUtilization` — the same ranking
10
+ * `pickSwitchTarget` uses (lowest max-utilization = most headroom); no new
11
+ * ranking is invented. Unlike `pickSwitchTarget` it is NOT threshold-gated: the
12
+ * user opened the dialog on demand, so a best-headroom target is always
13
+ * pre-selected as a convenience (the pick, not the suggestion, is the switch).
14
+ */
15
+ import { maxUtilization } from "./usage.js";
16
+ /**
17
+ * Build the dialog model. `runningProfile` is the profile the live session
18
+ * serves; it is excluded from the suggestion (and, marked `isRunning`, from the
19
+ * selectable rows). Pure — takes plain data, returns plain data.
20
+ */
21
+ export function buildRebindDialog(candidates, runningProfile) {
22
+ const rows = candidates.map((c) => ({
23
+ profile: c.name,
24
+ label: c.label ?? null,
25
+ maxUtil: c.snapshot ? maxUtilization(c.snapshot) : null,
26
+ isRunning: c.name === runningProfile,
27
+ }));
28
+ // Suggestion = lowest known max-utilization among the non-running rows (most
29
+ // headroom). Same ranking as pickSwitchTarget's `m < best.max`, reusing the
30
+ // maxUtilization helper — no new ranking invented. Ties keep the first row.
31
+ let best = null;
32
+ for (const r of rows) {
33
+ if (r.isRunning || r.maxUtil === null)
34
+ continue;
35
+ if (best === null || r.maxUtil < best.maxUtil)
36
+ best = r;
37
+ }
38
+ return { rows, suggestedProfile: best ? best.profile : null };
39
+ }
40
+ /** The selectable rows (non-running), in dialog order — the list the CLI
41
+ * numbers 1..N and that {@link resolveRebindChoice} indexes against. */
42
+ export function selectableRows(dialog) {
43
+ return dialog.rows.filter((r) => !r.isRunning);
44
+ }
45
+ /**
46
+ * Map a raw picker keystroke to a target profile or a control action — the pure
47
+ * selection→target mapping the readline loop delegates to. `q`/`c` (any case) =
48
+ * cancel; empty = the pre-selected suggestion (or invalid when none); a 1-based
49
+ * index into the selectable rows = that profile; anything else = invalid. Never
50
+ * switches — it only resolves intent.
51
+ */
52
+ export function resolveRebindChoice(input, dialog) {
53
+ const trimmed = input.trim().toLowerCase();
54
+ if (trimmed === "q" || trimmed === "c")
55
+ return { kind: "cancel" };
56
+ const selectable = selectableRows(dialog);
57
+ if (trimmed === "") {
58
+ return dialog.suggestedProfile ? { kind: "target", profile: dialog.suggestedProfile } : { kind: "invalid" };
59
+ }
60
+ const n = Number(trimmed);
61
+ if (Number.isInteger(n) && n >= 1 && n <= selectable.length) {
62
+ return { kind: "target", profile: selectable[n - 1].profile };
63
+ }
64
+ return { kind: "invalid" };
65
+ }