@event4u/agent-switch 2.1.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,7 +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
+ import { DEFAULT_GUARD_POLICY, FRESH_INSTALL_GUARD_POLICY, isGuardPolicy } from "./single-load.js";
8
8
  export const HOME = os.homedir();
9
9
  export const ROOT = process.env.AGENT_SWITCH_HOME ?? path.join(HOME, ".agent-switch");
10
10
  export const STATE_FILE = path.join(ROOT, "state.json");
@@ -13,6 +13,11 @@ export const PROFILE_LABELS = ["Work", "Personal", "Other"];
13
13
  export function isProfileLabel(v) {
14
14
  return typeof v === "string" && PROFILE_LABELS.includes(v);
15
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
+ }
16
21
  export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
17
22
  /**
18
23
  * Providers enabled out of the box. Everything else is available but off by
@@ -33,7 +38,13 @@ export function die(msg) {
33
38
  process.exit(1);
34
39
  }
35
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;
36
45
  fs.mkdirSync(ROOT, { recursive: true, mode: 0o700 });
46
+ if (fresh)
47
+ fs.writeFileSync(STATE_FILE, JSON.stringify(readState(), null, 2) + "\n", { mode: 0o600 });
37
48
  }
38
49
  function validateName(name) {
39
50
  if (!/^[A-Za-z0-9_-]+$/.test(name))
@@ -102,6 +113,16 @@ function normalizeLabels(raw) {
102
113
  }
103
114
  return out;
104
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
+ }
105
126
  function normalizeAutoSwitch(raw) {
106
127
  const r = (raw ?? {});
107
128
  const threshold = typeof r.threshold === "number" && r.threshold >= 1 && r.threshold <= 100 ? Math.round(r.threshold) : DEFAULT_AUTOSWITCH.threshold;
@@ -168,6 +189,8 @@ export function readState() {
168
189
  try {
169
190
  const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
170
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") : [];
171
194
  const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
172
195
  const providers = normalizeProvidersMap(raw?.providers);
173
196
  const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
@@ -176,17 +199,21 @@ export function readState() {
176
199
  const guard = normalizeGuard(raw?.guard);
177
200
  // v1: { active: "<name>" } — a single Claude profile.
178
201
  if (typeof raw?.active === "string") {
179
- return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
202
+ return { active: { ...emptyActive(), claude: raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
180
203
  }
181
204
  if (raw?.active && typeof raw.active === "object") {
182
- return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
205
+ return { active: { ...emptyActive(), ...raw.active }, labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
183
206
  }
184
- return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
207
+ return { active: emptyActive(), labels, colors, disabled, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
185
208
  }
186
209
  catch {
187
210
  /* absent / unparsable → default */
188
211
  }
189
- return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE }, guard: DEFAULT_GUARD_POLICY };
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 };
190
217
  }
191
218
  export function readOsNotifications() {
192
219
  return readState().osNotifications;
@@ -247,9 +274,40 @@ export function setLabel(providerId, name, label) {
247
274
  delete state.labels[key];
248
275
  writeState(state);
249
276
  }
250
- /** 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). */
251
279
  export function clearLabel(providerId, name) {
252
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);
253
311
  }
254
312
  /**
255
313
  * Rename a profile: move its config dir, carry its Claude credential across the
@@ -292,6 +350,11 @@ export function renameProfile(providerId, from, to, store = credentialStore()) {
292
350
  state.labels[toKey] = state.labels[fromKey];
293
351
  delete state.labels[fromKey];
294
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));
295
358
  writeState(state);
296
359
  }
297
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/rebind.js CHANGED
@@ -13,9 +13,10 @@
13
13
  *
14
14
  * Move-semantics + safety: the target account's credential is moved INTO the
15
15
  * running profile's store and the target's own store is emptied, so the family
16
- * is live in exactly one store. Both original credentials are stashed in a
17
- * per-profile binding-marker BEFORE any mutation, so `--restore` fully reverses
18
- * it and a crash leaves a recoverable marker. Nothing is deleted without a stash.
16
+ * is live in exactly one store. Both original credentials are stashed in the
17
+ * Keychain BEFORE any mutation and a per-profile binding-marker names those
18
+ * entries (never the tokens), so `--restore` fully reverses it and a crash
19
+ * leaves a recoverable marker. Nothing is deleted without a stash.
19
20
  *
20
21
  * Deferred to later Phase-2 slices (see road-to-live-rebind.md), not built here:
21
22
  * live pid/process auto-detection, a global cross-profile binding registry +
@@ -365,15 +366,30 @@ export function readBinding(cfgDir, d = defaultDeps()) {
365
366
  return null;
366
367
  try {
367
368
  const m = JSON.parse(raw);
368
- if (m && typeof m.boundToProfile === "string" && typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string") {
369
+ if (!m || typeof m.boundToProfile !== "string")
370
+ return null;
371
+ const stashed = typeof m.runningOwnStash === "string" && typeof m.targetOrigStash === "string";
372
+ const legacy = typeof m.runningOwnCredential === "string" && typeof m.targetOrigCredential === "string";
373
+ if (stashed || legacy)
369
374
  return m;
370
- }
371
375
  }
372
376
  catch {
373
377
  /* corrupt marker → treat as none */
374
378
  }
375
379
  return null;
376
380
  }
381
+ /** Keychain services that hold a binding's originals while the rebind is live. */
382
+ function stashServices(pConfig, d) {
383
+ const base = d.serviceNameFor(pConfig);
384
+ return { own: `${base}-agent-switch-rebind-own`, target: `${base}-agent-switch-rebind-target` };
385
+ }
386
+ /** The two original credentials a binding needs for restore — from the Keychain
387
+ * stash, or inline for a marker written before D16. Null when unreadable. */
388
+ export function bindingCredentials(m, d = defaultDeps()) {
389
+ const runningOwn = m.runningOwnStash ? d.kcGet(m.runningOwnStash) : (m.runningOwnCredential ?? null);
390
+ const targetOrig = m.targetOrigStash ? d.kcGet(m.targetOrigStash) : (m.targetOrigCredential ?? null);
391
+ return runningOwn && targetOrig ? { runningOwn, targetOrig } : null;
392
+ }
377
393
  export class RebindError extends Error {
378
394
  }
379
395
  /**
@@ -615,12 +631,21 @@ async function performRebind(opts, d) {
615
631
  // All guards passed — commit the global binding record.
616
632
  reg[account] = { runningProfile: profile, boundAt: new Date(d.now()).toISOString() };
617
633
  writeRegistry(reg, d);
618
- // 1. Recovery point BEFORE any store mutation: stash both originals.
634
+ // 1. Recovery point BEFORE any store mutation: stash both originals in the
635
+ // Keychain (never in the marker file), then point the marker at them.
636
+ const stash = stashServices(pConfig, d);
637
+ if (!d.kcAdd(stash.own, pCred) || !d.kcAdd(stash.target, credToMove)) {
638
+ d.kcDelete(stash.own);
639
+ d.kcDelete(stash.target);
640
+ delete reg[account];
641
+ writeRegistry(reg, d);
642
+ throw new RebindError(`could not stash the original credentials in the Keychain — no swap performed.`);
643
+ }
619
644
  const marker = {
620
645
  boundToProfile: account,
621
646
  boundAt: new Date(d.now()).toISOString(),
622
- runningOwnCredential: pCred,
623
- targetOrigCredential: credToMove,
647
+ runningOwnStash: stash.own,
648
+ targetOrigStash: stash.target,
624
649
  targetFileMoved: false,
625
650
  // Snapshot the running profile's OWN identity stamp before the swap: from
626
651
  // here on Claude Code will overwrite it with the borrowed account's.
@@ -648,6 +673,57 @@ async function performRebind(opts, d) {
648
673
  `Restore with: agent-switch rebind --restore --profile ${profile}`,
649
674
  };
650
675
  }
676
+ /**
677
+ * Drop an active rebind on `profile` WITHOUT restoring it — the `remove --force`
678
+ * path, where the running profile is about to be deleted. Deletes both Keychain
679
+ * stash entries (they would otherwise outlive the profile), releases the
680
+ * registry entry and the marker, and hands the borrowed account back to its own
681
+ * profile: the stashed original goes back into that store only while it is
682
+ * empty (a store that holds a login again is never overwritten), and a lent
683
+ * plaintext file is returned. Returns false when there was no rebind.
684
+ */
685
+ export function discardBinding(profile, d = defaultDeps()) {
686
+ const pConfig = configDir("claude", profile);
687
+ const marker = readBinding(pConfig, d);
688
+ if (!marker)
689
+ return false;
690
+ const originals = bindingCredentials(marker, d);
691
+ const aConfig = configDir("claude", marker.boundToProfile);
692
+ const aSvc = d.serviceNameFor(aConfig);
693
+ if (originals && d.kcGet(aSvc) === null)
694
+ d.kcAdd(aSvc, originals.targetOrig);
695
+ const aFile = credFile(aConfig);
696
+ if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX) && !d.exists(aFile))
697
+ d.renameFile(aFile + LENT_SUFFIX, aFile);
698
+ const stash = stashServices(pConfig, d);
699
+ d.kcDelete(marker.runningOwnStash ?? stash.own);
700
+ d.kcDelete(marker.targetOrigStash ?? stash.target);
701
+ const reg = readRegistry(d);
702
+ if (reg[marker.boundToProfile]?.runningProfile === profile) {
703
+ delete reg[marker.boundToProfile];
704
+ writeRegistry(reg, d);
705
+ }
706
+ d.removeFile(markerFile(pConfig));
707
+ return true;
708
+ }
709
+ /** The profile currently serving `account`'s login through a rebind, or null. */
710
+ export function borrowerOf(account, d = defaultDeps()) {
711
+ return readRegistry(d)[account]?.runningProfile ?? null;
712
+ }
713
+ /**
714
+ * Before `account`'s profile is deleted (`remove --force`): put the profile
715
+ * borrowing it back on its own login through the normal restore path, so both
716
+ * stores stay consistent and the stash is cleared. Restore's provenance checks
717
+ * still apply — a refusal propagates and nothing is released. Returns the
718
+ * borrower that was restored, or null when nothing borrowed the account.
719
+ */
720
+ export async function releaseBorrowedAccount(account, d = defaultDeps()) {
721
+ const holder = borrowerOf(account, d);
722
+ if (!holder)
723
+ return null;
724
+ await restoreRebind({ profile: holder }, d);
725
+ return holder;
726
+ }
651
727
  /** Reverse an active rebind on `profile`: restore both profiles' own credentials. */
652
728
  export async function restoreRebind(opts, d = defaultDeps()) {
653
729
  const { profile } = opts;
@@ -658,6 +734,11 @@ export async function restoreRebind(opts, d = defaultDeps()) {
658
734
  const marker = readBinding(pConfig, d);
659
735
  if (!marker)
660
736
  throw new RebindRefused(`no active rebind on profile "${profile}".`);
737
+ const originals = bindingCredentials(marker, d);
738
+ if (!originals) {
739
+ throw new RebindRefused(`the stashed credentials for profile "${profile}"'s rebind are not readable — refusing restore (no mutation). ` +
740
+ `The binding marker is left in place for manual recovery.`);
741
+ }
661
742
  const aConfig = configDir("claude", marker.boundToProfile);
662
743
  const pSvc = d.serviceNameFor(pConfig);
663
744
  const aSvc = d.serviceNameFor(aConfig);
@@ -672,7 +753,7 @@ export async function restoreRebind(opts, d = defaultDeps()) {
672
753
  // account we bound INTO it. If someone re-logged the running profile, its
673
754
  // store now holds a DIFFERENT account — never clobber that fresh login.
674
755
  const liveRunning = readCred(pConfig, d);
675
- const branch = await compareAccounts(marker.targetOrigCredential, liveRunning, d);
756
+ const branch = await compareAccounts(originals.targetOrig, liveRunning, d);
676
757
  if (branch === "unparseable") {
677
758
  throw new RebindRefused(`running profile "${profile}"'s live credential is missing or unparseable — refusing restore (no mutation). ` +
678
759
  `The binding marker is left in place for manual recovery.`);
@@ -691,11 +772,23 @@ export async function restoreRebind(opts, d = defaultDeps()) {
691
772
  }
692
773
  // "same" / "rotated" → the bound account is still there → restore normally.
693
774
  // Restore the running profile to its own account.
694
- d.kcAdd(pSvc, marker.runningOwnCredential);
775
+ if (!d.kcAdd(pSvc, originals.runningOwn)) {
776
+ throw new RebindError(`could not write profile "${profile}"'s own credential back to the Keychain — restore aborted. ` +
777
+ `Nothing was changed; the marker and the stashed credentials are kept — retry the restore.`);
778
+ }
695
779
  // Restore the target profile's own store. On "rotated" the live credential
696
780
  // is the same account with a FRESHER (rotated) refresh token — hand that
697
781
  // back instead of the stale stash, or the target profile logs out.
698
- d.kcAdd(aSvc, branch === "rotated" ? liveRunning : marker.targetOrigCredential);
782
+ if (!d.kcAdd(aSvc, branch === "rotated" ? liveRunning : originals.targetOrig)) {
783
+ // Put the running store back on the bound account, so the provenance
784
+ // check of a retry still sees the account it was rebound to (otherwise
785
+ // the retry would read its own credential as a foreign re-login).
786
+ const rolledBack = d.kcAdd(pSvc, liveRunning);
787
+ throw new RebindError(`could not write "${marker.boundToProfile}"'s credential back to the Keychain — restore aborted. ` +
788
+ (rolledBack
789
+ ? `Profile "${profile}" still serves "${marker.boundToProfile}"; the marker and the stashed credentials are kept — retry the restore.`
790
+ : `Profile "${profile}" could not be rolled back either; the marker and the stashed credentials are kept for manual recovery.`));
791
+ }
699
792
  if (marker.targetFileMoved && d.exists(aFile + LENT_SUFFIX)) {
700
793
  d.renameFile(aFile + LENT_SUFFIX, aFile);
701
794
  }
@@ -703,6 +796,10 @@ export async function restoreRebind(opts, d = defaultDeps()) {
703
796
  // Claude Code stamped into its `.claude.json` while it served the other account.
704
797
  await restoreStampedIdentity(pConfig, marker.runningOwnIdentity, d);
705
798
  d.removeFile(markerFile(pConfig));
799
+ if (marker.runningOwnStash)
800
+ d.kcDelete(marker.runningOwnStash);
801
+ if (marker.targetOrigStash)
802
+ d.kcDelete(marker.targetOrigStash);
706
803
  // Release the global binding-registry entry for the bound account.
707
804
  const reg = readRegistry(d);
708
805
  if (reg[marker.boundToProfile]) {