clauderipple 0.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/CHANGELOG.md +229 -0
- package/LICENSE +674 -0
- package/README.ko.md +328 -0
- package/README.md +372 -0
- package/bin/clauderipple.js +12 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/main.js +518 -0
- package/dist/cli/src/browser.js +21 -0
- package/dist/cli/src/bundle.js +51 -0
- package/dist/cli/src/certs.js +33 -0
- package/dist/cli/src/claude-auth.js +112 -0
- package/dist/cli/src/codex.js +172 -0
- package/dist/cli/src/gen-certs.js +7 -0
- package/dist/cli/src/hooks/agent-title.js +160 -0
- package/dist/cli/src/index.js +489 -0
- package/dist/cli/src/launchd.js +183 -0
- package/dist/cli/src/picker.js +166 -0
- package/dist/cli/src/probe.js +55 -0
- package/dist/cli/src/runtime.js +62 -0
- package/dist/cli/src/schtasks.js +134 -0
- package/dist/cli/src/settings.js +142 -0
- package/dist/cli/src/supervisor.js +100 -0
- package/dist/cli/src/tray.js +85 -0
- package/dist/router/src/admin.js +945 -0
- package/dist/router/src/bootstrap.js +80 -0
- package/dist/router/src/certs.js +65 -0
- package/dist/router/src/compat.js +172 -0
- package/dist/router/src/config.js +179 -0
- package/dist/router/src/health.js +45 -0
- package/dist/router/src/identity.js +51 -0
- package/dist/router/src/index.js +144 -0
- package/dist/router/src/ingress/models.js +29 -0
- package/dist/router/src/ingress/server.js +400 -0
- package/dist/router/src/ingress/translate.js +457 -0
- package/dist/router/src/log.js +81 -0
- package/dist/router/src/picker.js +74 -0
- package/dist/router/src/presets.js +267 -0
- package/dist/router/src/providers/anthropic-observed.js +88 -0
- package/dist/router/src/providers/anthropic-token-file.js +48 -0
- package/dist/router/src/providers/anthropic.js +203 -0
- package/dist/router/src/providers/chatgpt/auth.js +226 -0
- package/dist/router/src/providers/chatgpt/index.js +274 -0
- package/dist/router/src/providers/chatgpt/sse.js +28 -0
- package/dist/router/src/providers/chatgpt/translate.js +393 -0
- package/dist/router/src/providers/claude-oauth.js +252 -0
- package/dist/router/src/providers/openai/index.js +193 -0
- package/dist/router/src/providers/openai/translate.js +504 -0
- package/dist/router/src/proxy.js +724 -0
- package/dist/router/src/redact.js +43 -0
- package/dist/router/src/requestlog.js +346 -0
- package/dist/router/src/routing.js +113 -0
- package/dist/router/src/version.js +8 -0
- package/dist/router/src/x509.js +203 -0
- package/dist/ui/app.js +1228 -0
- package/dist/ui/i18n.js +95 -0
- package/dist/ui/index.html +104 -0
- package/dist/ui/presets-fallback.js +61 -0
- package/dist/ui/style.css +347 -0
- package/docs/ARCHITECTURE.md +441 -0
- package/package.json +66 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// `clauderipple picker on|off` — put real model names into Claude Desktop's model picker.
|
|
2
|
+
//
|
|
3
|
+
// on: 1. trust our CA in the login keychain (macOS asks the user for their password; we never see it)
|
|
4
|
+
// 2. point the app itself at the router: Config Library entry {egressProxyUrl} + _meta.json appliedId
|
|
5
|
+
// (~/Library/Application Support/Claude-3p/configLibrary/ — the app keeps its managed-config
|
|
6
|
+
// library under the "-3p" userData dir in BOTH deployment modes; read once at start, no MDM needed)
|
|
7
|
+
// 3. set picker.enabled in config.json
|
|
8
|
+
// → the user restarts Claude Desktop.
|
|
9
|
+
// off: reverse all three.
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
export const CA_NAME = "ClaudeRipple local CA";
|
|
16
|
+
function appSupport() {
|
|
17
|
+
// Verified in index.pre.js (AW()): userData + "-3p" regardless of 1P/3P mode. Not the plain "Claude" dir.
|
|
18
|
+
// Windows keeps userData under LOCALAPPDATA, not APPDATA — confirmed on Windows 11 (2026-09-14),
|
|
19
|
+
// where the app had already created %LOCALAPPDATA%\Claude-3p and read a configLibrary we put there.
|
|
20
|
+
if (process.env.CLAUDE_APP_SUPPORT)
|
|
21
|
+
return process.env.CLAUDE_APP_SUPPORT;
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
return path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"), "Claude-3p");
|
|
24
|
+
}
|
|
25
|
+
return path.join(os.homedir(), "Library", "Application Support", "Claude-3p");
|
|
26
|
+
}
|
|
27
|
+
export function configLibraryDir() {
|
|
28
|
+
return path.join(appSupport(), "configLibrary");
|
|
29
|
+
}
|
|
30
|
+
function loginKeychain() {
|
|
31
|
+
return path.join(os.homedir(), "Library", "Keychains", "login.keychain-db");
|
|
32
|
+
}
|
|
33
|
+
const isWindows = process.platform === "win32";
|
|
34
|
+
/** Single-quoted PowerShell literal; the only escape inside one is a doubled quote. */
|
|
35
|
+
function ps(value) {
|
|
36
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `interactive` drops -NonInteractive and lets a window show. Trusting (and untrusting) a root
|
|
40
|
+
* certificate is a UI operation: Windows puts up its own confirmation dialog with the fingerprint,
|
|
41
|
+
* and under -NonInteractive the call fails outright with "this operation cannot use the UI"
|
|
42
|
+
* (observed 2026-09-14 — the dialog never appeared and picker mode stopped there).
|
|
43
|
+
*/
|
|
44
|
+
function powershell(script, opts = {}) {
|
|
45
|
+
const stdio = opts.stdio ?? "pipe";
|
|
46
|
+
const args = ["-NoProfile", ...(opts.interactive ? [] : ["-NonInteractive"]), "-Command", script];
|
|
47
|
+
return execFileSync("powershell.exe", args, {
|
|
48
|
+
stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : stdio,
|
|
49
|
+
windowsHide: !opts.interactive,
|
|
50
|
+
})?.toString() ?? "";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Both platforms trust the CA for the current user only — never machine-wide, which would need
|
|
54
|
+
* administrator rights and would affect everyone on the box.
|
|
55
|
+
* macOS : login keychain, and the OS asks for the account password.
|
|
56
|
+
* Windows : Cert:\CurrentUser\Root, and the OS shows a confirmation dialog with the fingerprint.
|
|
57
|
+
* Measured on Windows 11 (2026-09-14) as a standard user: the import succeeded with no UAC prompt,
|
|
58
|
+
* only that dialog. Removal shows a second confirmation, so `picker off` prompts the user too.
|
|
59
|
+
*/
|
|
60
|
+
export function caTrusted() {
|
|
61
|
+
try {
|
|
62
|
+
if (isWindows) {
|
|
63
|
+
const out = powershell(`@(Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' }).Count`);
|
|
64
|
+
return Number(out.trim()) > 0;
|
|
65
|
+
}
|
|
66
|
+
execFileSync("security", ["find-certificate", "-c", CA_NAME, loginKeychain()], { stdio: "ignore" });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Adds the CA as a trusted root for the current user. The OS shows its own prompt; we never see a password. */
|
|
74
|
+
export function trustCa(caPem) {
|
|
75
|
+
if (caTrusted())
|
|
76
|
+
return;
|
|
77
|
+
if (isWindows) {
|
|
78
|
+
// stdio inherit: the confirmation dialog is the OS's, but errors should reach the user's terminal.
|
|
79
|
+
powershell(`Import-Certificate -FilePath ${ps(caPem)} -CertStoreLocation Cert:\\CurrentUser\\Root -ErrorAction Stop | Out-Null`, { stdio: "inherit", interactive: true });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
execFileSync("security", ["add-trusted-cert", "-r", "trustRoot", "-k", loginKeychain(), caPem], { stdio: "inherit" });
|
|
83
|
+
}
|
|
84
|
+
export function untrustCa(caPem) {
|
|
85
|
+
if (!caTrusted())
|
|
86
|
+
return false;
|
|
87
|
+
if (isWindows) {
|
|
88
|
+
try {
|
|
89
|
+
powershell(`Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' } | ForEach-Object { Remove-Item -Path $_.PSPath -Force }`, { stdio: "inherit", interactive: true });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* the user may have declined the removal dialog */
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
execFileSync("security", ["remove-trusted-cert", caPem], { stdio: "inherit" });
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* trust settings may already be gone */
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
execFileSync("security", ["delete-certificate", "-c", CA_NAME, loginKeychain()], { stdio: "ignore" });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
function readMeta() {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(fs.readFileSync(path.join(configLibraryDir(), "_meta.json"), "utf8"));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const ENTRY_MARK = "clauderipple";
|
|
119
|
+
export function currentAppProxy() {
|
|
120
|
+
const meta = readMeta();
|
|
121
|
+
const id = typeof meta.appliedId === "string" ? meta.appliedId : null;
|
|
122
|
+
if (!id)
|
|
123
|
+
return { appliedId: null, egressProxyUrl: null, ours: false };
|
|
124
|
+
try {
|
|
125
|
+
const entry = JSON.parse(fs.readFileSync(path.join(configLibraryDir(), `${id}.json`), "utf8"));
|
|
126
|
+
return { appliedId: id, egressProxyUrl: typeof entry.egressProxyUrl === "string" ? entry.egressProxyUrl : null, ours: meta[ENTRY_MARK]?.ourId === id };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { appliedId: id, egressProxyUrl: null, ours: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Writes our Config Library entry and applies it. Remembers what was applied before so `off` can restore it. */
|
|
133
|
+
export function applyAppProxy(proxyUrl) {
|
|
134
|
+
const dir = configLibraryDir();
|
|
135
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
136
|
+
const meta = readMeta();
|
|
137
|
+
const cur = currentAppProxy();
|
|
138
|
+
if (cur.ours && cur.appliedId) {
|
|
139
|
+
fs.writeFileSync(path.join(dir, `${cur.appliedId}.json`), JSON.stringify({ egressProxyUrl: proxyUrl }, null, 2) + "\n");
|
|
140
|
+
return { id: cur.appliedId, replaced: null };
|
|
141
|
+
}
|
|
142
|
+
const id = crypto.randomUUID();
|
|
143
|
+
// Only recognized keys in the entry: the app warns about and ignores unknown ones. Ownership lives in _meta.
|
|
144
|
+
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify({ egressProxyUrl: proxyUrl }, null, 2) + "\n");
|
|
145
|
+
const previous = typeof meta.appliedId === "string" ? meta.appliedId : null;
|
|
146
|
+
const next = { ...meta, appliedId: id, clauderipple: { ourId: id, previousAppliedId: previous } };
|
|
147
|
+
fs.writeFileSync(path.join(dir, "_meta.json"), JSON.stringify(next, null, 2) + "\n");
|
|
148
|
+
return { id, replaced: previous };
|
|
149
|
+
}
|
|
150
|
+
export function removeAppProxy() {
|
|
151
|
+
const dir = configLibraryDir();
|
|
152
|
+
const meta = readMeta();
|
|
153
|
+
const cur = currentAppProxy();
|
|
154
|
+
if (!cur.ours || !cur.appliedId)
|
|
155
|
+
return false;
|
|
156
|
+
const previous = meta.clauderipple?.previousAppliedId ?? null;
|
|
157
|
+
const next = { ...meta };
|
|
158
|
+
delete next.clauderipple;
|
|
159
|
+
if (previous)
|
|
160
|
+
next.appliedId = previous;
|
|
161
|
+
else
|
|
162
|
+
delete next.appliedId;
|
|
163
|
+
fs.writeFileSync(path.join(dir, "_meta.json"), JSON.stringify(next, null, 2) + "\n");
|
|
164
|
+
fs.rmSync(path.join(dir, `${cur.appliedId}.json`), { force: true });
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// End-to-end probe: CONNECT through the router, complete TLS against its leaf cert
|
|
2
|
+
// using only our CA, and fetch a cheap Anthropic path. 401 from Anthropic is a pass:
|
|
3
|
+
// it proves termination + passthrough without any credentials.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import tls from "node:tls";
|
|
7
|
+
export function probe(opts) {
|
|
8
|
+
const t0 = Date.now();
|
|
9
|
+
const path = opts.path ?? "/api/claude_cli/bootstrap";
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let done = false;
|
|
12
|
+
const finish = (ok, detail) => {
|
|
13
|
+
if (done)
|
|
14
|
+
return;
|
|
15
|
+
done = true;
|
|
16
|
+
resolve({ ok, detail, ms: Date.now() - t0 });
|
|
17
|
+
sock.destroy();
|
|
18
|
+
};
|
|
19
|
+
const sock = net.connect({ host: opts.host, port: opts.port });
|
|
20
|
+
const timer = setTimeout(() => finish(false, "timeout"), opts.timeoutMs ?? 8000);
|
|
21
|
+
sock.on("error", (e) => finish(false, `router unreachable: ${e.message}`));
|
|
22
|
+
sock.once("connect", () => {
|
|
23
|
+
sock.write(`CONNECT ${opts.upstream}:443 HTTP/1.1\r\nHost: ${opts.upstream}:443\r\n\r\n`);
|
|
24
|
+
});
|
|
25
|
+
sock.once("data", (d) => {
|
|
26
|
+
const line = d.toString("latin1").split("\r\n")[0] ?? "";
|
|
27
|
+
if (!/^HTTP\/1\.[01] 200/.test(line))
|
|
28
|
+
return finish(false, `CONNECT refused: ${line}`);
|
|
29
|
+
let ca;
|
|
30
|
+
try {
|
|
31
|
+
ca = fs.readFileSync(opts.caPem);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
return finish(false, `cannot read CA: ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
const t = tls.connect({ socket: sock, servername: opts.upstream, ca }, () => {
|
|
37
|
+
if (!t.authorized)
|
|
38
|
+
return finish(false, `TLS not authorized: ${t.authorizationError}`);
|
|
39
|
+
t.write(`GET ${path} HTTP/1.1\r\nHost: ${opts.upstream}\r\nConnection: close\r\n\r\n`);
|
|
40
|
+
});
|
|
41
|
+
let resp = "";
|
|
42
|
+
t.on("data", (c) => {
|
|
43
|
+
resp += c.toString("latin1");
|
|
44
|
+
const status = resp.split("\r\n")[0] ?? "";
|
|
45
|
+
if (/^HTTP\/1\.[01] \d{3}/.test(status)) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
const code = Number(status.split(" ")[1]);
|
|
48
|
+
finish(code === 401 || code === 200, `upstream answered ${status}`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
t.on("error", (e) => finish(false, `TLS error: ${e.message}`));
|
|
52
|
+
t.on("end", () => finish(false, "connection closed without a status line"));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// One runtime contract for the CLI, router supervisor, app shell, and hooks.
|
|
2
|
+
// Packaged apps execute TypeScript with Electron's bundled Node in type-stripping mode;
|
|
3
|
+
// development keeps using the Node executable that started the CLI.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
function appBundlePath(value) {
|
|
8
|
+
if (!value)
|
|
9
|
+
return null;
|
|
10
|
+
const match = value.match(/^(.*\.app)\/Contents(?:\/|$)/);
|
|
11
|
+
return match?.[1] ?? null;
|
|
12
|
+
}
|
|
13
|
+
export function runtime() {
|
|
14
|
+
const electronProcess = process;
|
|
15
|
+
const bundle = appBundlePath(electronProcess.resourcesPath) ?? appBundlePath(process.execPath);
|
|
16
|
+
if (bundle) {
|
|
17
|
+
const resources = path.join(bundle, "Contents", "Resources", "clauderipple");
|
|
18
|
+
return {
|
|
19
|
+
node: path.join(bundle, "Contents", "MacOS", path.basename(bundle, ".app")),
|
|
20
|
+
env: { ELECTRON_RUN_AS_NODE: "1" },
|
|
21
|
+
cli: path.join(resources, "packages", "cli", "src", "index.ts"),
|
|
22
|
+
router: path.join(resources, "packages", "router", "src", "index.ts"),
|
|
23
|
+
hookScript: path.join(resources, "packages", "cli", "src", "hooks", "agent-title.ts"),
|
|
24
|
+
repo: resources,
|
|
25
|
+
trayMain: "",
|
|
26
|
+
packaged: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// Windows (and any non-macOS electron-builder layout): the sources sit in resources/clauderipple
|
|
30
|
+
// next to app.asar, and the app's own executable runs them with ELECTRON_RUN_AS_NODE.
|
|
31
|
+
const resourcesPath = electronProcess.resourcesPath;
|
|
32
|
+
if (resourcesPath && fs.existsSync(path.join(resourcesPath, "clauderipple", "packages", "router", "src", "index.ts"))) {
|
|
33
|
+
const resources = path.join(resourcesPath, "clauderipple");
|
|
34
|
+
return {
|
|
35
|
+
node: process.execPath,
|
|
36
|
+
env: { ELECTRON_RUN_AS_NODE: "1" },
|
|
37
|
+
cli: path.join(resources, "packages", "cli", "src", "index.ts"),
|
|
38
|
+
router: path.join(resources, "packages", "router", "src", "index.ts"),
|
|
39
|
+
hookScript: path.join(resources, "packages", "cli", "src", "hooks", "agent-title.ts"),
|
|
40
|
+
repo: resources,
|
|
41
|
+
trayMain: "",
|
|
42
|
+
packaged: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Development runs the .ts sources from the checkout; an npm install runs the .js built from
|
|
46
|
+
// them, in the same shape one level down (dist/cli/src instead of packages/cli/src). This
|
|
47
|
+
// module's own file answers both: its extension says which, and the rest is the same relative
|
|
48
|
+
// walk, so `repo` is the checkout root or the installed package root without a special case.
|
|
49
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const ext = path.extname(fileURLToPath(import.meta.url));
|
|
51
|
+
const cli = path.join(here, `index${ext}`);
|
|
52
|
+
return {
|
|
53
|
+
node: process.execPath,
|
|
54
|
+
env: {},
|
|
55
|
+
cli,
|
|
56
|
+
router: path.resolve(here, `../../router/src/index${ext}`),
|
|
57
|
+
hookScript: path.resolve(here, `hooks/agent-title${ext}`),
|
|
58
|
+
repo: path.resolve(here, "../../.."),
|
|
59
|
+
trayMain: path.resolve(here, "../../..", ext === ".ts" ? "packages/app/dist/main.js" : "dist/app/dist/main.js"),
|
|
60
|
+
packaged: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Windows supervisor: a per-user scheduled task that starts the router at logon and restarts it
|
|
2
|
+
// if it dies. The launchd counterpart is launchd.ts; supervisor.ts picks between them.
|
|
3
|
+
//
|
|
4
|
+
// Measured in a Windows 11 VM (2026-09-14) as a standard, non-elevated user:
|
|
5
|
+
// Register-ScheduledTask with LogonType Interactive + RunLevel Limited → "registered OK", no UAC.
|
|
6
|
+
// That is the whole reason this is a scheduled task rather than a Windows service: creating a
|
|
7
|
+
// service needs administrator rights, and a personal proxy should not ask for them.
|
|
8
|
+
//
|
|
9
|
+
// Two things launchd gives us for free need doing by hand here:
|
|
10
|
+
// * KeepAlive → RestartCount/RestartInterval, which only fire on a *non-zero* exit. That still
|
|
11
|
+
// covers a crash and the health self-exit (code 75); a clean exit stays exited, which is what
|
|
12
|
+
// `clauderipple stop` wants anyway.
|
|
13
|
+
// * A graceful restart → there is no SIGTERM on Windows (process.kill terminates outright and
|
|
14
|
+
// would cut the drain), so restart asks the router over POST /api/shutdown and waits.
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
export const TASK_NAME = "ClaudeRippleRouter";
|
|
20
|
+
export function taskName() {
|
|
21
|
+
return process.env.CLAUDERIPPLE_TASK_NAME ?? TASK_NAME;
|
|
22
|
+
}
|
|
23
|
+
/** Single-quoted PowerShell literal; the only escape inside one is a doubled quote. */
|
|
24
|
+
function ps(value) {
|
|
25
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
26
|
+
}
|
|
27
|
+
function run(script) {
|
|
28
|
+
try {
|
|
29
|
+
const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
}).toString();
|
|
33
|
+
return { ok: true, out: out.trim() };
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const err = e;
|
|
37
|
+
return { ok: false, out: `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim() };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The task runs a PowerShell launcher rather than node directly, for three reasons.
|
|
42
|
+
*
|
|
43
|
+
* 1. It pins CLAUDERIPPLE_HOME and any extra environment the installer recorded, which a scheduled
|
|
44
|
+
* task cannot carry on its own, and keeps output in a log file the way StandardOutPath does.
|
|
45
|
+
* 2. `Start-Process` detaches the router from the console: a task started from one inherits it, and
|
|
46
|
+
* closing that window sends a Ctrl+C — measured 2026-09-14, the router died with 0xC000013A the
|
|
47
|
+
* moment the installer's window closed.
|
|
48
|
+
* 3. **It is the KeepAlive.** Task Scheduler's RestartCount/RestartInterval only cover failing to
|
|
49
|
+
* *start* a task; a process that dies while running just ends the task as complete (measured
|
|
50
|
+
* 2026-09-14: the router was killed, the task went back to Ready with 0xFFFFFFFF, and nothing
|
|
51
|
+
* restarted it for three minutes). So the launcher supervises the router itself, with launchd's
|
|
52
|
+
* semantics: a non-zero exit — a crash, or the health self-exit 75 — is relaunched; a clean exit
|
|
53
|
+
* is not, because that is `stop` or the drain half of `restart`, which starts the task again.
|
|
54
|
+
* A router that keeps dying immediately backs off instead of spinning.
|
|
55
|
+
*/
|
|
56
|
+
function writeLauncher(opts) {
|
|
57
|
+
const launcher = path.join(opts.home, "clauderipple-router.ps1");
|
|
58
|
+
const logDir = path.join(opts.home, "logs");
|
|
59
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
60
|
+
const env = { CLAUDERIPPLE_HOME: opts.home, ...(opts.env ?? {}) };
|
|
61
|
+
const lines = [
|
|
62
|
+
"# Generated by `clauderipple install`. Supervises the router; see schtasks.ts.",
|
|
63
|
+
...Object.entries(env).map(([k, v]) => `$env:${k} = ${ps(v)}`),
|
|
64
|
+
`$routerArgs = @(${opts.args.map((a) => ps(a)).join(", ")})`,
|
|
65
|
+
`$out = ${ps(path.join(logDir, "launcher.log"))}`,
|
|
66
|
+
`$err = ${ps(path.join(logDir, "launcher.err.log"))}`,
|
|
67
|
+
`$quickFailures = 0`,
|
|
68
|
+
`while ($true) {`,
|
|
69
|
+
` $startedAt = Get-Date`,
|
|
70
|
+
` $p = Start-Process -FilePath ${ps(opts.program)} -ArgumentList $routerArgs -WindowStyle Hidden -PassThru -Wait -RedirectStandardOutput $out -RedirectStandardError $err`,
|
|
71
|
+
` if ($p.ExitCode -eq 0) { break }`,
|
|
72
|
+
` if (((Get-Date) - $startedAt).TotalSeconds -lt 10) { $quickFailures++ } else { $quickFailures = 0 }`,
|
|
73
|
+
` Start-Sleep -Seconds $(if ($quickFailures -ge 5) { 60 } else { 2 })`,
|
|
74
|
+
`}`,
|
|
75
|
+
];
|
|
76
|
+
fs.writeFileSync(launcher, lines.join("\r\n") + "\r\n");
|
|
77
|
+
return launcher;
|
|
78
|
+
}
|
|
79
|
+
export function installAgent(opts) {
|
|
80
|
+
const launcher = writeLauncher({ program: opts.program, args: opts.args ?? [], home: opts.home, ...(opts.env ? { env: opts.env } : {}) });
|
|
81
|
+
const user = `${os.userInfo().username}`;
|
|
82
|
+
const script = [
|
|
83
|
+
`$ErrorActionPreference = 'Stop'`,
|
|
84
|
+
`$a = New-ScheduledTaskAction -Execute ${ps("powershell.exe")} -Argument ${ps(`-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`)}`,
|
|
85
|
+
`$t = New-ScheduledTaskTrigger -AtLogOn -User ${ps(user)}`,
|
|
86
|
+
// Limited: the router needs no elevation, and asking for it would put a UAC prompt at every logon.
|
|
87
|
+
`$p = New-ScheduledTaskPrincipal -UserId ${ps(user)} -LogonType Interactive -RunLevel Limited`,
|
|
88
|
+
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 99 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew`,
|
|
89
|
+
`Register-ScheduledTask -TaskName ${ps(taskName())} -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null`,
|
|
90
|
+
`'ok'`,
|
|
91
|
+
].join("; ");
|
|
92
|
+
const r = run(script);
|
|
93
|
+
if (!r.ok)
|
|
94
|
+
throw new Error(`Register-ScheduledTask failed: ${r.out}`);
|
|
95
|
+
return launcher;
|
|
96
|
+
}
|
|
97
|
+
export function removeAgent() {
|
|
98
|
+
const r = run(`Unregister-ScheduledTask -TaskName ${ps(taskName())} -Confirm:$false -ErrorAction Stop; 'ok'`);
|
|
99
|
+
return r.ok;
|
|
100
|
+
}
|
|
101
|
+
export function agentState() {
|
|
102
|
+
const r = run(`(Get-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction Stop).State`);
|
|
103
|
+
if (!r.ok)
|
|
104
|
+
return "not-loaded";
|
|
105
|
+
return /^Running/im.test(r.out) ? "running" : "loaded";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The scheduled task starts PowerShell, which starts the runtime, so the task's own pid is the
|
|
109
|
+
* launcher's. What callers want is the router, so find the process whose command line names our
|
|
110
|
+
* script. The runtime is `node.exe` for a source checkout and `ClaudeRipple.exe` (Electron as
|
|
111
|
+
* Node) for the packaged app — until 0.1.2 only `node.exe` was looked for, so on every packaged
|
|
112
|
+
* install `restart` found no router, skipped the shutdown, and `Start-ScheduledTask` was ignored
|
|
113
|
+
* by the task already running: "Restart Router" never restarted anything (found 2026-09-16).
|
|
114
|
+
*/
|
|
115
|
+
export function agentPid() {
|
|
116
|
+
const r = run(
|
|
117
|
+
// Three layouts run the same router: a checkout (packages\router\src\index.ts), an npm
|
|
118
|
+
// install (dist\router\src\index.js) and the packaged app (the .ts under resources). Matching
|
|
119
|
+
// on "router\src\index." covers all three and still cannot match an unrelated process.
|
|
120
|
+
`(Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='ClaudeRipple.exe'" | Where-Object { $_.CommandLine -like '*router\\src\\index.*' } | Select-Object -First 1).ProcessId`);
|
|
121
|
+
const n = Number(r.out.trim());
|
|
122
|
+
return r.ok && Number.isFinite(n) && n > 0 ? n : null;
|
|
123
|
+
}
|
|
124
|
+
export function startAgent() {
|
|
125
|
+
if (agentPid() !== null)
|
|
126
|
+
return "already-running";
|
|
127
|
+
const r = run(`Start-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction Stop; 'ok'`);
|
|
128
|
+
return r.ok ? "started" : "failed";
|
|
129
|
+
}
|
|
130
|
+
export function stopAgent() {
|
|
131
|
+
// Stop the task so the supervisor does not immediately start it again, then let the router drain.
|
|
132
|
+
run(`Stop-ScheduledTask -TaskName ${ps(taskName())} -ErrorAction SilentlyContinue`);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Edit ~/.claude/settings.json: only the two env keys the CLI reads for the proxy.
|
|
2
|
+
// Always backs up first; never touches any other key; refuses to overwrite a foreign proxy unless forced.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
export function settingsPath() {
|
|
7
|
+
return process.env.CLAUDE_SETTINGS_PATH ?? path.join(os.homedir(), ".claude", "settings.json");
|
|
8
|
+
}
|
|
9
|
+
function readSettings(file) {
|
|
10
|
+
if (!fs.existsSync(file))
|
|
11
|
+
return {};
|
|
12
|
+
const text = fs.readFileSync(file, "utf8");
|
|
13
|
+
if (text.trim() === "")
|
|
14
|
+
return {};
|
|
15
|
+
return JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
function backup(file) {
|
|
18
|
+
if (!fs.existsSync(file))
|
|
19
|
+
return null;
|
|
20
|
+
const ts = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
|
21
|
+
const b = `${file}.bak-clauderipple-${ts}`;
|
|
22
|
+
fs.copyFileSync(file, b);
|
|
23
|
+
return b;
|
|
24
|
+
}
|
|
25
|
+
function write(file, obj) {
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
|
|
28
|
+
}
|
|
29
|
+
export function applyProxyEnv(opts) {
|
|
30
|
+
const file = settingsPath();
|
|
31
|
+
const s = readSettings(file);
|
|
32
|
+
const env = { ...(s.env ?? {}) };
|
|
33
|
+
const notes = [];
|
|
34
|
+
const existing = env.HTTPS_PROXY;
|
|
35
|
+
if (existing && existing !== opts.proxyUrl && !opts.force) {
|
|
36
|
+
throw new Error(`settings.json env.HTTPS_PROXY is already "${existing}". Re-run with --force to replace it, or uninstall the other proxy first.`);
|
|
37
|
+
}
|
|
38
|
+
if (existing && existing !== opts.proxyUrl)
|
|
39
|
+
notes.push(`replaced HTTPS_PROXY ${existing}`);
|
|
40
|
+
const want = { HTTPS_PROXY: opts.proxyUrl, NODE_EXTRA_CA_CERTS: opts.caPath };
|
|
41
|
+
if (opts.maxContextTokens)
|
|
42
|
+
want.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(opts.maxContextTokens);
|
|
43
|
+
let changed = false;
|
|
44
|
+
for (const [k, v] of Object.entries(want)) {
|
|
45
|
+
if (env[k] !== v) {
|
|
46
|
+
env[k] = v;
|
|
47
|
+
changed = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!changed)
|
|
51
|
+
return { changed: false, backup: null, notes };
|
|
52
|
+
const b = backup(file);
|
|
53
|
+
write(file, { ...s, env });
|
|
54
|
+
return { changed: true, backup: b, notes };
|
|
55
|
+
}
|
|
56
|
+
export function removeProxyEnv(opts) {
|
|
57
|
+
const file = settingsPath();
|
|
58
|
+
const s = readSettings(file);
|
|
59
|
+
const env = { ...(s.env ?? {}) };
|
|
60
|
+
const notes = [];
|
|
61
|
+
let changed = false;
|
|
62
|
+
if (env.HTTPS_PROXY === opts.proxyUrl) {
|
|
63
|
+
delete env.HTTPS_PROXY;
|
|
64
|
+
changed = true;
|
|
65
|
+
}
|
|
66
|
+
else if (env.HTTPS_PROXY)
|
|
67
|
+
notes.push(`left HTTPS_PROXY=${env.HTTPS_PROXY} (not ours)`);
|
|
68
|
+
if (env.NODE_EXTRA_CA_CERTS === opts.caPath) {
|
|
69
|
+
delete env.NODE_EXTRA_CA_CERTS;
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
else if (env.NODE_EXTRA_CA_CERTS)
|
|
73
|
+
notes.push(`left NODE_EXTRA_CA_CERTS=${env.NODE_EXTRA_CA_CERTS} (not ours)`);
|
|
74
|
+
if (!changed)
|
|
75
|
+
return { changed: false, backup: null, notes };
|
|
76
|
+
const b = backup(file);
|
|
77
|
+
const next = { ...s };
|
|
78
|
+
if (Object.keys(env).length === 0)
|
|
79
|
+
delete next.env;
|
|
80
|
+
else
|
|
81
|
+
next.env = env;
|
|
82
|
+
write(file, next);
|
|
83
|
+
return { changed: true, backup: b, notes };
|
|
84
|
+
}
|
|
85
|
+
export function currentProxyEnv() {
|
|
86
|
+
const env = readSettings(settingsPath()).env ?? {};
|
|
87
|
+
const out = {};
|
|
88
|
+
if (env.HTTPS_PROXY)
|
|
89
|
+
out.HTTPS_PROXY = env.HTTPS_PROXY;
|
|
90
|
+
if (env.NODE_EXTRA_CA_CERTS)
|
|
91
|
+
out.NODE_EXTRA_CA_CERTS = env.NODE_EXTRA_CA_CERTS;
|
|
92
|
+
if (env.CLAUDE_CODE_MAX_CONTEXT_TOKENS)
|
|
93
|
+
out.CLAUDE_CODE_MAX_CONTEXT_TOKENS = env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
// ---- Agent-title hook (PreToolUse Agent|Task) -------------------------------------------
|
|
97
|
+
const HOOK_MARK = "_clauderipple";
|
|
98
|
+
/** Adds or removes ClaudeRipple's PreToolUse hook. Only entries carrying our marker are ever touched. */
|
|
99
|
+
function shellQuote(value) {
|
|
100
|
+
return '"' + value.replace(/(["\\$`])/g, "\\$1") + '"';
|
|
101
|
+
}
|
|
102
|
+
export function setAgentTitleHook(enabled, cmd) {
|
|
103
|
+
const file = settingsPath();
|
|
104
|
+
const s = readSettings(file);
|
|
105
|
+
const hooks = (typeof s.hooks === "object" && s.hooks ? s.hooks : {});
|
|
106
|
+
const list = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
107
|
+
const kept = list.filter((e) => e[HOOK_MARK] !== "agent-title");
|
|
108
|
+
const notes = [];
|
|
109
|
+
if (enabled) {
|
|
110
|
+
const prefix = Object.entries(cmd.env ?? {})
|
|
111
|
+
.map(([key, value]) => /^[A-Za-z0-9_./:-]+$/.test(value) ? `${key}=${value}` : `${key}=${shellQuote(value)}`)
|
|
112
|
+
.join(" ");
|
|
113
|
+
const command = [prefix, shellQuote(cmd.node), shellQuote(cmd.script)].filter(Boolean).join(" ");
|
|
114
|
+
kept.push({
|
|
115
|
+
matcher: "Agent|Task",
|
|
116
|
+
hooks: [{ type: "command", command, timeout: 10 }],
|
|
117
|
+
[HOOK_MARK]: "agent-title",
|
|
118
|
+
});
|
|
119
|
+
notes.push("hooks.PreToolUse: ClaudeRipple agent-title hook added");
|
|
120
|
+
}
|
|
121
|
+
else if (kept.length !== list.length) {
|
|
122
|
+
notes.push("hooks.PreToolUse: ClaudeRipple agent-title hook removed");
|
|
123
|
+
}
|
|
124
|
+
const changed = JSON.stringify(kept) !== JSON.stringify(list);
|
|
125
|
+
if (!changed)
|
|
126
|
+
return { changed: false, backup: null, notes };
|
|
127
|
+
const b = backup(file);
|
|
128
|
+
hooks.PreToolUse = kept;
|
|
129
|
+
s.hooks = hooks;
|
|
130
|
+
write(file, s);
|
|
131
|
+
return { changed: true, backup: b, notes };
|
|
132
|
+
}
|
|
133
|
+
export function agentTitleHookEnabled() {
|
|
134
|
+
try {
|
|
135
|
+
const s = readSettings(settingsPath());
|
|
136
|
+
const list = s.hooks?.PreToolUse;
|
|
137
|
+
return Array.isArray(list) && list.some((e) => e[HOOK_MARK] === "agent-title");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// One supervisor interface, two implementations: launchd on macOS, Task Scheduler on Windows.
|
|
2
|
+
// Everything outside this module talks to the router's lifecycle through here.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { ConfigStore, configPath } from "../../router/src/config.js";
|
|
5
|
+
import { adminPort } from "../../router/src/admin.js";
|
|
6
|
+
import * as launchd from "./launchd.js";
|
|
7
|
+
import * as schtasks from "./schtasks.js";
|
|
8
|
+
export const isWindows = process.platform === "win32";
|
|
9
|
+
export const isSupported = isWindows || process.platform === "darwin";
|
|
10
|
+
/** What the supervisor is called on this platform, for messages the user reads. */
|
|
11
|
+
export function supervisorName() {
|
|
12
|
+
return isWindows ? "scheduled task" : "launchd agent";
|
|
13
|
+
}
|
|
14
|
+
export function installAgent(opts) {
|
|
15
|
+
return isWindows ? schtasks.installAgent(opts) : launchd.installAgent(opts);
|
|
16
|
+
}
|
|
17
|
+
export function removeAgent() {
|
|
18
|
+
return isWindows ? schtasks.removeAgent() : launchd.removeAgent();
|
|
19
|
+
}
|
|
20
|
+
export function agentState() {
|
|
21
|
+
return isWindows ? schtasks.agentState() : launchd.agentState();
|
|
22
|
+
}
|
|
23
|
+
export function agentPid() {
|
|
24
|
+
return isWindows ? schtasks.agentPid() : launchd.agentPid();
|
|
25
|
+
}
|
|
26
|
+
export function startAgent() {
|
|
27
|
+
return isWindows ? schtasks.startAgent() : launchd.startAgent();
|
|
28
|
+
}
|
|
29
|
+
export function stopAgent() {
|
|
30
|
+
if (!isWindows)
|
|
31
|
+
return launchd.stopAgent();
|
|
32
|
+
// Ask the router to drain first: a clean exit also ends the launcher's supervision loop, so the
|
|
33
|
+
// task does not relaunch it. Then stop the task itself to clear anything left behind.
|
|
34
|
+
askShutdown();
|
|
35
|
+
waitForExit(schtasks.agentPid(), 120_000);
|
|
36
|
+
return schtasks.stopAgent();
|
|
37
|
+
}
|
|
38
|
+
/** Blocking POST to the router's shutdown endpoint. Returns false if it did not answer. */
|
|
39
|
+
function askShutdown() {
|
|
40
|
+
try {
|
|
41
|
+
execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Invoke-RestMethod -Method Post -Uri '${adminUrl()}/api/shutdown' -TimeoutSec 10 | Out-Null`], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Waits for `pid` to disappear. Returns true if it exited within the budget. */
|
|
49
|
+
function waitForExit(pid, budgetMs, onProgress) {
|
|
50
|
+
if (pid === null)
|
|
51
|
+
return true;
|
|
52
|
+
const t0 = Date.now();
|
|
53
|
+
let lastTick = 0;
|
|
54
|
+
while (Date.now() - t0 < budgetMs) {
|
|
55
|
+
const now = schtasks.agentPid();
|
|
56
|
+
if (now === null || now !== pid)
|
|
57
|
+
return true;
|
|
58
|
+
const s = Math.floor((Date.now() - t0) / 1000);
|
|
59
|
+
if (s >= 5 && s !== lastTick && s % 5 === 0) {
|
|
60
|
+
lastTick = s;
|
|
61
|
+
onProgress?.(`draining… ${s}s (waiting for in-flight model calls)`);
|
|
62
|
+
}
|
|
63
|
+
sleepSync(1000);
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** Where the router's admin API is listening, per the current config. */
|
|
68
|
+
function adminUrl() {
|
|
69
|
+
const cfg = new ConfigStore(configPath()).get();
|
|
70
|
+
return `http://127.0.0.1:${adminPort(cfg)}`;
|
|
71
|
+
}
|
|
72
|
+
function sleepSync(ms) {
|
|
73
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Windows restart. There is no SIGTERM here — `process.kill(pid, "SIGTERM")` terminates the target
|
|
77
|
+
* outright, which is exactly the drain-cutting behaviour launchd's `kickstart -k` was fixed for on
|
|
78
|
+
* macOS — so ask the router to drain over its admin API, wait for the process to go, then start the
|
|
79
|
+
* task again. The launcher's supervision loop treats a clean exit as "stay stopped", so the restart
|
|
80
|
+
* has to be explicit.
|
|
81
|
+
*/
|
|
82
|
+
function restartWindows(opts) {
|
|
83
|
+
const pid = schtasks.agentPid();
|
|
84
|
+
if (pid === null)
|
|
85
|
+
return schtasks.startAgent() === "failed" ? "failed" : "kickstarted";
|
|
86
|
+
if (!askShutdown())
|
|
87
|
+
opts.onProgress?.("router did not answer /api/shutdown; waiting for it to exit anyway");
|
|
88
|
+
const exited = waitForExit(pid, opts.waitMs ?? 120_000, opts.onProgress);
|
|
89
|
+
if (!exited)
|
|
90
|
+
return "failed";
|
|
91
|
+
// A clean drain ends the launcher loop too, so the task has to be started again.
|
|
92
|
+
return schtasks.startAgent() === "failed" ? "failed" : "drained";
|
|
93
|
+
}
|
|
94
|
+
export function restartAgent(opts = {}) {
|
|
95
|
+
return isWindows ? restartWindows(opts) : launchd.restartAgent(opts);
|
|
96
|
+
}
|
|
97
|
+
/** Path to the supervisor's own definition, for status output. */
|
|
98
|
+
export function agentDefinitionPath() {
|
|
99
|
+
return isWindows ? `Task Scheduler\\${schtasks.taskName()}` : launchd.plistPath();
|
|
100
|
+
}
|