@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/README.md +36 -1
- package/dist/api.js +5 -4
- package/dist/codex-reset.js +2 -2
- package/dist/config-transfer.js +194 -0
- package/dist/credentials.js +6 -4
- package/dist/daemon.js +68 -34
- package/dist/doctor.js +23 -2
- package/dist/gui-launch.js +45 -3
- package/dist/history.js +23 -0
- package/dist/hooks.js +5 -2
- package/dist/index.js +475 -72
- package/dist/keychain.js +14 -0
- package/dist/notifications.js +7 -2
- package/dist/profiles.js +42 -14
- package/dist/providers.js +3 -0
- package/dist/rebind-dialog.js +65 -0
- package/dist/rebind.js +715 -0
- package/dist/release-notes.js +154 -0
- package/dist/sessions.js +19 -0
- package/dist/shellenv.js +38 -4
- package/dist/single-load.js +171 -0
- package/dist/token-freshen.js +154 -0
- package/dist/tooling.js +73 -0
- package/dist/updates.js +37 -3
- package/dist/usage.js +8 -7
- package/package.json +1 -1
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);
|
package/dist/notifications.js
CHANGED
|
@@ -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 (
|
|
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,
|
|
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,
|
|
182
|
+
return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind, guard };
|
|
173
183
|
}
|
|
174
|
-
return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths,
|
|
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: {},
|
|
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
|
+
}
|