aegis-desktop 1.0.7 → 1.0.9

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.
@@ -0,0 +1,872 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
+ // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
+ // Conveying a modified copy, or running one as a network service, owes the
5
+ // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
+ 'use strict';
7
+
8
+ /**
9
+ * claude-pro.js — the Claude Code Pro class: the user's own Claude
10
+ * subscription, driven through the `claude` CLI.
11
+ *
12
+ * ── why this is a CLASS and not a provider row ──────────────────────────────
13
+ *
14
+ * The other three classes reach a model over an HTTP API: `aegis` and `byok`
15
+ * post to the AEGIS relay, `local` posts to a daemon on loopback. This one
16
+ * reaches no API at all. A Pro/Max subscription is not an API key and is
17
+ * deliberately not usable as one — Claude Code's OAuth session is bound to the
18
+ * `claude` binary, and the only supported way for another program to spend that
19
+ * subscription is to spawn that binary. `~/aegiscodex-dev` proved the shape
20
+ * (src/backend.js runClaude): `claude -p <prompt> --output-format stream-json
21
+ * --include-partial-messages --verbose`, reading the stream off stdout.
22
+ *
23
+ * So the class has no baseURL, no key row, and no relay. It has an ARGV.
24
+ *
25
+ * ── the two rules this file exists to keep ──────────────────────────────────
26
+ *
27
+ * 1. **The argv is DERIVED, never hand-typed.** `one/src/child.js` already owns
28
+ * that decision (AEGISONE decision D8 rule 4) and states why: a spawn site
29
+ * that writes its own flags is a second policy, and the child's permission
30
+ * system is the one nobody on this side can answer. The only argv builder is
31
+ * `claudeArgv(descriptor)`, fed a posture record from
32
+ * `one/src/permissions.js resolvePosture()`. This module therefore does NOT
33
+ * build flags; it asks for a posture record and hands over a descriptor.
34
+ *
35
+ * 2. **Auth is the CLI's, not ours.** ~/.claude/.credentials.json is read to
36
+ * ANSWER "is there a subscription here", and never written. That is the same
37
+ * read-only contract the rest of the repo states (client/credentials.js's
38
+ * "another product's file"), and it is why `claudeProStatus()` is an
39
+ * inspector rather than a login.
40
+ *
41
+ * Resolution of both sibling modules is by EXISTENCE in the two layouts the
42
+ * repo already uses (a checkout, and the staged vendor tree), and a missing
43
+ * child.js is a thrown error rather than a local re-implementation — a second
44
+ * argv builder is exactly the drift rule 4 forbids.
45
+ */
46
+
47
+ const fs = require('node:fs');
48
+ const os = require('node:os');
49
+ const path = require('node:path');
50
+
51
+ // client/env-file.js, resolved the same two ways engine.js already resolves
52
+ // it (a checkout, and a packaged desktop build's flat vendor dir) — the CLI's
53
+ // mirrored tree preserves this file's own relative position, so the same
54
+ // checkout path also resolves there.
55
+ let envFileLib = null;
56
+ try {
57
+ envFileLib = require('../../../client/env-file.js');
58
+ } catch {
59
+ try {
60
+ envFileLib = require('../../vendor/env-file.js');
61
+ } catch {
62
+ envFileLib = null;
63
+ }
64
+ }
65
+
66
+ /** Claude Code's own config dir; `$CLAUDE_CONFIG_DIR` wins, as the CLI's does. */
67
+ function claudeConfigDir(env) {
68
+ const e = env || process.env;
69
+ const override = e && e.CLAUDE_CONFIG_DIR;
70
+ if (override && String(override).trim()) return String(override).trim();
71
+ return path.join(os.homedir(), '.claude');
72
+ }
73
+
74
+ /**
75
+ * The user's Claude Code OAuth token, read the way the `claude` binary itself
76
+ * wants it handed to it: into the CHILD's environment only, never exported
77
+ * into this process's own `process.env`.
78
+ *
79
+ * `CLAUDE_CODE_OAUTH_TOKEN` is on client/env-file.js's FOREIGN_SECRET_NAMES —
80
+ * a credential for a different CLI, deliberately left OUT of the ambient
81
+ * `~/.aegiscode/.env` load every host does at start-up, because until this
82
+ * class existed no host in this repo read it. This class is now that one
83
+ * reader, and it reads it the way the split was designed for — explicitly,
84
+ * for the one process that needs it, never ambient to every other process on
85
+ * the machine.
86
+ *
87
+ * Checked in the order that keeps a real shell export authoritative:
88
+ * 1. the caller's own env bag (an explicit `CLAUDE_CODE_OAUTH_TOKEN=…`
89
+ * export always wins — the same rule `loadEnvFile`'s rule 1 states).
90
+ * 2. `~/.aegiscode/secrets.env` — the file the split moves this name to.
91
+ * 3. `~/.aegiscode/.env`, parsed directly and never applied to any env
92
+ * object — covers a token that has not been migrated to secrets.env yet
93
+ * (`tools/split-env-secrets.mjs` is the migration), without this class
94
+ * having to make the value ambient to get there.
95
+ *
96
+ * @param {object} [env] defaults to process.env
97
+ * @returns {string} '' when no source has it
98
+ */
99
+ function claudeOAuthToken(env) {
100
+ // client/env-file.js owns the deny list AND the secrets-file resolution, so
101
+ // it is the one place that answers "does a foreign secret exist anywhere" —
102
+ // the CLI's model picker (cli/src/models.js) reads the SAME function to
103
+ // decide whether `/model` still needs to collect this token, and a second
104
+ // implementation here is exactly the drift that would let the two disagree.
105
+ if (!envFileLib) return '';
106
+ return envFileLib.resolveForeignSecret('CLAUDE_CODE_OAUTH_TOKEN', { env });
107
+ }
108
+
109
+ /**
110
+ * The env names that SHADOW the subscription when the `claude` CLI starts.
111
+ *
112
+ * Claude Code authenticates with `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`)
113
+ * when either is set, and that choice wins over the `claudeAiOauth` entry the
114
+ * subscription lives in. So a value that is not a credential at all does not
115
+ * merely fail on its own — it takes the plan down with it.
116
+ */
117
+ const SHADOWING_ENV_VARS = Object.freeze(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN']);
118
+
119
+ /**
120
+ * The env for one `claude` child, with a shadowing value that cannot be a
121
+ * credential removed.
122
+ *
123
+ * ── why this exists (measured, 2026-10-14) ──────────────────────────────────
124
+ *
125
+ * `claude auth status` in the shell that launched this host:
126
+ *
127
+ * { "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
128
+ * "apiKeySource": "ANTHROPIC_API_KEY", "email": null, "orgId": null,
129
+ * "subscriptionType": null } ← the two-character state
130
+ *
131
+ * and the identical command with the one variable unset (`env -u
132
+ * ANTHROPIC_API_KEY claude auth status`):
133
+ *
134
+ * { … "email": "…", "orgId": "9d5e56e1-…", "orgName": "…",
135
+ * "subscriptionType": "pro" } ← the plan this user pays for
136
+ *
137
+ * The session was signed in the whole time; `~/.claude/.credentials.json` holds
138
+ * a valid `claudeAiOauth` entry naming `pro` (verified read-only). What was
139
+ * missing was only that the shell exported `ANTHROPIC_API_KEY=he` — two
140
+ * characters, the residue of a paste that lost its payload — so every `claude`
141
+ * child this host spawned authenticated as "API key: he" instead of as the
142
+ * subscription, and Claude Pro was unavailable in exactly the state a user is
143
+ * most likely to be in: the shell where they once pasted a key.
144
+ *
145
+ * ── what it does NOT do ─────────────────────────────────────────────────────
146
+ *
147
+ * It does not strip a real key. The floor is `client/env-file.js`'s
148
+ * `credentialLooksUsable` — the one place this repo decides "too short to be a
149
+ * credential" — so a genuine `ANTHROPIC_API_KEY` keeps its precedence, because
150
+ * a user who set one deliberately is asking for the pay-per-token path. Only a
151
+ * value that provably cannot be a key is dropped, and the drop is REPORTED
152
+ * rather than silent (see `envDroppedLine`).
153
+ *
154
+ * @param {object} [baseEnv] the caller's env bag
155
+ * @param {string} [token] a `CLAUDE_CODE_OAUTH_TOKEN` to hand the child
156
+ * @returns {{env: object, dropped: Array<{name: string, length: number}>}}
157
+ */
158
+ function childEnvPlan(baseEnv, token) {
159
+ const env = { ...(baseEnv || {}) };
160
+ const dropped = [];
161
+ const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
162
+ ? envFileLib.credentialLooksUsable
163
+ : null;
164
+ // A host whose env module predates the floor keeps today's behaviour (the
165
+ // variable passes through): a second copy of the floor here is the drift
166
+ // client/env-file.js exists to prevent.
167
+ if (usable) {
168
+ for (const name of SHADOWING_ENV_VARS) {
169
+ const value = env[name];
170
+ if (typeof value !== 'string' || usable(value)) continue;
171
+ delete env[name];
172
+ // A variable set to '' shadows nothing worth reporting — it is removed
173
+ // (harmless) but the user is not told about a value they never gave.
174
+ const length = value.trim().length;
175
+ if (length > 0) dropped.push({ name, length });
176
+ }
177
+ }
178
+ if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
179
+ return { env, dropped };
180
+ }
181
+
182
+ /** The one-line report for a child that started without a shadowing value. */
183
+ function envDroppedLine(dropped) {
184
+ const list = Array.isArray(dropped) ? dropped : [];
185
+ 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`;
188
+ }
189
+
190
+ /** The shadowing value in `env` that cannot be a credential, if any (see above). */
191
+ function shadowingValue(env) {
192
+ const bag = env || {};
193
+ const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
194
+ ? envFileLib.credentialLooksUsable
195
+ : null;
196
+ if (!usable) return null;
197
+ for (const name of SHADOWING_ENV_VARS) {
198
+ const value = bag[name];
199
+ if (typeof value === 'string' && !usable(value)) return { name, length: value.trim().length };
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * Read Claude Code's credential file, without ever writing it.
206
+ *
207
+ * `kind` is 'oauth' (a `claudeAiOauth` entry — the Pro/Max subscription),
208
+ * 'apikey', or 'env-token' (see below). `subscription` is the plan the file
209
+ * itself names ('pro', 'max'), so the picker can say which subscription is
210
+ * being spent instead of guessing from the token's shape.
211
+ *
212
+ * `loggedIn` is deliberately NOT `!expired`: access tokens are short-lived and
213
+ * refreshed by the CLI on its next run, so a file holding a refresh token is a
214
+ * working subscription even when the access token has lapsed. Only a file with
215
+ * NO token at all is "not signed in" — anything stricter would tell a signed-in
216
+ * user to sign in again.
217
+ *
218
+ * A file that genuinely has no usable token (never logged in, or a refresh
219
+ * token that outright expired — the CLI's own `claude auth status` agrees in
220
+ * that case, there is no stricter-than-the-CLI check being applied here) falls
221
+ * back to `claudeOAuthToken()`: a `CLAUDE_CODE_OAUTH_TOKEN` the user has set
222
+ * aside (shell export, `secrets.env`, or an unmigrated `.env` line) is handed
223
+ * to the `claude` child directly by `runClaudePro`, and the CLI authenticates
224
+ * with it regardless of what is on disk. Reporting "not logged in" while that
225
+ * token would actually work is the gap this closes — the file is read-only
226
+ * state, the token is a second, equally real way to be signed in.
227
+ */
228
+ function claudeProStatus(opts) {
229
+ const o = opts || {};
230
+ const env = o.env || process.env;
231
+ const file = path.join(o.dir || claudeConfigDir(env), '.credentials.json');
232
+ const out = {
233
+ claude: o.claudeVersion || null,
234
+ file,
235
+ exists: false,
236
+ kind: null,
237
+ subscription: null,
238
+ expiresAt: 0,
239
+ loggedIn: false,
240
+ };
241
+ let data;
242
+ try {
243
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
244
+ out.exists = true;
245
+ } catch {
246
+ data = null;
247
+ }
248
+ const oauth = data && data.claudeAiOauth;
249
+ if (oauth && (oauth.accessToken || oauth.refreshToken)) {
250
+ out.kind = 'oauth';
251
+ out.subscription = oauth.subscriptionType || null;
252
+ out.expiresAt = Number(oauth.refreshTokenExpiresAt) || 0;
253
+ out.loggedIn = true;
254
+ } else if (data && typeof data.token === 'string' && data.token) {
255
+ out.kind = 'apikey';
256
+ out.loggedIn = true;
257
+ } else if (claudeOAuthToken(env)) {
258
+ out.kind = 'env-token';
259
+ out.loggedIn = true;
260
+ }
261
+ // A shadowing env var that cannot be a credential does NOT change `loggedIn`
262
+ // — the subscription is there — but it is the one state where "signed in" and
263
+ // "actually spends the plan" differ, because the CLI prefers that name. The
264
+ // child is fixed regardless (childEnvPlan); this reports it so a status
265
+ // surface can say why, rather than leaving `apiKeySource: ANTHROPIC_API_KEY`
266
+ // to be discovered from `claude auth status` by hand.
267
+ out.shadowed = shadowingValue(env);
268
+ return out;
269
+ }
270
+
271
+ /** The model aliases the `claude` CLI itself accepts (`claude --model <alias>`). */
272
+ const PRO_MODELS = Object.freeze([
273
+ { id: 'default', label: 'Default (recommended)', note: 'the CLI picks for the subscription' },
274
+ { id: 'sonnet', label: 'Sonnet', note: 'alias the CLI resolves' },
275
+ { id: 'opus', label: 'Opus', note: 'alias the CLI resolves' },
276
+ { id: 'haiku', label: 'Haiku', note: 'alias the CLI resolves' },
277
+ ]);
278
+
279
+ /**
280
+ * The extra argv this class owns (what the child is told about the turn),
281
+ * separate from the argv `one/src/child.js` owns (what the child is allowed to
282
+ * do). Keeping the two apart is the point of rule 4: the permission half is
283
+ * derived from the posture record, and this half is derived from the payload.
284
+ *
285
+ * NOTE: the session flags are NOT here even though they are "about the turn".
286
+ * `--resume`/`--session-id` decide whether this turn is the same conversation
287
+ * as the previous one — that is a policy about continuity, and it lives with
288
+ * the other flags in the one argv builder, so a second spawn site cannot grow
289
+ * its own opinion about it. `runClaudePro` passes `resumeSessionId`/
290
+ * `sessionId` through the descriptor instead.
291
+ */
292
+ function turnArgs(payload) {
293
+ const args = [];
294
+ const p = payload || {};
295
+ if (p.system && String(p.system).trim()) {
296
+ args.push('--append-system-prompt', String(p.system));
297
+ }
298
+ return args;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // The stream-json reader
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Line-oriented parser for `claude --output-format stream-json`. Pure: it feeds
307
+ * callbacks and keeps only the tool bookkeeping between lines, so a test can
308
+ * replay a captured transcript without spawning anything.
309
+ *
310
+ * Ported from ~/aegiscodex-dev src/backend.js createStreamParser(), which is
311
+ * the reference implementation on this machine and carries the field names the
312
+ * protocol actually uses. Two details there are load-bearing and are kept
313
+ * verbatim:
314
+ *
315
+ * - `input_json_delta` fragments arrive routed BY BLOCK INDEX. Parallel
316
+ * same-name tool calls interleave their argument JSON, and matching on
317
+ * "the first collecting block" pairs a fragment with the wrong call.
318
+ * - one `user` line can carry a tool_result for EVERY call in a parallel
319
+ * round, so all of them are resolved, not just the first.
320
+ *
321
+ * `thinking_delta` is the third delta type and is NOT folded into `text`: an
322
+ * extended-thinking block is deliberation, not the answer, so it goes out on
323
+ * its own callback and its own accumulator. Dropping it entirely (which is what
324
+ * this parser did) left BOTH channels empty for the whole thinking phase, which
325
+ * is the longest stretch of a Pro turn — so the renderers' live meters, whose
326
+ * only literal `0` is the "nothing received yet" branch, read `↓0 tokens` for
327
+ * the entire time the turn was visibly working. Claude Code Pro runs extended
328
+ * thinking by default, so this was the normal case, not an edge.
329
+ */
330
+ function createStreamParser(handlers) {
331
+ const h = handlers || {};
332
+ const tools = new Map(); // id -> {id, index, name, args, startedAt, phase}
333
+ let text = '';
334
+ let thinking = '';
335
+ // Thinking progress the provider MEASURED, in tokens. On a real Pro turn
336
+ // `thinking_delta.thinking` is the empty string and this is the only figure
337
+ // the frame carries — see the `thinking_delta` branch below.
338
+ let thinkingTokens = 0;
339
+ let resultLine = null;
340
+
341
+ const emitRun = (block) => {
342
+ let args = block.args;
343
+ try {
344
+ args = block.args ? JSON.parse(block.args) : {};
345
+ } catch {
346
+ args = {};
347
+ }
348
+ tools.set(block.id, { ...block, args, phase: 'run' });
349
+ if (h.onTool) h.onTool({ id: block.id, name: block.name, args, phase: 'run' });
350
+ };
351
+
352
+ const collecting = (index) =>
353
+ [...tools.values()].find((t) => t.phase === 'collecting' && t.index === index) ||
354
+ [...tools.values()].find((t) => t.phase === 'collecting');
355
+
356
+ return {
357
+ line(raw) {
358
+ let j;
359
+ try {
360
+ j = JSON.parse(raw);
361
+ } catch {
362
+ return;
363
+ }
364
+
365
+ if (j.type === 'stream_event' && j.event) {
366
+ const e = j.event;
367
+ if (e.type === 'content_block_start' && e.content_block) {
368
+ if (e.content_block.type === 'tool_use') {
369
+ tools.set(e.content_block.id, {
370
+ id: e.content_block.id,
371
+ index: e.index == null ? 0 : e.index,
372
+ name: e.content_block.name,
373
+ args: '',
374
+ startedAt: Date.now(),
375
+ phase: 'collecting',
376
+ });
377
+ }
378
+ } else if (e.type === 'content_block_delta' && e.delta) {
379
+ if (e.delta.type === 'text_delta') {
380
+ const t = (e.delta.text || '').replace(/\x00/g, '');
381
+ text += t;
382
+ if (h.onText && t) h.onText(t);
383
+ } else if (e.delta.type === 'thinking_delta') {
384
+ // Same `\x00` scrub as the visible text, for the same reason: a
385
+ // NUL in the frame would otherwise be handed to a terminal.
386
+ const t = (e.delta.thinking || '').replace(/\x00/g, '');
387
+ thinking += t;
388
+ if (h.onThinking && t) h.onThinking(t);
389
+ // The figure that ACTUALLY moves on a subscription turn. Measured
390
+ // against Claude Code 2.1.228: every real `thinking_delta` carries
391
+ // `thinking: ""` with the deliberation withheld (the finished
392
+ // block ships a 5.4 KB `signature` and no text), while
393
+ // `estimated_tokens` advances in increments that sum to just under
394
+ // the billed `usage.output_tokens_details.thinking_tokens`
395
+ // (measured: 1900 vs 1943, 1800 vs 1832, 250 vs 339). So the text
396
+ // channel is empty for the WHOLE thinking phase on a Pro turn and
397
+ // the earlier `onThinking`-only fix never fired — the live meter
398
+ // still read `↓0 tokens` while 4114 thinking tokens were billed
399
+ // across three captured turns. Incremental, not cumulative: the
400
+ // increments sum to the billed figure, so they are added as they
401
+ // arrive rather than treated as a running total.
402
+ const est = e.delta.estimated_tokens;
403
+ if (typeof est === 'number' && est > 0 && Number.isFinite(est)) {
404
+ thinkingTokens += est;
405
+ if (h.onThinkingProgress) h.onThinkingProgress(est);
406
+ }
407
+ } else if (e.delta.type === 'input_json_delta') {
408
+ const t = collecting(e.index == null ? 0 : e.index);
409
+ if (t) t.args += e.delta.partial_json || '';
410
+ }
411
+ } else if (e.type === 'content_block_stop') {
412
+ const t = collecting(e.index == null ? 0 : e.index);
413
+ if (t) emitRun(t);
414
+ }
415
+ } else if (j.type === 'user' && j.message) {
416
+ const content = Array.isArray(j.message.content) ? j.message.content : [];
417
+ for (const tr of content) {
418
+ if (!tr || tr.type !== 'tool_result' || !tr.tool_use_id) continue;
419
+ const t = tools.get(tr.tool_use_id);
420
+ if (!t) continue;
421
+ const elapsed = Math.max(0, Date.now() - (t.startedAt || Date.now()));
422
+ if (h.onTool) {
423
+ h.onTool({ id: t.id, name: t.name, phase: 'done', elapsed, error: !!tr.is_error });
424
+ }
425
+ tools.delete(tr.tool_use_id);
426
+ }
427
+ } else if (j.type === 'result') {
428
+ resultLine = j;
429
+ if (h.onDone) {
430
+ h.onDone({
431
+ text: text || j.result || '',
432
+ error: j.is_error ? j.result || 'api error' : undefined,
433
+ usage: j.usage || null,
434
+ costUsd: typeof j.total_cost_usd === 'number' ? j.total_cost_usd : undefined,
435
+ durationMs: j.duration_ms,
436
+ sessionId: j.session_id,
437
+ // The deliberation this round DID produce, handed up so the engine
438
+ // can tell a thinking-only round apart from a genuinely empty one.
439
+ // On a real Pro turn the text is normally withheld and only the
440
+ // billed token count is available, so `thinking` is often empty
441
+ // even when the child thought — hence both fields, and the count.
442
+ thinking: thinking || '',
443
+ thinkingTokens,
444
+ });
445
+ }
446
+ }
447
+ // system / assistant / rate_limit_event lines carry no UI data.
448
+ },
449
+ get text() {
450
+ return text;
451
+ },
452
+ get thinking() {
453
+ return thinking;
454
+ },
455
+ // Thinking tokens the PROVIDER measured, not inferred from text — the only
456
+ // honest way to meter a Pro turn, whose thinking text is withheld.
457
+ get thinkingTokens() {
458
+ return thinkingTokens;
459
+ },
460
+ get resultLine() {
461
+ return resultLine;
462
+ },
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Resolve `one/src/child.js` and `one/src/permissions.js` in the two layouts the
468
+ * repo uses, or throw naming the file. No fallback implementation: rule 4's
469
+ * whole guarantee is that there is exactly ONE argv builder, so a local copy
470
+ * here would be the drift the decision forbids.
471
+ */
472
+ function loadSeam(dir) {
473
+ const here = dir || __dirname;
474
+ const candidates = [
475
+ // A checkout: desktop/lib/local → <repo>/one/src
476
+ path.join(here, '..', '..', '..', 'one', 'src'),
477
+ // A packaged desktop app: desktop/lib/local → desktop/vendor/one/src
478
+ path.join(here, '..', '..', 'vendor', 'one', 'src'),
479
+ // The published CLI's mirrored tree: cli/vendor/desktop/lib/local →
480
+ // cli/vendor/one/src (the manifest stages one/src there under 'mirror').
481
+ path.join(here, '..', '..', '..', '..', 'one', 'src'),
482
+ ];
483
+ for (const base of candidates) {
484
+ try {
485
+ const child = require(path.join(base, 'child.js'));
486
+ const permissions = require(path.join(base, 'permissions.js'));
487
+ if (typeof child.claudeArgv === 'function' && typeof permissions.resolvePosture === 'function') {
488
+ return { child, permissions };
489
+ }
490
+ } catch {
491
+ /* try the next layout */
492
+ }
493
+ }
494
+ throw new Error(
495
+ 'aegis: the Claude child argv builder (one/src/child.js + one/src/permissions.js) is missing ' +
496
+ 'from this install — refusing to build a second argv, because a second argv is a second policy.'
497
+ );
498
+ }
499
+
500
+ /** Whether the argv seam resolves here, without throwing. For status panels. */
501
+ function seamAvailable(dir) {
502
+ try {
503
+ loadSeam(dir);
504
+ return { ok: true, error: null };
505
+ } catch (e) {
506
+ return { ok: false, error: (e && e.message) || String(e) };
507
+ }
508
+ }
509
+
510
+ /**
511
+ * The `choices[0].message` a Pro turn reports.
512
+ *
513
+ * `content` is the child's own final answer, as before. The addition is the
514
+ * deliberation, carried on the shape the engine already reads: `reasoning_content`
515
+ * is what `engine.js` `reasoningTextOf()` looks for first, so a Pro round that
516
+ * spent its output thinking is now visible as exactly that.
517
+ *
518
+ * This matters because of how a Pro turn fails. When extended thinking is on —
519
+ * which is Claude Code's default — the child can finish a round having produced
520
+ * deliberation and no answer, and the engine's recovery ladder decides what to
521
+ * do next by asking `reasonedButSilent(res)`, i.e. "was this round empty, or did
522
+ * it think and stay silent?". Before this field existed, a Pro result never
523
+ * carried reasoning at all, so that question always answered "genuinely empty"
524
+ * and the ladder burned its rounds on the generic nudge — the ask that invites
525
+ * the same silent thinking pass a second time. Worse, `emptyTurnError` reported
526
+ * it as "No visible text came back from any of those calls", which is the wrong
527
+ * diagnosis: the model was not silent, it was thinking.
528
+ *
529
+ * `thinking_tokens` rides alongside as the provider's own billed count. On a
530
+ * real Pro turn the deliberation TEXT is withheld — measured across three turns,
531
+ * every `thinking_delta` carried `thinking: ""` while thousands of tokens were
532
+ * billed — so a text-only signal would still miss the case this exists for. The
533
+ * count is the honest figure, and it is the only one that arrives.
534
+ */
535
+ function proMessage(text, thinking, thinkingTokens) {
536
+ const msg = { content: text || '' };
537
+ const deliberation = typeof thinking === 'string' ? thinking : '';
538
+ if (deliberation) msg.reasoning_content = deliberation;
539
+ if (typeof thinkingTokens === 'number' && thinkingTokens > 0 && Number.isFinite(thinkingTokens)) {
540
+ msg.thinking_tokens = thinkingTokens;
541
+ }
542
+ return msg;
543
+ }
544
+
545
+ /**
546
+ * Run one turn on the user's Claude Code subscription.
547
+ *
548
+ * Returns the SAME shape the other three transports return, because the engine
549
+ * loop reads one shape (`choices[0].message.content`, `usage`, `stop_reason`) —
550
+ * `extractToolCalls`, `assistantText`, `finishReasonOf` and `addUsage` all read
551
+ * it. Returns NO tool_calls, and that is the design rather than an omission:
552
+ * the `claude` child runs its own agent loop with its own tools, so the work is
553
+ * already done by the time this resolves. Emitting tool_calls as well would
554
+ * hand a second, parallel loop the same task and run every tool twice.
555
+ *
556
+ * @param {object} opts
557
+ * @param {string} opts.prompt
558
+ * @param {string} [opts.model] a `claude --model` alias (PRO_MODELS)
559
+ * @param {string} [opts.system] appended to the child's own prompt
560
+ * @param {string} [opts.cwd] the directory the child works in
561
+ * @param {string[]} [opts.addDir] extra directories the child may work in.
562
+ * The child resolves every relative path
563
+ * and every tool's root against `cwd`
564
+ * alone, so a turn started in a
565
+ * subdirectory cannot see the tree above
566
+ * it; this is what widens that. Spelled
567
+ * by the ONE argv builder (one/src/child.js
568
+ * addDirArgs), never typed here.
569
+ * @param {object} [opts.rules] CLI permission rules (cli/src/config.js)
570
+ * @param {boolean} [opts.gate] the stored /confirm posture, if known
571
+ * @param {string} [opts.permissionMode] explicit passthrough, validated in child.js
572
+ * @param {string} [opts.resumeSessionId] reattach to the session a prior turn
573
+ * reported → the turn keeps its context
574
+ * @param {string} [opts.sessionId] name a NEW session (UUID) for a first turn
575
+ * @param {AbortSignal} [opts.signal]
576
+ * @param {Function} [opts.onSpawn] receives the child the moment it exists
577
+ * @param {number} [opts.idleTimeoutMs] silence budget (default 10 min)
578
+ * @returns {Promise<object>} an engine-shaped completion
579
+ */
580
+ function runClaudePro(opts, deps) {
581
+ const o = opts || {};
582
+ const d = deps || {};
583
+ const seam = d.seam || loadSeam(d.dir);
584
+ const spawnFn = d.spawn || require('node:child_process').spawn;
585
+
586
+ // One posture per turn, from the single resolver — never re-derived here.
587
+ const posture = seam.permissions.resolvePosture({
588
+ rules: o.rules,
589
+ gate: o.gate,
590
+ interactive: false,
591
+ childKind: seam.permissions.CHILD_KINDS.HEADLESS,
592
+ env: o.env || process.env,
593
+ });
594
+
595
+ const built = seam.child.claudeArgv({
596
+ posture,
597
+ prompt: o.prompt,
598
+ model: o.model || undefined,
599
+ permissionMode: o.permissionMode,
600
+ // Continuity, handed to the ONE argv builder rather than typed as `extra`.
601
+ // Without these the child starts a brand-new conversation every turn: the
602
+ // engine does not replay the transcript into it (`pro` runs its own agent
603
+ // loop), so the CLI's own session store is the ONLY thing that can carry
604
+ // context across turns. `resumeSessionId` reattaches to the session a
605
+ // previous turn reported; `sessionId` names a new one for a later resume.
606
+ resumeSessionId: o.resumeSessionId,
607
+ sessionId: o.sessionId,
608
+ // The widened sandbox for this turn, if the caller asked for one. It rides
609
+ // the descriptor rather than `extra` because `--add-dir` is variadic and its
610
+ // correct spelling is a decision (child.js addDirArgs), not a flag a caller
611
+ // may type.
612
+ addDir: o.addDir,
613
+ // `--include-partial-messages` is what makes `stream_event` lines arrive at
614
+ // all, and `--verbose` is required by the CLI alongside stream-json in print
615
+ // mode. Both belong to the transport (this file), not to the posture.
616
+ extra: [...turnArgs(o), '--include-partial-messages', '--verbose'],
617
+ });
618
+
619
+ return new Promise((resolve) => {
620
+ if (o.signal && o.signal.aborted) {
621
+ resolve({ choices: [{ message: { content: '' }, finish_reason: 'stop' }], stopped: true });
622
+ return;
623
+ }
624
+
625
+ // `CLAUDE_CODE_OAUTH_TOKEN` is merged into the CHILD's env only — never
626
+ // written back to `o.env`/`process.env` — so a token the on-disk
627
+ // credential file lacks (or has let expire) still reaches the one process
628
+ // that can spend it, without making the value ambient to this host or any
629
+ // other child it spawns. See claudeOAuthToken() for where it comes from.
630
+ //
631
+ // `childEnvPlan` additionally refuses to hand over a shadowing var that
632
+ // cannot be a credential. Without it a signed-in machine runs the child as
633
+ // "API key: he" and the subscription is unreachable — see childEnvPlan.
634
+ const baseEnv = o.env || process.env;
635
+ const token = claudeOAuthToken(baseEnv);
636
+ const plan = childEnvPlan(baseEnv, token);
637
+ const childEnv = plan.env;
638
+ const droppedLine = envDroppedLine(plan.dropped);
639
+ if (droppedLine && o.onDelta) {
640
+ // Same channel the transcript already paints notes on, and emitted BEFORE
641
+ // the spawn so the line lands above the child's own tool rows.
642
+ try {
643
+ o.onDelta({ delta: '', note: droppedLine });
644
+ } catch {
645
+ /* a throwing renderer must not take the turn down */
646
+ }
647
+ }
648
+
649
+ let child;
650
+ try {
651
+ child = spawnFn(d.executable || 'claude', built.argv, {
652
+ cwd: o.cwd || process.cwd(),
653
+ stdio: ['ignore', 'pipe', 'pipe'],
654
+ env: childEnv,
655
+ });
656
+ } catch (e) {
657
+ resolve({
658
+ choices: [{ message: { content: '' }, finish_reason: 'error' }],
659
+ error: `claude: could not start the child (${(e && e.message) || e})`,
660
+ ...(plan.dropped.length ? { envDropped: plan.dropped } : {}),
661
+ });
662
+ return;
663
+ }
664
+
665
+ if (o.onSpawn) {
666
+ try {
667
+ o.onSpawn(child, built.record);
668
+ } catch {
669
+ /* a throwing hook must not take the turn down */
670
+ }
671
+ }
672
+
673
+ let finished = false;
674
+ let stderr = '';
675
+ let buffer = '';
676
+ let idle = null;
677
+ const idleMs = Number(o.idleTimeoutMs) || 10 * 60 * 1000;
678
+
679
+ const settle = (result) => {
680
+ if (finished) return;
681
+ finished = true;
682
+ if (idle) clearTimeout(idle);
683
+ if (o.signal) o.signal.removeEventListener('abort', kill);
684
+ // Recorded on every settle path, the same way `childRecord` is: what the
685
+ // child was NOT given is part of what this turn did, and a caller logging
686
+ // only the argv would show a turn that "should" have failed as a mystery.
687
+ resolve(plan.dropped.length ? { ...result, envDropped: plan.dropped } : result);
688
+ };
689
+
690
+ const kill = () => {
691
+ try {
692
+ child.kill('SIGTERM');
693
+ } catch {
694
+ /* already gone */
695
+ }
696
+ setTimeout(() => {
697
+ try {
698
+ child.kill('SIGKILL');
699
+ } catch {
700
+ /* already gone */
701
+ }
702
+ }, 1500);
703
+ };
704
+ if (o.signal) o.signal.addEventListener('abort', kill, { once: true });
705
+
706
+ // Idle watchdog, not a flat turn cap: it resets on every byte, so it fires
707
+ // only on true silence — which is what a lapsed OAuth session looks like
708
+ // when the child opens a re-auth prompt nobody on this side can answer.
709
+ const armIdle = () => {
710
+ if (idle) clearTimeout(idle);
711
+ idle = setTimeout(() => {
712
+ kill();
713
+ settle({
714
+ choices: [{ message: { content: '' }, finish_reason: 'stop' }],
715
+ error: `claude: the child went silent for ${Math.round(idleMs / 1000)}s and was stopped`,
716
+ });
717
+ }, idleMs);
718
+ };
719
+ armIdle();
720
+
721
+ const parser = createStreamParser({
722
+ onText: (t) => {
723
+ armIdle();
724
+ if (o.onDelta) o.onDelta({ delta: t });
725
+ },
726
+ // Extended thinking, forwarded on the SAME channel the engine's pooled
727
+ // brain path already uses for a worker's deliberation (`onReasoning`,
728
+ // engine.js — `onDelta({ reasoning })`). Both renderers already consume
729
+ // that field, so the live token meter counts the thinking phase on a Pro
730
+ // turn exactly as it does everywhere else, with no consumer-side edit.
731
+ // `engine.js` hands `runClaudePro` the RAW `opts.onDelta` (not the wrapped
732
+ // `onReasoning`), so this transport is the one that must emit the field.
733
+ onThinking: (t) => {
734
+ armIdle();
735
+ if (o.onDelta) o.onDelta({ reasoning: t });
736
+ },
737
+ // The channel that actually fires on a Pro turn. `onThinking` above is
738
+ // kept for any provider that does stream deliberation text, but Claude
739
+ // Code withholds it: measured across three real turns, every
740
+ // `thinking_delta` carried `thinking: ""`, so `onThinking` never fired
741
+ // once while 4114 thinking tokens were billed. This carries the
742
+ // provider's own MEASURED count, on a numeric channel of its own rather
743
+ // than the text one — the consumers turn `reasoning` text into tokens at
744
+ // 4 chars:1 token, so a token count sent down that path would be divided
745
+ // by four and read as a quarter of the truth.
746
+ onThinkingProgress: (est) => {
747
+ armIdle();
748
+ if (o.onDelta) o.onDelta({ thinkingTokens: est });
749
+ },
750
+ onTool: (info) => {
751
+ armIdle();
752
+ if (!o.onDelta) return;
753
+ // Same tool frame the engine's own loop emits, so the renderers paint a
754
+ // subscription turn's tool rows with no second code path.
755
+ o.onDelta({
756
+ delta: '',
757
+ tool: {
758
+ name: info.name,
759
+ args: info.args,
760
+ ok: info.phase === 'done' ? !info.error : undefined,
761
+ id: info.id,
762
+ cwd: o.cwd,
763
+ phase: info.phase,
764
+ },
765
+ });
766
+ },
767
+ onDone: (r) => {
768
+ if (r.error) {
769
+ settle({
770
+ choices: [{ message: { content: r.text || '' }, finish_reason: 'error' }],
771
+ usage: r.usage || undefined,
772
+ error: r.error,
773
+ });
774
+ return;
775
+ }
776
+ settle({
777
+ choices: [{ message: proMessage(r.text || '', r.thinking, r.thinkingTokens), finish_reason: 'stop' }],
778
+ usage: r.usage || undefined,
779
+ costUsd: r.costUsd,
780
+ durationMs: r.durationMs,
781
+ sessionId: r.sessionId,
782
+ // Carried so a caller can log WHICH flags the child actually got —
783
+ // rule 4's "recorded, never inferred".
784
+ childRecord: built.record,
785
+ });
786
+ },
787
+ });
788
+
789
+ child.stdout.on('data', (chunk) => {
790
+ armIdle();
791
+ buffer += chunk.toString('utf8');
792
+ const lines = buffer.split('\n');
793
+ buffer = lines.pop() || '';
794
+ for (const line of lines) {
795
+ const t = line.trim();
796
+ if (t) parser.line(t);
797
+ }
798
+ });
799
+ child.stderr.on('data', (chunk) => {
800
+ armIdle();
801
+ if (stderr.length < 8192) stderr += chunk.toString('utf8');
802
+ });
803
+
804
+ child.on('error', (e) => {
805
+ settle({
806
+ choices: [{ message: { content: '' }, finish_reason: 'error' }],
807
+ error:
808
+ `claude: could not run the child (${(e && e.message) || e}). ` +
809
+ 'Install it with `npm i -g @anthropic-ai/claude-code`, then sign in with `claude auth login`.',
810
+ });
811
+ });
812
+
813
+ child.on('close', (code) => {
814
+ if (buffer.trim()) parser.line(buffer.trim());
815
+ // Only reached when no `result` line arrived — a clean exit always emits
816
+ // one. Anything else is a real failure and is reported as one rather than
817
+ // as an empty successful answer.
818
+ settle({
819
+ choices: [
820
+ {
821
+ message: proMessage(parser.text, parser.thinking, parser.thinkingTokens),
822
+ finish_reason: 'stop',
823
+ },
824
+ ],
825
+ usage: (parser.resultLine && parser.resultLine.usage) || undefined,
826
+ childRecord: built.record,
827
+ error:
828
+ parser.text || code === 0
829
+ ? undefined
830
+ : `claude: the child exited with status ${code}${
831
+ stderr.trim() ? ` — ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''
832
+ }`,
833
+ });
834
+ });
835
+ });
836
+ }
837
+
838
+ /**
839
+ * The run-log line for a child argv, from the seam that owns the wording. A
840
+ * missing seam degrades to a short local summary rather than throwing — this is
841
+ * a reporting path, and a turn that already ran must not fail to describe
842
+ * itself.
843
+ */
844
+ function childLine(record, dir) {
845
+ if (!record) return '';
846
+ try {
847
+ return loadSeam(dir).child.childRecordLine(record);
848
+ } catch {
849
+ const flag = record.skipFlag || 'no permission flag';
850
+ return `aegis: claude child [${record.kind}] ${flag}`;
851
+ }
852
+ }
853
+
854
+ module.exports = {
855
+ claudeConfigDir,
856
+ claudeOAuthToken,
857
+ claudeProStatus,
858
+ // The child-env half of "the subscription actually gets spent": the plan for
859
+ // what the `claude` child is given (and what a shadowing value cost), the
860
+ // one-line report of it, and the inspector `claudeProStatus` reports.
861
+ childEnvPlan,
862
+ envDroppedLine,
863
+ shadowingValue,
864
+ SHADOWING_ENV_VARS,
865
+ PRO_MODELS,
866
+ turnArgs,
867
+ createStreamParser,
868
+ loadSeam,
869
+ seamAvailable,
870
+ childLine,
871
+ runClaudePro,
872
+ };