@event4u/agent-switch 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -70
- package/dist/activity.js +97 -0
- package/dist/args.js +23 -3
- package/dist/auth-status.js +149 -0
- package/dist/balance.js +52 -0
- package/dist/browsers.js +90 -0
- package/dist/daemon.js +89 -38
- package/dist/desktop-sessions.js +115 -0
- package/dist/dir-resolve.js +151 -0
- package/dist/doctor.js +205 -69
- package/dist/gui-settings.js +67 -0
- package/dist/hooks.js +21 -0
- package/dist/index.js +690 -61
- package/dist/mappings.js +48 -9
- package/dist/pools.js +144 -0
- package/dist/profiles.js +69 -6
- package/dist/protection.js +93 -0
- package/dist/rebind.js +108 -11
- package/dist/service.js +24 -9
- package/dist/sessions.js +82 -13
- package/dist/share.js +92 -13
- package/dist/shellenv.js +100 -21
- package/dist/single-load.js +5 -0
- package/package.json +1 -1
package/dist/doctor.js
CHANGED
|
@@ -1,21 +1,146 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `agent-switch doctor` — a per-OS, per-provider self-check.
|
|
2
|
+
* `agent-switch doctor [--json]` — a per-OS, per-provider self-check.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Read-only — it never
|
|
4
|
+
* Every finding is a {@link DoctorCheck} (`id, status, title, detail, fix?`);
|
|
5
|
+
* the text output and `--json` render the same list. The exit code is 1 only
|
|
6
|
+
* on a hard error (a provider with profiles but no binary). Read-only — it never
|
|
7
|
+
* mutates a profile, and it never prints a credential or an env var's value.
|
|
7
8
|
*/
|
|
8
9
|
import { spawnSync } from "node:child_process";
|
|
9
10
|
import * as fs from "node:fs";
|
|
10
|
-
import
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { ROOT, activeFor, configDir, identity, listProfiles, profileExists, readGuard } from "./profiles.js";
|
|
11
14
|
import { allProviders } from "./providers.js";
|
|
12
15
|
import { credentialStore } from "./credentials.js";
|
|
13
16
|
import { sharedLinkHealth } from "./share.js";
|
|
14
17
|
import { checkAuth, liveSessionPids } from "./api.js";
|
|
15
18
|
import { checkTooling, doctorToolingLine } from "./tooling.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
import { readGuiSettings } from "./gui-settings.js";
|
|
20
|
+
/**
|
|
21
|
+
* Env vars that make a provider CLI ignore the profile's login and use a key
|
|
22
|
+
* or token from the environment instead (D5) — the same credential in every
|
|
23
|
+
* profile, often billed per use. Only the NAMES are ever reported.
|
|
24
|
+
*/
|
|
25
|
+
export const AUTH_OVERRIDE_ENV = {
|
|
26
|
+
ANTHROPIC_API_KEY: "claude",
|
|
27
|
+
ANTHROPIC_AUTH_TOKEN: "claude",
|
|
28
|
+
CLAUDE_CODE_OAUTH_TOKEN: "claude",
|
|
29
|
+
OPENAI_API_KEY: "codex",
|
|
30
|
+
CODEX_API_KEY: "codex",
|
|
31
|
+
GEMINI_API_KEY: "antigravity",
|
|
32
|
+
GOOGLE_API_KEY: "antigravity",
|
|
33
|
+
};
|
|
34
|
+
export function apiKeyOverrideCheck(env = process.env) {
|
|
35
|
+
const set = Object.keys(AUTH_OVERRIDE_ENV).filter((k) => typeof env[k] === "string" && env[k] !== "");
|
|
36
|
+
if (set.length === 0) {
|
|
37
|
+
return { id: "api-key-overrides", status: "ok", title: "No API-key overrides", detail: "No provider key or token is set in the environment, so each profile uses its own login." };
|
|
38
|
+
}
|
|
39
|
+
const which = set.map((k) => `${k} (${AUTH_OVERRIDE_ENV[k]})`).join(", ");
|
|
40
|
+
return {
|
|
41
|
+
id: "api-key-overrides",
|
|
42
|
+
status: "warn",
|
|
43
|
+
title: "API key overrides profile logins",
|
|
44
|
+
detail: `${which} is set in the environment agent-switch runs in. The provider uses it instead of the profile's login — the same credential in every profile, usually billed per use.`,
|
|
45
|
+
fix: `Unset it in your shell profile (e.g. \`unset ${set[0]}\`) unless you mean to use a key.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Is `agent-switch` reachable on PATH (the shell wrapper and the GUI call it by name)? */
|
|
49
|
+
export function cliOnPathCheck(env = process.env, platform = process.platform, exists = fs.existsSync) {
|
|
50
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(platform === "win32" ? ";" : ":").filter(Boolean);
|
|
51
|
+
const names = platform === "win32" ? ["agent-switch.cmd", "agent-switch.exe", "agent-switch"] : ["agent-switch"];
|
|
52
|
+
const hit = dirs.flatMap((d) => names.map((n) => path.join(d, n))).find(exists);
|
|
53
|
+
return hit
|
|
54
|
+
? { id: "cli-on-path", status: "ok", title: "agent-switch is on PATH", detail: hit }
|
|
55
|
+
: {
|
|
56
|
+
id: "cli-on-path",
|
|
57
|
+
status: "warn",
|
|
58
|
+
title: "agent-switch is not on PATH",
|
|
59
|
+
detail: "The shell wrapper and the desktop app call `agent-switch` by name.",
|
|
60
|
+
fix: "npm install -g @event4u/agent-switch",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function rcCandidates(home) {
|
|
64
|
+
return [
|
|
65
|
+
path.join(home, ".zshrc"),
|
|
66
|
+
path.join(home, ".bashrc"),
|
|
67
|
+
path.join(home, ".bash_profile"),
|
|
68
|
+
path.join(home, ".profile"),
|
|
69
|
+
path.join(home, ".config", "fish", "config.fish"),
|
|
70
|
+
path.join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1"),
|
|
71
|
+
path.join(home, "Documents", "WindowsPowerShell", "Microsoft.PowerShell_profile.ps1"),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
export function shellIntegrationCheck(home = os.homedir(), read = readOrNull) {
|
|
75
|
+
const found = rcCandidates(home).find((f) => /agent-switch\s+shellenv/.test(read(f) ?? ""));
|
|
76
|
+
return found
|
|
77
|
+
? { id: "shell-integration", status: "ok", title: "Shell integration installed", detail: `Loaded from ${found}.` }
|
|
78
|
+
: {
|
|
79
|
+
id: "shell-integration",
|
|
80
|
+
status: "warn",
|
|
81
|
+
title: "Shell integration not found",
|
|
82
|
+
detail: "Without it, `claude`/`codex`/`agy` in a terminal ignore the active profile and folder rules. (Checked the usual zsh, bash, fish and PowerShell profile files.)",
|
|
83
|
+
fix: 'Run `agent-switch shellenv install` — it adds `eval "$(agent-switch shellenv)"` (or your shell\'s equivalent) to your shell profile.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function autostartCheck(s) {
|
|
87
|
+
const a = s.autostart;
|
|
88
|
+
if (a.state === "enabled") {
|
|
89
|
+
return { id: "autostart", status: "ok", title: "Starts at login", detail: a.by === "user" ? "Turned on by you." : "On by default since the first launch." };
|
|
90
|
+
}
|
|
91
|
+
if (a.state === "disabled") {
|
|
92
|
+
return { id: "autostart", status: "info", title: "Does not start at login", detail: "Turned off by you; updates keep it off." };
|
|
93
|
+
}
|
|
94
|
+
if (s.autostartDegraded) {
|
|
95
|
+
return {
|
|
96
|
+
id: "autostart",
|
|
97
|
+
status: "warn",
|
|
98
|
+
title: "Start at login could not be confirmed",
|
|
99
|
+
detail: `The desktop app tried to turn it on but the system did not confirm it (${s.autostartDegraded.reason}). It retries on the next launch.`,
|
|
100
|
+
fix: "Open the desktop app → Settings → turn on start at login.",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { id: "autostart", status: "info", title: "Start at login not set up yet", detail: "The desktop app turns it on at its first launch (default on)." };
|
|
104
|
+
}
|
|
105
|
+
export function antigravityOsCheck(platform = process.platform) {
|
|
106
|
+
return platform === "darwin"
|
|
107
|
+
? { id: "antigravity-os", status: "ok", title: "Antigravity supported on this OS", detail: "Per-profile keychain and account readout work on macOS." }
|
|
108
|
+
: {
|
|
109
|
+
id: "antigravity-os",
|
|
110
|
+
status: "warn",
|
|
111
|
+
title: "Antigravity is only partly supported here",
|
|
112
|
+
detail: `On ${platform} agent-switch isolates agy's HOME per profile but cannot read which account is signed in or seed a per-profile keychain — both are macOS-only today.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Two profiles of one provider signed into the same account defeat the isolation. */
|
|
116
|
+
export function signInIsolationCheck(rows) {
|
|
117
|
+
const seen = new Map();
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
if (!r.identity)
|
|
120
|
+
continue;
|
|
121
|
+
const k = `${r.provider}\u0000${r.identity.toLowerCase()}`;
|
|
122
|
+
seen.set(k, [...(seen.get(k) ?? []), `${r.provider}/${r.name}`]);
|
|
123
|
+
}
|
|
124
|
+
const dupes = [...seen.values()].filter((v) => v.length > 1);
|
|
125
|
+
if (dupes.length === 0) {
|
|
126
|
+
return { id: "sign-in-isolation", status: "ok", title: "Each profile has its own account", detail: "No two profiles of one provider are signed into the same account (where the account is readable)." };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
id: "sign-in-isolation",
|
|
130
|
+
status: "warn",
|
|
131
|
+
title: "Profiles share an account",
|
|
132
|
+
detail: `Signed into the same account: ${dupes.map((d) => d.join(" + ")).join("; ")}. They share one set of usage limits.`,
|
|
133
|
+
fix: "Sign one of them into a different account, or remove the duplicate.",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function readOrNull(p) {
|
|
137
|
+
try {
|
|
138
|
+
return fs.readFileSync(p, "utf8");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
19
144
|
function binaryOnPath(binary) {
|
|
20
145
|
// Timeout so a hung binary can't hang the doctor.
|
|
21
146
|
const probe = spawnSync(binary, ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
@@ -37,42 +162,30 @@ function credentialReadable(p, name) {
|
|
|
37
162
|
return false;
|
|
38
163
|
}
|
|
39
164
|
}
|
|
40
|
-
|
|
41
|
-
let hardError = false;
|
|
42
|
-
const line = (mark, msg) => console.log(`${mark} ${msg}`);
|
|
43
|
-
console.log(`agent-switch doctor — platform ${process.platform}, node ${process.version}`);
|
|
44
|
-
console.log(process.platform === "darwin"
|
|
45
|
-
? " claude credential store: macOS Keychain (hashed per config dir), file fallback"
|
|
46
|
-
: " claude credential store: plaintext .credentials.json per profile");
|
|
47
|
-
console.log("");
|
|
165
|
+
async function providerChecks(checks) {
|
|
48
166
|
for (const p of allProviders()) {
|
|
49
167
|
const names = listProfiles(p.id);
|
|
50
168
|
const onPath = binaryOnPath(p.binary);
|
|
169
|
+
const addHint = `agent-switch add <name>${p.id === "claude" ? "" : ` --provider ${p.id}`}`;
|
|
51
170
|
if (onPath === "no") {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
line(WARN, `\`${p.binary}\` not on PATH (no ${p.id} profiles yet — fine unless you use ${p.id}).`);
|
|
58
|
-
}
|
|
171
|
+
checks.push(names.length > 0
|
|
172
|
+
? { id: `binary-${p.id}`, status: "error", title: `\`${p.binary}\` not on PATH`, detail: `${names.length} ${p.id} profile(s) exist but the CLI is missing.`, fix: `Install ${p.id}, or link it: agent-switch providers link --provider ${p.id} --path <path>` }
|
|
173
|
+
: { id: `binary-${p.id}`, status: "info", title: `\`${p.binary}\` not on PATH`, detail: `No ${p.id} profiles yet — fine unless you use ${p.id}.` });
|
|
59
174
|
continue;
|
|
60
175
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
line(OK, `\`${p.binary}\` is on PATH.`);
|
|
176
|
+
checks.push(onPath === "error"
|
|
177
|
+
? { id: `binary-${p.id}`, status: "warn", title: `Could not probe \`${p.binary} --version\``, detail: "The binary exists but did not answer." }
|
|
178
|
+
: { id: `binary-${p.id}`, status: "ok", title: `\`${p.binary}\` is on PATH`, detail: "" });
|
|
65
179
|
if (names.length === 0) {
|
|
66
|
-
|
|
180
|
+
checks.push({ id: `profiles-${p.id}`, status: "info", title: `No ${p.id} profiles yet`, detail: "", fix: addHint });
|
|
67
181
|
continue;
|
|
68
182
|
}
|
|
69
|
-
|
|
183
|
+
checks.push({ id: `profiles-${p.id}`, status: "ok", title: `${names.length} ${p.id} profile(s)`, detail: names.join(", ") });
|
|
70
184
|
const active = activeFor(p.id);
|
|
71
185
|
if (active && !profileExists(p.id, active)) {
|
|
72
|
-
|
|
186
|
+
checks.push({ id: `active-${p.id}`, status: "warn", title: `Active ${p.id} profile "${active}" no longer exists`, detail: "", fix: `agent-switch use <name>${p.id === "claude" ? "" : ` --provider ${p.id}`}` });
|
|
73
187
|
}
|
|
74
|
-
// Login validity (Claude only): probe readable credentials in parallel
|
|
75
|
-
// an expired login is caught here instead of failing silently at next use.
|
|
188
|
+
// Login validity (Claude only): probe readable credentials in parallel.
|
|
76
189
|
// Read-only; offline / transient degrades to a neutral note, never a WARN.
|
|
77
190
|
const authByName = new Map();
|
|
78
191
|
if (p.id === "claude") {
|
|
@@ -81,23 +194,32 @@ export async function runDoctor() {
|
|
|
81
194
|
readable.forEach((n, i) => authByName.set(n, states[i]));
|
|
82
195
|
}
|
|
83
196
|
for (const n of names) {
|
|
197
|
+
const id = `login-${p.id}-${n}`;
|
|
198
|
+
const run = `agent-switch run ${n}${p.id === "claude" ? "" : ` --provider ${p.id}`}`;
|
|
84
199
|
if (!credentialReadable(p, n)) {
|
|
85
|
-
|
|
200
|
+
checks.push({ id, status: "warn", title: `${p.id}/${n}: no readable login`, detail: "", fix: `${run} and sign in` });
|
|
86
201
|
continue;
|
|
87
202
|
}
|
|
88
203
|
const auth = authByName.get(n);
|
|
89
|
-
if (auth === "expired")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
else {
|
|
96
|
-
// Non-Claude (no probe) or Claude "unknown"/offline — don't over-claim.
|
|
97
|
-
line(OK, ` ${p.id}/${n}: credential readable${p.id === "claude" ? " (login not verified — offline?)" : ""}.`);
|
|
98
|
-
}
|
|
204
|
+
if (auth === "expired")
|
|
205
|
+
checks.push({ id, status: "warn", title: `${p.id}/${n}: login expired`, detail: "", fix: `${run} and /login again` });
|
|
206
|
+
else if (auth === "ok")
|
|
207
|
+
checks.push({ id, status: "ok", title: `${p.id}/${n}: login valid`, detail: "" });
|
|
208
|
+
else
|
|
209
|
+
checks.push({ id, status: "ok", title: `${p.id}/${n}: login stored`, detail: p.id === "claude" ? "Not verified — offline?" : "" });
|
|
99
210
|
}
|
|
100
211
|
}
|
|
212
|
+
}
|
|
213
|
+
export async function collectDoctorReport() {
|
|
214
|
+
const checks = [];
|
|
215
|
+
checks.push(cliOnPathCheck());
|
|
216
|
+
checks.push(shellIntegrationCheck());
|
|
217
|
+
checks.push(autostartCheck(readGuiSettings()));
|
|
218
|
+
checks.push(apiKeyOverrideCheck());
|
|
219
|
+
checks.push(antigravityOsCheck());
|
|
220
|
+
await providerChecks(checks);
|
|
221
|
+
const identities = allProviders().flatMap((p) => listProfiles(p.id).map((n) => ({ provider: p.id, name: n, identity: identity(p.id, n) })));
|
|
222
|
+
checks.push(signInIsolationCheck(identities));
|
|
101
223
|
// Share-link health (Claude-config sharing).
|
|
102
224
|
for (const n of listProfiles("claude")) {
|
|
103
225
|
const health = sharedLinkHealth(configDir("claude", n));
|
|
@@ -106,42 +228,56 @@ export async function runDoctor() {
|
|
|
106
228
|
const forked = health.filter((h) => h.state === "forked").map((h) => h.name);
|
|
107
229
|
const missing = health.filter((h) => h.state === "missing").map((h) => h.name);
|
|
108
230
|
if (forked.length === 0 && missing.length === 0) {
|
|
109
|
-
|
|
231
|
+
checks.push({ id: `share-${n}`, status: "ok", title: `claude/${n}: ${health.length} shared link(s) intact`, detail: "" });
|
|
110
232
|
}
|
|
111
233
|
else {
|
|
112
234
|
const parts = [forked.length ? `forked: ${forked.join(", ")}` : "", missing.length ? `missing: ${missing.join(", ")}` : ""].filter(Boolean);
|
|
113
|
-
|
|
235
|
+
checks.push({ id: `share-${n}`, status: "warn", title: `claude/${n}: shared links drifted`, detail: parts.join("; "), fix: "agent-switch share sync" });
|
|
114
236
|
}
|
|
115
237
|
}
|
|
116
238
|
// Single-load guard: the policy, and whether any profile is doubled RIGHT NOW.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
for (const n of listProfiles(p.id)) {
|
|
128
|
-
const count = liveSessionPids(configDir(p.id, n)).length;
|
|
129
|
-
if (count > 1)
|
|
130
|
-
doubled.push(`${p.id}/${n} (${count} sessions)`);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (doubled.length > 0) {
|
|
134
|
-
line(WARN, ` loaded more than once: ${doubled.join(", ")} — they share one account's rate limits.`);
|
|
239
|
+
const policy = readGuard();
|
|
240
|
+
checks.push({ id: "guard", status: policy === "off" ? "warn" : "ok", title: `Single-load guard: ${policy}`, detail: policy === "off" ? "A profile can be loaded twice." : "" });
|
|
241
|
+
const doubled = [];
|
|
242
|
+
for (const p of allProviders()) {
|
|
243
|
+
if (!p.hasLiveSessionSignal)
|
|
244
|
+
continue;
|
|
245
|
+
for (const n of listProfiles(p.id)) {
|
|
246
|
+
const count = liveSessionPids(configDir(p.id, n)).length;
|
|
247
|
+
if (count > 1)
|
|
248
|
+
doubled.push(`${p.id}/${n} (${count} sessions)`);
|
|
135
249
|
}
|
|
136
250
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
251
|
+
if (doubled.length > 0) {
|
|
252
|
+
checks.push({ id: "guard-doubled", status: "warn", title: "A profile is loaded more than once", detail: `${doubled.join(", ")} — they share one account's rate limits.` });
|
|
253
|
+
}
|
|
254
|
+
// Ecosystem tooling (agent-config + rtk) — both optional, so never an error.
|
|
140
255
|
for (const t of checkTooling({ ids: ["agent-config", "rtk"] })) {
|
|
141
|
-
|
|
256
|
+
checks.push({ id: `tooling-${t.id}`, status: t.healthy ? "ok" : "warn", title: doctorToolingLine(t), detail: "" });
|
|
257
|
+
}
|
|
258
|
+
const readiness = checks.some((c) => c.status === "error") ? "blocked" : checks.some((c) => c.status === "warn") ? "attention" : "ready";
|
|
259
|
+
return { readiness, platform: process.platform, checks };
|
|
260
|
+
}
|
|
261
|
+
const MARK = { ok: "✅", warn: "⚠️", error: "❌", info: "ℹ️" };
|
|
262
|
+
export async function runDoctor(json = false) {
|
|
263
|
+
const report = await collectDoctorReport();
|
|
264
|
+
if (json) {
|
|
265
|
+
console.log(JSON.stringify(report, null, 2));
|
|
266
|
+
return report.readiness === "blocked" ? 1 : 0;
|
|
267
|
+
}
|
|
268
|
+
console.log(`agent-switch doctor — platform ${process.platform}, node ${process.version}`);
|
|
269
|
+
console.log(process.platform === "darwin"
|
|
270
|
+
? " claude credential store: macOS Keychain (hashed per config dir), file fallback"
|
|
271
|
+
: " claude credential store: plaintext .credentials.json per profile");
|
|
272
|
+
console.log("");
|
|
273
|
+
for (const c of report.checks) {
|
|
274
|
+
console.log(`${MARK[c.status]} ${c.title}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
275
|
+
if (c.fix)
|
|
276
|
+
console.log(` fix: ${c.fix}`);
|
|
142
277
|
}
|
|
143
278
|
console.log(`\n profile root: ${ROOT}`);
|
|
144
279
|
console.log("");
|
|
145
|
-
|
|
146
|
-
|
|
280
|
+
const blocked = report.readiness === "blocked";
|
|
281
|
+
console.log(`${blocked ? MARK.error : MARK.ok} ${blocked ? "doctor found a blocking problem (see above)." : "no blocking problems."}`);
|
|
282
|
+
return blocked ? 1 : 0;
|
|
147
283
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GUI preferences that must outlive the webview's `localStorage` — the Pet
|
|
3
|
+
* intro once-flag (D36) and the launch-at-login preference. The GUI can only
|
|
4
|
+
* reach the filesystem through this CLI, so it reads and writes them with
|
|
5
|
+
* `agent-switch gui-settings`; `doctor` reads the autostart preference from the
|
|
6
|
+
* same file.
|
|
7
|
+
*
|
|
8
|
+
* Additive file (`<ROOT>/gui-settings.json`): absent keys mean "never decided",
|
|
9
|
+
* so an upgrade starts with nothing stored and the GUI migrates its old
|
|
10
|
+
* `localStorage` flags in once.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { ROOT, ensureRoot } from "./profiles.js";
|
|
15
|
+
export const GUI_SETTINGS_FILE = path.join(ROOT, "gui-settings.json");
|
|
16
|
+
function normalizeAutostart(raw) {
|
|
17
|
+
const r = (raw ?? {});
|
|
18
|
+
const at = typeof r.at === "string" ? r.at : "";
|
|
19
|
+
if (r.state === "enabled" && (r.by === "first-run" || r.by === "user"))
|
|
20
|
+
return { state: "enabled", by: r.by, at };
|
|
21
|
+
if (r.state === "disabled" && r.by === "user")
|
|
22
|
+
return { state: "disabled", by: "user", at };
|
|
23
|
+
return { state: "unset" };
|
|
24
|
+
}
|
|
25
|
+
export function readGuiSettings(file = GUI_SETTINGS_FILE) {
|
|
26
|
+
let raw = {};
|
|
27
|
+
try {
|
|
28
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8")) ?? {};
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* absent / unparsable → nothing decided */
|
|
32
|
+
}
|
|
33
|
+
const d = raw.autostartDegraded;
|
|
34
|
+
return {
|
|
35
|
+
petWelcomed: raw.petWelcomed === true,
|
|
36
|
+
autostart: normalizeAutostart(raw.autostart),
|
|
37
|
+
autostartDegraded: d && typeof d === "object" && typeof d.reason === "string"
|
|
38
|
+
? { at: typeof d.at === "string" ? d.at : "", reason: d.reason }
|
|
39
|
+
: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function writeGuiSettings(next, file = GUI_SETTINGS_FILE) {
|
|
43
|
+
if (file === GUI_SETTINGS_FILE)
|
|
44
|
+
ensureRoot();
|
|
45
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
46
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
47
|
+
fs.renameSync(tmp, file);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Apply one update. Guards the two invariants the roadmap pins: the pet intro
|
|
51
|
+
* flag only ever goes false → true, and a first-run default never replaces a
|
|
52
|
+
* stored choice (only `by: "user"` may overwrite one).
|
|
53
|
+
*/
|
|
54
|
+
export function applyGuiSetting(cur, u, now = new Date()) {
|
|
55
|
+
const at = now.toISOString();
|
|
56
|
+
if (u.key === "pet-welcomed")
|
|
57
|
+
return { ...cur, petWelcomed: true };
|
|
58
|
+
if (u.key === "autostart-degraded") {
|
|
59
|
+
return { ...cur, autostartDegraded: u.reason ? { at, reason: u.reason } : null };
|
|
60
|
+
}
|
|
61
|
+
if (u.state === "disabled" && u.by !== "user")
|
|
62
|
+
throw new Error("only the user can turn launch-at-login off");
|
|
63
|
+
if (u.by === "first-run" && cur.autostart.state !== "unset")
|
|
64
|
+
return cur;
|
|
65
|
+
const autostart = u.state === "enabled" ? { state: "enabled", by: u.by, at } : { state: "disabled", by: "user", at };
|
|
66
|
+
return { ...cur, autostart, autostartDegraded: null };
|
|
67
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -122,6 +122,27 @@ export function readEvents(file) {
|
|
|
122
122
|
return [];
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Sessions waiting on the user: their last hook event is `Notification` (a
|
|
127
|
+
* permission prompt, or a long wait for input) and the transcript has not been
|
|
128
|
+
* written since — a reply or an approval writes to it. `Stop` alone is not
|
|
129
|
+
* waiting: it ends every turn.
|
|
130
|
+
*/
|
|
131
|
+
export function waitingSessions(events, mtimeMs) {
|
|
132
|
+
const last = new Map();
|
|
133
|
+
for (const e of events)
|
|
134
|
+
if (e.sessionId)
|
|
135
|
+
last.set(e.sessionId, e);
|
|
136
|
+
// sessionId → when it started waiting.
|
|
137
|
+
const out = new Map();
|
|
138
|
+
for (const [id, e] of last) {
|
|
139
|
+
const written = mtimeMs(id);
|
|
140
|
+
// Two seconds of slack: the notification and the transcript line that led to it land together.
|
|
141
|
+
if (e.event === "Notification" && written !== null && Date.parse(e.at) + 2000 >= written)
|
|
142
|
+
out.set(id, e.at);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
125
146
|
/** Append one event, trimming the ring to EVENT_RING_CAP. Tolerant of a
|
|
126
147
|
* malformed existing file (starts fresh). */
|
|
127
148
|
export function appendEvent(file, rec) {
|