@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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Release-note normalisation — turn a GitHub release body into something a
3
+ * card or a terminal can show without it reading as broken.
4
+ *
5
+ * Mirrored in `gui/src/release-notes.ts` (same boundary reason as updates.ts:
6
+ * the GUI's tsconfig only includes `gui/src`, so pure helpers are duplicated
7
+ * rather than imported across it). `tests/release-notes.test.ts` byte-compares
8
+ * the two below their header comment, so a fix applied to one and not the other
9
+ * fails there — and it asserts against the real captured body
10
+ * (`tests/fixtures/agent-config-release-14.2.0.md`), which the GUI mirror's own
11
+ * suite cannot read: that project ships no `@types/node` on purpose.
12
+ *
13
+ * Why a normaliser and not `body.slice(0, 500)`: measured on agent-config
14
+ * 14.2.0, the first 500 characters are a heading, an HTML comment addressed to
15
+ * the release author, and five `_none_` placeholders. The shapes handled here
16
+ * are the ones that release template actually emits — headings, `*` items with
17
+ * a `**scope:**` prefix, `([sha](commit-url))` suffixes, `**bold**` / `_italic_`
18
+ * and horizontal rules. Everything is pure; no I/O, no markdown dependency.
19
+ */
20
+ /** Drop HTML comments, including multi-line ones. This is what removes the
21
+ * release template's author-facing instruction line ("fill before merge …") —
22
+ * a comment is invisible on GitHub and must stay invisible here. */
23
+ export function stripHtmlComments(md) {
24
+ return String(md).replace(/<!--[\s\S]*?-->/g, "");
25
+ }
26
+ /** Inline cleanup applied to every line's text: commit-link suffixes go, real
27
+ * links collapse to their label, bold/italic markers are unwrapped, and runs
28
+ * of whitespace collapse. Inline code keeps its backticks on purpose — they
29
+ * mark a literal name, which is information, not noise. Pure. */
30
+ export function normalizeInline(s) {
31
+ return String(s)
32
+ // ` ([abc1234](https://…/commit/abc…))` — the generated changelog's sha
33
+ // suffix. Removed before generic link handling, which would otherwise
34
+ // leave a bare `(abc1234)` behind.
35
+ .replace(/\s*\(\[[0-9a-f]{7,40}\]\([^)\s]*\)\)/gi, "")
36
+ // `[label](url)` → `label`
37
+ .replace(/\[([^\]]*)\]\([^)\s]*\)/g, "$1")
38
+ // `**bold**` / `__bold__` → bold
39
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
40
+ .replace(/__([^_]+)__/g, "$1")
41
+ // `_italic_` → italic (this is what turns `_none_` into `none`).
42
+ // Underscore-delimited only at word boundaries so `snake_case_names` and
43
+ // `file_name.ts` survive untouched.
44
+ .replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, "$1$2")
45
+ .replace(/[ \t]{2,}/g, " ")
46
+ .trim();
47
+ }
48
+ /** True for a markdown horizontal rule (`---`, `***`, `___`). */
49
+ function isHorizontalRule(line) {
50
+ return /^\s*([-*_])\s*(\1\s*){2,}$/.test(line);
51
+ }
52
+ /**
53
+ * Parse a release body into ordered blocks. Blank lines carry no block — the
54
+ * renderers space by `kind` instead, so a body with erratic blank runs comes
55
+ * out evenly spaced. An empty or comment-only body yields `[]`, which every
56
+ * caller must treat as "no notes" (render nothing — never a placeholder).
57
+ */
58
+ export function releaseNoteBlocks(md) {
59
+ const blocks = [];
60
+ for (const raw of stripHtmlComments(md).split(/\r?\n/)) {
61
+ if (!raw.trim() || isHorizontalRule(raw))
62
+ continue;
63
+ const heading = /^\s*#{1,6}\s+(.*)$/.exec(raw);
64
+ if (heading) {
65
+ const text = normalizeInline(heading[1]);
66
+ if (text)
67
+ blocks.push({ kind: "heading", text });
68
+ continue;
69
+ }
70
+ const item = /^\s*[-*+]\s+(.*)$/.exec(raw);
71
+ if (item) {
72
+ const text = normalizeInline(item[1]);
73
+ if (text)
74
+ blocks.push({ kind: "item", text });
75
+ continue;
76
+ }
77
+ const text = normalizeInline(raw);
78
+ if (text)
79
+ blocks.push({ kind: "text", text });
80
+ }
81
+ return blocks;
82
+ }
83
+ /** A `_none_`-style empty value — the release template's way of saying "this
84
+ * section genuinely has nothing", which must not be offered as a highlight. */
85
+ function isEmptyValue(value) {
86
+ return /^(none|n\/a|-|—)$/i.test(value.trim());
87
+ }
88
+ /**
89
+ * The one line worth putting in a notification: the first non-empty bullet
90
+ * under a "highlights"-ish heading, else the first bullet in the body. Returns
91
+ * null when the body carries no bullet with content — callers then say nothing
92
+ * rather than inventing a summary. Pure.
93
+ */
94
+ export function firstHighlight(blocks) {
95
+ const start = blocks.findIndex((b) => b.kind === "heading" && /highlight/i.test(b.text));
96
+ const scan = (from, stopAtHeading) => {
97
+ for (let i = from; i < blocks.length; i++) {
98
+ const b = blocks[i];
99
+ if (b.kind === "heading" && stopAtHeading && i > from)
100
+ return null;
101
+ if (b.kind !== "item")
102
+ continue;
103
+ // `Behaviour changes: none` → value `none`; a bullet with no colon is
104
+ // its own value.
105
+ const colon = b.text.indexOf(":");
106
+ const value = colon >= 0 ? b.text.slice(colon + 1) : b.text;
107
+ if (!isEmptyValue(value))
108
+ return b.text;
109
+ }
110
+ return null;
111
+ };
112
+ return start >= 0 ? (scan(start + 1, true) ?? scan(0, false)) : scan(0, false);
113
+ }
114
+ /**
115
+ * Render blocks as terminal lines: a blank line before each heading (never as
116
+ * the first line), items bulleted, text as-is. `omitted` is how many lines the
117
+ * cap dropped, so the caller can print an honest "… N more" plus the release
118
+ * URL instead of silently truncating. Pure.
119
+ */
120
+ export function releaseNotesText(blocks, opts = {}) {
121
+ const bullet = opts.bullet ?? "•";
122
+ const indent = opts.indent ?? "";
123
+ const all = [];
124
+ for (const b of blocks) {
125
+ if (b.kind === "heading") {
126
+ if (all.length)
127
+ all.push("");
128
+ all.push(`${indent}${b.text}`);
129
+ }
130
+ else if (b.kind === "item") {
131
+ all.push(`${indent} ${bullet} ${b.text}`);
132
+ }
133
+ else {
134
+ all.push(`${indent} ${b.text}`);
135
+ }
136
+ }
137
+ const max = opts.maxLines;
138
+ if (max === undefined || all.length <= max)
139
+ return { lines: all, omitted: 0 };
140
+ return { lines: all.slice(0, max), omitted: all.length - max };
141
+ }
142
+ /** Clamp to `max` characters on a word boundary, appending an ellipsis when
143
+ * anything was cut. Used for the one-line notification summary, where a raw
144
+ * bullet can run to several hundred characters. Pure. */
145
+ export function clampText(s, max) {
146
+ const t = String(s).trim();
147
+ if (t.length <= max)
148
+ return t;
149
+ const cut = t.slice(0, max);
150
+ const space = cut.lastIndexOf(" ");
151
+ // Prefer the word boundary, but only when it still keeps at least half the
152
+ // budget — otherwise a long first word would collapse the summary to nothing.
153
+ return `${(space > max * 0.5 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-]+$/, "")}…`;
154
+ }
package/dist/sessions.js CHANGED
@@ -174,6 +174,25 @@ export function markLive(configDir, rows, cwdOf = pidCwd) {
174
174
  row.live = true;
175
175
  }
176
176
  }
177
+ /**
178
+ * Auto-detect which Claude profile has a live session, for `rebind`'s
179
+ * no-`--profile` resolution. A profile is "running" when its config dir carries
180
+ * a live session pid (`<configDir>/sessions/<pid>.json` whose pid is alive).
181
+ *
182
+ * Returns `single` ONLY when exactly one profile is running — rebind writes a
183
+ * credential store, so the caller must never guess a target across ambiguity;
184
+ * `none` / `ambiguous` leave the caller on its explicit-`--profile` / active
185
+ * fallback. Pure (`livePidsOf` injected) so every branch is unit-tested without
186
+ * touching real processes.
187
+ */
188
+ export function detectRunningClaudeProfile(profiles, configDirOf, livePidsOf = liveSessionPids) {
189
+ const running = profiles.filter((p) => livePidsOf(configDirOf(p)).length > 0);
190
+ if (running.length === 1)
191
+ return { kind: "single", profile: running[0] };
192
+ if (running.length > 1)
193
+ return { kind: "ambiguous", profiles: running };
194
+ return { kind: "none" };
195
+ }
177
196
  /** Where a session id lives inside ONE profile config dir, or null. */
178
197
  export function locateSession(configDir, sessionId) {
179
198
  const projects = path.join(configDir, "projects");
package/dist/shellenv.js CHANGED
@@ -13,8 +13,29 @@
13
13
  * `asw` convenience: bare `asw` lists all providers' profiles; `asw <name>`
14
14
  * switches the active Claude profile; `asw <provider> <name>` switches that
15
15
  * provider's.
16
+ *
17
+ * SINGLE-LOAD GUARD. The wrapper is the one place every interactive start
18
+ * funnels through, so it is where the guard has to sit. `dir --guard` keeps the
19
+ * config path on stdout (unchanged) and writes the human message to stderr, so
20
+ * one invocation both feeds the wrapper and talks to the user — no second
21
+ * process launch per command.
22
+ *
23
+ * The wrapper blocks on EXACTLY ONE exit code (86, GUARD_REFUSED_EXIT) and on
24
+ * nothing else. That asymmetry is deliberate and load-bearing: a missing,
25
+ * older, or broken `agent-switch` exits 127 / 1 / 2 and the wrapper carries on
26
+ * to the real binary. This tool must never become the reason a user cannot
27
+ * reach their assistant.
28
+ *
29
+ * Each wrapper first checks that `agent-switch` exists at all and otherwise
30
+ * calls the real binary straight away. That check replaces the old
31
+ * `2>/dev/null`: the guard has to be able to SPEAK on stderr, so the blanket
32
+ * redirect had to go, and without the existence check an uninstalled
33
+ * `agent-switch` would print "command not found" on every single invocation.
34
+ * A side effect worth having: a genuinely broken agent-switch now surfaces its
35
+ * error instead of silently degrading the shell onto the default config dir.
16
36
  */
17
37
  import { allProviders } from "./providers.js";
38
+ import { GUARD_REFUSED_EXIT } from "./single-load.js";
18
39
  export const SHELLS = ["zsh", "bash", "fish", "powershell"];
19
40
  /** Resolve the target shell: explicit request wins, else detect from the
20
41
  * environment / platform. Windows defaults to PowerShell; POSIX reads $SHELL. */
@@ -40,8 +61,11 @@ export function detectShell(requested, env = process.env, platform = process.pla
40
61
  }
41
62
  function posixWrapper(binary, envVar, id) {
42
63
  return `${binary}() {
43
- local dir
44
- dir="$(command agent-switch dir --provider ${id} 2>/dev/null)"
64
+ local dir rc
65
+ command -v agent-switch >/dev/null 2>&1 || { command ${binary} "$@"; return; }
66
+ dir="$(command agent-switch dir --provider ${id} --guard)"
67
+ rc=$?
68
+ if [ $rc -eq ${GUARD_REFUSED_EXIT} ]; then return $rc; fi
45
69
  if [ -n "$dir" ]; then
46
70
  ${envVar}="$dir" command ${binary} "$@"
47
71
  else
@@ -51,7 +75,15 @@ function posixWrapper(binary, envVar, id) {
51
75
  }
52
76
  function fishWrapper(binary, envVar, id) {
53
77
  return `function ${binary}
54
- set -l dir (command agent-switch dir --provider ${id} 2>/dev/null)
78
+ if not type -q agent-switch
79
+ command ${binary} $argv
80
+ return
81
+ end
82
+ set -l dir (command agent-switch dir --provider ${id} --guard)
83
+ set -l rc $status
84
+ if test $rc -eq ${GUARD_REFUSED_EXIT}
85
+ return $rc
86
+ end
55
87
  if test -n "$dir"
56
88
  ${envVar}=$dir command ${binary} $argv
57
89
  else
@@ -63,7 +95,9 @@ function powershellWrapper(binary, envVar, id) {
63
95
  return `function ${binary} {
64
96
  $exe = Get-Command ${binary} -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
65
97
  if (-not $exe) { Write-Error '${binary} not found on PATH.'; return }
66
- $dir = (& agent-switch dir --provider ${id} 2>$null)
98
+ if (-not (Get-Command agent-switch -ErrorAction SilentlyContinue)) { & $exe.Source @args; return }
99
+ $dir = (& agent-switch dir --provider ${id} --guard)
100
+ if ($LASTEXITCODE -eq ${GUARD_REFUSED_EXIT}) { return }
67
101
  if ($dir) {
68
102
  $prev = Get-Item -Path Env:\\${envVar} -ErrorAction SilentlyContinue
69
103
  $env:${envVar} = $dir
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Single-load guard: one profile, one live session.
3
+ *
4
+ * The problem it solves: `agent-switch use` writes a GLOBAL active pointer and
5
+ * the shell wrapper re-resolves it on every invocation, so nothing stops two
6
+ * terminals from starting `claude` on the same profile. Two live sessions on
7
+ * one account share its rate-limit windows and both refresh one rotating OAuth
8
+ * store (the rotation `src/locks.ts` documents) — and, worst of the three, the
9
+ * user believes the second terminal is on a different account because they
10
+ * typed `asw <other>` there.
11
+ *
12
+ * Ground truth is Claude Code's own bookkeeping, never ours: a live session
13
+ * writes `<config>/sessions/<pid>.json`, and `liveSessionPids` filters dead
14
+ * pids on every read. That makes the signal self-healing — a crashed session
15
+ * leaves a stale file that is simply never counted, so this module needs no
16
+ * cleanup job and holds no lease of its own. (An agent-switch-owned lease would
17
+ * need a holder process; the shell wrapper execs `claude` and exits, so every
18
+ * crash would leak one.)
19
+ *
20
+ * Everything here is pure — processes, environment, and clock are injected — so
21
+ * every branch is unit-tested without spawning anything.
22
+ */
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { liveSessionPids } from "./api.js";
26
+ import { pidCwd } from "./sessions.js";
27
+ export const GUARD_POLICIES = ["block", "warn", "off"];
28
+ export const DEFAULT_GUARD_POLICY = "block";
29
+ export function isGuardPolicy(v) {
30
+ return typeof v === "string" && GUARD_POLICIES.includes(v);
31
+ }
32
+ /** Env var that waives the guard for exactly one invocation. An env var, not a
33
+ * flag: the shell wrapper passes every unknown flag THROUGH to the provider
34
+ * binary, so a `--force` there would reach `claude`, not us. */
35
+ export const OVERRIDE_ENV = "AGENT_SWITCH_ALLOW_DUPLICATE";
36
+ /** Exit code the wrapper keys on. Distinct from every generic failure (1, 2,
37
+ * 127 …) so the wrapper blocks on a REFUSAL and never on a missing or older
38
+ * `agent-switch` — fail-open is deliberate: this tool must never be the reason
39
+ * a user cannot start their assistant. */
40
+ export const GUARD_REFUSED_EXIT = 86;
41
+ function pidFileMtime(configDir, pid) {
42
+ try {
43
+ return fs.statSync(path.join(configDir, "sessions", `${pid}.json`)).mtimeMs;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Who currently holds `configDir`, and whether we are a child of one of them.
51
+ *
52
+ * `envVar` is the provider's isolation variable (`CLAUDE_CONFIG_DIR`, …). The
53
+ * nested comparison is on the RAW string, deliberately unresolved: that same
54
+ * unresolved string is what Claude Code hashes into its keychain service name
55
+ * (`profiles.ts` § configDir), so resolving it here would compare a different
56
+ * identity than the one the rest of the tool uses.
57
+ */
58
+ export function inspectLoad(configDir, envVar, deps = {}) {
59
+ const livePidsOf = deps.livePidsOf ?? liveSessionPids;
60
+ const cwdOf = deps.cwdOf ?? pidCwd;
61
+ const startedMsOf = deps.startedMsOf ?? pidFileMtime;
62
+ const env = deps.env ?? process.env;
63
+ const nested = env[envVar] === configDir;
64
+ const pids = livePidsOf(configDir);
65
+ // A nested start is allowed unconditionally, so its message is never
66
+ // rendered — and resolving a cwd costs an `lsof` per pid on darwin. Skip the
67
+ // lookup rather than pay it on a path that cannot use the answer.
68
+ const sessions = pids.map((pid) => ({
69
+ pid,
70
+ cwd: nested ? null : cwdOf(pid),
71
+ startedMs: startedMsOf(configDir, pid),
72
+ }));
73
+ return { sessions, loaded: sessions.length > 0, nested };
74
+ }
75
+ /**
76
+ * Pure policy resolution. Order matters and each precedence is load-bearing:
77
+ * an undetectable provider can never refuse (no evidence), a nested start is
78
+ * a deliberate child rather than a duplicate, and an explicit override outranks
79
+ * the policy because the user just stated an intent this turn.
80
+ */
81
+ export function guardVerdict({ policy, inspection, overridden = false, detectable = true }) {
82
+ if (!detectable)
83
+ return "allow";
84
+ if (policy === "off")
85
+ return "allow";
86
+ if (!inspection.loaded)
87
+ return "allow";
88
+ if (inspection.nested)
89
+ return "allow";
90
+ if (overridden)
91
+ return "warn"; // proceed, but still say what is happening
92
+ return policy === "warn" ? "warn" : "refuse";
93
+ }
94
+ /** True when the one-shot override env var is set to a meaningful value. */
95
+ export function overrideFromEnv(env = process.env) {
96
+ const v = env[OVERRIDE_ENV];
97
+ return typeof v === "string" && v !== "" && v !== "0" && v.toLowerCase() !== "false";
98
+ }
99
+ /** Relative age like "3m" / "2h" — mirrors the sessions listing's `ageOf`. */
100
+ function ageOf(startedMs, now) {
101
+ if (startedMs === null)
102
+ return null;
103
+ const s = Math.max(0, Math.round((now - startedMs) / 1000));
104
+ if (s < 60)
105
+ return `${s}s`;
106
+ if (s < 3600)
107
+ return `${Math.round(s / 60)}m`;
108
+ if (s < 86400)
109
+ return `${Math.round(s / 3600)}h`;
110
+ return `${Math.round(s / 86400)}d`;
111
+ }
112
+ /** Render actions as a table whose column width comes from the widest action,
113
+ * so a long override line can never collide with its own explanation. */
114
+ function actionTable(rows) {
115
+ const width = Math.max(...rows.map(([action]) => action.length));
116
+ return rows.map(([action, explanation]) => ` ${action.padEnd(width + 2)}${explanation}`);
117
+ }
118
+ /**
119
+ * The one place the guard speaks. Every enforcement point renders through this
120
+ * so the CLI, the wrapper path, and `use` never drift into three dialects.
121
+ *
122
+ * A refusal that only says "no" hands the problem straight back, so the message
123
+ * names WHO holds the profile (pid, age, directory), WHY that matters, and the
124
+ * concrete ways forward — including the names of the profiles that are actually
125
+ * free, and how to switch the guard off.
126
+ */
127
+ export function renderGuardMessage(input) {
128
+ const { profile, binary, verdict, inspection, offerForceFlag = false, alternatives = [], context = "start", now = Date.now(), } = input;
129
+ const n = inspection.sessions.length;
130
+ const held = `profile "${profile}" is already loaded by ${n === 1 ? "a running" : `${n} running`} ${binary} session${n === 1 ? "" : "s"}`;
131
+ const headline = context === "use"
132
+ ? verdict === "refuse"
133
+ ? `${held} — the next \`${binary}\` started here will be refused.`
134
+ : `${held} — a second session will be allowed (guard: warn).`
135
+ : verdict === "refuse"
136
+ ? `${held}.`
137
+ : `${held} — continuing anyway.`;
138
+ const lines = [headline, ""];
139
+ for (const sess of inspection.sessions) {
140
+ const age = ageOf(sess.startedMs, now);
141
+ const parts = [` pid ${sess.pid}`];
142
+ if (age)
143
+ parts.push(`started ${age} ago`);
144
+ if (sess.cwd)
145
+ parts.push(sess.cwd);
146
+ lines.push(parts.join(" "));
147
+ }
148
+ lines.push("", "Two sessions on one account share its rate limits, and both refresh the", "same rotating OAuth token.");
149
+ if (verdict === "refuse") {
150
+ const rows = [];
151
+ rows.push([
152
+ `agent-switch use ${alternatives.length > 0 ? alternatives[0] : "<other-profile>"}`,
153
+ alternatives.length > 1
154
+ ? `switch to a free account — also free: ${alternatives.slice(1).join(", ")}`
155
+ : alternatives.length === 1
156
+ ? "switch to the account that is free"
157
+ : "point new sessions at another account",
158
+ ]);
159
+ // Both overrides do the same thing, so only one is offered: `--force` where
160
+ // the caller can accept a flag, the env var everywhere else. A shell wrapper
161
+ // passes unknown flags THROUGH to the provider binary, which is why the env
162
+ // var exists at all — offering both would just be two names for one action.
163
+ rows.push(offerForceFlag
164
+ ? ["--force", "start it anyway, this once"]
165
+ : [`${OVERRIDE_ENV}=1 ${binary}`, "start it anyway, this once"]);
166
+ rows.push(["agent-switch guard warn", "warn instead of refusing, from now on"]);
167
+ rows.push(["agent-switch guard off", "turn the guard off entirely"]);
168
+ lines.push("", "Ways forward:", ...actionTable(rows));
169
+ }
170
+ return lines.join("\n");
171
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Keep a Claude profile's OAuth access token usable for the read-only usage /
3
+ * profile endpoints — WITHOUT agent-switch ever touching the credential store.
4
+ *
5
+ * The problem: a stored access token lives ~8h, the refresh token ~3 weeks.
6
+ * agent-switch reads the credential and calls the OAuth endpoints with whatever
7
+ * access token is in the store. Once that token expires the endpoints 401 and
8
+ * usage silently freezes on the last cached numbers — for exactly the profiles
9
+ * the operator is NOT actively using, which is the whole point of the readout.
10
+ * Starting Claude Code on the profile "fixes" it, because Claude Code refreshes.
11
+ *
12
+ * The mechanism: let Claude Code do the refresh. `claude doctor` is a LOCAL
13
+ * health check — it runs no completion and consumes no quota — but it goes
14
+ * through Claude Code's auth path, so an expired token is refreshed first,
15
+ * under Claude Code's own lock, into Claude Code's own store. Verified against
16
+ * CC 2.1.x (2026-07-25): with `expiresAt` in the past, `claude doctor` rotates
17
+ * BOTH the access and the refresh token and moves `expiresAt` ~8h out.
18
+ *
19
+ * Why not the refresh grant ourselves: that rotation is precisely the hazard the
20
+ * Phase-4 lock recorded — an out-of-band grant invalidates the refresh token a
21
+ * live session still holds. Delegating honours that lock instead of reopening
22
+ * it: agent-switch stays read-only outside `rebind` (ADR-003), and the one
23
+ * process allowed to rotate the token family is the one that owns it. Full
24
+ * rationale, evidence, and the rejected alternatives: ADR-004.
25
+ */
26
+ import { spawnSync } from "node:child_process";
27
+ import * as crypto from "node:crypto";
28
+ import * as fs from "node:fs";
29
+ import * as os from "node:os";
30
+ import * as path from "node:path";
31
+ import { accessTokenOf, readProfileCredential } from "./api.js";
32
+ import { ROOT } from "./profiles.js";
33
+ import { resolveBinary } from "./providers.js";
34
+ /** Treat a token as spent this close to `expiresAt` — mirrors Claude Code's own
35
+ * 5-minute refresh buffer, so we never race it into a just-expired window. */
36
+ export const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
37
+ /** Minimum gap between two freshen attempts for one profile. A truly dead login
38
+ * (refresh token expired / revoked) cannot be healed by retrying, and the GUI
39
+ * re-reads usage every few seconds — without this floor a dead profile would
40
+ * spawn `claude doctor` on every cycle. */
41
+ export const FRESHEN_COOLDOWN_MS = 10 * 60 * 1000;
42
+ /** How long to let Claude Code's health check run before giving up on it. The
43
+ * observed run is a few seconds; this is a stuck-process guard, and it bounds
44
+ * how long a daemon poll cycle can stall on one profile. */
45
+ const FRESHEN_TIMEOUT_MS = 30_000;
46
+ /** `expiresAt` (epoch ms) out of a credential blob; null when absent/unparseable. */
47
+ export function tokenExpiresAt(credentials) {
48
+ if (!credentials)
49
+ return null;
50
+ try {
51
+ const v = JSON.parse(credentials)?.claudeAiOauth?.expiresAt;
52
+ return typeof v === "number" ? v : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Is this credential's access token spent (or about to be)? Pure — the testable
60
+ * core. A credential we cannot read or whose `expiresAt` we cannot parse is NOT
61
+ * reported as expired: we have no evidence it is, and spawning Claude Code on a
62
+ * guess is worse than one failed read.
63
+ */
64
+ export function needsFreshen(credentials, now, bufferMs = EXPIRY_BUFFER_MS) {
65
+ const exp = tokenExpiresAt(credentials);
66
+ if (exp === null)
67
+ return false;
68
+ return exp - now <= bufferMs;
69
+ }
70
+ /** The attempt-stamp file for a config dir. Keyed by hash so the path stays
71
+ * flat and filesystem-safe, and lives in agent-switch's own root — never
72
+ * inside Claude Code's config dir. */
73
+ function attemptStampFile(configDir) {
74
+ const digest = crypto.createHash("sha256").update(configDir.normalize("NFC"), "utf8").digest("hex").slice(0, 12);
75
+ return path.join(ROOT, "freshen", `${digest}`);
76
+ }
77
+ /** Cooldown state is a FILE, not a module variable: the GUI reads usage by
78
+ * spawning a fresh `agent-switch status --json` per profile, so an in-process
79
+ * map would reset on every read and defeat the floor entirely. */
80
+ function readLastAttemptFile(configDir) {
81
+ try {
82
+ const n = Number(fs.readFileSync(attemptStampFile(configDir), "utf8").trim());
83
+ return Number.isFinite(n) ? n : 0;
84
+ }
85
+ catch {
86
+ return 0;
87
+ }
88
+ }
89
+ function recordAttemptFile(configDir, at) {
90
+ const file = attemptStampFile(configDir);
91
+ try {
92
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
93
+ fs.writeFileSync(file, String(at));
94
+ }
95
+ catch {
96
+ /* best-effort: a missing stamp only costs an extra attempt */
97
+ }
98
+ }
99
+ /**
100
+ * `claude doctor` against one profile's config dir. stdout/stderr are dropped —
101
+ * this is a side-effect call, not a readout. Any completed run returns true;
102
+ * whether the token actually got refreshed is decided by re-reading the store.
103
+ */
104
+ function runClaudeDoctor(configDir, linkedBinary) {
105
+ const res = spawnSync(resolveBinary("claude", linkedBinary), ["doctor"], {
106
+ env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
107
+ // Claude Code reads settings from the CWD; run it somewhere neutral so a
108
+ // project's config can never influence a background health check.
109
+ cwd: os.tmpdir(),
110
+ stdio: "ignore",
111
+ timeout: FRESHEN_TIMEOUT_MS,
112
+ });
113
+ if (res.error)
114
+ return false;
115
+ return true;
116
+ }
117
+ export function defaultFreshenDeps(linkedBinary = null) {
118
+ return {
119
+ now: () => Date.now(),
120
+ readCredential: readProfileCredential,
121
+ readLastAttempt: readLastAttemptFile,
122
+ recordAttempt: recordAttemptFile,
123
+ runHealthCheck: (configDir) => runClaudeDoctor(configDir, linkedBinary),
124
+ cooldownMs: FRESHEN_COOLDOWN_MS,
125
+ bufferMs: EXPIRY_BUFFER_MS,
126
+ };
127
+ }
128
+ /**
129
+ * Make sure the store behind `configDir` holds a usable access token, letting
130
+ * Claude Code refresh it when it does not. Idempotent, cooldown-guarded, and a
131
+ * no-op when the token is still good.
132
+ */
133
+ export function freshenToken(configDir, deps = defaultFreshenDeps()) {
134
+ const creds = deps.readCredential(configDir);
135
+ if (!needsFreshen(creds, deps.now(), deps.bufferMs))
136
+ return "not-needed";
137
+ if (deps.now() - deps.readLastAttempt(configDir) < deps.cooldownMs)
138
+ return "cooling-down";
139
+ deps.recordAttempt(configDir, deps.now());
140
+ if (!deps.runHealthCheck(configDir))
141
+ return "unavailable";
142
+ return needsFreshen(deps.readCredential(configDir), deps.now(), deps.bufferMs) ? "still-expired" : "refreshed";
143
+ }
144
+ /**
145
+ * The access token to call the OAuth endpoints with — freshened first when it
146
+ * has expired. Returns whatever the store holds afterwards (null when there is
147
+ * no readable credential at all); a freshen that could not heal the login just
148
+ * means the caller's read fails as it did before.
149
+ */
150
+ export function freshAccessToken(configDir, deps = defaultFreshenDeps()) {
151
+ freshenToken(configDir, deps);
152
+ const creds = deps.readCredential(configDir);
153
+ return creds ? accessTokenOf(creds) : null;
154
+ }
package/dist/tooling.js CHANGED
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { readBinaryPath } from "./profiles.js";
21
21
  import { npmSearchPath } from "./updates.js";
22
+ import { releaseNoteBlocks, releaseNotesText } from "./release-notes.js";
22
23
  export const TOOL_IDS = ["agent-config", "rtk", "claude", "codex", "agy"];
23
24
  /** Which provider a probed binary belongs to (for `providers link` hints and
24
25
  * the user-linked path lookup). */
@@ -322,3 +323,75 @@ export function doctorToolingLine(t) {
322
323
  }
323
324
  return `\`${t.id}\`: ${t.hint}`;
324
325
  }
326
+ // ---------- release notes (`tooling notes <tool>`) --------------------------------
327
+ /**
328
+ * Repos whose GitHub Releases are a tool's release-note source. Only tools with
329
+ * a source this repo has actually verified appear here — inventing a plausible
330
+ * repo for the rest would be worse than the refusal `toolNotesRefusal` prints.
331
+ * Same two sources the GUI uses (agent-config's release check, rtk's update
332
+ * check), so the CLI and the GUI can never describe a release differently.
333
+ */
334
+ export const TOOL_RELEASE_REPOS = {
335
+ "agent-config": "event4u-app/agent-config",
336
+ rtk: "rtk-ai/rtk",
337
+ };
338
+ /** The release-notes source for a tool, or null when it has none. */
339
+ export function toolReleaseRepo(id) {
340
+ return TOOL_RELEASE_REPOS[id] ?? null;
341
+ }
342
+ /** Why a tool has no readable release notes here — named, never guessed. */
343
+ export function toolNotesRefusal(id) {
344
+ if (id === "claude" || id === "codex") {
345
+ return (`\`${id}\` is distributed on npm and this repo has no verified GitHub release source for it, ` +
346
+ `so there are no notes to show rather than a guessed one.\n` +
347
+ ` Its installed version is in \`agent-switch tooling\`; upgrade with \`agent-switch tooling upgrade ${id}\`.`);
348
+ }
349
+ return (`\`${id}\` has no known release source — the Antigravity CLI ships with the Antigravity app and publishes no releases here.\n` +
350
+ ` Its installed version is in \`agent-switch tooling\`.`);
351
+ }
352
+ /** The header line: what is installed, what is published, and whether anything
353
+ * is waiting. Pure — every branch is unit-tested. */
354
+ export function toolNotesHeader(view) {
355
+ const { id, installed, latest } = view;
356
+ if (!installed) {
357
+ return `${id} — not installed · ${latest ? `latest ${latest}` : "latest unknown"}`;
358
+ }
359
+ if (!latest) {
360
+ return `${id} — installed ${installed} · latest unknown (offline or rate-limited)`;
361
+ }
362
+ const pending = view.releases.filter((r) => r.tag !== installed).length;
363
+ if (installed === latest || pending === 0)
364
+ return `${id} — installed ${installed} · up to date`;
365
+ return `${id} — installed ${installed} · latest ${latest} · ${pending} update${pending === 1 ? "" : "s"} to read`;
366
+ }
367
+ /**
368
+ * The full `tooling notes` rendering: header, then each release as a dated
369
+ * block of normalised notes plus its URL. A release whose body normalises to
370
+ * nothing is listed with its URL and an explicit "no notes in this release" —
371
+ * unlike the GUI panel, a terminal command the user typed must answer rather
372
+ * than print nothing. `maxLinesPerRelease` caps a long body and says how much
373
+ * was left out. Pure.
374
+ */
375
+ export function formatToolNotes(view, maxLinesPerRelease = 40) {
376
+ const out = [toolNotesHeader(view)];
377
+ if (view.releases.length === 0) {
378
+ out.push("", "No release notes available.");
379
+ return out;
380
+ }
381
+ for (const r of view.releases) {
382
+ const date = /^\d{4}-\d{2}-\d{2}/.test(r.publishedAt) ? r.publishedAt.slice(0, 10) : null;
383
+ out.push("", `${r.name || r.tag}${date ? ` — ${date}` : ""}`);
384
+ const { lines, omitted } = releaseNotesText(releaseNoteBlocks(r.notes), {
385
+ maxLines: maxLinesPerRelease,
386
+ indent: " ",
387
+ });
388
+ if (lines.length === 0)
389
+ out.push(" (no notes in this release)");
390
+ else
391
+ out.push(...lines);
392
+ if (omitted > 0)
393
+ out.push(` … ${omitted} more line${omitted === 1 ? "" : "s"}`);
394
+ out.push(` ${r.url}`);
395
+ }
396
+ return out;
397
+ }