aegis-desktop 1.0.6 → 1.0.8

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,755 @@
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
+ function createStreamParser(handlers) {
322
+ const h = handlers || {};
323
+ const tools = new Map(); // id -> {id, index, name, args, startedAt, phase}
324
+ let text = '';
325
+ let resultLine = null;
326
+
327
+ const emitRun = (block) => {
328
+ let args = block.args;
329
+ try {
330
+ args = block.args ? JSON.parse(block.args) : {};
331
+ } catch {
332
+ args = {};
333
+ }
334
+ tools.set(block.id, { ...block, args, phase: 'run' });
335
+ if (h.onTool) h.onTool({ id: block.id, name: block.name, args, phase: 'run' });
336
+ };
337
+
338
+ const collecting = (index) =>
339
+ [...tools.values()].find((t) => t.phase === 'collecting' && t.index === index) ||
340
+ [...tools.values()].find((t) => t.phase === 'collecting');
341
+
342
+ return {
343
+ line(raw) {
344
+ let j;
345
+ try {
346
+ j = JSON.parse(raw);
347
+ } catch {
348
+ return;
349
+ }
350
+
351
+ if (j.type === 'stream_event' && j.event) {
352
+ const e = j.event;
353
+ if (e.type === 'content_block_start' && e.content_block) {
354
+ if (e.content_block.type === 'tool_use') {
355
+ tools.set(e.content_block.id, {
356
+ id: e.content_block.id,
357
+ index: e.index == null ? 0 : e.index,
358
+ name: e.content_block.name,
359
+ args: '',
360
+ startedAt: Date.now(),
361
+ phase: 'collecting',
362
+ });
363
+ }
364
+ } else if (e.type === 'content_block_delta' && e.delta) {
365
+ if (e.delta.type === 'text_delta') {
366
+ const t = (e.delta.text || '').replace(/\x00/g, '');
367
+ text += t;
368
+ if (h.onText && t) h.onText(t);
369
+ } else if (e.delta.type === 'input_json_delta') {
370
+ const t = collecting(e.index == null ? 0 : e.index);
371
+ if (t) t.args += e.delta.partial_json || '';
372
+ }
373
+ } else if (e.type === 'content_block_stop') {
374
+ const t = collecting(e.index == null ? 0 : e.index);
375
+ if (t) emitRun(t);
376
+ }
377
+ } else if (j.type === 'user' && j.message) {
378
+ const content = Array.isArray(j.message.content) ? j.message.content : [];
379
+ for (const tr of content) {
380
+ if (!tr || tr.type !== 'tool_result' || !tr.tool_use_id) continue;
381
+ const t = tools.get(tr.tool_use_id);
382
+ if (!t) continue;
383
+ const elapsed = Math.max(0, Date.now() - (t.startedAt || Date.now()));
384
+ if (h.onTool) {
385
+ h.onTool({ id: t.id, name: t.name, phase: 'done', elapsed, error: !!tr.is_error });
386
+ }
387
+ tools.delete(tr.tool_use_id);
388
+ }
389
+ } else if (j.type === 'result') {
390
+ resultLine = j;
391
+ if (h.onDone) {
392
+ h.onDone({
393
+ text: text || j.result || '',
394
+ error: j.is_error ? j.result || 'api error' : undefined,
395
+ usage: j.usage || null,
396
+ costUsd: typeof j.total_cost_usd === 'number' ? j.total_cost_usd : undefined,
397
+ durationMs: j.duration_ms,
398
+ sessionId: j.session_id,
399
+ });
400
+ }
401
+ }
402
+ // system / assistant / rate_limit_event lines carry no UI data.
403
+ },
404
+ get text() {
405
+ return text;
406
+ },
407
+ get resultLine() {
408
+ return resultLine;
409
+ },
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Resolve `one/src/child.js` and `one/src/permissions.js` in the two layouts the
415
+ * repo uses, or throw naming the file. No fallback implementation: rule 4's
416
+ * whole guarantee is that there is exactly ONE argv builder, so a local copy
417
+ * here would be the drift the decision forbids.
418
+ */
419
+ function loadSeam(dir) {
420
+ const here = dir || __dirname;
421
+ const candidates = [
422
+ // A checkout: desktop/lib/local → <repo>/one/src
423
+ path.join(here, '..', '..', '..', 'one', 'src'),
424
+ // A packaged desktop app: desktop/lib/local → desktop/vendor/one/src
425
+ path.join(here, '..', '..', 'vendor', 'one', 'src'),
426
+ // The published CLI's mirrored tree: cli/vendor/desktop/lib/local →
427
+ // cli/vendor/one/src (the manifest stages one/src there under 'mirror').
428
+ path.join(here, '..', '..', '..', '..', 'one', 'src'),
429
+ ];
430
+ for (const base of candidates) {
431
+ try {
432
+ const child = require(path.join(base, 'child.js'));
433
+ const permissions = require(path.join(base, 'permissions.js'));
434
+ if (typeof child.claudeArgv === 'function' && typeof permissions.resolvePosture === 'function') {
435
+ return { child, permissions };
436
+ }
437
+ } catch {
438
+ /* try the next layout */
439
+ }
440
+ }
441
+ throw new Error(
442
+ 'aegis: the Claude child argv builder (one/src/child.js + one/src/permissions.js) is missing ' +
443
+ 'from this install — refusing to build a second argv, because a second argv is a second policy.'
444
+ );
445
+ }
446
+
447
+ /** Whether the argv seam resolves here, without throwing. For status panels. */
448
+ function seamAvailable(dir) {
449
+ try {
450
+ loadSeam(dir);
451
+ return { ok: true, error: null };
452
+ } catch (e) {
453
+ return { ok: false, error: (e && e.message) || String(e) };
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Run one turn on the user's Claude Code subscription.
459
+ *
460
+ * Returns the SAME shape the other three transports return, because the engine
461
+ * loop reads one shape (`choices[0].message.content`, `usage`, `stop_reason`) —
462
+ * `extractToolCalls`, `assistantText`, `finishReasonOf` and `addUsage` all read
463
+ * it. Returns NO tool_calls, and that is the design rather than an omission:
464
+ * the `claude` child runs its own agent loop with its own tools, so the work is
465
+ * already done by the time this resolves. Emitting tool_calls as well would
466
+ * hand a second, parallel loop the same task and run every tool twice.
467
+ *
468
+ * @param {object} opts
469
+ * @param {string} opts.prompt
470
+ * @param {string} [opts.model] a `claude --model` alias (PRO_MODELS)
471
+ * @param {string} [opts.system] appended to the child's own prompt
472
+ * @param {string} [opts.cwd] the directory the child works in
473
+ * @param {string[]} [opts.addDir] extra directories the child may work in.
474
+ * The child resolves every relative path
475
+ * and every tool's root against `cwd`
476
+ * alone, so a turn started in a
477
+ * subdirectory cannot see the tree above
478
+ * it; this is what widens that. Spelled
479
+ * by the ONE argv builder (one/src/child.js
480
+ * addDirArgs), never typed here.
481
+ * @param {object} [opts.rules] CLI permission rules (cli/src/config.js)
482
+ * @param {boolean} [opts.gate] the stored /confirm posture, if known
483
+ * @param {string} [opts.permissionMode] explicit passthrough, validated in child.js
484
+ * @param {string} [opts.resumeSessionId] reattach to the session a prior turn
485
+ * reported → the turn keeps its context
486
+ * @param {string} [opts.sessionId] name a NEW session (UUID) for a first turn
487
+ * @param {AbortSignal} [opts.signal]
488
+ * @param {Function} [opts.onSpawn] receives the child the moment it exists
489
+ * @param {number} [opts.idleTimeoutMs] silence budget (default 10 min)
490
+ * @returns {Promise<object>} an engine-shaped completion
491
+ */
492
+ function runClaudePro(opts, deps) {
493
+ const o = opts || {};
494
+ const d = deps || {};
495
+ const seam = d.seam || loadSeam(d.dir);
496
+ const spawnFn = d.spawn || require('node:child_process').spawn;
497
+
498
+ // One posture per turn, from the single resolver — never re-derived here.
499
+ const posture = seam.permissions.resolvePosture({
500
+ rules: o.rules,
501
+ gate: o.gate,
502
+ interactive: false,
503
+ childKind: seam.permissions.CHILD_KINDS.HEADLESS,
504
+ env: o.env || process.env,
505
+ });
506
+
507
+ const built = seam.child.claudeArgv({
508
+ posture,
509
+ prompt: o.prompt,
510
+ model: o.model || undefined,
511
+ permissionMode: o.permissionMode,
512
+ // Continuity, handed to the ONE argv builder rather than typed as `extra`.
513
+ // Without these the child starts a brand-new conversation every turn: the
514
+ // engine does not replay the transcript into it (`pro` runs its own agent
515
+ // loop), so the CLI's own session store is the ONLY thing that can carry
516
+ // context across turns. `resumeSessionId` reattaches to the session a
517
+ // previous turn reported; `sessionId` names a new one for a later resume.
518
+ resumeSessionId: o.resumeSessionId,
519
+ sessionId: o.sessionId,
520
+ // The widened sandbox for this turn, if the caller asked for one. It rides
521
+ // the descriptor rather than `extra` because `--add-dir` is variadic and its
522
+ // correct spelling is a decision (child.js addDirArgs), not a flag a caller
523
+ // may type.
524
+ addDir: o.addDir,
525
+ // `--include-partial-messages` is what makes `stream_event` lines arrive at
526
+ // all, and `--verbose` is required by the CLI alongside stream-json in print
527
+ // mode. Both belong to the transport (this file), not to the posture.
528
+ extra: [...turnArgs(o), '--include-partial-messages', '--verbose'],
529
+ });
530
+
531
+ return new Promise((resolve) => {
532
+ if (o.signal && o.signal.aborted) {
533
+ resolve({ choices: [{ message: { content: '' }, finish_reason: 'stop' }], stopped: true });
534
+ return;
535
+ }
536
+
537
+ // `CLAUDE_CODE_OAUTH_TOKEN` is merged into the CHILD's env only — never
538
+ // written back to `o.env`/`process.env` — so a token the on-disk
539
+ // credential file lacks (or has let expire) still reaches the one process
540
+ // that can spend it, without making the value ambient to this host or any
541
+ // other child it spawns. See claudeOAuthToken() for where it comes from.
542
+ //
543
+ // `childEnvPlan` additionally refuses to hand over a shadowing var that
544
+ // cannot be a credential. Without it a signed-in machine runs the child as
545
+ // "API key: he" and the subscription is unreachable — see childEnvPlan.
546
+ const baseEnv = o.env || process.env;
547
+ const token = claudeOAuthToken(baseEnv);
548
+ const plan = childEnvPlan(baseEnv, token);
549
+ const childEnv = plan.env;
550
+ const droppedLine = envDroppedLine(plan.dropped);
551
+ if (droppedLine && o.onDelta) {
552
+ // Same channel the transcript already paints notes on, and emitted BEFORE
553
+ // the spawn so the line lands above the child's own tool rows.
554
+ try {
555
+ o.onDelta({ delta: '', note: droppedLine });
556
+ } catch {
557
+ /* a throwing renderer must not take the turn down */
558
+ }
559
+ }
560
+
561
+ let child;
562
+ try {
563
+ child = spawnFn(d.executable || 'claude', built.argv, {
564
+ cwd: o.cwd || process.cwd(),
565
+ stdio: ['ignore', 'pipe', 'pipe'],
566
+ env: childEnv,
567
+ });
568
+ } catch (e) {
569
+ resolve({
570
+ choices: [{ message: { content: '' }, finish_reason: 'error' }],
571
+ error: `claude: could not start the child (${(e && e.message) || e})`,
572
+ ...(plan.dropped.length ? { envDropped: plan.dropped } : {}),
573
+ });
574
+ return;
575
+ }
576
+
577
+ if (o.onSpawn) {
578
+ try {
579
+ o.onSpawn(child, built.record);
580
+ } catch {
581
+ /* a throwing hook must not take the turn down */
582
+ }
583
+ }
584
+
585
+ let finished = false;
586
+ let stderr = '';
587
+ let buffer = '';
588
+ let idle = null;
589
+ const idleMs = Number(o.idleTimeoutMs) || 10 * 60 * 1000;
590
+
591
+ const settle = (result) => {
592
+ if (finished) return;
593
+ finished = true;
594
+ if (idle) clearTimeout(idle);
595
+ if (o.signal) o.signal.removeEventListener('abort', kill);
596
+ // Recorded on every settle path, the same way `childRecord` is: what the
597
+ // child was NOT given is part of what this turn did, and a caller logging
598
+ // only the argv would show a turn that "should" have failed as a mystery.
599
+ resolve(plan.dropped.length ? { ...result, envDropped: plan.dropped } : result);
600
+ };
601
+
602
+ const kill = () => {
603
+ try {
604
+ child.kill('SIGTERM');
605
+ } catch {
606
+ /* already gone */
607
+ }
608
+ setTimeout(() => {
609
+ try {
610
+ child.kill('SIGKILL');
611
+ } catch {
612
+ /* already gone */
613
+ }
614
+ }, 1500);
615
+ };
616
+ if (o.signal) o.signal.addEventListener('abort', kill, { once: true });
617
+
618
+ // Idle watchdog, not a flat turn cap: it resets on every byte, so it fires
619
+ // only on true silence — which is what a lapsed OAuth session looks like
620
+ // when the child opens a re-auth prompt nobody on this side can answer.
621
+ const armIdle = () => {
622
+ if (idle) clearTimeout(idle);
623
+ idle = setTimeout(() => {
624
+ kill();
625
+ settle({
626
+ choices: [{ message: { content: '' }, finish_reason: 'stop' }],
627
+ error: `claude: the child went silent for ${Math.round(idleMs / 1000)}s and was stopped`,
628
+ });
629
+ }, idleMs);
630
+ };
631
+ armIdle();
632
+
633
+ const parser = createStreamParser({
634
+ onText: (t) => {
635
+ armIdle();
636
+ if (o.onDelta) o.onDelta({ delta: t });
637
+ },
638
+ onTool: (info) => {
639
+ armIdle();
640
+ if (!o.onDelta) return;
641
+ // Same tool frame the engine's own loop emits, so the renderers paint a
642
+ // subscription turn's tool rows with no second code path.
643
+ o.onDelta({
644
+ delta: '',
645
+ tool: {
646
+ name: info.name,
647
+ args: info.args,
648
+ ok: info.phase === 'done' ? !info.error : undefined,
649
+ id: info.id,
650
+ cwd: o.cwd,
651
+ phase: info.phase,
652
+ },
653
+ });
654
+ },
655
+ onDone: (r) => {
656
+ if (r.error) {
657
+ settle({
658
+ choices: [{ message: { content: r.text || '' }, finish_reason: 'error' }],
659
+ usage: r.usage || undefined,
660
+ error: r.error,
661
+ });
662
+ return;
663
+ }
664
+ settle({
665
+ choices: [{ message: { content: r.text || '' }, finish_reason: 'stop' }],
666
+ usage: r.usage || undefined,
667
+ costUsd: r.costUsd,
668
+ durationMs: r.durationMs,
669
+ sessionId: r.sessionId,
670
+ // Carried so a caller can log WHICH flags the child actually got —
671
+ // rule 4's "recorded, never inferred".
672
+ childRecord: built.record,
673
+ });
674
+ },
675
+ });
676
+
677
+ child.stdout.on('data', (chunk) => {
678
+ armIdle();
679
+ buffer += chunk.toString('utf8');
680
+ const lines = buffer.split('\n');
681
+ buffer = lines.pop() || '';
682
+ for (const line of lines) {
683
+ const t = line.trim();
684
+ if (t) parser.line(t);
685
+ }
686
+ });
687
+ child.stderr.on('data', (chunk) => {
688
+ armIdle();
689
+ if (stderr.length < 8192) stderr += chunk.toString('utf8');
690
+ });
691
+
692
+ child.on('error', (e) => {
693
+ settle({
694
+ choices: [{ message: { content: '' }, finish_reason: 'error' }],
695
+ error:
696
+ `claude: could not run the child (${(e && e.message) || e}). ` +
697
+ 'Install it with `npm i -g @anthropic-ai/claude-code`, then sign in with `claude auth login`.',
698
+ });
699
+ });
700
+
701
+ child.on('close', (code) => {
702
+ if (buffer.trim()) parser.line(buffer.trim());
703
+ // Only reached when no `result` line arrived — a clean exit always emits
704
+ // one. Anything else is a real failure and is reported as one rather than
705
+ // as an empty successful answer.
706
+ settle({
707
+ choices: [{ message: { content: parser.text }, finish_reason: 'stop' }],
708
+ usage: (parser.resultLine && parser.resultLine.usage) || undefined,
709
+ childRecord: built.record,
710
+ error:
711
+ parser.text || code === 0
712
+ ? undefined
713
+ : `claude: the child exited with status ${code}${
714
+ stderr.trim() ? ` — ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''
715
+ }`,
716
+ });
717
+ });
718
+ });
719
+ }
720
+
721
+ /**
722
+ * The run-log line for a child argv, from the seam that owns the wording. A
723
+ * missing seam degrades to a short local summary rather than throwing — this is
724
+ * a reporting path, and a turn that already ran must not fail to describe
725
+ * itself.
726
+ */
727
+ function childLine(record, dir) {
728
+ if (!record) return '';
729
+ try {
730
+ return loadSeam(dir).child.childRecordLine(record);
731
+ } catch {
732
+ const flag = record.skipFlag || 'no permission flag';
733
+ return `aegis: claude child [${record.kind}] ${flag}`;
734
+ }
735
+ }
736
+
737
+ module.exports = {
738
+ claudeConfigDir,
739
+ claudeOAuthToken,
740
+ claudeProStatus,
741
+ // The child-env half of "the subscription actually gets spent": the plan for
742
+ // what the `claude` child is given (and what a shadowing value cost), the
743
+ // one-line report of it, and the inspector `claudeProStatus` reports.
744
+ childEnvPlan,
745
+ envDroppedLine,
746
+ shadowingValue,
747
+ SHADOWING_ENV_VARS,
748
+ PRO_MODELS,
749
+ turnArgs,
750
+ createStreamParser,
751
+ loadSeam,
752
+ seamAvailable,
753
+ childLine,
754
+ runClaudePro,
755
+ };