clauderipple 0.8.1 → 0.8.3

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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.3 — 2026-10-06
4
+
5
+ ### Fixed
6
+
7
+ - **No PowerShell window at every Windows logon** (#49). With Windows Terminal as the default
8
+ terminal — the Windows 11 default — the router task's PowerShell window stayed on screen after
9
+ each logon, because Windows Terminal ignores `-WindowStyle Hidden`. The task now starts
10
+ PowerShell inside a headless console host. Updating re-registers the task; where Windows
11
+ refuses that, the old task is kept and the router runs as before.
12
+
13
+ ## 0.8.2 — 2026-10-06
14
+
15
+ ### Fixed
16
+
17
+ - **Effort works on API-key providers.** A provider with no effort ladder of its own — DeepSeek,
18
+ Kimi, GLM, MiniMax, Qwen, Groq, Mistral, or any key and URL — stripped the effort you picked and
19
+ the Claude app showed no effort menu for its models. ClaudeRipple now asks each such model: it
20
+ sends a level no vendor defines, and if the endpoint refuses it, the levels it does accept become
21
+ that model's effort menu. An endpoint that accepts anything is left without effort, since it is
22
+ not reading the field. Providers you already set up are measured a minute after the router
23
+ starts; nothing needs re-saving.
24
+ - **DeepSeek's API offers low / high / max**, the three levels its thinking mode maps every
25
+ request onto.
26
+
3
27
  ## 0.8.1 — 2026-10-06
4
28
 
5
29
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "type": "commonjs"
5
5
  }
@@ -63,7 +63,7 @@ export function taskDefinitionMatches(value, expected) {
63
63
  return false;
64
64
  const task = value;
65
65
  return (task.actionCount === 1 &&
66
- task.execute?.toLowerCase() === "powershell.exe" &&
66
+ task.execute?.toLowerCase() === expected.execute.toLowerCase() &&
67
67
  task.arguments === expected.arguments &&
68
68
  sameAccount(task.userId, expected.userId) &&
69
69
  task.runLevel === 0 &&
@@ -143,16 +143,24 @@ export function existingTaskDefinition() {
143
143
  export function installAgent(opts) {
144
144
  const launcher = writeLauncher({ program: opts.program, args: opts.args ?? [], home: opts.home, ...(opts.env ? { env: opts.env } : {}) });
145
145
  const user = taskUser();
146
- const actionArgs = `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
146
+ const powershellArgs = `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}"`;
147
+ // PowerShell is started inside a headless console host. Started directly, it gets a console from
148
+ // the user's default terminal, and when that is Windows Terminal (the Windows 11 default)
149
+ // `-WindowStyle Hidden` hides nothing: a PowerShell window sat on screen after every logon (#49;
150
+ // measured 2026-10-06 in a Windows 11 24H2 VM — a CASCADIA_HOSTING_WINDOW_CLASS window appeared
151
+ // for `powershell.exe`, none for `conhost.exe --headless powershell.exe`, and both ran the script).
152
+ const execute = "conhost.exe";
153
+ const actionArgs = `--headless powershell.exe ${powershellArgs}`;
154
+ const existing = existingTaskDefinition();
147
155
  // Updating the generated launcher does not require re-registering an otherwise correct task.
148
156
  // Some Windows installations protect an existing per-user task's security descriptor so that
149
157
  // even the same unelevated user gets E_ACCESSDENIED from Register-ScheduledTask -Force. Treat the
150
158
  // verified existing definition as success; never silently accept a same-named foreign task.
151
- if (taskDefinitionMatches(existingTaskDefinition(), { arguments: actionArgs, userId: user }))
159
+ if (taskDefinitionMatches(existing, { execute, arguments: actionArgs, userId: user }))
152
160
  return launcher;
153
161
  const script = [
154
162
  `$ErrorActionPreference = 'Stop'`,
155
- `$a = New-ScheduledTaskAction -Execute ${ps("powershell.exe")} -Argument ${ps(actionArgs)}`,
163
+ `$a = New-ScheduledTaskAction -Execute ${ps(execute)} -Argument ${ps(actionArgs)}`,
156
164
  `$t = New-ScheduledTaskTrigger -AtLogOn -User ${ps(user)}`,
157
165
  // Limited: the router needs no elevation, and asking for it would put a UAC prompt at every logon.
158
166
  `$p = New-ScheduledTaskPrincipal -UserId ${ps(user)} -LogonType Interactive -RunLevel Limited`,
@@ -161,8 +169,13 @@ export function installAgent(opts) {
161
169
  `'ok'`,
162
170
  ].join("; ");
163
171
  const r = run(script);
164
- if (!r.ok)
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;
165
177
  throw new Error(`Register-ScheduledTask failed: ${r.out}`);
178
+ }
166
179
  return launcher;
167
180
  }
168
181
  export function removeAgent() {
@@ -981,6 +981,26 @@ function providerLadder(p) {
981
981
  return [...(p.caps?.effortLevels ?? [])];
982
982
  return [];
983
983
  }
984
+ /**
985
+ * Whether nothing says what effort this provider's models take, so a request strips it: the case
986
+ * worth measuring a model for. Read the way the request path reads it. An anthropic-compatible
987
+ * provider falls back to its preset live (`compatCaps` in proxy.ts) and strips on an empty ladder; the
988
+ * OpenAI adapter reads only its own `caps`, and `reasoning: "effort"` with no levels passes effort
989
+ * through as asked. An anthropic-compatible ladder the config states is a decision, even an empty
990
+ * one. `reasoning: "none"` is not: the provider form writes it for every OpenAI-compatible provider
991
+ * whose preset has no ladder (Groq, Mistral, a key and a URL), with no control to say otherwise.
992
+ */
993
+ function effortUndeclared(p) {
994
+ if (p.type === "anthropic-compatible") {
995
+ if (p.caps?.effortLevels !== undefined)
996
+ return false;
997
+ const preset = p.preset ? PRESETS.find((entry) => entry.id === p.preset) : undefined;
998
+ return (preset?.effortLevels ?? []).length === 0;
999
+ }
1000
+ if (p.type === "openai-compatible")
1001
+ return p.caps?.reasoning !== "effort";
1002
+ return false;
1003
+ }
984
1004
  /**
985
1005
  * Fold measurements into the config on disk.
986
1006
  *
@@ -1025,6 +1045,37 @@ function applyMeasurements(configFile, results) {
1025
1045
  writeConfigFile(configFile, current);
1026
1046
  return { applied };
1027
1047
  }
1048
+ /** Register a measurement job and run it detached; returns the id a caller polls. */
1049
+ function startMeasureJob(providerName, models, deps) {
1050
+ sweepMeasureJobs();
1051
+ const jobId = crypto.randomBytes(12).toString("hex");
1052
+ MEASURE_JOBS.set(jobId, { state: "running", done: 0, total: models.length, results: [] });
1053
+ deps.log.info(`admin: measuring ${providerName} (${models.length} model(s)) -> ${jobId.slice(0, 8)}`);
1054
+ void runMeasureJob(jobId, providerName, models, deps);
1055
+ return jobId;
1056
+ }
1057
+ /**
1058
+ * Measure the models whose effort is still stripped and that nobody has settled, without waiting
1059
+ * for a save in the GUI. A model was measured only when its provider was saved, so a provider set up
1060
+ * before effort discovery — every DeepSeek, Kimi or GLM key on 0.8.1 and earlier — would otherwise
1061
+ * stay without effort until its owner happened to save it again. A model with a ladder (measured,
1062
+ * or typed) is the config's and left alone; so is one whose provider declares a ladder.
1063
+ */
1064
+ export function measureUnsettledModels(deps) {
1065
+ let started = 0;
1066
+ for (const [name, provider] of Object.entries(deps.config().providers)) {
1067
+ if (provider.type !== "openai-compatible" && provider.type !== "anthropic-compatible")
1068
+ continue;
1069
+ if (!effortUndeclared(provider))
1070
+ continue;
1071
+ const models = (provider.models ?? []).filter((model) => model.effortLevels === undefined).map((model) => model.id);
1072
+ if (models.length === 0)
1073
+ continue;
1074
+ startMeasureJob(name, models, deps);
1075
+ started++;
1076
+ }
1077
+ return started;
1078
+ }
1028
1079
  /** Run one provider measurement job to completion. Detached: the request that started it has already answered. */
1029
1080
  async function runMeasureJob(jobId, providerName, models, deps) {
1030
1081
  const job = MEASURE_JOBS.get(jobId);
@@ -1036,6 +1087,9 @@ async function runMeasureJob(jobId, providerName, models, deps) {
1036
1087
  return;
1037
1088
  }
1038
1089
  const ladder = providerLadder(provider);
1090
+ // No ladder from the provider, its preset or the config: find out per model whether it takes
1091
+ // effort at all (`discoverLadder`), rather than leave it stripped.
1092
+ const discover = effortUndeclared(provider);
1039
1093
  const fetchImpl = deps.measureFetch ?? measureFetchWithTimeout;
1040
1094
  const headers = "headers" in provider ? provider.headers : undefined;
1041
1095
  const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
@@ -1047,9 +1101,9 @@ async function runMeasureJob(jobId, providerName, models, deps) {
1047
1101
  const candidates = measureCandidates(provider, id, listed?.wire);
1048
1102
  // A ladder the catalogue states is taken as stated; the wire is still asked, once, because that
1049
1103
  // one request is also what finds a model the plan refuses (403 FreeTierError, DataPolicyError).
1050
- const stated = ladder.length > 0 ? listed?.effortLevels : undefined;
1104
+ const stated = ladder.length > 0 || discover ? listed?.effortLevels : undefined;
1051
1105
  const known = stated !== undefined;
1052
- const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) });
1106
+ const measured = await measureModel(id, candidates, known ? [] : ladder, { fetch: fetchImpl, ...(sessionHeader ? { sessionHeader } : {}), ...(headers ? { headers } : {}) }, { discover: discover && !known });
1053
1107
  if (stated && measured.wire)
1054
1108
  measured.effortLevels = stated;
1055
1109
  job.results.push(measured);
@@ -1341,13 +1395,8 @@ export function startAdmin(deps) {
1341
1395
  sendJson(res, 400, { error: `provider "${body.provider}" is not openai-compatible or anthropic-compatible` });
1342
1396
  return;
1343
1397
  }
1344
- sweepMeasureJobs();
1345
- const jobId = crypto.randomBytes(12).toString("hex");
1346
- const job = { state: "running", done: 0, total: body.models.length, results: [] };
1347
- MEASURE_JOBS.set(jobId, job);
1348
- deps.log.info(`admin: measuring ${body.provider} (${body.models.length} model(s)) -> ${jobId.slice(0, 8)}`);
1349
- void runMeasureJob(jobId, body.provider, body.models, deps);
1350
- sendJson(res, 202, { jobId, total: job.total });
1398
+ const jobId = startMeasureJob(body.provider, body.models, deps);
1399
+ sendJson(res, 202, { jobId, total: body.models.length });
1351
1400
  return;
1352
1401
  }
1353
1402
  if (pathname.startsWith("/api/providers/measure/") && method === "GET") {
@@ -136,12 +136,63 @@ async function candidateLevels(id, candidate, ladder, deps) {
136
136
  listed = parseLevels(await response.text());
137
137
  }
138
138
  catch { /* the endpoint said nothing; fall back to everything known */ }
139
- const wanted = listed.length > 0 ? listed : [...ladder, ...KNOWN_EFFORTS];
140
- const seen = new Set(wanted);
141
- // Canonical order first so the GUI reads weakest to strongest, then anything newly named.
142
- return [...KNOWN_EFFORTS.filter((level) => seen.has(level)), ...wanted.filter((level) => !KNOWN_EFFORTS.includes(level))]
139
+ return canonicalOrder(listed.length > 0 ? listed : [...ladder, ...KNOWN_EFFORTS]);
140
+ }
141
+ /** Canonical order first so the GUI reads weakest to strongest, then anything newly named. */
142
+ function canonicalOrder(levels) {
143
+ const seen = new Set(levels);
144
+ return [...KNOWN_EFFORTS.filter((level) => seen.has(level)), ...levels.filter((level) => !KNOWN_EFFORTS.includes(level))]
143
145
  .filter((level, index, all) => all.indexOf(level) === index);
144
146
  }
147
+ /**
148
+ * Whether a model with no declared ladder takes reasoning effort at all, and which levels.
149
+ *
150
+ * A provider set up from a key and a URL declares no ladder, and without one every effort the user
151
+ * picked was stripped and the app showed no effort menu — for DeepSeek's API, which takes effort,
152
+ * among others. Whether a model takes effort is not knowable from its id, and "the endpoint
153
+ * accepted the field" is not proof either: an endpoint that ignores unknown fields accepts any
154
+ * value. So a value no vendor defines is sent first:
155
+ *
156
+ * - accepted → the field is not read; no effort (`[]`).
157
+ * - refused (400/422) → the field is validated. The levels the refusal names, else every known one,
158
+ * are sent one at a time, and the accepted ones are the ladder. DeepSeek refuses with the list
159
+ * `none … max` and accepts each (measured 2026-10-06).
160
+ * - anything else (429, 5xx, network) → nothing is concluded (`undefined`), so a later save measures
161
+ * again. Likewise when a level could not be settled, or no level was accepted while one could not
162
+ * be asked: a partial ladder from a hiccup would read as the model's own.
163
+ */
164
+ async function discoverLadder(id, candidate, deps) {
165
+ const url = endpointFor(candidate.wire, candidate.url);
166
+ let probe;
167
+ try {
168
+ probe = await deps.fetch(url, { method: "POST", headers: requestHeaders(candidate, deps), body: requestBody(candidate.wire, id, NOT_A_LEVEL) });
169
+ }
170
+ catch {
171
+ return undefined;
172
+ }
173
+ if (probe.ok)
174
+ return [];
175
+ if (!refusedLevel(probe.status))
176
+ return undefined;
177
+ const listed = parseLevels(await probe.text().catch(() => ""));
178
+ const accepted = [];
179
+ let unsettled = false;
180
+ for (const level of canonicalOrder(listed.length > 0 ? listed : KNOWN_EFFORTS)) {
181
+ let response;
182
+ try {
183
+ response = await deps.fetch(url, { method: "POST", headers: requestHeaders(candidate, deps), body: requestBody(candidate.wire, id, level) });
184
+ }
185
+ catch {
186
+ unsettled = true;
187
+ continue;
188
+ }
189
+ if (response.ok)
190
+ accepted.push(level);
191
+ else if (!refusedLevel(response.status))
192
+ unsettled = true;
193
+ }
194
+ return unsettled ? undefined : accepted;
195
+ }
145
196
  /** The levels an endpoint listed in its complaint, or nothing when it did not list any. */
146
197
  export function parseLevels(message) {
147
198
  const listed = /supported values:\s*\[([^\]]+)\]/i.exec(message)?.[1] ?? /expected one of\s+(.+?)(?:\s+at line\b|$)/i.exec(message)?.[1];
@@ -181,8 +232,11 @@ async function measureLadder(id, candidate, ladder, deps) {
181
232
  * was refused before the path mattered, so reporting anything but "auth" would send the user to
182
233
  * check the wire when the problem is the credential. When no candidate answers, the result carries
183
234
  * the last status and body as `error` and no `wire`.
235
+ *
236
+ * `discover` is for a model with no ladder from anywhere: an empty `ladder` then means "find out"
237
+ * (`discoverLadder`) rather than "do not measure effort".
184
238
  */
185
- export async function measureModel(id, candidates, ladder, deps) {
239
+ export async function measureModel(id, candidates, ladder, deps, options = {}) {
186
240
  if (candidates.length === 0)
187
241
  return { id, error: "no candidate wire to try" };
188
242
  let failure = "no candidate wire answered";
@@ -204,8 +258,12 @@ export async function measureModel(id, candidates, ladder, deps) {
204
258
  return { id, error: refusedByPlan(detail) ? `not-entitled: ${detail}` : "auth" };
205
259
  }
206
260
  if (response.ok) {
207
- if (ladder.length === 0)
208
- return { id, wire: candidate.wire };
261
+ if (ladder.length === 0) {
262
+ if (!options.discover)
263
+ return { id, wire: candidate.wire };
264
+ const discovered = await discoverLadder(id, candidate, deps);
265
+ return { id, wire: candidate.wire, ...(discovered !== undefined ? { effortLevels: discovered } : {}) };
266
+ }
209
267
  return { id, wire: candidate.wire, effortLevels: await measureLadder(id, candidate, ladder, deps) };
210
268
  }
211
269
  failure = `${response.status} ${snippet(await response.text())}`.trim();
@@ -14,7 +14,7 @@ import { CertStore } from "./certs.js";
14
14
  import { Logger } from "./log.js";
15
15
  import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
16
16
  import { Proxy } from "./proxy.js";
17
- import { startAdmin } from "./admin.js";
17
+ import { measureUnsettledModels, startAdmin } from "./admin.js";
18
18
  import { loadModelCatalog } from "./catalog.js";
19
19
  import { RequestLog } from "./requestlog.js";
20
20
  import { OpenAiIngress } from "./ingress/server.js";
@@ -138,7 +138,7 @@ proxy
138
138
  // Quota only arrives on GPT response headers, so a quiet day left the status 11 hours stale
139
139
  // (2026-09-20). Ask once now, after listen(), and never wait for the answer.
140
140
  proxy.chatgptRefreshAll();
141
- const admin = await startAdmin({
141
+ const adminDeps = {
142
142
  config: () => store.get(),
143
143
  configFile: configPath(),
144
144
  log: log,
@@ -167,9 +167,13 @@ proxy
167
167
  image: (req, signal) => proxy.generateImage(req, signal),
168
168
  modelCatalog: () => loadModelCatalog(),
169
169
  shutdown: () => beginDrain("shutdown requested"),
170
- });
170
+ };
171
+ const admin = await startAdmin(adminDeps);
171
172
  log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
172
173
  setImmediate(premintLeaves);
174
+ // A minute in, once start-up traffic has settled: a few one-token requests per model whose
175
+ // effort nobody has measured yet, so effort works without the owner re-saving the provider.
176
+ setTimeout(() => measureUnsettledModels(adminDeps), 60_000).unref();
173
177
  })
174
178
  .catch((e) => {
175
179
  log.error(`listen failed: ${e.message}`);
@@ -14,9 +14,12 @@ export const PRESETS = [
14
14
  { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
15
15
  { id: "deepseek-flash", name: "DeepSeek Flash" },
16
16
  ],
17
- // https://api-docs.deepseek.com/guides/anthropic_api/ documents thinking and output_config.effort,
18
- // but no accepted effort-level set for its Anthropic endpoint; strip effort rather than guess.
19
- effortLevels: [],
17
+ // https://api-docs.deepseek.com/guides/thinking_mode/ maps every requested effort onto three:
18
+ // minimal/low → low, medium/high/xhigh → high, max/ultra → max (thinking on, high, by default).
19
+ // Measured 2026-10-06 on this endpoint: `output_config.effort` is validated (an unknown value is
20
+ // 422 "expected one of none … max") and low/high/max answer 200 on deepseek-v4-pro and
21
+ // deepseek-flash. Offering the three real levels keeps the menu from showing steps that collapse.
22
+ effortLevels: ["low", "high", "max"],
20
23
  thinking: "enabled",
21
24
  // Measured 2026-09-17, not documented: a `web_search_20250305` tool sent to this endpoint came
22
25
  // back with server_tool_use, a web_search_tool_result holding ten hits, and
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.8.1";
8
+ export const VERSION = "0.8.3";
@@ -1138,6 +1138,7 @@ subscription exists in this environment. Files are under `src/`:
1138
1138
  | The tray's Electron (~270MB) was fetched into the installed package's own `node_modules`, and `npm install -g` of any other version deleted it with the old files (measured 2026-09-28): every update silently took the tray away, and on Windows a running tray held `electron.exe` inside the folder npm had to replace | Electron lives in `<home>/tray-runtime`, outside anything an update replaces. Both install scripts move one found in the old place there before installing (Windows first stops a tray running from it, and starts it again after setup) — checked on macOS and in the VM with a locked `electron.exe`. |
1139
1139
  | Adding a provider in the settings window restored three OpenCode effort ladders realigned by hand five hours earlier, so the app's effort menus for DeepSeek and GLM never narrowed (2026-10-02 23:13, found 10-05). `PUT /api/config` takes the whole config, and the screen built it from the copy it loaded at open and never re-read; the router itself also writes `config.json` (a measurement's results), so any of those was undone by the next unrelated save | Every GUI save fetches the router's config first and applies its own change to that. A provider form, which holds per-model fields (wire, ladder, window) from when it opened and has no control for them, takes a saved model's values from the router's copy. The provider and client screens re-read the config whenever they load. |
1140
1140
  | In picker mode, claude.ai requests failed as `502 … ECONNRESET` in under a second — 259 times in four days (2026-10-03..06), mostly `current_user_access`, `desktop/features` and `event_logging`. The kept-alive upstream socket had been closed by claude.ai as the next request was written on it, and Node does not resend | A GET/HEAD/OPTIONS whose reused socket resets before any response is sent again (twice at most), logged `STALE`. A POST is never resent: a reset does not prove the upstream did not act on it. Not yet shown to be the cause of the Windows "chat history loads forever" report (DC, 2026-10-06). |
1141
+ | An API-key provider with no declared effort ladder — DeepSeek, Kimi, GLM, MiniMax, Qwen, Groq, Mistral, a key and a URL — stripped every effort the user picked (`effort high→removed`) and the Claude app showed no effort menu for its models (DC reports, 2026-10-06). Declaring nothing was meant as "do not guess", but DeepSeek's API, for one, validates `output_config.effort` and maps it onto low/high/max | When nothing declares a ladder (no config ladder, no preset ladder, OpenAI-compatible `reasoning` not `effort` — the form writes `none` by default), a measurement sends a level no vendor defines first: accepted means the field is ignored (no effort, `[]`); refused means it is validated, and the levels the refusal names (else every known one) are sent one by one and the accepted ones become the model's ladder. A 429/5xx concludes nothing. The router runs this a minute after start for unsettled models, so existing providers need no re-save. The DeepSeek preset now declares low/high/max from its docs. |
1141
1142
  | ChatGPT accounts that had bought Codex credits were rested by the router the moment a window read 100%, though OpenAI serves them from credits past the window | `exhaustedForMs` ignores a full window while `credits.has_credits` or `unlimited` holds (and `overage_limit_reached` does not); an actual 429 still rests the account until the window resets. |
1142
1143
 
1143
1144
  ## 6. Blocked paths (measured, do not retry)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Use GPT (ChatGPT Plus/Pro), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [