clauderipple 0.9.5 → 0.10.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 +67 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/translate.js +13 -2
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +108 -8
- package/package.json +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Code tab sessions on an SSH host route through Ripple.** Claude Code on a remote host went
|
|
8
|
+
straight to Anthropic and could not find the GPT workers, because Claude Desktop opens that
|
|
9
|
+
connection itself and never reads `RemoteForward` (#67). `clauderipple remote add <host>` copies
|
|
10
|
+
the CA and the generated agent files over plain `sh`, points the host's `settings.json` at a
|
|
11
|
+
router-held reverse tunnel on `127.0.0.1:18791`, re-syncs the agents whenever they change, and
|
|
12
|
+
`remote hosts` / `remote remove` manage them. It needs key-based `ssh` with no password prompt;
|
|
13
|
+
the mod is not on the remote host yet.
|
|
14
|
+
- **The logs page shows token usage for any period, and every provider.** The chips covered only
|
|
15
|
+
the last hour. A period control (1 hour, today, 7 days, 30 days, this month, all time, custom
|
|
16
|
+
dates) now reads an hourly history that survives restarts, a per-model table shows where the
|
|
17
|
+
tokens went, and the provider menu lists every configured provider instead of only the ones in
|
|
18
|
+
the last 200 requests, filtering the chips, the table and the recent requests together. A figure
|
|
19
|
+
too wide for a narrow pane or a 1,024px window steps its type down instead of being cut.
|
|
20
|
+
- **The band's limits row shows every provider.** Claude, ChatGPT, Antigravity (Gemini), OpenCode
|
|
21
|
+
Go and a Grok subscription each draw a group in their provider's colour, from one
|
|
22
|
+
`/api/limits` read; before, only Claude and ChatGPT did. At about 100 columns four providers
|
|
23
|
+
took roughly 207 cells as gauges against 96, so the row picks the roomiest of three densities
|
|
24
|
+
and gives up later groups only when none fits. Each provider can be kept off the status bar.
|
|
25
|
+
- **The Agents pane gives each agent a card.** The pane showed "no agents running · 2 done" over
|
|
26
|
+
an empty panel. Each card now holds the state, title, the model that answered and the effort it
|
|
27
|
+
was sent at, the API's real token counts with cache share, the tool count, and the elapsed time,
|
|
28
|
+
and keeps its size steady while the agent runs. Opening a card shows its conversation in the
|
|
29
|
+
pane, with a run of tool calls collapsed into one summary line ("3 files read, 1 command run"),
|
|
30
|
+
as the Claude app draws it.
|
|
31
|
+
- **The dashboard's Phone view pairs Ripple Remote and manages the connection.** Pairing used to
|
|
32
|
+
mean running `npm run remote -- pair` in a source checkout. The view now shows the host's state,
|
|
33
|
+
a pairing QR on request, the mode, the phones seen and a key rotation, answered on a
|
|
34
|
+
loopback-only `/local` API with a secret from a `0600` file. The published CLI and dashboard now
|
|
35
|
+
install the phone host, which runs under Windows and Linux user supervisors as well as launchd.
|
|
36
|
+
- **Ripple Remote tells you which session finished and what it did.** Notifications carried one
|
|
37
|
+
fixed line for every session; the host now sends the title and the last answer, approval request
|
|
38
|
+
or failure reason inside an encrypted blob, leaving Expo, APNs and FCM with only the fallback
|
|
39
|
+
text. Mac sessions push when a turn ends while the Mac is away, Claude app sessions send and
|
|
40
|
+
receive photos, a subagent shows its model and opens its own chat, and thinking no longer
|
|
41
|
+
splits a run (one real session went from 1,442 rows to 549).
|
|
42
|
+
- **The desktop has two skins, deep and flat.** A `skin` setting picks them on the quick settings
|
|
43
|
+
panel and the dashboard's Look card; deep stays the default. Flat follows the app's own surface
|
|
44
|
+
and quiet text colour, keeps one accent, and the band's labels, counts and controls carry the
|
|
45
|
+
right colour.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **Google and Antigravity prompt refusals are retried instead of failing the turn.** A refusal is
|
|
50
|
+
now returned unwritten and retried once along a configured fallback route with the original
|
|
51
|
+
request, and an Antigravity 429 is called a real limit only when fresh account and project quota
|
|
52
|
+
evidence says so.
|
|
53
|
+
- **Parallel tool results with images stay together on the OpenAI-compatible wire.** Three image
|
|
54
|
+
Reads used to interleave `tool|user|tool|user`, and DeepSeek refused them
|
|
55
|
+
("insufficient tool messages following tool_calls", #64). Images now wait until the run of tool
|
|
56
|
+
results ends.
|
|
57
|
+
- **A slow reasoning model passes the connection check.** Hive answers a streamed request in 0.3
|
|
58
|
+
seconds but a whole one in 3–23, past the 8-second probe, so a working key read as "could not
|
|
59
|
+
connect"; the check now streams and drops the body once the status is in.
|
|
60
|
+
- **A provider named in Hangul is named after its domain.** "하이브" slugged to nothing and showed
|
|
61
|
+
as "provider"; it now falls back to the URL's domain ("thehive").
|
|
62
|
+
- **A failed restart no longer ends the Windows supervisor loop.** One `Start-Process` error, such
|
|
63
|
+
as a locked log file, stopped the script and left the router and host unsupervised until logon.
|
|
64
|
+
- **A dark-mode select's options are readable.** Native options and optgroups now get opaque,
|
|
65
|
+
theme-aware backgrounds.
|
|
66
|
+
- **Slash commands stay available when settings fail to load.** A throw while loading settings
|
|
67
|
+
took `/ripple`, `/ripple-log` and `/ripple-bar` with it (#62); the settings step now has its own
|
|
68
|
+
catch, so the commands register either way.
|
|
69
|
+
|
|
3
70
|
## 0.9.5 — 2026-10-14
|
|
4
71
|
|
|
5
72
|
### Fixed
|
package/README.md
CHANGED
|
@@ -495,6 +495,37 @@ same ChatGPT provider as the search backend in `config.json`:
|
|
|
495
495
|
This is opt-in. With no `webSearch` setting, Claude Code's existing search path
|
|
496
496
|
is unchanged.
|
|
497
497
|
|
|
498
|
+
### Remote (SSH) sessions
|
|
499
|
+
|
|
500
|
+
Claude Desktop's Code tab can run a session on a remote Linux host over SSH.
|
|
501
|
+
Claude Code then runs **on that host**, where ClaudeRipple is not, so routed
|
|
502
|
+
models are unknown there (`Agent type 'gpt-6-1-sol' not found`). Connect the
|
|
503
|
+
host once from the machine ClaudeRipple runs on:
|
|
504
|
+
|
|
505
|
+
```bash
|
|
506
|
+
clauderipple remote add my-server # an ssh alias or user@host; --port N (default 18791)
|
|
507
|
+
clauderipple remote hosts # tunnel state per host (also in `remote status`)
|
|
508
|
+
clauderipple remote remove my-server # undo it
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
`remote add` needs key-based `ssh my-server` to work without a password prompt.
|
|
512
|
+
It copies the router's CA certificate (the public certificate only) to
|
|
513
|
+
`~/.clauderipple-remote/` on the host, points the host's
|
|
514
|
+
`~/.claude/settings.json` at the tunnel (`HTTPS_PROXY=http://127.0.0.1:18791`,
|
|
515
|
+
`NODE_EXTRA_CA_CERTS`; the original is kept as
|
|
516
|
+
`settings.json.clauderipple-backup`; a different proxy already there is refused
|
|
517
|
+
without `--force`), and copies the generated agent files. The router then keeps
|
|
518
|
+
a reverse SSH tunnel open from the host's `127.0.0.1:18791` to itself, restarts
|
|
519
|
+
it when it drops, and copies the agent files again whenever they change. The
|
|
520
|
+
host needs only `sh` — no Node and no ClaudeRipple.
|
|
521
|
+
|
|
522
|
+
While the tunnel is down, Claude Code on the host gets *connection refused*
|
|
523
|
+
rather than going to Anthropic around the router. The remote session only exists
|
|
524
|
+
while this machine's Claude Desktop is connected to it anyway. The tunnel
|
|
525
|
+
listens on the host's loopback only, but on a shared host every user there can
|
|
526
|
+
reach that port, and through it your router. The status mod and image
|
|
527
|
+
generation are not available on remote hosts yet.
|
|
528
|
+
|
|
498
529
|
### Codex app and Codex CLI
|
|
499
530
|
|
|
500
531
|
```bash
|
package/dist/app/package.json
CHANGED
|
@@ -25,13 +25,16 @@ import { fileURLToPath } from "node:url";
|
|
|
25
25
|
const MARKER = /^\s*\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/;
|
|
26
26
|
const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
27
27
|
const OUR_PREFIX = /^[^\s·]+(?:·[a-z]+)? · /;
|
|
28
|
-
const CLAUDE_ID = /^claude-(opus|sonnet|haiku|fable)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
|
|
28
|
+
const CLAUDE_ID = /^claude-(opus|sonnet|haiku|fable|mythos)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
|
|
29
29
|
/** The router's default `effortClamp` (config.ts), for when config.json cannot be read. */
|
|
30
30
|
const DEFAULT_CLAMP = { ultra: "max" };
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* A model's name in a title: the last word of its display name ("GPT-6.1 Sol" → "Sol"), without
|
|
33
|
+
* the vendor path a hosted id carries ("deepseek-ai/deepseek-v4.1-flash-1017" → "deepseek-v4.1-flash-1017").
|
|
34
|
+
*/
|
|
32
35
|
export function shortName(name) {
|
|
33
36
|
const words = name.trim().split(/\s+/);
|
|
34
|
-
return words[words.length - 1];
|
|
37
|
+
return words[words.length - 1].replace(/^.*\//, "");
|
|
35
38
|
}
|
|
36
39
|
function titleConfig() {
|
|
37
40
|
const names = { "gpt-5.6-terra": "Terra", "gpt-5.6-sol": "Sol", "gpt-5.6-luna": "Luna", "gpt-6-astra": "Astra", "gpt-6-sol": "Sol", "gpt-6-luna": "Luna" };
|
|
@@ -84,7 +87,7 @@ export function fromAgentFile(subagentType, dirs) {
|
|
|
84
87
|
}
|
|
85
88
|
export function claudePretty(modelId) {
|
|
86
89
|
const v = (modelId ?? "").trim().toLowerCase();
|
|
87
|
-
if (["opus", "sonnet", "haiku", "fable"].includes(v))
|
|
90
|
+
if (["opus", "sonnet", "haiku", "fable", "mythos"].includes(v))
|
|
88
91
|
return v[0].toUpperCase() + v.slice(1);
|
|
89
92
|
const m = CLAUDE_ID.exec(v);
|
|
90
93
|
return m ? m[1][0].toUpperCase() + m[1].slice(1) + m[2].replace(/-/g, ".") : null;
|
package/dist/cli/src/index.js
CHANGED
|
@@ -451,6 +451,12 @@ function help() {
|
|
|
451
451
|
status
|
|
452
452
|
start | stop | restart
|
|
453
453
|
logs [-n N] [-f]
|
|
454
|
+
remote [install|uninstall|status|pair] run or manage the phone host (macOS, Windows, Linux systemd)
|
|
455
|
+
remote add <ssh-host> [--port N] [--force]
|
|
456
|
+
route Claude Desktop sessions that run on a remote (SSH) host through ClaudeRipple:
|
|
457
|
+
sets up the host over your key-based ssh and keeps a reverse tunnel open (remote port, default 18791)
|
|
458
|
+
remote remove <ssh-host> undo "remote add" on that host and stop its tunnel
|
|
459
|
+
remote hosts list remote (SSH) hosts and their tunnels (also shown by "remote status")
|
|
454
460
|
config print the config file path
|
|
455
461
|
ui open the dashboard in your browser
|
|
456
462
|
tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
|
|
@@ -473,6 +479,43 @@ Home directory: ${homeDir()} (override with CLAUDERIPPLE_HOME)`);
|
|
|
473
479
|
}
|
|
474
480
|
try {
|
|
475
481
|
switch (cmd) {
|
|
482
|
+
case "remote": {
|
|
483
|
+
const sub = args[1];
|
|
484
|
+
// Remote (SSH) hosts for Claude Desktop sessions; every other `remote` verb is the phone host's.
|
|
485
|
+
if (sub === "add" || sub === "remove" || sub === "hosts") {
|
|
486
|
+
const { remoteAdd, remoteRemove, remoteHosts, REMOTE_SSH_USAGE } = await import("./remote.js");
|
|
487
|
+
const host = args[2];
|
|
488
|
+
if (sub === "hosts")
|
|
489
|
+
await remoteHosts();
|
|
490
|
+
else if (!host || host.startsWith("--"))
|
|
491
|
+
throw new Error(REMOTE_SSH_USAGE);
|
|
492
|
+
else if (sub === "add") {
|
|
493
|
+
const port = opt("port");
|
|
494
|
+
await remoteAdd(host, { ...(port !== undefined ? { port: Number(port) } : {}), force: flag("force") });
|
|
495
|
+
}
|
|
496
|
+
else
|
|
497
|
+
await remoteRemove(host);
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
const script = path.resolve(here, "../../remote/src/main" + path.extname(fileURLToPath(import.meta.url)));
|
|
501
|
+
const { spawn } = await import("node:child_process");
|
|
502
|
+
const child = spawn(installedRuntime.node, [script, ...args.slice(1)], { stdio: "inherit", env: { ...process.env, ...installedRuntime.env } });
|
|
503
|
+
for (const signal of ["SIGTERM", "SIGINT"])
|
|
504
|
+
process.on(signal, () => child.kill(signal));
|
|
505
|
+
process.exitCode = await new Promise((resolve, reject) => {
|
|
506
|
+
child.once("error", reject);
|
|
507
|
+
child.once("exit", code => resolve(code ?? 1));
|
|
508
|
+
});
|
|
509
|
+
if (sub === "status") {
|
|
510
|
+
// Only when there are any, so the phone host's status reads exactly as before otherwise.
|
|
511
|
+
const { configuredHosts, remoteHosts } = await import("./remote.js");
|
|
512
|
+
if (configuredHosts(configPath()).length > 0) {
|
|
513
|
+
console.log("\nremote (SSH) hosts:");
|
|
514
|
+
await remoteHosts();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
476
519
|
case "install":
|
|
477
520
|
await install();
|
|
478
521
|
break;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// clauderipple remote add|remove|hosts — Claude Desktop sessions on a remote (SSH) host (issue #67).
|
|
2
|
+
//
|
|
3
|
+
// `add` prepares the remote over the user's own key-based ssh (CA certificate, settings.json env,
|
|
4
|
+
// agent files; remote-host.ts), records the host in config.json, and asks the running router to
|
|
5
|
+
// open the reverse tunnel (remote-tunnel.ts). `remove` undoes exactly that. The phone host keeps
|
|
6
|
+
// `remote install|uninstall|status|pair` and the bare `remote`; these verbs are new beside them.
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { defaultAgentDir } from "../../router/src/agents.js";
|
|
9
|
+
import { ConfigStore, adminPort, configPath, homeDir } from "../../router/src/config.js";
|
|
10
|
+
import { DEFAULT_REMOTE_PORT, REMOTE_DIR, SETTINGS_BACKUP_SUFFIX, SshConnectError, assetLines, hostProblem, inspectRemote, planAgents, portProblem, readLocalAssets, remoteCaPath, remoteProxyUrl, runRemoteScript, script, settingsGuardLine, settingsWriteLines, spawnSsh, } from "../../router/src/remote-host.js";
|
|
11
|
+
import { refuseForeignProxy } from "./settings.js";
|
|
12
|
+
function parseSettings(text, host) {
|
|
13
|
+
if (text === null || text.trim() === "")
|
|
14
|
+
return {};
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
throw new Error(`${host}:~/.claude/settings.json is not valid JSON (${e.message}); fix it first — nothing was changed`);
|
|
21
|
+
}
|
|
22
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
23
|
+
throw new Error(`${host}:~/.claude/settings.json is not a JSON object; nothing was changed`);
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
function envOf(s) {
|
|
27
|
+
return { ...(s.env ?? {}) };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The remote settings.json with our two env keys set, or null when it already has them. Same rule
|
|
31
|
+
* as the local install (settings.ts): every other key is kept, and a different HTTPS_PROXY is
|
|
32
|
+
* refused unless forced.
|
|
33
|
+
*/
|
|
34
|
+
export function mergeRemoteSettings(text, host, want, force) {
|
|
35
|
+
const s = parseSettings(text, host);
|
|
36
|
+
const env = envOf(s);
|
|
37
|
+
try {
|
|
38
|
+
refuseForeignProxy(env.HTTPS_PROXY, want.proxyUrl, force);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
throw new Error(`${host}: ${e.message}`);
|
|
42
|
+
}
|
|
43
|
+
const notes = [];
|
|
44
|
+
if (env.HTTPS_PROXY && env.HTTPS_PROXY !== want.proxyUrl)
|
|
45
|
+
notes.push(`replaced HTTPS_PROXY ${env.HTTPS_PROXY}`);
|
|
46
|
+
if (env.NODE_EXTRA_CA_CERTS && env.NODE_EXTRA_CA_CERTS !== want.caPath)
|
|
47
|
+
notes.push(`replaced NODE_EXTRA_CA_CERTS ${env.NODE_EXTRA_CA_CERTS}`);
|
|
48
|
+
if (env.HTTPS_PROXY === want.proxyUrl && env.NODE_EXTRA_CA_CERTS === want.caPath)
|
|
49
|
+
return { content: null, notes };
|
|
50
|
+
env.HTTPS_PROXY = want.proxyUrl;
|
|
51
|
+
env.NODE_EXTRA_CA_CERTS = want.caPath;
|
|
52
|
+
return { content: JSON.stringify({ ...s, env }, null, 2) + "\n", notes };
|
|
53
|
+
}
|
|
54
|
+
/** The remote settings.json without our two keys (only where they still hold our values), or null when unchanged. */
|
|
55
|
+
export function unmergeRemoteSettings(text, host, ours) {
|
|
56
|
+
if (text === null)
|
|
57
|
+
return { content: null, notes: [] };
|
|
58
|
+
const s = parseSettings(text, host);
|
|
59
|
+
const env = envOf(s);
|
|
60
|
+
const notes = [];
|
|
61
|
+
let changed = false;
|
|
62
|
+
for (const [key, value] of [["HTTPS_PROXY", ours.proxyUrl], ["NODE_EXTRA_CA_CERTS", ours.caPath]]) {
|
|
63
|
+
if (env[key] === value) {
|
|
64
|
+
delete env[key];
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
else if (env[key])
|
|
68
|
+
notes.push(`left ${key}=${env[key]} (not ours)`);
|
|
69
|
+
}
|
|
70
|
+
if (!changed)
|
|
71
|
+
return { content: null, notes };
|
|
72
|
+
const next = { ...s };
|
|
73
|
+
if (Object.keys(env).length === 0)
|
|
74
|
+
delete next.env;
|
|
75
|
+
else
|
|
76
|
+
next.env = env;
|
|
77
|
+
return { content: JSON.stringify(next, null, 2) + "\n", notes };
|
|
78
|
+
}
|
|
79
|
+
export function configuredHosts(configFile) {
|
|
80
|
+
try {
|
|
81
|
+
const raw = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
82
|
+
const hosts = raw.remote?.hosts;
|
|
83
|
+
return Array.isArray(hosts) ? hosts.filter((h) => !!h && typeof h.host === "string" && typeof h.port === "number") : [];
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Rewrite `remote.hosts` in config.json, everything else as it was; atomic, as the router hot-reloads it. */
|
|
90
|
+
function updateHosts(configFile, mutate) {
|
|
91
|
+
if (!fs.existsSync(configFile))
|
|
92
|
+
throw new Error(`no config at ${configFile}; run \`clauderipple install\` first`);
|
|
93
|
+
const raw = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
94
|
+
const remote = (raw.remote && typeof raw.remote === "object" ? raw.remote : {});
|
|
95
|
+
const hosts = mutate(Array.isArray(remote.hosts) ? remote.hosts : []);
|
|
96
|
+
if (hosts.length > 0)
|
|
97
|
+
raw.remote = { ...remote, hosts };
|
|
98
|
+
else
|
|
99
|
+
delete raw.remote;
|
|
100
|
+
const tmp = `${configFile}.tmp-${process.pid}-${Date.now()}`;
|
|
101
|
+
fs.writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n");
|
|
102
|
+
fs.renameSync(tmp, configFile);
|
|
103
|
+
}
|
|
104
|
+
async function routerCall(adminUrl, method, pathname) {
|
|
105
|
+
if (adminUrl === null)
|
|
106
|
+
return { ok: false, why: "not asked" };
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(`${adminUrl}${pathname}`, { method, signal: AbortSignal.timeout(5_000) });
|
|
109
|
+
if (res.status === 404 || res.status === 501)
|
|
110
|
+
return { ok: false, why: "the running router does not manage remote tunnels (an older version?); restart it with `clauderipple restart`" };
|
|
111
|
+
if (!res.ok)
|
|
112
|
+
return { ok: false, why: `the router answered HTTP ${res.status}` };
|
|
113
|
+
return { ok: true, hosts: (await res.json()).hosts ?? [] };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { ok: false, why: "the router is not answering; the tunnel starts when it does (`clauderipple start`)" };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function defaultAdminUrl(configFile) {
|
|
120
|
+
return `http://127.0.0.1:${adminPort(new ConfigStore(configFile).get())}`;
|
|
121
|
+
}
|
|
122
|
+
function describe(s) {
|
|
123
|
+
return `${s.state}${s.state === "up" ? ` since ${s.since}` : ""}${s.lastError ? ` — ${s.lastError}` : ""}`;
|
|
124
|
+
}
|
|
125
|
+
function resolved(opts) {
|
|
126
|
+
const configFile = opts.configFile ?? configPath();
|
|
127
|
+
return {
|
|
128
|
+
run: opts.run ?? spawnSsh,
|
|
129
|
+
home: opts.home ?? homeDir(),
|
|
130
|
+
agentDir: opts.agentDir ?? defaultAgentDir(),
|
|
131
|
+
configFile,
|
|
132
|
+
adminUrl: opts.adminUrl === undefined ? defaultAdminUrl(configFile) : opts.adminUrl,
|
|
133
|
+
print: opts.print ?? ((line) => console.log(line)),
|
|
134
|
+
waitMs: opts.waitMs ?? 20_000,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function reachError(host, e) {
|
|
138
|
+
if (e instanceof SshConnectError) {
|
|
139
|
+
return new Error(`${e.message}\nClaudeRipple uses your own ssh with key-based login and never asks for a password. Set that up first ` +
|
|
140
|
+
`(for example \`ssh-copy-id ${host}\`), check that \`ssh ${host} true\` runs without a prompt, then run this again.`);
|
|
141
|
+
}
|
|
142
|
+
return e;
|
|
143
|
+
}
|
|
144
|
+
// ---- commands ------------------------------------------------------------------------------
|
|
145
|
+
export async function remoteAdd(host, o = {}) {
|
|
146
|
+
const problem = hostProblem(host);
|
|
147
|
+
if (problem)
|
|
148
|
+
throw new Error(problem);
|
|
149
|
+
const port = o.port ?? DEFAULT_REMOTE_PORT;
|
|
150
|
+
const portError = portProblem(port);
|
|
151
|
+
if (portError)
|
|
152
|
+
throw new Error(`--port: ${portError}`);
|
|
153
|
+
const r = resolved(o);
|
|
154
|
+
const assets = readLocalAssets({ home: r.home, agentDir: r.agentDir });
|
|
155
|
+
if (!fs.existsSync(r.configFile))
|
|
156
|
+
throw new Error(`no config at ${r.configFile}; run \`clauderipple install\` first`);
|
|
157
|
+
let state;
|
|
158
|
+
try {
|
|
159
|
+
state = await inspectRemote(r.run, host);
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
throw reachError(host, e);
|
|
163
|
+
}
|
|
164
|
+
r.print(`✓ ${host}: key-based ssh works (home ${state.home})`);
|
|
165
|
+
const want = { proxyUrl: remoteProxyUrl(port), caPath: remoteCaPath(state.home) };
|
|
166
|
+
const settings = mergeRemoteSettings(state.settings, host, want, o.force === true);
|
|
167
|
+
const plan = planAgents(assets.agents.map((a) => a.name), state);
|
|
168
|
+
// The CA goes before the env that names it; the guard before both, so a refusal changes nothing.
|
|
169
|
+
const lines = settings.content !== null
|
|
170
|
+
? [settingsGuardLine(state), ...assetLines(assets, plan, want), ...settingsWriteLines(state, settings.content)]
|
|
171
|
+
: assetLines(assets, plan, want);
|
|
172
|
+
await runRemoteScript(r.run, host, script(lines));
|
|
173
|
+
r.print(`✓ CA certificate copied to ${want.caPath} (the public certificate only)`);
|
|
174
|
+
if (settings.content !== null) {
|
|
175
|
+
r.print(`✓ ${host}:~/.claude/settings.json env: HTTPS_PROXY=${want.proxyUrl}, NODE_EXTRA_CA_CERTS=${want.caPath}`);
|
|
176
|
+
if (state.settings !== null && !state.backup)
|
|
177
|
+
r.print(` backup: ~/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}`);
|
|
178
|
+
}
|
|
179
|
+
else
|
|
180
|
+
r.print(`✓ ${host}:~/.claude/settings.json already points at the tunnel`);
|
|
181
|
+
for (const n of settings.notes)
|
|
182
|
+
r.print(` note: ${n}`);
|
|
183
|
+
r.print(`✓ ${plan.write.length} agent file${plan.write.length === 1 ? "" : "s"} copied to ~/.claude/agents${plan.remove.length ? `, ${plan.remove.length} no longer generated removed` : ""}`);
|
|
184
|
+
for (const n of plan.skipped)
|
|
185
|
+
r.print(` note: ~/.claude/agents/${n}.md on ${host} is not ours; left alone`);
|
|
186
|
+
updateHosts(r.configFile, (hosts) => [...hosts.filter((h) => h.host !== host), { host, port }]);
|
|
187
|
+
r.print(`✓ ${host} saved in ${r.configFile}`);
|
|
188
|
+
const reload = await routerCall(r.adminUrl, "POST", "/api/remote/reload");
|
|
189
|
+
if (!reload.ok) {
|
|
190
|
+
if (r.adminUrl !== null)
|
|
191
|
+
r.print(` ${reload.why}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// The tunnel comes up asynchronously; say how it went rather than that it was asked for.
|
|
195
|
+
const deadline = Date.now() + r.waitMs;
|
|
196
|
+
let last = reload.hosts.find((h) => h.host === host);
|
|
197
|
+
while (last?.state !== "up" && Date.now() < deadline) {
|
|
198
|
+
await new Promise((res) => setTimeout(res, 500));
|
|
199
|
+
const now = await routerCall(r.adminUrl, "GET", "/api/remote");
|
|
200
|
+
if (now.ok)
|
|
201
|
+
last = now.hosts.find((h) => h.host === host);
|
|
202
|
+
}
|
|
203
|
+
if (last?.state === "up")
|
|
204
|
+
r.print(`✓ tunnel up: 127.0.0.1:${port} on ${host} reaches ClaudeRipple. New Claude Desktop sessions on ${host} go through it.`);
|
|
205
|
+
else {
|
|
206
|
+
r.print(`✗ tunnel ${last ? describe(last) : "not started"}. The router keeps retrying; \`clauderipple remote hosts\` shows its state.`);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export async function remoteRemove(host, o = {}) {
|
|
211
|
+
const problem = hostProblem(host);
|
|
212
|
+
if (problem)
|
|
213
|
+
throw new Error(problem);
|
|
214
|
+
const r = resolved(o);
|
|
215
|
+
const entry = configuredHosts(r.configFile).find((h) => h.host === host);
|
|
216
|
+
// The tunnel goes first, so nothing re-copies files while they are being removed.
|
|
217
|
+
if (entry) {
|
|
218
|
+
updateHosts(r.configFile, (hosts) => hosts.filter((h) => h.host !== host));
|
|
219
|
+
r.print(`✓ ${host} removed from ${r.configFile}`);
|
|
220
|
+
const reload = await routerCall(r.adminUrl, "POST", "/api/remote/reload");
|
|
221
|
+
r.print(reload.ok ? "✓ tunnel stopped" : ` ${reload.why}`);
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
r.print(` ${host} was not in ${r.configFile}; cleaning up the host anyway`);
|
|
225
|
+
let state;
|
|
226
|
+
try {
|
|
227
|
+
state = await inspectRemote(r.run, host);
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
const err = reachError(host, e);
|
|
231
|
+
throw new Error(`${err.message}\nNothing on ${host} was changed. Run \`clauderipple remote remove ${host}\` again once it is reachable.`);
|
|
232
|
+
}
|
|
233
|
+
const record = state.record ?? { agents: [] };
|
|
234
|
+
const ours = { proxyUrl: record.proxyUrl ?? remoteProxyUrl(entry?.port ?? DEFAULT_REMOTE_PORT), caPath: record.caPath ?? remoteCaPath(state.home) };
|
|
235
|
+
const settings = unmergeRemoteSettings(state.settings, host, ours);
|
|
236
|
+
const lines = [];
|
|
237
|
+
if (settings.content !== null)
|
|
238
|
+
lines.push(...settingsWriteLines(state, settings.content));
|
|
239
|
+
lines.push(...planAgents([], { record, agentFiles: state.agentFiles }).remove.map((n) => `rm -f "$HOME/.claude/agents/${n}.md"`));
|
|
240
|
+
lines.push(`if [ -n "$HOME" ] && [ "$HOME" != "/" ]; then rm -rf "$HOME/${REMOTE_DIR}"; fi`);
|
|
241
|
+
await runRemoteScript(r.run, host, script(lines));
|
|
242
|
+
r.print(settings.content !== null ? `✓ ${host}:~/.claude/settings.json: HTTPS_PROXY and NODE_EXTRA_CA_CERTS removed (everything else kept)` : `✓ ${host}:~/.claude/settings.json had no ClaudeRipple keys`);
|
|
243
|
+
for (const n of settings.notes)
|
|
244
|
+
r.print(` note: ${n}`);
|
|
245
|
+
r.print(`✓ ${record.agents.length} agent file${record.agents.length === 1 ? "" : "s"} we copied removed, and ~/${REMOTE_DIR}`);
|
|
246
|
+
if (state.backup)
|
|
247
|
+
r.print(` the original settings.json is still in ~/.claude/settings.json${SETTINGS_BACKUP_SUFFIX}`);
|
|
248
|
+
}
|
|
249
|
+
export async function remoteHosts(o = {}) {
|
|
250
|
+
const r = resolved(o);
|
|
251
|
+
const hosts = configuredHosts(r.configFile);
|
|
252
|
+
if (hosts.length === 0) {
|
|
253
|
+
r.print("no remote (SSH) hosts. Add one with: clauderipple remote add <ssh-host> [--port N]");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const live = await routerCall(r.adminUrl, "GET", "/api/remote");
|
|
257
|
+
const w = Math.max(...hosts.map((h) => h.host.length));
|
|
258
|
+
for (const h of hosts) {
|
|
259
|
+
const s = live.ok ? live.hosts.find((x) => x.host === h.host) : undefined;
|
|
260
|
+
r.print(`${h.host.padEnd(w)} 127.0.0.1:${h.port} ${s ? describe(s) : live.ok ? "not started" : live.why}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export const REMOTE_SSH_USAGE = "usage: clauderipple remote add <ssh-host> [--port N] [--force] | remote remove <ssh-host> | remote hosts";
|
package/dist/cli/src/schtasks.js
CHANGED
|
@@ -94,27 +94,32 @@ export function taskDefinitionMatches(value, expected) {
|
|
|
94
94
|
* is not, because that is `stop` or the drain half of `restart`, which starts the task again.
|
|
95
95
|
* A router that keeps dying immediately backs off instead of spinning.
|
|
96
96
|
*/
|
|
97
|
-
function
|
|
98
|
-
const launcher = path.join(opts.home, "clauderipple-router.ps1");
|
|
97
|
+
export function renderLauncher(opts) {
|
|
99
98
|
const logDir = path.join(opts.home, "logs");
|
|
100
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
101
99
|
const env = { CLAUDERIPPLE_HOME: opts.home, ...(opts.env ?? {}) };
|
|
102
|
-
const
|
|
103
|
-
|
|
100
|
+
const quoteArg = (arg) => ps(`"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`);
|
|
101
|
+
// No `$ErrorActionPreference = 'Stop'`: one failed Start-Process (a locked log file, say) would end
|
|
102
|
+
// the loop and leave the router or host unsupervised; the loop retries instead.
|
|
103
|
+
return [
|
|
104
104
|
...Object.entries(env).map(([k, v]) => `$env:${k} = ${ps(v)}`),
|
|
105
|
-
`$routerArgs = @(${opts.args.map(
|
|
105
|
+
`$routerArgs = @(${opts.args.map(quoteArg).join(", ")})`,
|
|
106
106
|
`$out = ${ps(path.join(logDir, "launcher.log"))}`,
|
|
107
107
|
`$err = ${ps(path.join(logDir, "launcher.err.log"))}`,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
"$quickFailures = 0",
|
|
109
|
+
"while ($true) {",
|
|
110
|
+
" $startedAt = Get-Date",
|
|
111
111
|
` $p = Start-Process -FilePath ${ps(opts.program)} -ArgumentList $routerArgs -WindowStyle Hidden -PassThru -Wait -RedirectStandardOutput $out -RedirectStandardError $err`,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
];
|
|
117
|
-
|
|
112
|
+
" if ($p.ExitCode -eq 0) { break }",
|
|
113
|
+
" if (((Get-Date) - $startedAt).TotalSeconds -lt 10) { $quickFailures++ } else { $quickFailures = 0 }",
|
|
114
|
+
" Start-Sleep -Seconds $(if ($quickFailures -ge 5) { 60 } else { 2 })",
|
|
115
|
+
"}",
|
|
116
|
+
].join("\r\n") + "\r\n";
|
|
117
|
+
}
|
|
118
|
+
function writeLauncher(opts) {
|
|
119
|
+
const launcher = path.join(opts.home, `${opts.name ?? "clauderipple-router"}.ps1`);
|
|
120
|
+
const logDir = path.join(opts.home, "logs");
|
|
121
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
122
|
+
fs.writeFileSync(launcher, renderLauncher(opts));
|
|
118
123
|
return launcher;
|
|
119
124
|
}
|
|
120
125
|
/**
|
|
@@ -123,9 +128,9 @@ function writeLauncher(opts) {
|
|
|
123
128
|
* DisallowStartIfOnBatteries/StopIfGoingOnBatteries to false — so reading the switch names gave
|
|
124
129
|
* null, and until #38 no existing task ever matched: every install re-registered.
|
|
125
130
|
*/
|
|
126
|
-
export function existingTaskDefinition() {
|
|
131
|
+
export function existingTaskDefinition(name = taskName()) {
|
|
127
132
|
const existing = run([
|
|
128
|
-
`$t = Get-ScheduledTask -TaskName ${ps(
|
|
133
|
+
`$t = Get-ScheduledTask -TaskName ${ps(name)} -ErrorAction SilentlyContinue`,
|
|
129
134
|
`if ($null -eq $t) { 'null'; exit 0 }`,
|
|
130
135
|
`$a = @($t.Actions); $p = $t.Principal; $tr = @($t.Triggers); $s = $t.Settings`,
|
|
131
136
|
`[pscustomobject]@{ actionCount = $a.Count; execute = $a[0].Execute; arguments = $a[0].Arguments; userId = $p.UserId; runLevel = [int]$p.RunLevel; logonType = [int]$p.LogonType; triggerCount = $tr.Count; triggerType = $tr[0].CimClass.CimClassName; triggerUserId = $tr[0].UserId; restartCount = $s.RestartCount; restartInterval = [string]$s.RestartInterval; executionTimeLimit = [string]$s.ExecutionTimeLimit; multipleInstances = [int]$s.MultipleInstances; allowStartIfOnBatteries = $s.DisallowStartIfOnBatteries -eq $false; dontStopIfGoingOnBatteries = $s.StopIfGoingOnBatteries -eq $false } | ConvertTo-Json -Compress`,
|
|
@@ -141,7 +146,8 @@ export function existingTaskDefinition() {
|
|
|
141
146
|
}
|
|
142
147
|
}
|
|
143
148
|
export function installAgent(opts) {
|
|
144
|
-
const
|
|
149
|
+
const name = opts.name ?? taskName();
|
|
150
|
+
const launcher = writeLauncher({ ...opts, args: opts.args ?? [] });
|
|
145
151
|
const user = taskUser();
|
|
146
152
|
const powershellArgs = `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
|
|
147
153
|
// PowerShell is started inside a headless console host. Started directly, it gets a console from
|
|
@@ -151,39 +157,41 @@ export function installAgent(opts) {
|
|
|
151
157
|
// for `powershell.exe`, none for `conhost.exe --headless powershell.exe`, and both ran the script).
|
|
152
158
|
const execute = "conhost.exe";
|
|
153
159
|
const actionArgs = `--headless powershell.exe ${powershellArgs}`;
|
|
154
|
-
const existing = existingTaskDefinition();
|
|
160
|
+
const existing = existingTaskDefinition(name);
|
|
155
161
|
// Updating the generated launcher does not require re-registering an otherwise correct task.
|
|
156
162
|
// Some Windows installations protect an existing per-user task's security descriptor so that
|
|
157
163
|
// even the same unelevated user gets E_ACCESSDENIED from Register-ScheduledTask -Force. Treat the
|
|
158
164
|
// verified existing definition as success; never silently accept a same-named foreign task.
|
|
159
165
|
if (taskDefinitionMatches(existing, { execute, arguments: actionArgs, userId: user }))
|
|
160
166
|
return launcher;
|
|
161
|
-
const script =
|
|
167
|
+
const script = renderTaskRegistration(name, user, execute, actionArgs);
|
|
168
|
+
const r = run(script);
|
|
169
|
+
if (!r.ok) {
|
|
170
|
+
// Retain the pre-headless task only when its entire definition matches.
|
|
171
|
+
if (taskDefinitionMatches(existing, { execute: "powershell.exe", arguments: powershellArgs, userId: user }))
|
|
172
|
+
return launcher;
|
|
173
|
+
throw new Error(`Register-ScheduledTask failed: ${r.out}`);
|
|
174
|
+
}
|
|
175
|
+
return launcher;
|
|
176
|
+
}
|
|
177
|
+
export function renderTaskRegistration(name, user, execute, actionArgs) {
|
|
178
|
+
return [
|
|
162
179
|
`$ErrorActionPreference = 'Stop'`,
|
|
163
180
|
`$a = New-ScheduledTaskAction -Execute ${ps(execute)} -Argument ${ps(actionArgs)}`,
|
|
164
181
|
`$t = New-ScheduledTaskTrigger -AtLogOn -User ${ps(user)}`,
|
|
165
182
|
// Limited: the router needs no elevation, and asking for it would put a UAC prompt at every logon.
|
|
166
183
|
`$p = New-ScheduledTaskPrincipal -UserId ${ps(user)} -LogonType Interactive -RunLevel Limited`,
|
|
167
184
|
`$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 99 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew`,
|
|
168
|
-
`Register-ScheduledTask -TaskName ${ps(
|
|
185
|
+
`Register-ScheduledTask -TaskName ${ps(name)} -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null`,
|
|
169
186
|
`'ok'`,
|
|
170
187
|
].join("; ");
|
|
171
|
-
const r = run(script);
|
|
172
|
-
if (!r.ok) {
|
|
173
|
-
// The task from before #49 still starts and supervises the router, only with a window. On an
|
|
174
|
-
// install that cannot re-register it (E_ACCESSDENIED, above), keeping it beats failing the update.
|
|
175
|
-
if (taskDefinitionMatches(existing, { execute: "powershell.exe", arguments: powershellArgs, userId: user }))
|
|
176
|
-
return launcher;
|
|
177
|
-
throw new Error(`Register-ScheduledTask failed: ${r.out}`);
|
|
178
|
-
}
|
|
179
|
-
return launcher;
|
|
180
188
|
}
|
|
181
|
-
export function removeAgent() {
|
|
182
|
-
const r = run(`Unregister-ScheduledTask -TaskName ${ps(
|
|
189
|
+
export function removeAgent(name = taskName()) {
|
|
190
|
+
const r = run(`Unregister-ScheduledTask -TaskName ${ps(name)} -Confirm:$false -ErrorAction Stop; 'ok'`);
|
|
183
191
|
return r.ok;
|
|
184
192
|
}
|
|
185
|
-
export function agentState() {
|
|
186
|
-
const r = run(`(Get-ScheduledTask -TaskName ${ps(
|
|
193
|
+
export function agentState(name = taskName()) {
|
|
194
|
+
const r = run(`(Get-ScheduledTask -TaskName ${ps(name)} -ErrorAction Stop).State`);
|
|
187
195
|
if (!r.ok)
|
|
188
196
|
return "not-loaded";
|
|
189
197
|
return /^Running/im.test(r.out) ? "running" : "loaded";
|
|
@@ -205,14 +213,14 @@ export function agentPid() {
|
|
|
205
213
|
const n = Number(r.out.trim());
|
|
206
214
|
return r.ok && Number.isFinite(n) && n > 0 ? n : null;
|
|
207
215
|
}
|
|
208
|
-
export function startAgent() {
|
|
209
|
-
if (agentPid() !== null)
|
|
216
|
+
export function startAgent(name = taskName()) {
|
|
217
|
+
if (name === taskName() ? agentPid() !== null : agentState(name) === "running")
|
|
210
218
|
return "already-running";
|
|
211
|
-
const r = run(`Start-ScheduledTask -TaskName ${ps(
|
|
219
|
+
const r = run(`Start-ScheduledTask -TaskName ${ps(name)} -ErrorAction Stop; 'ok'`);
|
|
212
220
|
return r.ok ? "started" : "failed";
|
|
213
221
|
}
|
|
214
|
-
export function stopAgent() {
|
|
215
|
-
//
|
|
216
|
-
run(`Stop-ScheduledTask -TaskName ${ps(
|
|
222
|
+
export function stopAgent(name = taskName()) {
|
|
223
|
+
// Host callers first request authenticated graceful shutdown and wait for its clean exit.
|
|
224
|
+
run(`Stop-ScheduledTask -TaskName ${ps(name)} -ErrorAction SilentlyContinue`);
|
|
217
225
|
return true;
|
|
218
226
|
}
|
package/dist/cli/src/settings.js
CHANGED
|
@@ -36,7 +36,8 @@ export const MODEL_SLOT_ENV = {
|
|
|
36
36
|
smallFast: "ANTHROPIC_SMALL_FAST_MODEL",
|
|
37
37
|
subagent: "CLAUDE_CODE_SUBAGENT_MODEL",
|
|
38
38
|
};
|
|
39
|
-
|
|
39
|
+
/** Also applied to a remote host's settings.json (remote.ts): one rule for whose proxy wins. */
|
|
40
|
+
export function refuseForeignProxy(existing, proxyUrl, force) {
|
|
40
41
|
if (existing && existing !== proxyUrl && !force) {
|
|
41
42
|
throw new Error(`settings.json env.HTTPS_PROXY is already "${existing}". Re-run with --force to replace it, or uninstall the other proxy first.`);
|
|
42
43
|
}
|