aegis-desktop 1.0.9 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Prebuilt binaries (AppImage / MSI+NSIS / dmg) ship with each release, and the
13
13
  package is on npm:
14
14
 
15
15
  ```bash
16
- npm install -g aegis-desktop # requires Node 18+
16
+ npm install -g aegis-desktop # requires Node 22+
17
17
  aegis # launch
18
18
  ```
19
19
 
package/lib/auto.js CHANGED
@@ -12,13 +12,17 @@
12
12
  * BACKGROUND. desktop/main.js and desktop/preload.js both stated an absolute
13
13
  * rule when the queue shipped: "NOTHING DRAINS UNLESS THE USER ASKED. There is
14
14
  * no interval, no drain at startup, and no drain-on-idle anywhere in this
15
- * file." That rule was right when the only trigger was a click, and it is still
16
- * the behaviour of a default install — but it made "queue a task and walk away"
17
- * impossible, which is the one thing an unattended queue is FOR. The user asked
18
- * for both automations explicitly (option A of that discussion), so the rule
19
- * becomes a DEFAULT rather than an absolute, in the only way that keeps the
20
- * original sentence true: an explicit, persisted, default-off switch, and a
21
- * tick that does nothing unless it is armed.
15
+ * file." A click was the only trigger, which made "queue a task and walk away"
16
+ * impossible — the one thing an unattended queue is FOR. The user asked for the
17
+ * automation (twice: "autonomous queue", then "work in the background
18
+ * instead"), so both switches now DEFAULT ON and the sidebar card that used to
19
+ * gate them is hidden: draining and sealing happen with nothing on screen.
20
+ *
21
+ * What keeps that safe is not an off-by-default — it is the guards below, which
22
+ * are unchanged and all of them skips rather than drains: an empty queue, a
23
+ * drain already in flight, another host holding the lock, and a switch turned
24
+ * off since the last tick (re-read EVERY tick, so off stops the very next one,
25
+ * whoever wrote it: the settings file, the CLI, a hand edit).
22
26
  *
23
27
  * There are exactly two automations, because there are exactly two acts in the
24
28
  * app that are irreversible-ish and cost something:
@@ -94,12 +94,43 @@ function slug(value, max = 80) {
94
94
  * @param {object} o
95
95
  * @param {string} o.dir the app's data directory (userData in a package)
96
96
  * @param {object} [o.env] process env, injectable for tests
97
+ * @param {() => string} [o.getVaultBackend] reads the live preference
98
+ * ('aegiscloud' | 'drive' | 'off', desktop/lib/settings.js's
99
+ * vaultBackendConfig) — a function, not a value, so a change made in
100
+ * Settings while the app is running reaches the very next vault call with
101
+ * no restart, the same contract getConfirmMode's gate already keeps.
97
102
  */
98
- function createFingerprintService({ dir, env } = {}) {
103
+ function createFingerprintService({ dir, env, getVaultBackend } = {}) {
99
104
  if (!dir) throw new Error('aegis-desktop: createFingerprintService needs a data dir');
100
105
  // One store: the shared modules read AEGIS_HOME and nothing else.
101
106
  const scope = { ...(env || process.env), AEGIS_HOME: (env || process.env).AEGIS_HOME || dir };
102
107
 
108
+ /** `true` only when the user has explicitly turned sync off — not when no
109
+ * preference has been recorded yet, which must keep resolving to whatever
110
+ * record-vault.js's own default is. */
111
+ function vaultDisabled() {
112
+ return typeof getVaultBackend === 'function' && getVaultBackend() === 'off';
113
+ }
114
+
115
+ /**
116
+ * `scope`, with the vault backend layered on top of it when a preference
117
+ * names one. 'aegiscloud' and an absent preference both fall through to
118
+ * `scope` unchanged — client/record-vault.js's own DEFAULT_BACKEND is
119
+ * already 'aegiscloud', so there is nothing to add for either case. 'drive'
120
+ * is the one preference that actually changes the destination: it asks the
121
+ * rclone adapter for the `gdrive` remote (the user's own Google Drive,
122
+ * configured by them — see client/record-vault.js's DEFAULT_REMOTE). 'off'
123
+ * is handled by `vaultDisabled()` before this is ever called, not here.
124
+ */
125
+ function vaultScope() {
126
+ if (typeof getVaultBackend === 'function' && getVaultBackend() === 'drive') {
127
+ return { ...scope, AEGIS_VAULT_BACKEND: 'rclone', AEGIS_VAULT_REMOTE: scope.AEGIS_VAULT_REMOTE || 'gdrive' };
128
+ }
129
+ return scope;
130
+ }
131
+
132
+ const VAULT_OFF_ERROR = 'the vault is turned off — turn it back on (Google Drive or AE) in Settings to publish or inspect it';
133
+
103
134
  const recordPath = (record, session) =>
104
135
  path.join(dir, 'fingerprints', `${slug(session)}.${String(record.createdAt).replace(/[:.]/g, '-')}.json`);
105
136
 
@@ -110,7 +141,22 @@ function createFingerprintService({ dir, env } = {}) {
110
141
  const { fp, authorship, vault } = load();
111
142
  const key = authorship.resolveKey({ argv: [], env: scope });
112
143
  const history = path.join(authorship.dataDir(scope), 'history.jsonl');
113
- const target = vault.vaultTarget(scope);
144
+ if (vaultDisabled()) {
145
+ return {
146
+ ok: true,
147
+ configured: Boolean(key.secret),
148
+ keySource: key.source,
149
+ keyPath: key.path,
150
+ keyFingerprint: key.secret ? fp.keyFingerprint(key.secret) : null,
151
+ publicKey: key.public,
152
+ defaultKeyPath: authorship.defaultKeyPath(scope),
153
+ historyPath: history,
154
+ historyPresent: fs.existsSync(history),
155
+ dataDir: authorship.dataDir(scope),
156
+ vault: { backend: 'off', remote: null, root: null, recordsDir: null },
157
+ };
158
+ }
159
+ const target = vault.vaultTarget(vaultScope());
114
160
  return {
115
161
  ok: true,
116
162
  configured: Boolean(key.secret),
@@ -125,7 +171,12 @@ function createFingerprintService({ dir, env } = {}) {
125
171
  // Where a publish goes, so the panel can say it before the user asks
126
172
  // for one — and so a misconfigured root is visible, not discovered by
127
173
  // a record landing in the wrong folder.
128
- vault: { remote: target.remote, root: target.root, recordsDir: `${target.root}/${vault.RECORDS_DIR}` },
174
+ vault: {
175
+ backend: target.backend,
176
+ remote: target.remote,
177
+ root: target.root,
178
+ recordsDir: target.backend === vault.BACKEND_CLOUD ? vault.CLOUD_PREFIX : `${target.root}/${vault.RECORDS_DIR}`,
179
+ },
129
180
  };
130
181
  } catch (e) {
131
182
  return { ok: false, error: (e && e.message) || String(e) };
@@ -155,7 +206,7 @@ function createFingerprintService({ dir, env } = {}) {
155
206
  * session carry the same behaviour digest — and the same AI digest, from the
156
207
  * same turns.
157
208
  */
158
- mint({ history = null, session = null, turns = null, label = null, account = null,
209
+ async mint({ history = null, session = null, turns = null, label = null, account = null,
159
210
  model = null, tools = null, codeRoots = null, codeDirty = null, out = null,
160
211
  createdAt = null, publish = false, aiSecret = undefined, aiModel = null } = {}) {
161
212
  try {
@@ -196,11 +247,15 @@ function createFingerprintService({ dir, env } = {}) {
196
247
  let vault = null;
197
248
  let vaultError = null;
198
249
  if (publish) {
199
- try {
200
- const key = authorship.resolveKey({ argv: [], env: scope });
201
- vault = load().vault.publish({ file, argv: [], env: scope, secret: key.secret });
202
- } catch (e) {
203
- vaultError = (e && e.message) || String(e);
250
+ if (vaultDisabled()) {
251
+ vaultError = VAULT_OFF_ERROR;
252
+ } else {
253
+ try {
254
+ const key = authorship.resolveKey({ argv: [], env: scope });
255
+ vault = await load().vault.publish({ file, argv: [], env: vaultScope(), secret: key.secret });
256
+ } catch (e) {
257
+ vaultError = (e && e.message) || String(e);
258
+ }
204
259
  }
205
260
  }
206
261
  return {
@@ -225,13 +280,14 @@ function createFingerprintService({ dir, env } = {}) {
225
280
  * preferred path is the file, because the sha256 in the anchor is then about
226
281
  * the bytes on disk rather than a re-serialisation of them.
227
282
  */
228
- publish({ file = null, record = null, dryRun = false } = {}) {
283
+ async publish({ file = null, record = null, dryRun = false } = {}) {
284
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
229
285
  try {
230
286
  const { authorship, vault } = load();
231
287
  const key = authorship.resolveKey({ argv: [], env: scope });
232
288
  return {
233
289
  ok: true,
234
- ...vault.publish({ file, record, argv: [], env: scope, secret: key.secret, dryRun }),
290
+ ...(await vault.publish({ file, record, argv: [], env: vaultScope(), secret: key.secret, dryRun })),
235
291
  };
236
292
  } catch (e) {
237
293
  return { ok: false, error: (e && e.message) || String(e) };
@@ -246,22 +302,24 @@ function createFingerprintService({ dir, env } = {}) {
246
302
  * sealed it has moved on" are three different situations and a single
247
303
  * boolean would flatten them into one that hides the interesting case.
248
304
  */
249
- anchor({ file = null, record = null, anchor = null } = {}) {
305
+ async anchor({ file = null, record = null, anchor = null } = {}) {
306
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
250
307
  try {
251
308
  const { authorship, vault } = load();
252
309
  if (!file && !record && !anchor) return { ok: false, error: 'anchor needs a record or an anchor' };
253
310
  const key = authorship.resolveKey({ argv: [], env: scope });
254
- return { ok: true, ...vault.checkAnchor({ file, record, anchor, argv: [], env: scope, key }) };
311
+ return { ok: true, ...(await vault.checkAnchor({ file, record, anchor, argv: [], env: vaultScope(), key })) };
255
312
  } catch (e) {
256
313
  return { ok: false, error: (e && e.message) || String(e) };
257
314
  }
258
315
  },
259
316
 
260
317
  /** What is in the vault. */
261
- vaultList() {
318
+ async vaultList() {
319
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
262
320
  try {
263
321
  const { vault } = load();
264
- return { ok: true, ...vault.list({ argv: [], env: scope }) };
322
+ return { ok: true, ...(await vault.list({ argv: [], env: vaultScope() })) };
265
323
  } catch (e) {
266
324
  return { ok: false, error: (e && e.message) || String(e) };
267
325
  }
@@ -272,11 +330,12 @@ function createFingerprintService({ dir, env } = {}) {
272
330
  * already there before publishing was restricted. A finding is a fact about
273
331
  * the vault, not about this app, so it is returned rather than acted on.
274
332
  */
275
- vaultAudit() {
333
+ async vaultAudit() {
334
+ if (vaultDisabled()) return { ok: false, error: VAULT_OFF_ERROR };
276
335
  try {
277
336
  const { authorship, vault } = load();
278
337
  const key = authorship.resolveKey({ argv: [], env: scope });
279
- return { ok: true, ...vault.audit({ argv: [], env: scope, secret: key.secret || null }) };
338
+ return { ok: true, ...(await vault.audit({ argv: [], env: vaultScope(), secret: key.secret || null })) };
280
339
  } catch (e) {
281
340
  return { ok: false, error: (e && e.message) || String(e) };
282
341
  }
@@ -209,8 +209,15 @@ const DEFAULT_MODEL = 'nexus-brain';
209
209
  * depends on what the user's own daemon happens to have. Both REQUIRE an
210
210
  * explicit model, refused by name instead of silently resolving to
211
211
  * something nobody chose.
212
+ * - 'pro' DOES have a default, but not one this app chooses: a blank model
213
+ * means "the `claude` CLI picks for the subscription", which is what
214
+ * `--model` omitted says (engine.js sends no flag for `default`). Once a
215
+ * model IS stated it must be one of claude-pro.js's PRO_MODELS aliases —
216
+ * the same list the model picker offers — because the child accepts only
217
+ * those four spellings, and a `provider:model` id copied from a byok task
218
+ * would otherwise be handed to `claude --model` and fail there.
212
219
  */
213
- const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local']);
220
+ const MODEL_CLASSES = Object.freeze(['aegis', 'byok', 'local', 'pro']);
214
221
 
215
222
  const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
216
223
  const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
@@ -270,6 +277,34 @@ function modelRefusal(id, cls) {
270
277
  }
271
278
  return '';
272
279
  }
280
+ if (normalized === 'pro') {
281
+ // The subscription route. A blank model is legal and meaningful: engine.js
282
+ // sends no `--model` at all for 'default', so the child resolves the pick
283
+ // itself. Anything STATED must be a PRO_MODELS alias, because those four
284
+ // spellings are exactly what `claude --model` accepts — a byok-style
285
+ // "provider:model" copied between tasks would be handed to the child and
286
+ // fail there, three stages into a drain, with the queue's own file naming
287
+ // something the queue never validated.
288
+ if (!stated) return '';
289
+ // Required lazily: claude-pro.js is a heavy sibling (child spawn, session
290
+ // files) and this module is loaded by queue.js at every CLI start. The
291
+ // require is a plain sibling path with no back-require, so there is no
292
+ // cycle to guard — this is about load cost, not correctness.
293
+ let aliases;
294
+ try {
295
+ aliases = require('./claude-pro.js').PRO_MODELS;
296
+ } catch {
297
+ // If the alias list cannot be read we cannot validate a stated model, and
298
+ // accepting it unvalidated is the exact failure this arm exists to stop.
299
+ return `a pro task's model cannot be checked right now — queue it with no --model to let the claude CLI pick`;
300
+ }
301
+ const ids = (Array.isArray(aliases) ? aliases : []).map((m) => String(m && m.id).trim());
302
+ if (ids.includes(stated.toLowerCase())) return '';
303
+ return (
304
+ `the autonomous queue's Pro class runs the subscription through the claude CLI, ` +
305
+ `which accepts only its own model aliases (${ids.join('/')}); "${stated}" is not one`
306
+ );
307
+ }
273
308
  // normalized === 'local'
274
309
  if (!stated) return 'a local task needs a model — the tag of whatever is pulled into the local daemon';
275
310
  return '';
@@ -594,13 +629,18 @@ function createQueueWorker({
594
629
  */
595
630
  async function runTask(item, { carry = '', commit } = {}) {
596
631
  const cwd = item.cwd || process.cwd();
597
- // Aegis Cloud or nothing — checked BEFORE the turn, not at the server. An
598
- // item whose model is not a pooled tier (a hand-edited queue file, a
599
- // `--model` the CLI accepted before this rule existed, another host's
600
- // older build) would otherwise go out as `class: 'aegis'` with an id the
601
- // pool does not serve: billed work if it happens to be a per-provider
602
- // spelling, an opaque server error otherwise. Failing here names the model
603
- // and the allowed ones, and costs nothing.
632
+ // The task's own (model, class) pair checked BEFORE the turn, not at the
633
+ // server. An item whose model does not belong to its class (a hand-edited
634
+ // queue file, a `--model` the CLI accepted before this rule existed,
635
+ // another host's older build) would otherwise go out under a transport
636
+ // that does not serve it: billed work if it happens to be a per-provider
637
+ // spelling, an opaque server error otherwise, and for 'pro' a `claude
638
+ // --model` that the child rejects. Failing here names the model and the
639
+ // spellings that class accepts, and costs nothing.
640
+ //
641
+ // "Aegis Cloud or nothing" was this comment's old claim, and it stopped
642
+ // being true when byok/local were added (9eeef09) — modelRefusal is what
643
+ // actually enforces the rule now, per class, and it runs for all four.
604
644
  const cls = normalizeClass(item.class);
605
645
  const refusal = modelRefusal(item.model, cls);
606
646
  if (refusal) {
@@ -116,6 +116,23 @@ function claudeOAuthToken(env) {
116
116
  */
117
117
  const SHADOWING_ENV_VARS = Object.freeze(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN']);
118
118
 
119
+ /**
120
+ * Whether `value` is shaped like an Anthropic console API key.
121
+ *
122
+ * Deliberately a SHAPE test, not a validity test. This process cannot know
123
+ * whether a key is live without spending it, so it does not try: it answers
124
+ * only "could this possibly be the console credential that is allowed to
125
+ * outrank a subscription". Anthropic console keys are `sk-ant-`-prefixed. The
126
+ * check is case-sensitive and anchored, because that is the literal the vendor
127
+ * uses — a case-insensitive match would accept `SK-ANT-` junk.
128
+ *
129
+ * @param {string} value
130
+ * @returns {boolean}
131
+ */
132
+ function isAnthropicConsoleKey(value) {
133
+ return typeof value === 'string' && /^sk-ant-[A-Za-z0-9_-]{8,}$/.test(value.trim());
134
+ }
135
+
119
136
  /**
120
137
  * The env for one `claude` child, with a shadowing value that cannot be a
121
138
  * credential removed.
@@ -151,28 +168,69 @@ const SHADOWING_ENV_VARS = Object.freeze(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_T
151
168
  * value that provably cannot be a key is dropped, and the drop is REPORTED
152
169
  * rather than silent (see `envDroppedLine`).
153
170
  *
171
+ * ── the second rule: a length floor is not a shape (measured, 2026-10-21) ───
172
+ *
173
+ * The floor above answers "is this too short to be anything?" and nothing more.
174
+ * A 35-character value that is NOT an Anthropic console key clears it, still
175
+ * wins the CLI's precedence, and still takes the plan down — so the floor alone
176
+ * reproduced the original bug one paste later. Measured state that produced it:
177
+ *
178
+ * ~/.aegiscode/.env held ANTHROPIC_API_KEY=<35 chars, not sk-ant-*>
179
+ * ~/.claude/.credentials.json held a valid `claudeAiOauth` for plan `pro`
180
+ * `aegiscode -p "say hi" --json`
181
+ * → {"text":"Invalid API key · Fix external API key","model":"sonnet",
182
+ * "tokens":0,"balance_eur":1.54} exit 0, in 2.3 s
183
+ *
184
+ * and the child was spawned with the stale value present (`ANTHROPIC_LEN=35`,
185
+ * observed by a fake `claude` on PATH). Unsetting the variable in the SHELL did
186
+ * not help either: this host loads `~/.aegiscode/.env` into `process.env`
187
+ * itself, so the file is an input no caller can undo.
188
+ *
189
+ * So when a subscription IS present, only a value shaped like an Anthropic
190
+ * console key may shadow it. That is the narrowest rule that keeps the promise
191
+ * the old comment made — "a user who set one deliberately is asking for the
192
+ * pay-per-token path" — while refusing a value that provably is not one. With
193
+ * no subscription there is nothing to shadow and no opinion is taken: any value
194
+ * clearing the floor passes, exactly as before.
195
+ *
154
196
  * @param {object} [baseEnv] the caller's env bag
155
197
  * @param {string} [token] a `CLAUDE_CODE_OAUTH_TOKEN` to hand the child
156
- * @returns {{env: object, dropped: Array<{name: string, length: number}>}}
198
+ * @param {{hasSubscription?: boolean}} [opts] whether a subscription is present
199
+ * (see the second rule above); absent means "assume not", which keeps every
200
+ * pre-existing caller and test on the old behaviour.
201
+ * @returns {{env: object, dropped: Array<{name: string, length: number, reason: string}>}}
157
202
  */
158
- function childEnvPlan(baseEnv, token) {
203
+ function childEnvPlan(baseEnv, token, opts) {
159
204
  const env = { ...(baseEnv || {}) };
160
205
  const dropped = [];
161
206
  const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
162
207
  ? envFileLib.credentialLooksUsable
163
208
  : null;
209
+ // Only a present subscription can be shadowed, so it is the only case that
210
+ // earns the stricter shape rule (see the docstring's second section).
211
+ const hasSubscription = !!(opts && opts.hasSubscription);
164
212
  // A host whose env module predates the floor keeps today's behaviour (the
165
213
  // variable passes through): a second copy of the floor here is the drift
166
214
  // client/env-file.js exists to prevent.
167
215
  if (usable) {
168
216
  for (const name of SHADOWING_ENV_VARS) {
169
217
  const value = env[name];
170
- if (typeof value !== 'string' || usable(value)) continue;
218
+ if (typeof value !== 'string') continue;
219
+ // Two independent reasons to refuse, and they are reported apart because
220
+ // they mean different things to the user: too short is junk, while a
221
+ // well-formed value that is not a console key is a key for something
222
+ // else that would silently outrank the plan they are paying for.
223
+ let reason = null;
224
+ if (!usable(value)) reason = 'too-short';
225
+ else if (hasSubscription && name === 'ANTHROPIC_API_KEY' && !isAnthropicConsoleKey(value)) {
226
+ reason = 'not-a-console-key';
227
+ }
228
+ if (!reason) continue;
171
229
  delete env[name];
172
230
  // A variable set to '' shadows nothing worth reporting — it is removed
173
231
  // (harmless) but the user is not told about a value they never gave.
174
232
  const length = value.trim().length;
175
- if (length > 0) dropped.push({ name, length });
233
+ if (length > 0) dropped.push({ name, length, reason });
176
234
  }
177
235
  }
178
236
  if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
@@ -183,20 +241,51 @@ function childEnvPlan(baseEnv, token) {
183
241
  function envDroppedLine(dropped) {
184
242
  const list = Array.isArray(dropped) ? dropped : [];
185
243
  if (!list.length) return null;
186
- const names = list.map((d) => `${d.name} (${d.length} character${d.length === 1 ? '' : 's'})`).join(', ');
187
- return `aegis: not passed to the claude child — ${names}: too short to be a credential, and the CLI would have spent it instead of your subscription`;
244
+ const shaped = list.filter((d) => d && d.reason === 'not-a-console-key');
245
+ const short = list.filter((d) => !d || d.reason !== 'not-a-console-key');
246
+ const parts = [];
247
+ if (short.length) {
248
+ const names = short.map((d) => `${d.name} (${d.length} character${d.length === 1 ? '' : 's'})`).join(', ');
249
+ parts.push(`${names}: too short to be a credential, and the CLI would have spent it instead of your subscription`);
250
+ }
251
+ if (shaped.length) {
252
+ const names = shaped.map((d) => `${d.name} (${d.length} characters)`).join(', ');
253
+ parts.push(
254
+ `${names}: not an Anthropic console key (\`sk-ant-\`), and the CLI would have spent it instead of your subscription`
255
+ );
256
+ }
257
+ return `aegis: not passed to the claude child — ${parts.join('; ')}`;
188
258
  }
189
259
 
190
- /** The shadowing value in `env` that cannot be a credential, if any (see above). */
191
- function shadowingValue(env) {
260
+ /**
261
+ * The shadowing value in `env` that cannot be spent as the credential the CLI
262
+ * would take it for, if any (see the two rules in `childEnvPlan`).
263
+ *
264
+ * This must agree with `childEnvPlan` exactly: a status surface that says "no
265
+ * shadowing value" while the child is in fact being handed one is worse than no
266
+ * report at all. Both reasons are therefore reported, and each carries the same
267
+ * `reason` tag the drop report uses so the two lines cannot drift apart.
268
+ *
269
+ * @param {object} env
270
+ * @param {{hasSubscription?: boolean}} [opts]
271
+ * @returns {{name: string, length: number, reason: string}|null}
272
+ */
273
+ function shadowingValue(env, opts) {
192
274
  const bag = env || {};
193
275
  const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
194
276
  ? envFileLib.credentialLooksUsable
195
277
  : null;
196
278
  if (!usable) return null;
279
+ const hasSubscription = !!(opts && opts.hasSubscription);
197
280
  for (const name of SHADOWING_ENV_VARS) {
198
281
  const value = bag[name];
199
- if (typeof value === 'string' && !usable(value)) return { name, length: value.trim().length };
282
+ if (typeof value !== 'string') continue;
283
+ let reason = null;
284
+ if (!usable(value)) reason = 'too-short';
285
+ else if (hasSubscription && name === 'ANTHROPIC_API_KEY' && !isAnthropicConsoleKey(value)) {
286
+ reason = 'not-a-console-key';
287
+ }
288
+ if (reason) return { name, length: value.trim().length, reason };
200
289
  }
201
290
  return null;
202
291
  }
@@ -264,7 +353,30 @@ function claudeProStatus(opts) {
264
353
  // child is fixed regardless (childEnvPlan); this reports it so a status
265
354
  // surface can say why, rather than leaving `apiKeySource: ANTHROPIC_API_KEY`
266
355
  // to be discovered from `claude auth status` by hand.
267
- out.shadowed = shadowingValue(env);
356
+ // A SUBSCRIPTION — the thing a stray `ANTHROPIC_API_KEY` would outrank — is an
357
+ // `oauth` plan or a `CLAUDE_CODE_OAUTH_TOKEN` handed to the child. A
358
+ // `credentials.json` holding a plain API key is not: there is no plan there to
359
+ // shadow, so the strict shape rule must not fire and drop the user's own key.
360
+ // This is computed once and published, so the report and the child env read
361
+ // ONE answer instead of each deciding for itself.
362
+ out.subscriptionPresent = out.loggedIn && out.kind !== 'apikey';
363
+ // The stricter rule only applies when a subscription is present, so the
364
+ // subscription this function just resolved is the same input the child env
365
+ // gets. Passing it here is what keeps `shadowed` and the drop report agreeing.
366
+ out.shadowed = shadowingValue(env, { hasSubscription: out.subscriptionPresent });
367
+ // "Can this host serve a `pro` turn at all?" is NOT the same question as
368
+ // "is there a subscription to shadow?" (above). A pin is phantom only when
369
+ // nothing usable survives to the child, and a bare API key in
370
+ // `credentials.json` (`kind: 'apikey'`) is a route with no plan behind it —
371
+ // so `subscriptionPresent` excludes it deliberately, while callability must
372
+ // count it. Answered by running the SAME planner the child env comes from, so
373
+ // this cannot disagree with what `claude` is actually handed: a variable the
374
+ // planner drops is not a credential, whatever it looks like in `env`.
375
+ const plan = childEnvPlan(env, null, { hasSubscription: out.subscriptionPresent });
376
+ out.credentialPresent = out.loggedIn
377
+ || typeof plan.env.ANTHROPIC_API_KEY === 'string'
378
+ || typeof plan.env.ANTHROPIC_AUTH_TOKEN === 'string'
379
+ || typeof plan.env.CLAUDE_CODE_OAUTH_TOKEN === 'string';
268
380
  return out;
269
381
  }
270
382
 
@@ -633,7 +745,18 @@ function runClaudePro(opts, deps) {
633
745
  // "API key: he" and the subscription is unreachable — see childEnvPlan.
634
746
  const baseEnv = o.env || process.env;
635
747
  const token = claudeOAuthToken(baseEnv);
636
- const plan = childEnvPlan(baseEnv, token);
748
+ // Whether a subscription is present decides whether a non-console
749
+ // `ANTHROPIC_API_KEY` may shadow it (childEnvPlan's second rule). It is read
750
+ // from the same credential file `claudeProStatus` reports on, and a failure
751
+ // to read it must NOT weaken the child's env: an unreadable file means "no
752
+ // subscription proven", which is the old behaviour, not a crash mid-turn.
753
+ let hasSubscription = false;
754
+ try {
755
+ hasSubscription = !!claudeProStatus({ env: baseEnv }).subscriptionPresent;
756
+ } catch {
757
+ /* unprovable subscription → the floor-only rule, exactly as before */
758
+ }
759
+ const plan = childEnvPlan(baseEnv, token, { hasSubscription });
637
760
  const childEnv = plan.env;
638
761
  const droppedLine = envDroppedLine(plan.dropped);
639
762
  if (droppedLine && o.onDelta) {
@@ -524,27 +524,43 @@ const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
524
524
  /**
525
525
  * DeepSeek native reasoning level per config rung.
526
526
  *
527
- * The field is `reasoning_effort`, NOT `effort`. Verified live 2026-10-02: a
528
- * DeepSeek body carrying `effort` returns HTTP 200 and is ignored, while
529
- * `reasoning_effort` binds and rejects an unknown value with a 422 naming its
530
- * enum (none|minimal|low|medium|high|xhigh|ultra|max). Behaviourally,
531
- * `reasoning_effort:"none"` produced 0 reasoning_content chars and `"max"`
532
- * produced 138, whereas `effort:"none"` still produced 112 — thinking stayed
533
- * on. So the old field was inert on every turn, and inertness is the worst
534
- * failure here: it 200s, nothing surfaces, and the model merely thinks at the
535
- * wrong depth.
527
+ * The field is `effort`, NOT `reasoning_effort`. Verified live 2026-10-21
528
+ * against the surface this client actually reaches — the relay's
529
+ * `https://api.deepseek.com/anthropic` transport — by measuring output tokens
530
+ * over interleaved paired samples (n=12) against an absent-field baseline:
536
531
  *
537
- * Every rung of the knob has a namesake in that enum, so the map is 1:1 rather
538
- * than the earlier (low|max, no medium) fold, which rested on the mistaken
539
- * belief that DeepSeek had no `medium`. `max_tokens` (EFFORT_TOKEN_BUDGET)
540
- * still scales as the output ceiling, since hidden chain-of-thought shares that
541
- * budget on DeepSeek.
532
+ * absent 818 · low 772 · medium 836 · high 818 · max 1494
542
533
  *
543
- * Mirrors the upstream engine's src/backend.js DEEPSEEK_EFFORT verbatim;
544
- * desktop/renderer/budget.js carries the renderer's copy for the same
545
- * no-drift guarantee as DEEPSEEK_REASONING_MODEL_RE above.
534
+ * Only `max` separates from sending nothing. `GET /models` reports
535
+ * `supported_levels: ["low","high","max"]`, default `high`, on both live ids
536
+ * (`deepseek-flash`, `deepseek-v4-pro`) — so `medium` is not a level these
537
+ * models have. It passes the deserializer (unknown values *do* 422 with the
538
+ * enum, so validation is real) and is then absorbed into the default, which is
539
+ * this codebase's worst failure mode: a 200, nothing surfaced, and the model
540
+ * thinking at a depth nobody chose.
541
+ *
542
+ * What the earlier version of this comment asserted — that `reasoning_effort`
543
+ * binds and `effort` is ignored — was measured on the vendor's OpenAI-shaped
544
+ * `/chat/completions`, a surface this client never reaches, and it led to the
545
+ * knob being dead end-to-end: aegis1 forwards `reasoning_effort` only for
546
+ * BYOK_REASONING_EFFORT_PROVIDERS (openai/groq/gemini/grok — deepseek is
547
+ * absent), so the field was dropped at the relay. The `effort` channel is the
548
+ * one aegis1 gates on `provider_id == "deepseek"` (app.py) and that the
549
+ * Anthropic adapter nests as `output_config.effort`; it is the only channel
550
+ * that reaches the vendor, and the only one that measurably binds.
551
+ *
552
+ * The map folds `medium` upward for the same reason the Gemini row does: the
553
+ * vendor has no such level, and a rung that 200s while doing nothing is worse
554
+ * than one that says what it does. `max` is deliberately NOT reachable — it is
555
+ * not a rung of this knob, and mapping the default rung onto it would silently
556
+ * multiply reasoning spend on every turn nobody asked to deepen.
557
+ * `max_tokens` (EFFORT_TOKEN_BUDGET) still scales as the output ceiling, since
558
+ * hidden chain-of-thought shares that budget on DeepSeek.
559
+ *
560
+ * desktop/renderer/budget.js carries the renderer's copy; test/budget.test.mjs
561
+ * compares the two tables, so the mirror cannot drift silently.
546
562
  */
547
- const DEEPSEEK_EFFORT = { low: 'low', medium: 'medium', high: 'high' };
563
+ const DEEPSEEK_EFFORT = { low: 'low', medium: 'high', high: 'high' };
548
564
 
549
565
  /**
550
566
  * Per-family effort translation, mirroring the upstream engine's
@@ -565,7 +581,7 @@ const DEEPSEEK_EFFORT = { low: 'low', medium: 'medium', high: 'high' };
565
581
  const EFFORT_IDENTITY = { low: 'low', medium: 'medium', high: 'high' };
566
582
  const EFFORT_INERT_RE = /non-reasoning/i;
567
583
  const EFFORT_DIALECTS = [
568
- { provider: 'deepseek', re: DEEPSEEK_REASONING_MODEL_RE, field: 'reasoning_effort', map: DEEPSEEK_EFFORT },
584
+ { provider: 'deepseek', re: DEEPSEEK_REASONING_MODEL_RE, field: 'effort', map: DEEPSEEK_EFFORT },
569
585
  { provider: 'openai', re: /^(gpt-5|o[0-9])/i, field: 'reasoning_effort', map: EFFORT_IDENTITY },
570
586
  { provider: 'groq', re: /^(openai\/gpt-oss|qwen\/qwen3)/i, field: 'reasoning_effort', map: EFFORT_IDENTITY },
571
587
  { provider: 'xai', re: /^grok-(4\.[5-9]|[5-9])/, field: 'reasoning_effort', map: EFFORT_IDENTITY },
@@ -1967,8 +1983,8 @@ function createLocalEngine({
1967
1983
  const { provider, model: bareModel } = splitByokModel(opts.model);
1968
1984
  // Translate the rung for THIS vendor once, and carry the verdict rather
1969
1985
  // than a bare level: the relay validates strictly upstream and the field
1970
- // name is not portable (DeepSeek's OpenAI-compatible surface takes
1971
- // `reasoning_effort`, same spelling as OpenAI's). Undefined for a family
1986
+ // name is not portable (DeepSeek's `/anthropic` transport takes `effort`,
1987
+ // OpenAI-shaped vendors take `reasoning_effort`). Undefined for a family
1972
1988
  // with no dialect, which the transport then omits entirely.
1973
1989
  const effort = nativeEffort(provider, bareModel, opts.effort);
1974
1990
  try {
@@ -1999,10 +2015,16 @@ function createLocalEngine({
1999
2015
  toolChoice: opts.toolChoice,
2000
2016
  // Native effort knob, field and level both from the dialect table.
2001
2017
  // `bareModel` is already provider-less, so it matches the dialect
2002
- // regexes without further stripping. Only a dialect whose field is
2003
- // `reasoning_effort` is forwarded: DeepSeek used to ride a separate
2004
- // `effort` body key, which the API 200s and ignores, so that whole
2005
- // second channel is gone rather than left as a way to reintroduce it.
2018
+ // regexes without further stripping. Both wire channels are live, and
2019
+ // which one is used is the dialect's decision, not this call site's:
2020
+ // DeepSeek's `/anthropic` transport takes `effort` (aegis1 gates it on
2021
+ // provider_id == "deepseek" and the adapter nests it as
2022
+ // `output_config.effort`), while the OpenAI-shaped vendors take
2023
+ // `reasoning_effort`. Routing on the field name is what keeps a
2024
+ // dialect from being silently dropped here — the previous form
2025
+ // forwarded only `reasoning_effort`, so turning DeepSeek's dialect
2026
+ // over to `effort` would have died at this line.
2027
+ effort: effort && effort.field === 'effort' ? effort.level : undefined,
2006
2028
  reasoningEffort: effort && effort.field === 'reasoning_effort' ? effort.level : undefined,
2007
2029
  stream: true,
2008
2030
  onStream: opts.onDelta,