clauderipple 0.9.5 → 0.11.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 +118 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +70 -0
- package/README.pt-BR.md +45 -0
- package/README.zh-CN.md +17 -0
- package/dist/app/assets/icon.icns +0 -0
- package/dist/app/assets/icon.ico +0 -0
- package/dist/app/assets/icon.png +0 -0
- package/dist/app/assets/trayDirectTemplate.png +0 -0
- package/dist/app/assets/trayDirectTemplate@2x.png +0 -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/trayWinDirect.png +0 -0
- package/dist/app/assets/trayWinDirect@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/claude-desktop.js +67 -0
- package/dist/app/dist/main.js +423 -110
- package/dist/app/dist/preload.js +21 -0
- package/dist/app/dist/protocol.js +121 -0
- package/dist/app/dist/status-model.js +87 -0
- package/dist/app/dist/window.js +133 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/awake-setup.js +314 -0
- package/dist/cli/src/bundle.js +9 -5
- package/dist/cli/src/direct.js +159 -0
- package/dist/cli/src/doctor.js +205 -0
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +227 -31
- package/dist/cli/src/launchd.js +29 -20
- package/dist/cli/src/launcher.js +215 -0
- package/dist/cli/src/power.js +182 -0
- package/dist/cli/src/registration.js +157 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +50 -40
- package/dist/cli/src/settings.js +26 -1
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +473 -227
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +237 -32
- package/dist/mod/hooks/session.ts +102 -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 +903 -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 +120 -4
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +408 -27
- package/dist/mod/types/index.d.ts +66 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +336 -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 +331 -5
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +94 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +108 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +191 -14
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
- package/dist/router/src/providers/google/transport.js +1 -1
- package/dist/router/src/providers/google/usage.js +302 -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/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +371 -261
- 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/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +1083 -59
- package/dist/ui/i18n.js +670 -69
- package/dist/ui/index.html +33 -6
- package/dist/ui/style.css +177 -0
- package/docs/ARCHITECTURE.md +402 -14
- package/package.json +8 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Hold new model calls for a moment, without refusing them.
|
|
2
|
+
//
|
|
3
|
+
// The way back from the iPhone hotspot to Wi-Fi (tether.ts) drops the link for a few seconds, so it
|
|
4
|
+
// waits for a quiet moment. With Claude and subagents working back-to-back there is none, and on
|
|
5
|
+
// 2026-10-20 the return was postponed from 19:22:54 until the hotspot itself went away. So after a
|
|
6
|
+
// grace period the tether controller makes the gap instead: it engages this gate, waits for the
|
|
7
|
+
// calls already running to finish, switches, and releases the gate.
|
|
8
|
+
//
|
|
9
|
+
// While the gate is engaged, a NEW model call (the proxy's /v1/messages, the OpenAI ingress's
|
|
10
|
+
// Responses/Chat calls) waits before its body is read: nothing is written to it, it is not refused
|
|
11
|
+
// and not answered 503 — zero vanished requests is an acceptance metric. It goes ahead when the gate
|
|
12
|
+
// is released or after HOLD_MAX_MS, whichever comes first. Non-model traffic is never held.
|
|
13
|
+
//
|
|
14
|
+
// Release is guaranteed three ways: the owner releases in a `finally`, a failsafe timer set at
|
|
15
|
+
// engage() releases on its own, and close() (the router's drain) releases and refuses to engage again.
|
|
16
|
+
/**
|
|
17
|
+
* The longest any one call waits at the gate; then it proceeds as if there were none. Longer than the
|
|
18
|
+
* tether's drain wait plus its switch (tether.ts RETURN_DRAIN_WAIT_MS), shorter than the ~180s after
|
|
19
|
+
* which Claude Code gave up on a call with no answer yet (2026-10-20).
|
|
20
|
+
*/
|
|
21
|
+
export const HOLD_MAX_MS = 120_000;
|
|
22
|
+
const realTimers = {
|
|
23
|
+
set: (fn, ms) => {
|
|
24
|
+
const t = setTimeout(fn, ms);
|
|
25
|
+
t.unref();
|
|
26
|
+
return t;
|
|
27
|
+
},
|
|
28
|
+
clear: (t) => clearTimeout(t),
|
|
29
|
+
};
|
|
30
|
+
export class HoldGate {
|
|
31
|
+
reasonText = null;
|
|
32
|
+
failsafe = null;
|
|
33
|
+
isClosed = false;
|
|
34
|
+
waiters = new Set();
|
|
35
|
+
maxHoldMs;
|
|
36
|
+
timers;
|
|
37
|
+
now;
|
|
38
|
+
constructor(opts = {}) {
|
|
39
|
+
this.maxHoldMs = opts.maxHoldMs ?? HOLD_MAX_MS;
|
|
40
|
+
this.timers = opts.timers ?? realTimers;
|
|
41
|
+
this.now = opts.now ?? Date.now;
|
|
42
|
+
}
|
|
43
|
+
get engaged() {
|
|
44
|
+
return this.reasonText !== null;
|
|
45
|
+
}
|
|
46
|
+
get reason() {
|
|
47
|
+
return this.reasonText;
|
|
48
|
+
}
|
|
49
|
+
/** Calls waiting at the gate right now. */
|
|
50
|
+
get waiting() {
|
|
51
|
+
return this.waiters.size;
|
|
52
|
+
}
|
|
53
|
+
get closed() {
|
|
54
|
+
return this.isClosed;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Start holding new calls. `failsafeMs` releases the gate by itself if its owner never does.
|
|
58
|
+
* False after close(): a draining router holds nothing.
|
|
59
|
+
*/
|
|
60
|
+
engage(reason, failsafeMs) {
|
|
61
|
+
if (this.isClosed)
|
|
62
|
+
return false;
|
|
63
|
+
this.reasonText = reason;
|
|
64
|
+
if (this.failsafe !== null)
|
|
65
|
+
this.timers.clear(this.failsafe);
|
|
66
|
+
this.failsafe = this.timers.set(() => {
|
|
67
|
+
this.failsafe = null;
|
|
68
|
+
this.release();
|
|
69
|
+
}, failsafeMs);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/** Let every held call go ahead. Idempotent. Returns how many were waiting. */
|
|
73
|
+
release() {
|
|
74
|
+
if (this.failsafe !== null)
|
|
75
|
+
this.timers.clear(this.failsafe);
|
|
76
|
+
this.failsafe = null;
|
|
77
|
+
this.reasonText = null;
|
|
78
|
+
const all = [...this.waiters];
|
|
79
|
+
for (const wake of all)
|
|
80
|
+
wake();
|
|
81
|
+
return all.length;
|
|
82
|
+
}
|
|
83
|
+
/** Release, and never engage again (the router is draining or exiting). */
|
|
84
|
+
close() {
|
|
85
|
+
this.isClosed = true;
|
|
86
|
+
return this.release();
|
|
87
|
+
}
|
|
88
|
+
/** Resolves at once when the gate is open; else when it is released or after HOLD_MAX_MS. */
|
|
89
|
+
wait() {
|
|
90
|
+
if (this.reasonText === null)
|
|
91
|
+
return Promise.resolve({ ms: 0, timedOut: false });
|
|
92
|
+
const t0 = this.now();
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let timer = null;
|
|
95
|
+
const done = (timedOut) => {
|
|
96
|
+
if (!this.waiters.delete(wake))
|
|
97
|
+
return;
|
|
98
|
+
if (timer !== null)
|
|
99
|
+
this.timers.clear(timer);
|
|
100
|
+
resolve({ ms: this.now() - t0, timedOut });
|
|
101
|
+
};
|
|
102
|
+
const wake = () => done(false);
|
|
103
|
+
this.waiters.add(wake);
|
|
104
|
+
timer = this.timers.set(() => done(true), this.maxHoldMs);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/router/src/index.js
CHANGED
|
@@ -8,11 +8,15 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { codexEnabled, followCodexCache, writeCodexCatalog } from "../../cli/src/codex.js";
|
|
10
10
|
import { ingressModels } from "./ingress/models.js";
|
|
11
|
-
import { ConfigStore, configPath, homeDir, terminateHosts } from "./config.js";
|
|
11
|
+
import { ConfigStore, awakeSettings, configPath, homeDir, terminateHosts, tetherSettings } from "./config.js";
|
|
12
|
+
import { AwakeController, execRunner } from "./awake.js";
|
|
13
|
+
import { TetherController } from "./tether.js";
|
|
14
|
+
import { powerPlatform } from "./power.js";
|
|
12
15
|
import { defaultAgentDir, syncAgentFiles } from "./agents.js";
|
|
13
16
|
import { CertStore } from "./certs.js";
|
|
14
17
|
import { Logger } from "./log.js";
|
|
15
|
-
import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
|
|
18
|
+
import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE, checkConnectivity } from "./health.js";
|
|
19
|
+
import { HoldGate } from "./hold.js";
|
|
16
20
|
import { Proxy } from "./proxy.js";
|
|
17
21
|
import { adoptNewClaudeModels, backfillContextWindows, measureUnsettledModels, startAdmin } from "./admin.js";
|
|
18
22
|
import { loadModelCatalog } from "./catalog.js";
|
|
@@ -21,6 +25,8 @@ import { OpenAiIngress } from "./ingress/server.js";
|
|
|
21
25
|
import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
|
|
22
26
|
import { VERSION } from "./version.js";
|
|
23
27
|
import { allowLongSilences } from "./providers/retry.js";
|
|
28
|
+
import { assetsFingerprint, readLocalAssets, spawnSsh, syncRemoteAssets } from "./remote-host.js";
|
|
29
|
+
import { RemoteTunnels, proxyConnectHost } from "./remote-tunnel.js";
|
|
24
30
|
// Startup timing. Claude Desktop routes *all* its traffic through us in picker mode, so every
|
|
25
31
|
// second before the socket is open is a second the app cannot reach anything (ERR_PROXY_-
|
|
26
32
|
// CONNECTION_FAILED, observed 2026-09-14 after a reboot: 26s between exec and listening).
|
|
@@ -31,6 +37,8 @@ const since = (t) => Date.now() - t;
|
|
|
31
37
|
const home = homeDir();
|
|
32
38
|
const logFile = process.env.CLAUDERIPPLE_NO_LOGFILE ? null : path.join(home, "logs", "router.log");
|
|
33
39
|
let log = null;
|
|
40
|
+
// Created after listen(); the config callback below runs before that and finds it null.
|
|
41
|
+
let tunnels = null;
|
|
34
42
|
const store = new ConfigStore(undefined, (c, errors) => {
|
|
35
43
|
for (const e of errors)
|
|
36
44
|
(log ?? console).warn(`config: ${e}`);
|
|
@@ -51,6 +59,9 @@ const store = new ConfigStore(undefined, (c, errors) => {
|
|
|
51
59
|
catch (e) {
|
|
52
60
|
(log ?? console).warn(`agent files: ${e.message}`);
|
|
53
61
|
}
|
|
62
|
+
// Remote hosts get the regenerated agent files too, and a hand edit of `remote.hosts` takes effect.
|
|
63
|
+
tunnels?.reconcile(c.remote?.hosts ?? []);
|
|
64
|
+
tunnels?.assetsChanged();
|
|
54
65
|
});
|
|
55
66
|
const cfg0 = store.get();
|
|
56
67
|
log = new Logger(logFile, cfg0.log.maxBytes, cfg0.log.keep, !!process.env.CLAUDERIPPLE_ECHO || !logFile);
|
|
@@ -81,15 +92,56 @@ function premintLeaves() {
|
|
|
81
92
|
}
|
|
82
93
|
}
|
|
83
94
|
}
|
|
84
|
-
|
|
85
|
-
|
|
95
|
+
// One gate for the proxy and the ingress: the tether controller holds new model calls at it while
|
|
96
|
+
// it switches the network back to Wi-Fi (hold.ts). Closed when the router drains or exits.
|
|
97
|
+
const hold = new HoldGate();
|
|
98
|
+
const powerRunner = execRunner();
|
|
99
|
+
const health = new UpstreamHealth(() => store.get().health.maxConsecutiveUpstreamFailures, (n, c) => {
|
|
100
|
+
const why = !c ? "" : c.result === "online" ? ` while this machine is online (${c.detail}): this process is stuck` : ` and the connectivity check could not tell (${c.detail})`;
|
|
101
|
+
log.error(`${n} consecutive upstream connect failures${why}; exiting ${EXIT_UPSTREAM_UNREACHABLE} for the supervisor to restart`);
|
|
102
|
+
hold.close();
|
|
86
103
|
setTimeout(() => process.exit(EXIT_UPSTREAM_UNREACHABLE), 100);
|
|
104
|
+
}, {
|
|
105
|
+
// A fresh process (curl) asks first; a machine that is offline is not helped by a restart.
|
|
106
|
+
connectivity: () => checkConnectivity(powerRunner, process.platform),
|
|
107
|
+
onOffline: (n, c) => log.warn(`${n} consecutive upstream connect failures, but this machine is offline too (${c.detail}); not restarting`),
|
|
87
108
|
});
|
|
88
109
|
const observedClaudeCodeAuth = new ObservedClaudeCodeAuth(path.join(home, "claude-auth-observed.json"));
|
|
89
110
|
// Before any provider fetches: a reasoning model may stay silent past fetch's 300s default.
|
|
90
111
|
allowLongSilences();
|
|
91
|
-
const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth });
|
|
92
|
-
const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth, chatgpt: (name) => proxy.chatgptForCodex(name) });
|
|
112
|
+
const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth, hold });
|
|
113
|
+
const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth, chatgpt: (name) => proxy.chatgptForCodex(name), hold });
|
|
114
|
+
// Lid-closed awake and hotspot failover (macOS and Windows; no-ops elsewhere, off until configured).
|
|
115
|
+
// They read Claude's activity from the two servers' counters. Started after listen(), below.
|
|
116
|
+
const activity = () => {
|
|
117
|
+
const p = proxy.stats;
|
|
118
|
+
const i = ingress.stats;
|
|
119
|
+
const latest = (a, b) => (a === undefined ? b : b === undefined ? a : Math.max(a, b));
|
|
120
|
+
const lastStartedAt = latest(p.lastMessageStartedAt, i.lastMessageStartedAt);
|
|
121
|
+
const lastEndedAt = latest(p.lastMessageEndedAt, i.lastMessageEndedAt);
|
|
122
|
+
return {
|
|
123
|
+
inFlight: p.messagesInFlight + i.messagesInFlight,
|
|
124
|
+
...(lastStartedAt !== undefined ? { lastStartedAt } : {}),
|
|
125
|
+
...(lastEndedAt !== undefined ? { lastEndedAt } : {}),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
const awake = new AwakeController({
|
|
129
|
+
platform: process.platform,
|
|
130
|
+
run: powerRunner,
|
|
131
|
+
settings: () => awakeSettings(store.get()),
|
|
132
|
+
activity,
|
|
133
|
+
journalFile: path.join(home, "awake.json"),
|
|
134
|
+
log: { info: (m) => log.info(m), warn: (m) => log.warn(m) },
|
|
135
|
+
});
|
|
136
|
+
const tether = new TetherController({
|
|
137
|
+
platform: process.platform,
|
|
138
|
+
run: powerRunner,
|
|
139
|
+
settings: () => tetherSettings(store.get()),
|
|
140
|
+
activity,
|
|
141
|
+
log: { info: (m) => log.info(m), warn: (m) => log.warn(m) },
|
|
142
|
+
hold,
|
|
143
|
+
stateFile: path.join(home, "tether.json"),
|
|
144
|
+
});
|
|
93
145
|
process.on("uncaughtException", (e) => log.error(`uncaught ${e.stack ?? e}`));
|
|
94
146
|
process.on("unhandledRejection", (e) => log.error(`unhandled ${e?.stack ?? e}`));
|
|
95
147
|
const DRAIN_MS = 90_000; // new model calls are refused during drain, so this is the longest single call we wait for; `clauderipple restart` waits 120s
|
|
@@ -108,6 +160,8 @@ function beginDrain(reason) {
|
|
|
108
160
|
draining = true;
|
|
109
161
|
log.info(`${reason}: draining, proxy=${proxy.stats.messagesInFlight} ingress=${ingress.stats.messagesInFlight} model calls`);
|
|
110
162
|
let lastReported = -1;
|
|
163
|
+
// Each drain() closes the shared hold gate first (hold.ts): a call held for a network switch is
|
|
164
|
+
// let go, refused with the retryable 503 like any new call, and waited for.
|
|
111
165
|
void Promise.all([
|
|
112
166
|
proxy.drain(DRAIN_MS, () => { }),
|
|
113
167
|
ingress.drain(DRAIN_MS, () => { }),
|
|
@@ -116,11 +170,20 @@ function beginDrain(reason) {
|
|
|
116
170
|
const left = proxy.stats.messagesInFlight + ingress.stats.messagesInFlight;
|
|
117
171
|
if (left !== lastReported)
|
|
118
172
|
log.info(`drain complete, exiting (model calls still open: ${left})`);
|
|
173
|
+
// The sleep bit is left as it is (awake.ts header): the successor reconciles it within one
|
|
174
|
+
// tick, and the journal carries the working window across the restart.
|
|
175
|
+
tether.stop();
|
|
176
|
+
awake.stop();
|
|
177
|
+
awake.beforeExit();
|
|
119
178
|
setTimeout(() => process.exit(0), 200).unref();
|
|
120
179
|
});
|
|
121
180
|
}
|
|
122
181
|
for (const sig of ["SIGINT", "SIGTERM"])
|
|
123
182
|
process.on(sig, () => beginDrain(sig));
|
|
183
|
+
// Tunnels go with the process, not with the start of a drain: a remote session's in-flight model
|
|
184
|
+
// call travels through one, and the drain exists to let it finish. Every exit path ends here
|
|
185
|
+
// (drain, the health self-exit, a fatal startup error); a SIGKILL is covered by the pid record.
|
|
186
|
+
process.on("exit", () => tunnels?.stopAll());
|
|
124
187
|
const statsEvery = 5 * 60 * 1000;
|
|
125
188
|
setInterval(() => {
|
|
126
189
|
const s = proxy.stats;
|
|
@@ -138,6 +201,24 @@ proxy
|
|
|
138
201
|
` (startup ${since(T_EXEC)}ms: node ${T_MODULE - T_EXEC}, config ${T_CONFIG - T_MODULE}, listen ${since(T_CONFIG)})`);
|
|
139
202
|
const ingressPort = await ingress.listen();
|
|
140
203
|
log.info(`clauderipple OpenAI ingress listening on 127.0.0.1:${ingressPort}`);
|
|
204
|
+
// Reverse tunnels to remote (SSH) hosts, after listen() so their far end has something to reach.
|
|
205
|
+
const assets = () => readLocalAssets({ home, agentDir: defaultAgentDir() });
|
|
206
|
+
tunnels = new RemoteTunnels({
|
|
207
|
+
log: log,
|
|
208
|
+
proxyTarget: () => ({ host: proxyConnectHost(store.get().listen.host), port: store.get().listen.port }),
|
|
209
|
+
pidFile: path.join(home, "remote-tunnels.json"),
|
|
210
|
+
sync: (host) => syncRemoteAssets(spawnSsh, host, assets()),
|
|
211
|
+
fingerprint: () => {
|
|
212
|
+
try {
|
|
213
|
+
return assetsFingerprint(assets());
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
tunnels.cleanupStale();
|
|
221
|
+
tunnels.reconcile(c.remote?.hosts ?? []);
|
|
141
222
|
// Quota only arrives on GPT response headers, so a quiet day left the status 11 hours stale
|
|
142
223
|
// (2026-09-20). Ask once now, after listen(), and never wait for the answer.
|
|
143
224
|
proxy.chatgptRefreshAll();
|
|
@@ -161,6 +242,7 @@ proxy
|
|
|
161
242
|
google: () => ({
|
|
162
243
|
accounts: () => proxy.googleAccounts(),
|
|
163
244
|
models: (name) => proxy.googleFetchModels(name),
|
|
245
|
+
usage: (name) => proxy.googleUsage(name),
|
|
164
246
|
clearCooldown: (id) => proxy.googleClearCooldown(id),
|
|
165
247
|
recheckVerification: (id) => proxy.googleRecheckVerification(id),
|
|
166
248
|
}),
|
|
@@ -169,12 +251,32 @@ proxy
|
|
|
169
251
|
observedClaudeCodeAuth,
|
|
170
252
|
claudeUsage: () => proxy.claudeUsage.snapshot(),
|
|
171
253
|
claudeModels: () => proxy.claudeModels.list(),
|
|
254
|
+
opencodeUsage: (name) => proxy.opencodeLimits(name),
|
|
255
|
+
grokUsage: (name) => proxy.grokLimits(name),
|
|
172
256
|
image: (req, signal) => proxy.generateImage(req, signal),
|
|
173
257
|
modelCatalog: () => loadModelCatalog(),
|
|
174
258
|
shutdown: () => beginDrain("shutdown requested"),
|
|
259
|
+
remote: {
|
|
260
|
+
status: () => tunnels.status(),
|
|
261
|
+
reload: () => {
|
|
262
|
+
tunnels.reconcile(store.get().remote?.hosts ?? []);
|
|
263
|
+
return tunnels.status();
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
power: {
|
|
267
|
+
status: () => ({ platform: powerPlatform(process.platform), awake: awake.status(), tether: tether.status() }),
|
|
268
|
+
pin: (on) => tether.setPin(on),
|
|
269
|
+
poke: async () => {
|
|
270
|
+
tether.kick();
|
|
271
|
+
await awake.refresh();
|
|
272
|
+
},
|
|
273
|
+
},
|
|
175
274
|
};
|
|
176
275
|
const admin = await startAdmin(adminDeps);
|
|
177
276
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
277
|
+
// The first awake tick is the reconcile: a bit a previous process set is kept or cleared now.
|
|
278
|
+
void awake.start().catch((e) => log.warn(`awake: ${e.message}`));
|
|
279
|
+
tether.start();
|
|
178
280
|
setImmediate(premintLeaves);
|
|
179
281
|
// A minute in, once start-up traffic has settled: first the catalogue's context windows are
|
|
180
282
|
// written into models already saved without one (a one-shot pass), then a few one-token requests
|
|
@@ -131,6 +131,8 @@ export class OpenAiIngress {
|
|
|
131
131
|
claudeAccounts;
|
|
132
132
|
deps;
|
|
133
133
|
draining = false;
|
|
134
|
+
/** Model calls waiting at the hold gate (not yet in `stats`): drain() waits for them too. */
|
|
135
|
+
held = 0;
|
|
134
136
|
constructor(deps) {
|
|
135
137
|
this.deps = deps;
|
|
136
138
|
this.claudeAccounts = deps.claudeAccounts ?? new ClaudeAccountAuthPool({
|
|
@@ -166,9 +168,13 @@ export class OpenAiIngress {
|
|
|
166
168
|
/** Stop new connections, reject new model work on keep-alive sockets, then close after model calls drain. */
|
|
167
169
|
async drain(maxMs, onProgress) {
|
|
168
170
|
this.draining = true;
|
|
171
|
+
// As in the proxy: a call held for a network switch goes on now, meets the 503, and is waited for.
|
|
172
|
+
const released = this.deps.hold?.close() ?? 0;
|
|
173
|
+
if (released)
|
|
174
|
+
this.deps.log.info(`OPENAI drain: released ${released} model call(s) held for a network switch`);
|
|
169
175
|
this.server.close();
|
|
170
176
|
const start = Date.now();
|
|
171
|
-
while (this.stats.messagesInFlight > 0 && Date.now() - start < maxMs) {
|
|
177
|
+
while ((this.stats.messagesInFlight > 0 || this.held > 0) && Date.now() - start < maxMs) {
|
|
172
178
|
onProgress?.(this.stats.messagesInFlight);
|
|
173
179
|
await new Promise((resolveP) => setTimeout(resolveP, 250));
|
|
174
180
|
}
|
|
@@ -187,11 +193,25 @@ export class OpenAiIngress {
|
|
|
187
193
|
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
188
194
|
const method = req.method ?? "GET";
|
|
189
195
|
const isCall = method === "POST" && (path === "/v1/responses" || path === "/v1/responses/compact" || path === "/v1/chat/completions");
|
|
196
|
+
// As in the proxy (hold.ts): during a network switch a new model call waits here, unread and
|
|
197
|
+
// uncounted, then goes ahead. Draining refuses below instead.
|
|
198
|
+
const hold = this.deps.hold;
|
|
199
|
+
if (isCall && hold?.engaged && !this.draining) {
|
|
200
|
+
this.held++;
|
|
201
|
+
const held = await hold.wait().finally(() => this.held--);
|
|
202
|
+
this.deps.log.info(`OPENAI HOLD ${method} ${path} waited ${(held.ms / 1000).toFixed(1)}s${held.timedOut ? " (longest hold reached; going ahead)" : ""}`);
|
|
203
|
+
if (req.socket?.destroyed) {
|
|
204
|
+
this.deps.log.warn(`OPENAI HOLD ${method} ${path}: the client left while held`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
190
208
|
this.stats.started++;
|
|
191
209
|
this.stats.inFlight++;
|
|
192
|
-
if (isCall)
|
|
193
|
-
this.stats.messagesInFlight++;
|
|
194
210
|
const started = Date.now();
|
|
211
|
+
if (isCall) {
|
|
212
|
+
this.stats.messagesInFlight++;
|
|
213
|
+
this.stats.lastMessageStartedAt = started;
|
|
214
|
+
}
|
|
195
215
|
let record = { kind: "messages", source: "-", target: "-", provider: "-", stream: false };
|
|
196
216
|
let completed = false;
|
|
197
217
|
const finish = (status, bytes, extra = {}) => {
|
|
@@ -199,8 +219,10 @@ export class OpenAiIngress {
|
|
|
199
219
|
return;
|
|
200
220
|
completed = true;
|
|
201
221
|
this.stats.inFlight--;
|
|
202
|
-
if (isCall)
|
|
222
|
+
if (isCall) {
|
|
203
223
|
this.stats.messagesInFlight--;
|
|
224
|
+
this.stats.lastMessageEndedAt = Date.now();
|
|
225
|
+
}
|
|
204
226
|
if (status >= 200 && status < 400)
|
|
205
227
|
this.stats.completed++;
|
|
206
228
|
else
|
|
@@ -170,9 +170,13 @@ export function effortFromRequest(value) {
|
|
|
170
170
|
return effort ?? undefined;
|
|
171
171
|
}
|
|
172
172
|
/** Convert an OpenAI Responses request to a cache-friendly Anthropic Messages request. */
|
|
173
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Claude models that accept `output_config.effort` (measured 2026-09-13: Haiku 4.5 answers 400 "does
|
|
175
|
+
* not support the effort parameter"). Mythos 5 and 5.1 list `effort` among their capabilities in
|
|
176
|
+
* Claude Code 2.1.295's model catalogue (read 2026-10-14).
|
|
177
|
+
*/
|
|
174
178
|
export function claudeSupportsEffort(model) {
|
|
175
|
-
return /^claude-(opus|sonnet|fable)-(4-[6-9]|5)(-|$)/.test(model);
|
|
179
|
+
return /^claude-(opus|sonnet|fable|mythos)-(4-[6-9]|5)(-|$)/.test(model);
|
|
176
180
|
}
|
|
177
181
|
/**
|
|
178
182
|
* With a borrowed Claude Code login, Anthropic accepts the request only when the system prompt is
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Admin → host control bridge. Never forwards arbitrary URLs, headers or redirects.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { DEFAULT_PORT, remoteHome } from "../../remote/src/config.js";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const INSTALL_HINT = "clauderipple remote install";
|
|
8
|
+
function target(dir) {
|
|
9
|
+
let port = DEFAULT_PORT;
|
|
10
|
+
try {
|
|
11
|
+
const config = JSON.parse(fs.readFileSync(path.join(dir, "host.json"), "utf8"));
|
|
12
|
+
if (config.port !== undefined) {
|
|
13
|
+
if (typeof config.port !== "number" || !Number.isInteger(config.port) || config.port < 1 || config.port > 65535)
|
|
14
|
+
throw new Error("invalid host port");
|
|
15
|
+
port = config.port;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== "ENOENT")
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const secret = fs.readFileSync(path.join(dir, "local.secret"), "utf8").trim();
|
|
23
|
+
if (secret.length < 32 || !/^[\w-]+$/.test(secret))
|
|
24
|
+
throw new Error("invalid local secret");
|
|
25
|
+
return { port, secret };
|
|
26
|
+
}
|
|
27
|
+
function execute(file, args) {
|
|
28
|
+
return new Promise((resolve, reject) => execFile(file, args, { timeout: 150_000, windowsHide: true }, error => error ? reject(error) : resolve()));
|
|
29
|
+
}
|
|
30
|
+
export async function phoneRequest(route, method, body, deps = {}) {
|
|
31
|
+
if (route === "restart" && method === "POST") {
|
|
32
|
+
const uid = deps.uid ?? process.getuid?.();
|
|
33
|
+
const command = uid === undefined ? "npm run remote -- install" : `launchctl kickstart -k gui/${uid}/com.clauderipple.host`;
|
|
34
|
+
if ((deps.platform ?? process.platform) !== "darwin") {
|
|
35
|
+
const script = fileURLToPath(new URL(`../../remote/src/main${path.extname(import.meta.url)}`, import.meta.url));
|
|
36
|
+
try {
|
|
37
|
+
await (deps.exec ?? execute)(process.execPath, [script, "install"]);
|
|
38
|
+
return { status: 200, body: { ok: true } };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return { status: 502, body: { error: "host service install/restart failed", command: INSTALL_HINT } };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (uid === undefined)
|
|
45
|
+
return { status: 500, body: { error: "host restart unavailable" } };
|
|
46
|
+
try {
|
|
47
|
+
await (deps.exec ?? execute)("launchctl", ["kickstart", "-k", `gui/${uid}/com.clauderipple.host`]);
|
|
48
|
+
return { status: 200, body: { ok: true } };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { status: 502, body: { error: "host restart failed", command } };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!((method === "GET" && (route === "status" || route === "pairing")) || (method === "POST" && (route === "settings" || route === "rotate")))) {
|
|
55
|
+
return { status: 404, body: { error: "unknown phone endpoint or method" } };
|
|
56
|
+
}
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
const { port, secret } = target(deps.home ?? remoteHome());
|
|
60
|
+
response = await (deps.fetch ?? fetch)(`http://127.0.0.1:${port}/local/${route}`, {
|
|
61
|
+
method, headers: { "x-ripple-local": secret, "content-type": "application/json" },
|
|
62
|
+
...(body !== undefined ? { body } : {}), redirect: "error", signal: AbortSignal.timeout(5000),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return route === "status"
|
|
67
|
+
? { status: 200, body: { hostRunning: false, hint: INSTALL_HINT } }
|
|
68
|
+
: { status: 503, body: { error: "phone host unavailable", hint: INSTALL_HINT } };
|
|
69
|
+
}
|
|
70
|
+
// A live old host or rejected local secret is not a stopped host. Report the boundary failure.
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
return { status: response.status === 400 ? 400 : 502, body: { error: `host control HTTP ${response.status}; update the host if its local API is unavailable` } };
|
|
73
|
+
try {
|
|
74
|
+
const data = await response.json();
|
|
75
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
76
|
+
throw new Error("invalid host response");
|
|
77
|
+
return { status: 200, body: route === "status" ? { ...data, hostRunning: true } : data };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { status: 502, body: { error: "invalid host control response" } };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The admin contract for lid-closed awake + hotspot failover (`GET|PUT /api/power`).
|
|
2
|
+
//
|
|
3
|
+
// GET → { platform: "darwin"|"win32"|"linux", awake: AwakeStatus, tether: TetherStatus }
|
|
4
|
+
// (`platform` lets a client word things per OS: "Mac" or "PC", the Windows hints. Anything
|
|
5
|
+
// that is neither macOS nor Windows reads "linux", where both features are `unsupported`.)
|
|
6
|
+
// PUT { awake?: { mode?: "off"|"working"|"on", until?: ISO string | null },
|
|
7
|
+
// tether?: { enabled?: boolean, pin?: boolean } }
|
|
8
|
+
// mode/until/enabled are saved to config.json (the router picks the file up by mtime, like
|
|
9
|
+
// every other settings save); `pin` is in-memory only — it pauses switching until unpinned or
|
|
10
|
+
// until the router restarts.
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
export function powerPlatform(p) {
|
|
14
|
+
return p === "darwin" || p === "win32" ? p : "linux";
|
|
15
|
+
}
|
|
16
|
+
/** Validates a PUT body. Returns the change, or what is wrong with it. */
|
|
17
|
+
export function parsePowerChange(raw) {
|
|
18
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
19
|
+
return "expected an object";
|
|
20
|
+
const body = raw;
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const key of Object.keys(body))
|
|
23
|
+
if (key !== "awake" && key !== "tether")
|
|
24
|
+
return `unknown field "${key}"`;
|
|
25
|
+
if (body.awake !== undefined) {
|
|
26
|
+
const a = body.awake;
|
|
27
|
+
if (!a || typeof a !== "object" || Array.isArray(a))
|
|
28
|
+
return "awake must be an object";
|
|
29
|
+
for (const key of Object.keys(a))
|
|
30
|
+
if (key !== "mode" && key !== "until")
|
|
31
|
+
return `unknown field "awake.${key}"`;
|
|
32
|
+
const awake = {};
|
|
33
|
+
if (a.mode !== undefined) {
|
|
34
|
+
if (a.mode !== "off" && a.mode !== "working" && a.mode !== "on")
|
|
35
|
+
return 'awake.mode must be "off", "working" or "on"';
|
|
36
|
+
awake.mode = a.mode;
|
|
37
|
+
}
|
|
38
|
+
if (a.until !== undefined) {
|
|
39
|
+
if (a.until !== null && (typeof a.until !== "string" || Number.isNaN(Date.parse(a.until))))
|
|
40
|
+
return "awake.until must be an ISO date-time or null";
|
|
41
|
+
awake.until = a.until === null ? null : new Date(Date.parse(a.until)).toISOString();
|
|
42
|
+
}
|
|
43
|
+
out.awake = awake;
|
|
44
|
+
}
|
|
45
|
+
if (body.tether !== undefined) {
|
|
46
|
+
const t = body.tether;
|
|
47
|
+
if (!t || typeof t !== "object" || Array.isArray(t))
|
|
48
|
+
return "tether must be an object";
|
|
49
|
+
// The hotspot name is set by `awake setup` only: the sudoers rule names it.
|
|
50
|
+
for (const key of Object.keys(t))
|
|
51
|
+
if (key !== "enabled" && key !== "pin")
|
|
52
|
+
return `unknown field "tether.${key}"${key === "hotspotSsid" ? " (set it with `clauderipple awake setup --ssid`)" : ""}`;
|
|
53
|
+
const tether = {};
|
|
54
|
+
if (t.enabled !== undefined) {
|
|
55
|
+
if (typeof t.enabled !== "boolean")
|
|
56
|
+
return "tether.enabled must be true or false";
|
|
57
|
+
tether.enabled = t.enabled;
|
|
58
|
+
}
|
|
59
|
+
if (t.pin !== undefined) {
|
|
60
|
+
if (typeof t.pin !== "boolean")
|
|
61
|
+
return "tether.pin must be true or false";
|
|
62
|
+
tether.pin = t.pin;
|
|
63
|
+
}
|
|
64
|
+
out.tether = tether;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Applies the persistent part of a change to the config file as it is on disk (re-read, so a save
|
|
70
|
+
* made elsewhere meanwhile is not reverted) and writes it atomically. Returns whether it wrote.
|
|
71
|
+
*/
|
|
72
|
+
export function persistPowerChange(configFile, change) {
|
|
73
|
+
const awake = change.awake ?? {};
|
|
74
|
+
const tether = change.tether ?? {};
|
|
75
|
+
if (awake.mode === undefined && awake.until === undefined && tether.enabled === undefined && tether.hotspotSsid === undefined)
|
|
76
|
+
return false;
|
|
77
|
+
let raw = {};
|
|
78
|
+
try {
|
|
79
|
+
raw = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
if (e.code !== "ENOENT")
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
if (awake.mode !== undefined || awake.until !== undefined) {
|
|
86
|
+
const next = { ...(raw.awake ?? {}) };
|
|
87
|
+
if (awake.mode !== undefined)
|
|
88
|
+
next.mode = awake.mode;
|
|
89
|
+
if (awake.until === null)
|
|
90
|
+
delete next.until;
|
|
91
|
+
else if (awake.until !== undefined)
|
|
92
|
+
next.until = awake.until;
|
|
93
|
+
raw.awake = next;
|
|
94
|
+
}
|
|
95
|
+
if (tether.enabled !== undefined || tether.hotspotSsid !== undefined) {
|
|
96
|
+
raw.tether = {
|
|
97
|
+
...(raw.tether ?? {}),
|
|
98
|
+
...(tether.enabled !== undefined ? { enabled: tether.enabled } : {}),
|
|
99
|
+
...(tether.hotspotSsid !== undefined ? { hotspotSsid: tether.hotspotSsid } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true });
|
|
103
|
+
const tmp = `${configFile}.tmp-${process.pid}-${Date.now()}`;
|
|
104
|
+
fs.writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n");
|
|
105
|
+
fs.renameSync(tmp, configFile);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
@@ -25,7 +25,39 @@ export function claudeUsageFromBody(body) {
|
|
|
25
25
|
const secondary = win(b?.seven_day, WEEK);
|
|
26
26
|
if (!primary && !secondary)
|
|
27
27
|
return null;
|
|
28
|
-
|
|
28
|
+
const scoped = scopedFromLimits(b?.limits);
|
|
29
|
+
return {
|
|
30
|
+
type: "claude.usage",
|
|
31
|
+
rate_limits: { primary: primary ?? secondary, secondary: primary ? secondary : null },
|
|
32
|
+
...(scoped.length ? { scoped } : {}),
|
|
33
|
+
at: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Per-model weekly windows from the same body: entries of `limits` with kind `weekly_scoped`
|
|
38
|
+
* carry the model in `scope.model.display_name` (measured 2026-10-15:
|
|
39
|
+
* `{"kind":"weekly_scoped","group":"weekly","percent":2,…,"scope":{"model":{"id":null,
|
|
40
|
+
* "display_name":"Fable"},"surface":null},"is_active":false}`). They ride beside
|
|
41
|
+
* `rate_limits` (which the GUI and the tray read) so those readers keep working unchanged.
|
|
42
|
+
*/
|
|
43
|
+
function scopedFromLimits(limits) {
|
|
44
|
+
if (!Array.isArray(limits))
|
|
45
|
+
return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const entry of limits) {
|
|
48
|
+
if (!entry || typeof entry !== "object" || entry.kind !== "weekly_scoped")
|
|
49
|
+
continue;
|
|
50
|
+
const name = entry.scope?.model?.display_name;
|
|
51
|
+
const percent = entry.percent;
|
|
52
|
+
if (typeof name !== "string" || !name || typeof percent !== "number" || !Number.isFinite(percent))
|
|
53
|
+
continue;
|
|
54
|
+
const w = { name, used_percent: percent, window_minutes: WEEK };
|
|
55
|
+
const at = typeof entry.resets_at === "string" ? Date.parse(entry.resets_at) : NaN;
|
|
56
|
+
if (Number.isFinite(at))
|
|
57
|
+
w.reset_at = Math.round(at / 1000);
|
|
58
|
+
out.push(w);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
29
61
|
}
|
|
30
62
|
/**
|
|
31
63
|
* Per-account snapshots, fetched at most once per `ttlMs` per account. The GUI polls the status
|