@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 CHANGED
@@ -313,7 +313,9 @@ The CLI is fully usable on its own. Two optional layers add convenience:
313
313
  - **Tray/menubar GUI** (`gui/`, Tauri) — a small panel to see per-profile usage
314
314
  and switch/open sessions. It is a **client of the CLI** (`agent-switch <cmd>
315
315
  --json`) and never re-implements profile logic; the CLI core stays
316
- dependency-free. See [`gui/README.md`](gui/README.md).
316
+ dependency-free. See [`gui/README.md`](gui/README.md). Running `agent-switch`
317
+ **with no command** launches it (same as `agent-switch gui`); if the GUI is
318
+ already running, no second copy starts.
317
319
 
318
320
  Usage readout is per-provider: Claude exposes an OAuth `/usage` endpoint;
319
321
  **Codex and Gemini have no usage readout**, so they show identity only (never a
package/dist/api.js CHANGED
@@ -7,10 +7,11 @@
7
7
  * both with `Authorization: Bearer <accessToken>` and the
8
8
  * `anthropic-beta: oauth-2025-04-20` header.
9
9
  *
10
- * Deliberately NOT adopted from claude-swap: the token-refresh grant. Our
11
- * profiles are live logins — Claude Code refreshes its own tokens per config
12
- * dir. Refreshing from outside would rotate the refresh token underneath a
13
- * running session for no benefit.
10
+ * This module never refreshes a token. An out-of-band refresh grant rotates the
11
+ * refresh token underneath a running session and would strand Claude Code with
12
+ * a dead one (ADR-004 § Evidence — the rotation is confirmed, not theoretical).
13
+ * A spent access token is instead handed to `token-freshen.ts`, which lets
14
+ * Claude Code refresh its own store; this stays the low-level fetch layer.
14
15
  */
15
16
  import * as fs from "node:fs";
16
17
  import * as path from "node:path";
@@ -13,8 +13,8 @@
13
13
  *
14
14
  * SAFETY: a reset credit is scarce and non-refundable, and redeeming is known to
15
15
  * be occasionally buggy upstream. This module only redeems when the caller asks;
16
- * the daemon's reset-first path guards it to at most once per reset cycle so a
17
- * loop can never burn a user's whole balance. The redeem call itself cannot be
16
+ * the daemon's reset-redemption path guards it to at most once per reset cycle so
17
+ * a loop can never burn a user's whole balance. The redeem call itself cannot be
18
18
  * exercised in CI (it would consume a real credit) — the request construction is
19
19
  * unit-tested and it is validated live the first time a limit is hit.
20
20
  */
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Config-only cross-machine export/import (SAFE subset).
3
+ *
4
+ * Moves a profile's SHARED config — settings.json, keybindings.json, CLAUDE.md,
5
+ * and the skills/, commands/, agents/ trees — between machines as a single
6
+ * self-describing JSON bundle (base64-encoded file bodies). It handles NO
7
+ * credentials: the allowlist is the exact `SHARED_ITEMS` set from `share.ts`,
8
+ * and the account-scoped deny-set (`.claude.json`, `.credentials.json`,
9
+ * `plugins/`, `sessions/`, `ide/`, `statsig/`) is excluded by construction —
10
+ * we only ever walk allowlisted top-level names, and both export and import
11
+ * additionally assert the top-level segment against the allowlist/deny-set.
12
+ *
13
+ * A `--full` variant that would bundle live OAuth tokens is deliberately NOT
14
+ * implemented (see {@link assertNotFull}) — it is an account-takeover vector.
15
+ *
16
+ * Threat-model controls implemented here (untrusted bundle on import):
17
+ * - path shape guard: no absolute paths, no `..` traversal, no empty/`.`
18
+ * segments, top-level segment must be an allowlisted `SHARED_ITEMS` name.
19
+ * - path-confinement (zip-slip): the resolved real path must stay inside the
20
+ * target config dir.
21
+ * - bounded input: at most {@link MAX_ENTRIES} entries and {@link MAX_FILE_BYTES}
22
+ * per decoded file.
23
+ * - deny-set is never written even if present in the bundle (rejected).
24
+ */
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { SHARED_ITEMS } from "./share.js";
28
+ /** The single source of the allowlist — the same names `share.ts` links. */
29
+ const ALLOW = new Set(SHARED_ITEMS);
30
+ /** Account-/instance-scoped top-level names that must never be in a bundle.
31
+ * Kept in sync with the deny-set documented in `share.ts`'s header. Disjoint
32
+ * from ALLOW, so `!ALLOW.has(top)` already excludes these — asserted anyway
33
+ * for a clearer error and defense-in-depth. */
34
+ const DENY_SET = new Set([".claude.json", ".credentials.json", "plugins", "sessions", "ide", "statsig"]);
35
+ /** Bound untrusted input: refuse a bundle with more entries than this. */
36
+ export const MAX_ENTRIES = 5000;
37
+ /** Bound untrusted input: refuse a single decoded file larger than this. */
38
+ export const MAX_FILE_BYTES = 25 * 1024 * 1024;
39
+ export const FULL_REFUSAL = "--full (bundling live OAuth tokens) is intentionally not implemented — it is an account-takeover vector; export is config-only by design.";
40
+ /** Refuse the `--full` (credential-bundling) variant. Throws so callers can
41
+ * surface it via `die`; kept here because it is a config-transfer policy. */
42
+ export function assertNotFull(full) {
43
+ if (full)
44
+ throw new Error(FULL_REFUSAL);
45
+ }
46
+ function toPosix(p) {
47
+ return p.split(path.sep).join("/");
48
+ }
49
+ function topSegment(relPosix) {
50
+ return relPosix.split("/")[0];
51
+ }
52
+ /** Add one file's body to the bundle, keyed by its POSIX-relative path. The
53
+ * top-level allowlist/deny-set assertion is redundant (we only walk allowlisted
54
+ * trees) but enforced regardless. */
55
+ function addFile(root, rel, out) {
56
+ const posix = toPosix(rel);
57
+ const top = topSegment(posix);
58
+ if (DENY_SET.has(top) || !ALLOW.has(top))
59
+ return; // excluded by construction — assert anyway
60
+ out[posix] = fs.readFileSync(path.join(root, rel)).toString("base64");
61
+ }
62
+ /** Recursively collect files under an allowlisted directory tree. Follows
63
+ * symlinks to their real target (a `share on` link) so the bundle carries
64
+ * content, not a link we cannot reproduce on another machine; skips dangling
65
+ * links. */
66
+ function walkDir(root, relDir, out) {
67
+ const absDir = path.join(root, relDir);
68
+ let entries;
69
+ try {
70
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
71
+ }
72
+ catch {
73
+ return;
74
+ }
75
+ for (const entry of entries) {
76
+ const rel = `${relDir}/${entry.name}`;
77
+ const abs = path.join(root, rel);
78
+ let st;
79
+ try {
80
+ st = fs.statSync(abs); // statSync (not lstat) follows a shared-mode link to its target
81
+ }
82
+ catch {
83
+ continue; // dangling / unreadable
84
+ }
85
+ if (st.isDirectory())
86
+ walkDir(root, rel, out);
87
+ else if (st.isFile())
88
+ addFile(root, rel, out);
89
+ }
90
+ }
91
+ /**
92
+ * Walk ONLY the SHARED_ITEMS allowlist (files + directory trees, recursively)
93
+ * under `configDir`, base64-encode each file, and return a JSON bundle string.
94
+ * The deny-set is never read (we only walk allowlisted names). Absent items are
95
+ * skipped.
96
+ */
97
+ export function exportConfig(configDir) {
98
+ const files = {};
99
+ for (const name of SHARED_ITEMS) {
100
+ const abs = path.join(configDir, name);
101
+ let st;
102
+ try {
103
+ st = fs.statSync(abs); // follow a top-level shared-mode link to its target
104
+ }
105
+ catch {
106
+ continue; // absent / dangling
107
+ }
108
+ if (st.isFile())
109
+ addFile(configDir, name, files);
110
+ else if (st.isDirectory())
111
+ walkDir(configDir, name, files);
112
+ }
113
+ const bundle = { version: 1, files };
114
+ return JSON.stringify(bundle);
115
+ }
116
+ /**
117
+ * Parse a config bundle and write its config-only files under `configDir`.
118
+ * Returns the list of written relative paths. Rejects (throws) on any control
119
+ * violation — an absolute path, `..` traversal, a non-allowlisted or deny-set
120
+ * top-level segment, a path escaping the config dir, or an over-limit bundle.
121
+ * Never writes a credential/deny-set path even if present.
122
+ */
123
+ export function importConfig(configDir, bundleJson) {
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(bundleJson);
127
+ }
128
+ catch {
129
+ throw new Error("invalid config bundle: not valid JSON");
130
+ }
131
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
132
+ throw new Error("invalid config bundle: expected an object");
133
+ }
134
+ const b = parsed;
135
+ if (b.version !== 1)
136
+ throw new Error(`unsupported config bundle version ${String(b.version)} (expected 1)`);
137
+ if (!b.files || typeof b.files !== "object" || Array.isArray(b.files)) {
138
+ throw new Error("invalid config bundle: missing 'files' map");
139
+ }
140
+ const entries = Object.entries(b.files);
141
+ if (entries.length > MAX_ENTRIES) {
142
+ throw new Error(`config bundle has ${entries.length} entries (> ${MAX_ENTRIES}) — refusing`);
143
+ }
144
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
145
+ const root = fs.realpathSync(configDir); // resolve symlinks once for the confinement check
146
+ const coarseB64Limit = 4 * Math.ceil(MAX_FILE_BYTES / 3) + 4;
147
+ const written = [];
148
+ for (const [rawPath, value] of entries) {
149
+ if (typeof value !== "string")
150
+ throw new Error(`invalid bundle entry "${rawPath}": value is not a base64 string`);
151
+ // (a) path shape guards — normalize any backslashes to POSIX for the checks.
152
+ const rel = rawPath.replace(/\\/g, "/");
153
+ if (path.isAbsolute(rawPath) || rel.startsWith("/")) {
154
+ throw new Error(`refusing absolute path in bundle: "${rawPath}"`);
155
+ }
156
+ const segments = rel.split("/");
157
+ if (segments.some((s) => s === ".."))
158
+ throw new Error(`refusing path traversal ('..') in bundle: "${rawPath}"`);
159
+ if (segments.some((s) => s === "" || s === "."))
160
+ throw new Error(`refusing malformed path in bundle: "${rawPath}"`);
161
+ const top = segments[0];
162
+ if (DENY_SET.has(top))
163
+ throw new Error(`refusing deny-set path in bundle: "${rawPath}" (account-scoped, never imported)`);
164
+ if (!ALLOW.has(top))
165
+ throw new Error(`refusing non-allowlisted path in bundle: "${rawPath}"`);
166
+ // bounded input: coarse check on the base64 length before allocating, then
167
+ // a precise check on the decoded size.
168
+ if (value.length > coarseB64Limit)
169
+ throw new Error(`refusing oversized file "${rawPath}" (> ${MAX_FILE_BYTES} bytes)`);
170
+ const buf = Buffer.from(value, "base64");
171
+ if (buf.length > MAX_FILE_BYTES)
172
+ throw new Error(`refusing oversized file "${rawPath}" (> ${MAX_FILE_BYTES} bytes)`);
173
+ // (b) path-confinement / zip-slip: the resolved real path must stay inside root.
174
+ const dest = path.resolve(root, rel);
175
+ if (dest !== root && !dest.startsWith(root + path.sep)) {
176
+ throw new Error(`refusing path escaping the config dir: "${rawPath}"`);
177
+ }
178
+ // (c) write the decoded body (mkdir -p parent).
179
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
180
+ fs.writeFileSync(dest, buf, { mode: 0o600 });
181
+ written.push(rel);
182
+ }
183
+ return written;
184
+ }
@@ -2,10 +2,12 @@
2
2
  * Per-OS credential access — a read-only abstraction over where Claude Code
3
3
  * keeps a profile's OAuth credential.
4
4
  *
5
- * By design there is NO `write()`: agent-switch never writes Claude Code's
6
- * credential storage. The only supported way to place a credential is seeding
7
- * a plaintext `.credentials.json` file inside the config dir (see `import`),
8
- * which Claude Code then migrates into its own store on first use.
5
+ * By design this store has NO `write()`: agent-switch never writes Claude Code's
6
+ * credential storage **outside the `rebind` path** (ADR-003 — the single
7
+ * sanctioned exception, which writes the macOS Keychain entry directly under
8
+ * Claude Code's lock via `keychain.addPassword`). The only other way to place a
9
+ * credential is seeding a plaintext `.credentials.json` file inside the config
10
+ * dir (see `import`), which Claude Code migrates into its own store on first use.
9
11
  *
10
12
  * Backends:
11
13
  * - darwin → Keychain (hashed service per config dir), then the plaintext
package/dist/daemon.js CHANGED
@@ -1,28 +1,29 @@
1
1
  /**
2
- * Background usage daemon — poller, cache, and (opt-in) auto-switcher.
2
+ * Background usage daemon — poller, cache, and (opt-in) threshold notifier.
3
3
  *
4
4
  * It polls Claude profiles for their OWN usage, appends history, detects
5
5
  * active-profile threshold crossings, and writes `daemon-state.json` so
6
6
  * `status`/the GUI can read a fresh snapshot without hitting the API. When
7
- * `autoSwitch.enabled` (default OFF) it also watches every profile and moves the
8
- * active pointer to the account with the most headroom once the active one hits
9
- * the configured threshold — pooling accounts to route around limits, which the
10
- * operator opts into deliberately (see `AutoSwitchConfig`). With auto-switch off
11
- * it stays a pure poller/cache that never mutates the active profile.
7
+ * `autoSwitch.enabled` (default OFF) it also watches every profile and, once the
8
+ * active one hits the configured threshold, NOTIFIES the operator and SUGGESTS
9
+ * the account with the most headroom — it never switches automatically. The
10
+ * suggestion is a convenience; the actual switch is always a user interaction
11
+ * (see `AutoSwitchConfig`). The daemon never mutates the active profile.
12
12
  *
13
13
  * The pure helpers (single-instance, poll-target selection, backoff, state
14
- * freshness, switch decision in `usage.ts`) are exported for unit testing;
14
+ * freshness, suggestion decision in `usage.ts`) are exported for unit testing;
15
15
  * `runDaemon` is the loop.
16
16
  */
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
- import { ROOT, activeFor, configDir, labelFor, listProfiles, readAutoSwitch, setActive, readSwitchStrategy } from "./profiles.js";
19
+ import { ROOT, activeFor, configDir, labelFor, listProfiles, readAutoSwitch, readBinaryPath } from "./profiles.js";
20
20
  import { profileDir } from "./profiles.js";
21
- import { accessTokenOf, fetchUsage, liveSessionPids, readProfileCredential } from "./api.js";
21
+ import { fetchUsage, liveSessionPids } from "./api.js";
22
+ import { defaultFreshenDeps, freshAccessToken } from "./token-freshen.js";
22
23
  import { parseUsage, detectCrossings, pickSwitchTarget, maxUtilization } from "./usage.js";
23
24
  import { readCodexUsage } from "./codex-usage.js";
24
25
  import { redeemResetCredit } from "./codex-reset.js";
25
- import { appendSample } from "./history.js";
26
+ import { recordHistorySample } from "./history.js";
26
27
  import { appendNotification, markOsNotified } from "./notifications.js";
27
28
  import { readOsNotifications } from "./profiles.js";
28
29
  import { osNotify } from "./os-notify.js";
@@ -41,8 +42,8 @@ function notify(event) {
41
42
  markOsNotified(created.id);
42
43
  }
43
44
  }
44
- // Providers whose active profile the daemon may auto-switch (they expose a
45
- // usage readout to base a headroom decision on). Antigravity has none.
45
+ // Providers the daemon may suggest a switch for on threshold (they expose a
46
+ // usage readout to base a headroom suggestion on). Antigravity has none.
46
47
  const AUTO_PROVIDERS = ["claude", "codex"];
47
48
  export const PIDFILE = path.join(ROOT, "daemon.pid");
48
49
  export const STATE_FILE = path.join(ROOT, "daemon-state.json");
@@ -137,6 +138,26 @@ export function nextIntervalMs(baseMs, consecutiveFailures, capMs = MAX_BACKOFF_
137
138
  return base;
138
139
  return Math.min(base * 2 ** consecutiveFailures, capMs);
139
140
  }
141
+ /**
142
+ * Compliance-locked threshold decision (Council follow-up). Given the active
143
+ * profile and the same-provider candidates polled this cycle, decide whether to
144
+ * NOTIFY and which account to SUGGEST. Pure — no I/O, and crucially NO `setActive`:
145
+ * the daemon never auto-switches. Returns `{ notify: false }` while the active
146
+ * profile still has headroom (or auto-switch is off / there is no active profile).
147
+ * `notify` fires only alongside a concrete suggestion, mirroring the behavior
148
+ * before this decision was extracted from `pollProvider`.
149
+ */
150
+ export function decideThresholdAction(active, polled, autoSwitch, isEligible = () => true) {
151
+ if (!autoSwitch.enabled || !active)
152
+ return { notify: false, suggestedTarget: null };
153
+ const activeSnap = polled.find((p) => p.name === active)?.snapshot;
154
+ const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
155
+ if (activeMax === null || activeMax < autoSwitch.threshold)
156
+ return { notify: false, suggestedTarget: null };
157
+ const target = pickSwitchTarget(active, polled, autoSwitch.threshold, isEligible);
158
+ const suggestedTarget = target && target !== active ? target : null;
159
+ return { notify: suggestedTarget !== null, suggestedTarget };
160
+ }
140
161
  // ---------- context monitoring (own-session; Phase 3) ----------
141
162
  let _claudeVer;
142
163
  function claudeVer() {
@@ -223,14 +244,16 @@ export function monitorContext(provider, name, state, log) {
223
244
  async function snapshotFor(provider, name) {
224
245
  if (provider === "codex")
225
246
  return readCodexUsage(configDir("codex", name));
226
- const creds = readProfileCredential(configDir("claude", name));
227
- const token = creds ? accessTokenOf(creds) : null;
247
+ // An expired access token is the daemon's most common "usage stopped moving"
248
+ // cause — a profile nobody is running never refreshes itself. Let Claude Code
249
+ // refresh it first (cooldown-guarded, no-op while the token is still good).
250
+ const token = freshAccessToken(configDir("claude", name), defaultFreshenDeps(readBinaryPath("claude")));
228
251
  if (!token)
229
252
  return null;
230
253
  const raw = await fetchUsage(token);
231
254
  return raw ? parseUsage(raw) : null;
232
255
  }
233
- async function pollProvider(provider, strategy, state, thresholds, redeemed, log) {
256
+ async function pollProvider(provider, state, thresholds, redeemed, log) {
234
257
  const names = listProfiles(provider);
235
258
  const active = activeFor(provider);
236
259
  const autoSwitch = readAutoSwitch(provider);
@@ -263,7 +286,7 @@ async function pollProvider(provider, strategy, state, thresholds, redeemed, log
263
286
  }
264
287
  state.profiles[key] = snapshot;
265
288
  polled.push({ name, snapshot });
266
- appendSample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
289
+ recordHistorySample(path.join(profileDir(provider, name), "usage-history.json"), snapshot);
267
290
  if (name === active) {
268
291
  const prev = thresholds.get(`${provider}/${name}`) ?? {};
269
292
  const { crossings, state: next } = detectCrossings(snapshot, prev);
@@ -288,12 +311,12 @@ async function pollProvider(provider, strategy, state, thresholds, redeemed, log
288
311
  const activeMax = activeSnap ? maxUtilization(activeSnap) : null;
289
312
  if (activeMax === null || activeMax < autoSwitch.threshold)
290
313
  return failures; // still has headroom
291
- // reset-first (Codex only — banked resets are a Codex feature): redeem a reset
292
- // instead of switching, but AT MOST ONCE per reset cycle. We record the
293
- // window's resets_at BEFORE calling, so a buggy reset that doesn't actually
294
- // clear usage can never loop and burn the whole balance — it falls through to
295
- // a profile switch on the same poll's next-cycle.
296
- if (strategy === "reset-first" && provider === "codex" && activeSnap) {
314
+ // Codex only (banked resets are a Codex feature): redeem a reset and STAY, but
315
+ // AT MOST ONCE per reset cycle. We record the window's resets_at BEFORE calling,
316
+ // so a buggy reset that doesn't actually clear usage can never loop and burn the
317
+ // whole balance — it falls through to a switch SUGGESTION on the same poll's
318
+ // next-cycle. Redeeming still stays on the active profile.
319
+ if (provider === "codex" && activeSnap) {
297
320
  const stuck = activeSnap.windows.find((w) => typeof w.utilization === "number" && w.utilization >= autoSwitch.threshold);
298
321
  const cycle = stuck?.resetsAt ?? "";
299
322
  const guardKey = `codex/${active}`;
@@ -309,29 +332,39 @@ async function pollProvider(provider, strategy, state, thresholds, redeemed, log
309
332
  });
310
333
  return failures;
311
334
  }
312
- log(`auto-switch: codex/${active} reset redeem failed (${r.reason ?? "?"}) → falling back to a profile switch`);
335
+ log(`auto-switch: codex/${active} reset redeem failed (${r.reason ?? "?"}) → falling back to a switch suggestion`);
313
336
  }
314
337
  }
315
338
  // Tag filter: only accounts carrying the configured label are eligible switch
316
339
  // targets ("all" = no filter). Lets the operator pool e.g. only Work accounts.
317
340
  const eligible = (name) => autoSwitch.tag === "all" || labelFor(provider, name) === autoSwitch.tag;
318
- const target = pickSwitchTarget(active, polled, autoSwitch.threshold, eligible);
319
- if (target && target !== active) {
320
- setActive(provider, target);
321
- log(`auto-switch: ${provider}/${active} out of headroom (≥${autoSwitch.threshold}%) → switched active to ${provider}/${target}`);
341
+ // Compliance decision is a pure function (decideThresholdAction): it returns
342
+ // notify + a suggested target and NEVER a switch — see the daemon no-auto-switch
343
+ // regression test. pollProvider only turns that decision into log + notify I/O.
344
+ const action = decideThresholdAction(active, polled, autoSwitch, eligible);
345
+ const target = action.suggestedTarget;
346
+ if (action.notify && target) {
347
+ // Compliance: the daemon NEVER switches automatically. It only names the
348
+ // suggested account with the most headroom; switching stays a user
349
+ // interaction (CLI/GUI). No `setActive` call here.
350
+ log(`threshold: ${provider}/${active} out of headroom (≥${autoSwitch.threshold}%) → suggesting ${provider}/${target} (no automatic switch)`);
351
+ // Name the command that opens the limit dialog (claude only — rebind is
352
+ // claude-scoped). Wording only; the notify/suggest DECISION above is unchanged.
353
+ const how = provider === "claude"
354
+ ? "Run `agent-switch rebind` to switch (no automatic switch)."
355
+ : "Switch manually (no automatic switch).";
322
356
  notify({
323
- kind: "success",
324
- title: "Auto-switched account",
325
- message: `${provider}/${active} hit ≥${autoSwitch.threshold}% — switched active to ${provider}/${target}.`,
357
+ kind: "warning",
358
+ title: "Usage limit near",
359
+ message: `${provider}/${active} hit ≥${autoSwitch.threshold}% — suggested profile: ${provider}/${target}. ${how}`,
326
360
  });
327
361
  }
328
362
  return failures;
329
363
  }
330
364
  async function pollOnce(state, thresholds, redeemed, log) {
331
- const strategy = readSwitchStrategy();
332
365
  let failures = 0;
333
366
  for (const provider of AUTO_PROVIDERS) {
334
- failures += await pollProvider(provider, strategy, state, thresholds, redeemed, log);
367
+ failures += await pollProvider(provider, state, thresholds, redeemed, log);
335
368
  }
336
369
  state.lastPoll = new Date().toISOString();
337
370
  state.lastError = failures > 0 ? `${failures} profile(s) failed to poll` : null;
@@ -363,8 +396,9 @@ export async function runDaemon(opts = {}) {
363
396
  // Load persisted usage-window fired state so crossings do NOT re-fire after a
364
397
  // daemon restart within the same window cycle (the pre-existing in-memory gap).
365
398
  const thresholds = new Map(Object.entries(state.usageThresholds ?? {}));
366
- // Anti-loop guard for reset-first: `<provider>/<name>` → the window resets_at
367
- // we last redeemed for, so we never redeem the same stuck cycle twice.
399
+ // Anti-loop guard for the Codex reset redemption: `<provider>/<name>` → the
400
+ // window resets_at we last redeemed for, so we never redeem the same stuck
401
+ // cycle twice.
368
402
  const redeemed = new Map();
369
403
  let consecutiveFailures = 0;
370
404
  let cycles = 0;
@@ -44,12 +44,49 @@ export function guiAssetSpec(platform = process.platform, arch = process.arch) {
44
44
  return { match: /-setup\.exe$/i, kind: "win-setup" };
45
45
  return null;
46
46
  }
47
- /** Program + args to launch a prepared artifact. Pure. */
47
+ /** Program + args to launch a prepared artifact. Pure.
48
+ * macOS uses `open` WITHOUT `-n`: a bare `open` activates the already-running
49
+ * app instead of forcing a second copy, so it is the OS-level half of the
50
+ * single-instance guarantee (`isGuiRunning` is the other half). */
48
51
  export function guiLaunchArgv(kind, target) {
49
52
  if (kind === "app")
50
- return { program: "open", args: ["-n", target] };
53
+ return { program: "open", args: [target] };
51
54
  return { program: target, args: [] }; // AppImage runs directly; win-setup runs the installer
52
55
  }
56
+ /** Substring that identifies a RUNNING GUI process for this kind inside a
57
+ * process-list snapshot. Specific enough not to match the Node CLI itself
58
+ * (whose command line is `node …/dist/index.js`). Pure. */
59
+ export function guiProcessSignature(kind) {
60
+ if (kind === "app")
61
+ return "agent-switch.app/Contents/MacOS/agent-switch"; // the running .app binary
62
+ if (kind === "appimage")
63
+ return "agent-switch.AppImage";
64
+ return "agent-switch.exe"; // win: the installed app process (not the -setup installer)
65
+ }
66
+ /** Whether `signature` appears in a process-list snapshot. Pure. */
67
+ export function guiRunningIn(signature, processList) {
68
+ return processList.includes(signature);
69
+ }
70
+ /** Live process-list snapshot for the host (full command line per process). */
71
+ function processListSnapshot(platform = process.platform) {
72
+ try {
73
+ if (platform === "win32") {
74
+ return spawnSync("tasklist", [], { encoding: "utf8" }).stdout ?? "";
75
+ }
76
+ return spawnSync("ps", ["ax", "-o", "command="], { encoding: "utf8" }).stdout ?? "";
77
+ }
78
+ catch {
79
+ return ""; // ps/tasklist unavailable → treat as "not detectably running"
80
+ }
81
+ }
82
+ /** Whether the desktop GUI is already running on this host. Used to enforce
83
+ * the single-instance rule before spawning a second copy. */
84
+ export function isGuiRunning() {
85
+ const spec = guiAssetSpec();
86
+ if (!spec)
87
+ return false;
88
+ return guiRunningIn(guiProcessSignature(spec.kind), processListSnapshot());
89
+ }
53
90
  /** Find a matching GUI asset on the release for `version`, falling back to the
54
91
  * latest release. Returns null when neither has one. */
55
92
  async function findAsset(version, match) {
@@ -115,14 +152,19 @@ async function ensureGuiArtifact(spec) {
115
152
  fs.chmodSync(launchPath, 0o755);
116
153
  return launchPath;
117
154
  }
118
- /** Launch the desktop GUI (downloading + caching it on first use). */
155
+ /** Launch the desktop GUI (downloading + caching it on first use).
156
+ * Single-instance: if a GUI is already running, no second copy is started —
157
+ * returns "already-running" so the caller can say so. */
119
158
  export async function launchGui() {
120
159
  const spec = guiAssetSpec();
121
160
  if (!spec) {
122
161
  throw new Error(`the desktop GUI has no prebuilt artifact for ${process.platform}/${process.arch} — build it from source with \`task gui:build\`.`);
123
162
  }
163
+ if (isGuiRunning())
164
+ return "already-running";
124
165
  const target = await ensureGuiArtifact(spec);
125
166
  const { program, args } = guiLaunchArgv(spec.kind, target);
126
167
  const child = spawn(program, args, { detached: true, stdio: "ignore" });
127
168
  child.unref();
169
+ return "launched";
128
170
  }
package/dist/history.js CHANGED
@@ -15,6 +15,29 @@ export function readHistory(file) {
15
15
  return [];
16
16
  }
17
17
  }
18
+ /** Minimum spacing between two recorded samples. The ring holds MAX_SAMPLES
19
+ * (720) and the chart spans ~30 days, so ~one sample/hour fills it. Frequent
20
+ * callers — a GUI refresh every few minutes, the 60s daemon poll — would
21
+ * otherwise pack 720 samples into a few hours and shrink the window to that.
22
+ * 55 min (not a full 60) so a refresh landing slightly early still counts. */
23
+ export const HISTORY_MIN_GAP_MS = 55 * 60 * 1000;
24
+ /**
25
+ * Throttled append: record `snapshot` only when the newest existing sample is
26
+ * at least {@link HISTORY_MIN_GAP_MS} older (or there is none). This is the entry
27
+ * point every recorder uses so no caller's cadence can bloat the ring. Time is
28
+ * the snapshot's own `capturedAt`; an unparseable one always records.
29
+ */
30
+ export function recordHistorySample(file, snapshot) {
31
+ const samples = readHistory(file);
32
+ const capturedMs = Date.parse(snapshot.capturedAt);
33
+ const last = samples[samples.length - 1];
34
+ if (last && Number.isFinite(capturedMs)) {
35
+ const lastMs = Date.parse(last.at);
36
+ if (Number.isFinite(lastMs) && capturedMs - lastMs < HISTORY_MIN_GAP_MS)
37
+ return samples;
38
+ }
39
+ return appendSample(file, snapshot);
40
+ }
18
41
  /** Append a snapshot as one sample, trimming to the newest MAX_SAMPLES. */
19
42
  export function appendSample(file, snapshot) {
20
43
  const samples = readHistory(file);
package/dist/hooks.js CHANGED
@@ -20,8 +20,11 @@
20
20
  import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  /** The hook events we install. `source`/matcher values confirmed in spikes/t6
23
- * (the stdin field is `source`, not `matcher`). */
24
- export const HOOK_EVENTS = ["SessionStart", "SessionEnd", "PreCompact", "PostCompact"];
23
+ * (the stdin field is `source`, not `matcher`). `Stop` fires when Claude ends a
24
+ * turn (finished / now waiting on you); `Notification` fires when it needs your
25
+ * attention (a permission prompt, or a long idle) — the two session-awareness
26
+ * signals the pet surfaces. */
27
+ export const HOOK_EVENTS = ["SessionStart", "SessionEnd", "PreCompact", "PostCompact", "Stop", "Notification"];
25
28
  /** Marker stamped on every entry we add, so uninstall touches only our own. */
26
29
  export const HOOK_MARKER = "asw_managed";
27
30
  export const EVENT_RING_CAP = 500;