@event4u/agent-switch 1.7.0 → 2.0.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 +3 -1
- package/dist/api.js +5 -4
- package/dist/codex-reset.js +2 -2
- package/dist/config-transfer.js +184 -0
- package/dist/credentials.js +6 -4
- package/dist/daemon.js +68 -34
- package/dist/gui-launch.js +45 -3
- package/dist/history.js +23 -0
- package/dist/hooks.js +5 -2
- package/dist/index.js +233 -51
- package/dist/keychain.js +14 -0
- package/dist/notifications.js +7 -2
- package/dist/profiles.js +26 -14
- package/dist/rebind-dialog.js +65 -0
- package/dist/rebind.js +715 -0
- package/dist/token-freshen.js +154 -0
- package/dist/usage.js +8 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -13,22 +13,27 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
+
import * as readline from "node:readline";
|
|
16
17
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
|
-
import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch,
|
|
18
|
+
import { HOME, browserDir, configDir, die, ensureRoot, identity, listAllProfiles, listProfiles, migrateLegacyLayout, profileDir, profileExists, readJson, requireProfile, activeFor, setActive, labelFor, setLabel, clearLabel, renameProfile, isProfileLabel, PROFILE_LABELS, readAutoSwitch, readAutoSwitchAll, setAutoSwitch, readOsNotifications, setOsNotifications, readProviders, setProviderSurface, readBinaryPath, readBinaryPaths, setBinaryPath, ROOT, resetRebindKillSwitch, } from "./profiles.js";
|
|
18
19
|
import { PROVIDER_IDS, provider, isProviderInstalled, resolveBinary } from "./providers.js";
|
|
20
|
+
import { rebind, restoreRebind } from "./rebind.js";
|
|
21
|
+
import { buildRebindDialog, resolveRebindChoice, selectableRows } from "./rebind-dialog.js";
|
|
19
22
|
import { parseArgs, parseRun, resolveProviderValue } from "./args.js";
|
|
20
23
|
import { extractBrief, writeBrief, sweepBriefs, seedPrompt } from "./handoff.js";
|
|
21
24
|
import { credentialStore } from "./credentials.js";
|
|
22
25
|
import { withProperLock } from "./locks.js";
|
|
23
26
|
import { applySharing, removeSharing, syncSharing, sharedLinkHealth } from "./share.js";
|
|
27
|
+
import { assertNotFull, exportConfig, importConfig } from "./config-transfer.js";
|
|
24
28
|
import { detectShell, shellenvScript } from "./shellenv.js";
|
|
25
29
|
import { runDoctor } from "./doctor.js";
|
|
26
30
|
import { TOOL_IDS, checkTooling, formatToolingLines, runToolAction } from "./tooling.js";
|
|
27
|
-
import { readHistory } from "./history.js";
|
|
31
|
+
import { readHistory, recordHistorySample } from "./history.js";
|
|
28
32
|
import { launchGui } from "./gui-launch.js";
|
|
29
33
|
import { checkForUpdate, selfUpdate } from "./updates.js";
|
|
30
34
|
import { mappingRows, pruneMappings, removeMapping, resolveMapping, setMapping, } from "./mappings.js";
|
|
31
|
-
import {
|
|
35
|
+
import { checkAuth, fetchProfile, fetchUsage, liveSessionPids, } from "./api.js";
|
|
36
|
+
import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
|
|
32
37
|
import { formatSnapshot, parseUsage } from "./usage.js";
|
|
33
38
|
import { readCodexUsage } from "./codex-usage.js";
|
|
34
39
|
import { redeemResetCredit } from "./codex-reset.js";
|
|
@@ -272,8 +277,7 @@ async function claudeSnapshot(name) {
|
|
|
272
277
|
if (cached)
|
|
273
278
|
return cached;
|
|
274
279
|
}
|
|
275
|
-
const
|
|
276
|
-
const token = creds ? accessTokenOf(creds) : null;
|
|
280
|
+
const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
|
|
277
281
|
if (!token)
|
|
278
282
|
return null;
|
|
279
283
|
const raw = await fetchUsage(token);
|
|
@@ -295,6 +299,12 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
295
299
|
: pid === "codex"
|
|
296
300
|
? await readCodexUsage(configDir("codex", active))
|
|
297
301
|
: null;
|
|
302
|
+
// Feed the 30-day usage history from the GUI's own fetches: the GUI polls
|
|
303
|
+
// every displayed profile on each refresh, so recording the fresh snapshot
|
|
304
|
+
// here (throttled to ~hourly) fills the chart for all profiles without
|
|
305
|
+
// depending on the background daemon or an "active" profile being set.
|
|
306
|
+
if (usage)
|
|
307
|
+
recordHistorySample(path.join(profileDir(pid, active), "usage-history.json"), usage);
|
|
298
308
|
const wc = worstLiveContext(pid, active);
|
|
299
309
|
const context = wc
|
|
300
310
|
? { sessionId: wc.sessionId, pct: wc.pct, contextTokens: wc.contextTokens, windowTokens: wc.windowTokens, model: wc.model, confidence: wc.confidence }
|
|
@@ -328,8 +338,7 @@ async function cmdStatus(providerId, name, json = false) {
|
|
|
328
338
|
console.log(" (no usage readout for this provider — shows own usage only where available)");
|
|
329
339
|
continue;
|
|
330
340
|
}
|
|
331
|
-
const
|
|
332
|
-
const token = creds ? accessTokenOf(creds) : null;
|
|
341
|
+
const token = freshAccessToken(configDir("claude", n), defaultFreshenDeps(readBinaryPath("claude")));
|
|
333
342
|
if (!token) {
|
|
334
343
|
console.log(" (credential not readable — profile may not have run yet)");
|
|
335
344
|
continue;
|
|
@@ -451,6 +460,55 @@ function cmdShare(mode, flags = []) {
|
|
|
451
460
|
die("usage: agent-switch share on|sync|off|status [--history] [--source <profile|default>]");
|
|
452
461
|
}
|
|
453
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* `agent-switch config export|import <profile> [--full]` — move a Claude
|
|
465
|
+
* profile's SHARED config (settings.json, keybindings.json, CLAUDE.md, skills/,
|
|
466
|
+
* commands/, agents/) between machines as a JSON bundle. Config-ONLY — it never
|
|
467
|
+
* touches credentials. Claude-scoped (config lives under the Claude layout).
|
|
468
|
+
*
|
|
469
|
+
* export → the bundle goes to STDOUT (pipe it: `| gpg -c > backup.gpg`), all
|
|
470
|
+
* status on STDERR so stdout stays a clean bundle.
|
|
471
|
+
* import → reads a bundle from STDIN into the profile's config dir.
|
|
472
|
+
* --full → REFUSED (would bundle live OAuth tokens; account-takeover vector).
|
|
473
|
+
*/
|
|
474
|
+
function cmdConfig(sub, name, flags = {}) {
|
|
475
|
+
try {
|
|
476
|
+
assertNotFull(!!flags.full);
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
die(e.message);
|
|
480
|
+
}
|
|
481
|
+
if (sub === "export") {
|
|
482
|
+
const n = requireProfile("claude", name, "config export");
|
|
483
|
+
const bundle = exportConfig(configDir("claude", n));
|
|
484
|
+
// Status on stderr — stdout is the bundle (so `... | gpg -c > backup.gpg` works).
|
|
485
|
+
console.error(`Exported config-only bundle for claude/${n} (SHARED_ITEMS allowlist; no credentials).`);
|
|
486
|
+
process.stdout.write(bundle + "\n");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (sub === "import") {
|
|
490
|
+
const n = requireProfile("claude", name, "config import");
|
|
491
|
+
let input;
|
|
492
|
+
try {
|
|
493
|
+
input = fs.readFileSync(0, "utf8"); // stdin
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
die("config import reads a bundle from stdin — pipe one in (e.g. `gpg -d backup.gpg | agent-switch config import <profile>`).");
|
|
497
|
+
}
|
|
498
|
+
let written;
|
|
499
|
+
try {
|
|
500
|
+
written = importConfig(configDir("claude", n), input);
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
die(e.message);
|
|
504
|
+
}
|
|
505
|
+
console.error(`Imported ${written.length} config file(s) into claude/${n} (config-only; no credentials):`);
|
|
506
|
+
for (const w of written)
|
|
507
|
+
console.error(` ${w}`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
die("usage: agent-switch config export|import <profile> (config-only; pipe export | gpg -c > backup.gpg)");
|
|
511
|
+
}
|
|
454
512
|
// ---------- session inventory + takeover (Claude; Codex lands per G0.3) ------
|
|
455
513
|
/** Relative age like "3m" / "2h" / "5d" for the sessions table. */
|
|
456
514
|
function ageOf(mtimeMs) {
|
|
@@ -660,6 +718,16 @@ function cmdHookEvent() {
|
|
|
660
718
|
at: new Date().toISOString(),
|
|
661
719
|
};
|
|
662
720
|
appendEvent(eventFile(ROOT, who.provider, who.profile), rec);
|
|
721
|
+
// Session awareness: surface "Claude needs you / finished" as a notification
|
|
722
|
+
// the GUI routes to the pet. dedupe=false — each turn is a distinct moment,
|
|
723
|
+
// so a solo session's repeated turns are never collapsed. Best-effort; a hook
|
|
724
|
+
// must never throw.
|
|
725
|
+
if (rec.event === "Notification") {
|
|
726
|
+
appendNotification({ kind: "warning", title: "Claude is waiting", message: `${who.provider}/${who.profile} needs your input.` }, undefined, undefined, false);
|
|
727
|
+
}
|
|
728
|
+
else if (rec.event === "Stop") {
|
|
729
|
+
appendNotification({ kind: "info", title: "Claude finished", message: `${who.provider}/${who.profile} finished its turn.` }, undefined, undefined, false);
|
|
730
|
+
}
|
|
663
731
|
}
|
|
664
732
|
catch {
|
|
665
733
|
// never throw from a hook
|
|
@@ -1212,7 +1280,8 @@ function cmdLabel(providerId, name, label) {
|
|
|
1212
1280
|
setLabel(providerId, name, label);
|
|
1213
1281
|
console.log(`Labeled ${providerId}/${name} as ${label}.`);
|
|
1214
1282
|
}
|
|
1215
|
-
/** Enable/disable opt-in
|
|
1283
|
+
/** Enable/disable opt-in near-limit notifications (notify + suggest, never an
|
|
1284
|
+
* automatic switch), or show the current setting. */
|
|
1216
1285
|
/** Rename a profile (move its config dir + carry active/label/mappings). */
|
|
1217
1286
|
function cmdRename(providerId, from, to) {
|
|
1218
1287
|
const n = requireProfile(providerId, from, "rename");
|
|
@@ -1256,23 +1325,7 @@ async function cmdReset(providerId, name) {
|
|
|
1256
1325
|
die(`reset failed: ${r.reason ?? "unknown"}`);
|
|
1257
1326
|
}
|
|
1258
1327
|
}
|
|
1259
|
-
function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}
|
|
1260
|
-
// `autoswitch strategy [reset-first|rotation-first]` — global switch behaviour.
|
|
1261
|
-
if (mode === "strategy") {
|
|
1262
|
-
if (value === "reset-first" || value === "rotation-first") {
|
|
1263
|
-
setSwitchStrategy(value);
|
|
1264
|
-
}
|
|
1265
|
-
else if (value !== undefined) {
|
|
1266
|
-
die("usage: agent-switch autoswitch strategy [reset-first|rotation-first]");
|
|
1267
|
-
}
|
|
1268
|
-
if (flags.json) {
|
|
1269
|
-
console.log(JSON.stringify({ strategy: readSwitchStrategy() }));
|
|
1270
|
-
}
|
|
1271
|
-
else {
|
|
1272
|
-
console.log(`Auto-switch strategy: ${readSwitchStrategy()}.`);
|
|
1273
|
-
}
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1328
|
+
function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}) {
|
|
1276
1329
|
const thresholdFlag = flags.threshold;
|
|
1277
1330
|
const threshold = typeof thresholdFlag === "string" ? Number(thresholdFlag) : undefined;
|
|
1278
1331
|
if (threshold !== undefined && (!Number.isFinite(threshold) || threshold < 1 || threshold > 100)) {
|
|
@@ -1301,22 +1354,12 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
|
|
|
1301
1354
|
...(tag !== undefined ? { tag } : {}),
|
|
1302
1355
|
});
|
|
1303
1356
|
const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
|
|
1304
|
-
console.log(`
|
|
1357
|
+
console.log(`Near-limit notifications for ${providerId} ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
|
|
1305
1358
|
if (cfg.enabled) {
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
console.warn("⚠️ Usage-policy warning: quota-triggered rotation across accounts can violate\n" +
|
|
1311
|
-
" the providers' usage policies — it pools separate subscriptions to route around\n" +
|
|
1312
|
-
" per-account rate limits. An internal review unanimously recommended removing it;\n" +
|
|
1313
|
-
" it ships off-by-default anyway. See the README before enabling. (Per-context\n" +
|
|
1314
|
-
" profile switching — private / work / client — is unaffected; this applies only\n" +
|
|
1315
|
-
" to automated quota-driven rotation.)");
|
|
1316
|
-
const strat = readSwitchStrategy();
|
|
1317
|
-
console.log(`Strategy: ${strat}${strat === "reset-first" ? " (redeem a banked reset before switching, Codex)" : ""}.\n` +
|
|
1318
|
-
`The daemon moves the active ${providerId} profile to the account with the most headroom once the\n` +
|
|
1319
|
-
"active one hits the threshold. Run `agent-switch service start` so the daemon is watching.");
|
|
1359
|
+
console.log(`Once the active ${providerId} profile hits the threshold, the daemon NOTIFIES you and SUGGESTS the\n` +
|
|
1360
|
+
"account with the most headroom — it never switches automatically; switching stays a manual action.\n" +
|
|
1361
|
+
"On Codex it redeems a banked reset first (and stays on the account) before suggesting a switch.\n" +
|
|
1362
|
+
"Run `agent-switch service start` so the daemon is watching.");
|
|
1320
1363
|
}
|
|
1321
1364
|
return;
|
|
1322
1365
|
}
|
|
@@ -1326,15 +1369,14 @@ function cmdAutoswitch(providerId, providerExplicit, mode, flags = {}, value) {
|
|
|
1326
1369
|
console.log(JSON.stringify(readAutoSwitchAll()));
|
|
1327
1370
|
return;
|
|
1328
1371
|
}
|
|
1329
|
-
console.log(`strategy: ${readSwitchStrategy()}`);
|
|
1330
1372
|
for (const p of providerExplicit ? [providerId] : PROVIDER_IDS) {
|
|
1331
1373
|
const cfg = readAutoSwitch(p);
|
|
1332
1374
|
const tagNote = cfg.tag === "all" ? "" : `, tag ${cfg.tag}`;
|
|
1333
|
-
console.log(`${p}:
|
|
1375
|
+
console.log(`${p}: near-limit notifications ${cfg.enabled ? "ON" : "OFF"} (threshold ${cfg.threshold}%${tagNote}).`);
|
|
1334
1376
|
}
|
|
1335
1377
|
return;
|
|
1336
1378
|
}
|
|
1337
|
-
die("usage: agent-switch autoswitch on|off|status
|
|
1379
|
+
die("usage: agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] [--tag all|work|personal|other] [--json]");
|
|
1338
1380
|
}
|
|
1339
1381
|
/**
|
|
1340
1382
|
* Enable/disable a provider's surfaces (cli / ui), or show the current setting.
|
|
@@ -1551,8 +1593,8 @@ async function cmdGui() {
|
|
|
1551
1593
|
// Launch the tray/menubar GUI — downloads its prebuilt artifact from the
|
|
1552
1594
|
// matching GitHub Release on first use, then caches it (see gui-launch.ts).
|
|
1553
1595
|
try {
|
|
1554
|
-
await launchGui();
|
|
1555
|
-
console.log("Launched the agent-switch GUI.");
|
|
1596
|
+
const result = await launchGui();
|
|
1597
|
+
console.log(result === "already-running" ? "The agent-switch GUI is already running." : "Launched the agent-switch GUI.");
|
|
1556
1598
|
}
|
|
1557
1599
|
catch (err) {
|
|
1558
1600
|
die(String(err?.message ?? err));
|
|
@@ -1683,6 +1725,7 @@ function usage() {
|
|
|
1683
1725
|
console.log(`agent-switch — switch accounts for Claude Code, Codex, and Antigravity (macOS · Linux · Windows)
|
|
1684
1726
|
|
|
1685
1727
|
Provider defaults to claude; pass --provider codex|antigravity for the others.
|
|
1728
|
+
Run with no command to launch the tray/menubar GUI (single-instance).
|
|
1686
1729
|
|
|
1687
1730
|
agent-switch add [--provider P] <name> create a profile and log it in
|
|
1688
1731
|
agent-switch import [--provider P] <name> migrate the default install (no re-login)
|
|
@@ -1698,26 +1741,28 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
|
|
|
1698
1741
|
agent-switch unmap [--provider P] [dir] remove a directory mapping
|
|
1699
1742
|
agent-switch mappings list directory mappings
|
|
1700
1743
|
agent-switch share on|sync|off|status [--history] [--source <profile|default>] [--json] (Claude)
|
|
1744
|
+
agent-switch config export|import <profile> config-only cross-machine bundle to stdout / from stdin (settings, keybindings, CLAUDE.md, skills/, commands/, agents/ — NEVER credentials; pipe export | gpg -c)
|
|
1701
1745
|
agent-switch sessions [profile] [--recent N] [--json] recent + live Claude sessions per profile (with context %)
|
|
1702
1746
|
agent-switch hooks install|uninstall|status [profile] manage lifecycle push hooks in Claude settings.json
|
|
1703
1747
|
agent-switch alerts on|off|status [--threshold 80,95] record context/usage crossings to the notification log (off by default)
|
|
1704
1748
|
agent-switch compact <profile> [--clear] [--dry-run] [--force] type /compact (or /clear) into the profile's managed tmux pane
|
|
1705
1749
|
agent-switch takeover <id> --to <profile> [--from <profile>] [--keep-source] [--in-place] [--print-only] [--force] move a session to another profile and resume it
|
|
1750
|
+
agent-switch rebind [<account>] [--profile <p>] | rebind --restore [--profile <p>] | rebind --reset switch a RUNNING profile's account (macOS; no <account> = interactive picker; --restore reverses; --reset re-enables after a circuit-breaker trip)
|
|
1706
1751
|
agent-switch web <name> claude.ai in a persistent browser (Claude)
|
|
1707
1752
|
agent-switch remove [--provider P] <name> [--force] delete a profile
|
|
1708
1753
|
agent-switch label [--provider P] <name> [Work|Personal|Other|none] tag a profile
|
|
1709
|
-
agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>]
|
|
1754
|
+
agent-switch autoswitch on|off|status [--provider P] [--threshold <1-100>] notify + suggest near a usage limit; never switches automatically (default OFF)
|
|
1710
1755
|
agent-switch reset <profile> --provider codex redeem one banked Codex rate-limit reset
|
|
1711
1756
|
agent-switch rename <old> <new> [--provider P] rename a profile (name & keep its tag)
|
|
1712
1757
|
agent-switch providers enable|disable|status [--provider P] [--surface cli|ui] enable/disable a provider (default: Claude + Codex)
|
|
1713
|
-
agent-switch gui
|
|
1758
|
+
agent-switch [gui] launch the tray/menubar GUI (the default with no command; won't start a second if one is running)
|
|
1714
1759
|
agent-switch check-update [--json] check GitHub Releases for a newer version
|
|
1715
1760
|
agent-switch update self-update to the latest version (via npm)
|
|
1716
1761
|
agent-switch apps list launchable GUI apps (macOS)
|
|
1717
1762
|
agent-switch open <app> [profile] launch a GUI app on a profile, isolated (macOS)
|
|
1718
1763
|
agent-switch shellenv [--shell zsh|bash|fish|powershell] shell integration
|
|
1719
1764
|
agent-switch service run|start|stop|status|install|uninstall background usage daemon
|
|
1720
|
-
agent-switch notifications [clear] [--json] recent notifications (
|
|
1765
|
+
agent-switch notifications [clear] [--json] recent notifications (usage-limit suggestions, fetch failures)
|
|
1721
1766
|
agent-switch notify --kind K --title T --message M [--json] record a notification event
|
|
1722
1767
|
agent-switch os-notify [on|off|status] [--json] daemon-side OS desktop notifications (default off)
|
|
1723
1768
|
agent-switch uninstall [--force] remove all agent-switch data + daemon
|
|
@@ -1726,6 +1771,135 @@ Provider defaults to claude; pass --provider codex|antigravity for the others.
|
|
|
1726
1771
|
agent-switch tooling install|upgrade <tool> install/upgrade an ecosystem tool (agent-config, rtk, claude, codex)
|
|
1727
1772
|
agent-switch doctor per-OS, per-provider self-check`);
|
|
1728
1773
|
}
|
|
1774
|
+
/**
|
|
1775
|
+
* The interactive limit dialog (Phase 4) — `agent-switch rebind` with no account
|
|
1776
|
+
* and no `--restore`. Gathers the OTHER claude profiles + their usage, pre-selects
|
|
1777
|
+
* the best-headroom profile as a convenience, and prompts the user to pick. Only
|
|
1778
|
+
* an explicit selection switches (via `rebind`); cancel stays put. The user
|
|
1779
|
+
* interaction IS the compliance line — there is NO automatic switch anywhere in
|
|
1780
|
+
* here. macOS-only for the actual switch; a non-TTY stdin prints the suggested
|
|
1781
|
+
* command instead of hanging on a prompt no one can answer.
|
|
1782
|
+
*/
|
|
1783
|
+
async function rebindDialog(running) {
|
|
1784
|
+
const others = listProfiles("claude").filter((n) => n !== running);
|
|
1785
|
+
const candidates = [];
|
|
1786
|
+
for (const n of others) {
|
|
1787
|
+
// Same usage read path `status` uses (daemon cache when fresh, else the
|
|
1788
|
+
// OAuth /usage endpoint); null when unreadable — the row shows no %.
|
|
1789
|
+
candidates.push({ name: n, snapshot: await claudeSnapshot(n), label: labelFor("claude", n) });
|
|
1790
|
+
}
|
|
1791
|
+
const dialog = buildRebindDialog(candidates, running);
|
|
1792
|
+
const rows = selectableRows(dialog);
|
|
1793
|
+
if (rows.length === 0) {
|
|
1794
|
+
die(`no other claude profile to rebind "${running}" to — add one with \`agent-switch add <name>\`.`);
|
|
1795
|
+
}
|
|
1796
|
+
// Non-interactive stdin: never hang. Print the pre-selected profile + the exact
|
|
1797
|
+
// command that performs the switch, then return (no switch — the user has to run it).
|
|
1798
|
+
if (!process.stdin.isTTY) {
|
|
1799
|
+
const sug = dialog.suggestedProfile;
|
|
1800
|
+
if (sug) {
|
|
1801
|
+
console.error(`Suggested profile (most headroom): ${sug}`);
|
|
1802
|
+
console.error(`Switch "${running}" to it with: agent-switch rebind ${sug} --profile ${running}`);
|
|
1803
|
+
}
|
|
1804
|
+
else {
|
|
1805
|
+
console.error(`No usage data to pre-select a profile. Switch with: agent-switch rebind <account> --profile ${running}`);
|
|
1806
|
+
}
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
// The actual switch is macOS-only (the Linux/Windows credential backend is
|
|
1810
|
+
// unproven — spike R0.1). Refuse before prompting rather than after a pick.
|
|
1811
|
+
if (process.platform !== "darwin") {
|
|
1812
|
+
die("rebind is macOS-only for now (the Linux/Windows credential backend is unproven — spike R0.1).");
|
|
1813
|
+
}
|
|
1814
|
+
console.log(`Rebind "${running}" to which account? A running Claude session adopts it on its next message.`);
|
|
1815
|
+
rows.forEach((r, i) => {
|
|
1816
|
+
const marker = r.profile === dialog.suggestedProfile ? " ← suggested (most headroom)" : "";
|
|
1817
|
+
const label = r.label ? ` [${r.label}]` : "";
|
|
1818
|
+
const util = r.maxUtil === null ? "usage n/a" : `${r.maxUtil}% used`;
|
|
1819
|
+
console.log(` ${i + 1}) ${(r.profile + label).padEnd(24)} ${util}${marker}`);
|
|
1820
|
+
});
|
|
1821
|
+
const enterHint = dialog.suggestedProfile ?? "(no suggestion)";
|
|
1822
|
+
console.log(` [enter] = ${enterHint} · q = cancel (stay on "${running}")`);
|
|
1823
|
+
const SIGINT = "\u0003"; // sentinel distinct from empty input (empty = accept the suggestion)
|
|
1824
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1825
|
+
const answer = await new Promise((resolve) => {
|
|
1826
|
+
rl.on("SIGINT", () => resolve(SIGINT)); // Ctrl-C -> cancel, no switch
|
|
1827
|
+
rl.question("> ", (a) => resolve(a));
|
|
1828
|
+
});
|
|
1829
|
+
rl.close();
|
|
1830
|
+
if (answer === SIGINT) {
|
|
1831
|
+
console.log(`\nStayed on "${running}" — no switch.`);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const choice = resolveRebindChoice(answer, dialog);
|
|
1835
|
+
if (choice.kind === "cancel") {
|
|
1836
|
+
console.log(`Stayed on "${running}" — no switch.`);
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
if (choice.kind === "invalid") {
|
|
1840
|
+
console.log(`No valid selection — stayed on "${running}".`);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
try {
|
|
1844
|
+
const r = await rebind({ account: choice.profile, profile: running });
|
|
1845
|
+
console.log(r.uxNote);
|
|
1846
|
+
}
|
|
1847
|
+
catch (e) {
|
|
1848
|
+
die(e.message);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* `agent-switch rebind <account> [--profile <p>]` — point a RUNNING profile's
|
|
1853
|
+
* credential store at another account, so a live Claude session adopts it on its
|
|
1854
|
+
* next message (macOS; the user-interaction switch, ADR-003). `--restore` reverses
|
|
1855
|
+
* it. With NO account and NO `--restore`, opens the interactive limit dialog
|
|
1856
|
+
* ({@link rebindDialog}). Claude only; the target + running profiles must be logged in.
|
|
1857
|
+
*/
|
|
1858
|
+
async function cmdRebind(providerId, providerExplicit, account, flags) {
|
|
1859
|
+
if (providerExplicit && providerId !== "claude")
|
|
1860
|
+
die(`rebind supports claude only (not ${providerId})`);
|
|
1861
|
+
const profile = typeof flags.profile === "string" ? flags.profile : activeFor("claude") ?? undefined;
|
|
1862
|
+
if (flags.reset) {
|
|
1863
|
+
// Kill-switch reset (ADR-003): clear `disabled` + the failure counter so a
|
|
1864
|
+
// circuit-breaker trip (or a manual disable) no longer refuses rebind.
|
|
1865
|
+
resetRebindKillSwitch();
|
|
1866
|
+
console.log("rebind re-enabled — circuit-breaker cleared (disabled=false, consecutiveFailures=0).");
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
if (flags.restore) {
|
|
1870
|
+
if (!profile)
|
|
1871
|
+
die("usage: agent-switch rebind --restore [--profile <p>] — no active claude profile to restore");
|
|
1872
|
+
requireProfile("claude", profile, "rebind --restore --profile");
|
|
1873
|
+
try {
|
|
1874
|
+
const r = await restoreRebind({ profile });
|
|
1875
|
+
console.log(`Restored "${r.restoredProfile}" to its own account (was rebound to "${r.wasBoundTo}").`);
|
|
1876
|
+
}
|
|
1877
|
+
catch (e) {
|
|
1878
|
+
die(e.message);
|
|
1879
|
+
}
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
if (!account) {
|
|
1883
|
+
// No account + no --restore → the interactive limit dialog (Phase 4). The
|
|
1884
|
+
// switch is user-interaction-gated: the picker pre-selects the best-headroom
|
|
1885
|
+
// profile as a convenience, but only an explicit pick switches.
|
|
1886
|
+
if (!profile)
|
|
1887
|
+
die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
|
|
1888
|
+
requireProfile("claude", profile, "rebind --profile");
|
|
1889
|
+
return rebindDialog(profile);
|
|
1890
|
+
}
|
|
1891
|
+
if (!profile)
|
|
1892
|
+
die("no running claude profile — pass --profile <name> or set one active with `asw <name>`.");
|
|
1893
|
+
requireProfile("claude", account, "rebind");
|
|
1894
|
+
requireProfile("claude", profile, "rebind --profile");
|
|
1895
|
+
try {
|
|
1896
|
+
const r = await rebind({ account, profile });
|
|
1897
|
+
console.log(r.uxNote);
|
|
1898
|
+
}
|
|
1899
|
+
catch (e) {
|
|
1900
|
+
die(e.message);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1729
1903
|
// ---------- main -------------------------------------------------------------
|
|
1730
1904
|
async function main() {
|
|
1731
1905
|
const argv = process.argv.slice(2);
|
|
@@ -1740,7 +1914,9 @@ async function main() {
|
|
|
1740
1914
|
// profiles) skip it.
|
|
1741
1915
|
if (cmd === "__hook-event")
|
|
1742
1916
|
return cmdHookEvent(); // internal, silent, no migration
|
|
1743
|
-
|
|
1917
|
+
// A bare `agent-switch` (no command) launches the GUI, which reads profiles —
|
|
1918
|
+
// so it runs the migration too. Only pure help / shellenv skip it.
|
|
1919
|
+
if (cmd === undefined || !["help", "--help", "-h", "shellenv"].includes(cmd)) {
|
|
1744
1920
|
const moved = migrateLegacyLayout();
|
|
1745
1921
|
if (moved.length > 0) {
|
|
1746
1922
|
// stderr, not stdout: `dir` is machine-consumed by the shell wrapper
|
|
@@ -1755,7 +1931,7 @@ async function main() {
|
|
|
1755
1931
|
case "use": return cmdUse(providerId, positional[0]);
|
|
1756
1932
|
case "deactivate": return cmdDeactivate(providerId);
|
|
1757
1933
|
case "label": return cmdLabel(providerId, positional[0], positional[1]);
|
|
1758
|
-
case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags
|
|
1934
|
+
case "autoswitch": return cmdAutoswitch(providerId, providerExplicit, positional[0], flags);
|
|
1759
1935
|
case "reset": return cmdReset(providerId, positional[0]);
|
|
1760
1936
|
case "rename": return cmdRename(providerId, positional[0], positional[1]);
|
|
1761
1937
|
case "providers": return cmdProviders(providerId, providerExplicit, positional[0], flags);
|
|
@@ -1785,6 +1961,7 @@ async function main() {
|
|
|
1785
1961
|
case "unmap": return cmdUnmap(providerExplicit ? providerId : undefined, positional[0]);
|
|
1786
1962
|
case "mappings": return cmdMappings();
|
|
1787
1963
|
case "share": return cmdShare(positional[0], rest.slice(1));
|
|
1964
|
+
case "config": return cmdConfig(positional[0], positional[1], flags);
|
|
1788
1965
|
case "sessions":
|
|
1789
1966
|
if (positional[0] === "rm")
|
|
1790
1967
|
return cmdSessionsRm(providerId, providerExplicit, positional[1], flags);
|
|
@@ -1797,6 +1974,7 @@ async function main() {
|
|
|
1797
1974
|
case "alerts": return cmdAlerts(positional[0], flags);
|
|
1798
1975
|
case "compact": return cmdCompact(providerId, positional[0], flags);
|
|
1799
1976
|
case "takeover": return cmdTakeover(providerId, providerExplicit, positional[0], flags);
|
|
1977
|
+
case "rebind": return cmdRebind(providerId, providerExplicit, positional[0], flags);
|
|
1800
1978
|
case "web": return cmdWeb(positional[0]);
|
|
1801
1979
|
case "remove":
|
|
1802
1980
|
case "rm": return cmdRemove(providerId, positional[0], !!flags.force);
|
|
@@ -1815,9 +1993,13 @@ async function main() {
|
|
|
1815
1993
|
case "help":
|
|
1816
1994
|
case "--help":
|
|
1817
1995
|
case "-h": return usage();
|
|
1996
|
+
// A bare `agent-switch` (no command) launches the GUI; an unknown command
|
|
1997
|
+
// still prints usage and exits non-zero.
|
|
1818
1998
|
default:
|
|
1999
|
+
if (cmd === undefined)
|
|
2000
|
+
return cmdGui();
|
|
1819
2001
|
usage();
|
|
1820
|
-
process.exit(
|
|
2002
|
+
process.exit(1);
|
|
1821
2003
|
}
|
|
1822
2004
|
}
|
|
1823
2005
|
main().catch((err) => die(String(err?.message ?? err)));
|
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
|
@@ -13,7 +13,6 @@ export function isProfileLabel(v) {
|
|
|
13
13
|
return typeof v === "string" && PROFILE_LABELS.includes(v);
|
|
14
14
|
}
|
|
15
15
|
export const DEFAULT_AUTOSWITCH = { enabled: false, threshold: 95, tag: "all" };
|
|
16
|
-
export const DEFAULT_SWITCH_STRATEGY = "reset-first";
|
|
17
16
|
/**
|
|
18
17
|
* Providers enabled out of the box. Everything else is available but off by
|
|
19
18
|
* default — the user opts it in from the Providers settings tab. A provider that
|
|
@@ -21,6 +20,7 @@ export const DEFAULT_SWITCH_STRATEGY = "reset-first";
|
|
|
21
20
|
* upgrading never hides an account the user already set up.
|
|
22
21
|
*/
|
|
23
22
|
export const PROVIDERS_ON_BY_DEFAULT = ["claude", "codex"];
|
|
23
|
+
export const DEFAULT_REBIND_STATE = { disabled: false, consecutiveFailures: 0 };
|
|
24
24
|
function emptyActive() {
|
|
25
25
|
return { claude: null, codex: null, antigravity: null };
|
|
26
26
|
}
|
|
@@ -155,6 +155,11 @@ function normalizeBinaryPaths(raw) {
|
|
|
155
155
|
out[p] = r[p];
|
|
156
156
|
return out;
|
|
157
157
|
}
|
|
158
|
+
function normalizeRebindState(raw) {
|
|
159
|
+
const r = (raw ?? {});
|
|
160
|
+
const consecutiveFailures = typeof r.consecutiveFailures === "number" && r.consecutiveFailures >= 0 ? Math.floor(r.consecutiveFailures) : 0;
|
|
161
|
+
return { disabled: r.disabled === true, consecutiveFailures };
|
|
162
|
+
}
|
|
158
163
|
export function readState() {
|
|
159
164
|
try {
|
|
160
165
|
const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
@@ -162,29 +167,21 @@ export function readState() {
|
|
|
162
167
|
const autoSwitch = normalizeAutoSwitchMap(raw?.autoSwitch);
|
|
163
168
|
const providers = normalizeProvidersMap(raw?.providers);
|
|
164
169
|
const binaryPaths = normalizeBinaryPaths(raw?.binaryPaths);
|
|
165
|
-
const switchStrategy = raw?.switchStrategy === "rotation-first" ? "rotation-first" : DEFAULT_SWITCH_STRATEGY;
|
|
166
170
|
const osNotifications = raw?.osNotifications === true;
|
|
171
|
+
const rebind = normalizeRebindState(raw?.rebind);
|
|
167
172
|
// v1: { active: "<name>" } — a single Claude profile.
|
|
168
173
|
if (typeof raw?.active === "string") {
|
|
169
|
-
return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths,
|
|
174
|
+
return { active: { ...emptyActive(), claude: raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
|
|
170
175
|
}
|
|
171
176
|
if (raw?.active && typeof raw.active === "object") {
|
|
172
|
-
return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths,
|
|
177
|
+
return { active: { ...emptyActive(), ...raw.active }, labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
|
|
173
178
|
}
|
|
174
|
-
return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths,
|
|
179
|
+
return { active: emptyActive(), labels, autoSwitch, providers, binaryPaths, osNotifications, rebind };
|
|
175
180
|
}
|
|
176
181
|
catch {
|
|
177
182
|
/* absent / unparsable → default */
|
|
178
183
|
}
|
|
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);
|
|
184
|
+
return { active: emptyActive(), labels: {}, autoSwitch: emptyAutoSwitch(), providers: emptyProviders(), binaryPaths: {}, osNotifications: false, rebind: { ...DEFAULT_REBIND_STATE } };
|
|
188
185
|
}
|
|
189
186
|
export function readOsNotifications() {
|
|
190
187
|
return readState().osNotifications;
|
|
@@ -194,6 +191,21 @@ export function setOsNotifications(on) {
|
|
|
194
191
|
state.osNotifications = on;
|
|
195
192
|
writeState(state);
|
|
196
193
|
}
|
|
194
|
+
// ---------- rebind kill-switch / circuit-breaker (ADR-003) ----------
|
|
195
|
+
/** Current rollback/circuit-breaker state for the `rebind` write path. */
|
|
196
|
+
export function readRebindKillSwitch() {
|
|
197
|
+
return readState().rebind;
|
|
198
|
+
}
|
|
199
|
+
/** Persist the rebind kill-switch state (breaker counter + disabled flag). */
|
|
200
|
+
export function writeRebindKillSwitch(next) {
|
|
201
|
+
const state = readState();
|
|
202
|
+
state.rebind = normalizeRebindState(next);
|
|
203
|
+
writeState(state);
|
|
204
|
+
}
|
|
205
|
+
/** Re-enable `rebind`: clear `disabled` + reset the failure counter (`--reset`). */
|
|
206
|
+
export function resetRebindKillSwitch() {
|
|
207
|
+
writeRebindKillSwitch({ ...DEFAULT_REBIND_STATE });
|
|
208
|
+
}
|
|
197
209
|
export function writeState(state) {
|
|
198
210
|
ensureRoot();
|
|
199
211
|
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
@@ -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
|
+
}
|